From b356f451c7dfb68635d873aeed952355e055005b Mon Sep 17 00:00:00 2001 From: kst Date: Sun, 23 Aug 2026 18:48:21 +0900 Subject: [PATCH] tunneling --- tunneling/.dockerignore | 4 + tunneling/.env.example | 16 +++ tunneling/.gitignore | 1 + tunneling/Dockerfile | 228 ++++++++++++++++++++++++++++++++++ tunneling/README.md | 95 ++++++++++++++ tunneling/docker-compose.yml | 44 +++++++ tunneling/templates/AGENTS.md | 71 +++++++++++ 7 files changed, 459 insertions(+) create mode 100644 tunneling/.dockerignore create mode 100644 tunneling/.env.example create mode 100644 tunneling/.gitignore create mode 100644 tunneling/Dockerfile create mode 100644 tunneling/README.md create mode 100644 tunneling/docker-compose.yml create mode 100644 tunneling/templates/AGENTS.md diff --git a/tunneling/.dockerignore b/tunneling/.dockerignore new file mode 100644 index 0000000..53f4c57 --- /dev/null +++ b/tunneling/.dockerignore @@ -0,0 +1,4 @@ +.env +.git +.gitignore +README.md diff --git a/tunneling/.env.example b/tunneling/.env.example new file mode 100644 index 0000000..56610e3 --- /dev/null +++ b/tunneling/.env.example @@ -0,0 +1,16 @@ +# Absolute host directory mounted at /shared. +SHARED_PATH=/absolute/path/to/shared + +# Public base URL assigned to the Cloudflare Tunnel. +MCP_PUBLIC_URL=https://your-domain.example + +# Token for a remotely managed Cloudflare Tunnel. +CLOUDFLARE_TUNNEL_TOKEN=replace-with-your-tunnel-token + +# Optional settings. +TZ=UTC +COKACREMOTE_REF=main +# WORKMACHINE_IMAGE=workmachine:local +# MCP_ALLOWED_HOSTS= +# MCP_OAUTH_APPROVAL_KEY= +# MCP_AUTH_TOKEN= diff --git a/tunneling/.gitignore b/tunneling/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/tunneling/.gitignore @@ -0,0 +1 @@ +.env diff --git a/tunneling/Dockerfile b/tunneling/Dockerfile new file mode 100644 index 0000000..a056f53 --- /dev/null +++ b/tunneling/Dockerfile @@ -0,0 +1,228 @@ +# syntax=docker/dockerfile:1 +FROM ubuntu:24.04 + +ARG DEBIAN_FRONTEND=noninteractive +ARG TIMEZONE=UTC +ARG COKACREMOTE_REPOSITORY=https://github.com/kstost/cokacremote.git +ARG COKACREMOTE_REF=main + +ENV TZ=${TIMEZONE} \ + NODE_ENV=production \ + MCP_HOST=127.0.0.1 \ + MCP_PORT=3000 \ + MCP_DEFAULT_CWD=/ \ + MCP_ENDPOINT=/mcp \ + MCP_TRUST_PROXY_HOPS=1 \ + MCP_AUTH_TOKEN="" \ + MCP_OAUTH_ENABLED=true \ + MCP_OAUTH_STATE_FILE=/var/lib/cokacremote/oauth-state.json \ + MCP_OAUTH_APPROVAL_KEY_FILE=/var/lib/cokacremote/oauth-approval-key + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + nginx \ + openssl \ + supervisor \ + tmux \ + tzdata \ + && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +RUN git clone --depth 1 --branch "${COKACREMOTE_REF}" \ + "${COKACREMOTE_REPOSITORY}" /opt/cokacremote \ + && cd /opt/cokacremote \ + && npm ci --include=dev \ + && npm run build \ + && npm prune --omit=dev \ + && npm cache clean --force + +COPY templates/AGENTS.md /usr/local/share/workmachine/AGENTS.md + +RUN <<'SETUP' +set -eu + +rm -f /etc/nginx/sites-enabled/default +mkdir -p /etc/nginx/routes.d /etc/nginx/snippets /var/lib/cokacremote /shared +chmod 0700 /var/lib/cokacremote + +cat > /etc/nginx/snippets/workmachine-proxy.conf <<'NGINX' +proxy_http_version 1.1; +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Host $host; +proxy_set_header X-Forwarded-Proto $workmachine_forwarded_proto; +proxy_set_header Upgrade $http_upgrade; +proxy_set_header Connection $workmachine_connection_upgrade; +proxy_read_timeout 3600s; +proxy_send_timeout 3600s; +NGINX + +cat > /etc/nginx/conf.d/workmachine.conf <<'NGINX' +map $http_upgrade $workmachine_connection_upgrade { + default upgrade; + '' close; +} + +map $http_x_forwarded_proto $workmachine_forwarded_proto { + default $http_x_forwarded_proto; + '' $scheme; +} + +server { + listen 2999 default_server; + server_name _; + + include /etc/nginx/routes.d/*.conf; + include /shared/nginx/routes.d/*.conf; +} +NGINX + +cat > /etc/nginx/routes.d/10-cokacremote.conf <<'NGINX' +location = /mcp { + include /etc/nginx/snippets/workmachine-proxy.conf; + proxy_buffering off; + proxy_request_buffering off; + proxy_pass http://127.0.0.1:3000; +} + +location = /health { + include /etc/nginx/snippets/workmachine-proxy.conf; + proxy_pass http://127.0.0.1:3000; +} + +location ^~ /.well-known/ { + include /etc/nginx/snippets/workmachine-proxy.conf; + proxy_pass http://127.0.0.1:3000; +} + +location ~ ^/(authorize|token|register|revoke)$ { + include /etc/nginx/snippets/workmachine-proxy.conf; + proxy_buffering off; + proxy_pass http://127.0.0.1:3000; +} +NGINX + +# Example: add a new application later without changing the default routes. +# Create /shared/nginx/routes.d/20-newapp.conf: +# +# location = /newapp { +# return 308 /newapp/; +# } +# +# location ^~ /newapp/ { +# include /etc/nginx/snippets/workmachine-proxy.conf; +# proxy_pass http://127.0.0.1:5000/; +# } +# +# Then validate and reload Nginx: +# nginx -t && nginx -s reload + +cat > /etc/supervisor/conf.d/workmachine.conf <<'SUPERVISOR' +[supervisord] +nodaemon=true +logfile=/dev/null +pidfile=/run/supervisord.pid + +[program:nginx] +command=/usr/sbin/nginx -g "daemon off;" +priority=10 +autostart=true +autorestart=true +startsecs=2 +stopsignal=QUIT +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:cokacremote] +command=/usr/bin/npm start +directory=/opt/cokacremote +priority=20 +autostart=true +autorestart=true +startsecs=2 +stopasgroup=true +killasgroup=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +SUPERVISOR + +cat > /usr/local/bin/workmachine-entrypoint <<'ENTRYPOINT' +#!/usr/bin/env bash +set -euo pipefail + +install -d -m 0755 /shared/nginx/routes.d + +public_host="" +public_mcp_url="not configured" + +if [[ "${MCP_OAUTH_ENABLED:-false}" == "true" ]]; then + : "${MCP_PUBLIC_URL:?Set MCP_PUBLIC_URL to the externally accessible base URL}" + MCP_PUBLIC_URL="${MCP_PUBLIC_URL%/}" + export MCP_PUBLIC_URL + export MCP_OAUTH_ISSUER="${MCP_OAUTH_ISSUER:-${MCP_PUBLIC_URL}}" + export MCP_OAUTH_RESOURCE="${MCP_OAUTH_RESOURCE:-${MCP_PUBLIC_URL}${MCP_ENDPOINT:-/mcp}}" +fi + +if [[ -n "${MCP_PUBLIC_URL:-}" ]]; then + public_host="$(node -e 'process.stdout.write(new URL(process.env.MCP_PUBLIC_URL).hostname)')" + public_mcp_url="${MCP_PUBLIC_URL}${MCP_ENDPOINT:-/mcp}" +fi + +if [[ -z "${MCP_ALLOWED_HOSTS:-}" ]]; then + if [[ -n "${public_host}" ]]; then + export MCP_ALLOWED_HOSTS="${public_host},localhost,127.0.0.1" + else + export MCP_ALLOWED_HOSTS="localhost,127.0.0.1" + fi +fi + +if [[ ! -e /shared/AGENTS.md && ! -L /shared/AGENTS.md ]]; then + agents_tmp="$(mktemp)" + PUBLIC_BASE_URL="${MCP_PUBLIC_URL:-not configured}" PUBLIC_DOMAIN="${public_host:-not configured}" PUBLIC_MCP_URL="${public_mcp_url}" node -e 'const fs = require("fs"); const source = fs.readFileSync("/usr/local/share/workmachine/AGENTS.md", "utf8"); process.stdout.write(source.replaceAll("{{PUBLIC_BASE_URL}}", process.env.PUBLIC_BASE_URL).replaceAll("{{PUBLIC_DOMAIN}}", process.env.PUBLIC_DOMAIN).replaceAll("{{PUBLIC_MCP_URL}}", process.env.PUBLIC_MCP_URL));' > "${agents_tmp}" + install -m 0644 "${agents_tmp}" /shared/AGENTS.md + rm -f "${agents_tmp}" +fi + +install -d -m 0700 "$(dirname "${MCP_OAUTH_STATE_FILE}")" +install -d -m 0700 "$(dirname "${MCP_OAUTH_APPROVAL_KEY_FILE}")" + +if [[ -z "${MCP_OAUTH_APPROVAL_KEY:-}" ]]; then + if [[ ! -s "${MCP_OAUTH_APPROVAL_KEY_FILE}" ]]; then + openssl rand -hex 32 > "${MCP_OAUTH_APPROVAL_KEY_FILE}" + chmod 0600 "${MCP_OAUTH_APPROVAL_KEY_FILE}" + fi + export MCP_OAUTH_APPROVAL_KEY + MCP_OAUTH_APPROVAL_KEY="$(<"${MCP_OAUTH_APPROVAL_KEY_FILE}")" +fi + +nginx -t +exec /usr/bin/supervisord -c /etc/supervisor/conf.d/workmachine.conf +ENTRYPOINT + +chmod 0755 /usr/local/bin/workmachine-entrypoint +SETUP + +VOLUME ["/var/lib/cokacremote"] + +WORKDIR /shared + +EXPOSE 2999 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS -H "Host: localhost" http://127.0.0.1:2999/health || exit 1 + +STOPSIGNAL SIGTERM + +ENTRYPOINT ["/usr/local/bin/workmachine-entrypoint"] diff --git a/tunneling/README.md b/tunneling/README.md new file mode 100644 index 0000000..1cf2caa --- /dev/null +++ b/tunneling/README.md @@ -0,0 +1,95 @@ +# workmachine + +A long-running Ubuntu development machine controlled through cokacremote MCP. + +On first start, workmachine creates `/shared/AGENTS.md` from `templates/AGENTS.md` if the file does not already exist. Existing instructions are never overwritten. + +## Configure + +Run commands from the directory containing `docker-compose.yml`: + +```bash +cd /absolute/path/to/workmachine +cp .env.example .env +``` + +Set these values in `.env`: + +- `SHARED_PATH`: absolute host directory mounted at `/shared` +- `MCP_PUBLIC_URL`: public HTTPS base URL without `/mcp` +- `CLOUDFLARE_TUNNEL_TOKEN`: Cloudflare Tunnel token + +Example for macOS: + +```dotenv +SHARED_PATH=/Users/yourname/Documents/workspace +MCP_PUBLIC_URL=https://example.com +CLOUDFLARE_TUNNEL_TOKEN=replace-with-your-real-tunnel-token +TZ=Asia/Seoul +COKACREMOTE_REF=main +``` + +This example creates the public MCP endpoint `https://example.com/mcp`. Use an absolute path for `SHARED_PATH`, do not add a trailing slash to `MCP_PUBLIC_URL`, and never commit the populated `.env` file. + +In the Cloudflare Tunnel public-hostname settings, set the service URL to: + +```text +http://localhost:2999 +``` + +## Start + +From the directory containing `docker-compose.yml`: + +```bash +cd /absolute/path/to/workmachine +docker compose -p workmachine up -d --build +docker compose -p workmachine ps +docker compose -p workmachine logs -f +``` + +To rebuild without reusing layers from previous Docker builds, then recreate the +containers from the new image: + +```bash +docker compose -p workmachine build --no-cache --pull +docker compose -p workmachine up -d --force-recreate +``` + +This keeps the existing `cokacremote-state` volume and its OAuth state. + +To run from any directory, specify both the Compose file and environment file: + +```bash +docker compose -p workmachine -f /absolute/path/to/workmachine/docker-compose.yml --env-file /absolute/path/to/workmachine/.env up -d --build +``` + +Nginx listens on port 2999 and forwards cokacremote routes to port 3000. + +Read the generated OAuth approval key: + +```bash +docker compose -p workmachine exec workmachine cat /var/lib/cokacremote/oauth-approval-key +``` + +## Add an application route + +Create a file under `${SHARED_PATH}/nginx/routes.d`, for example `20-newapp.conf`: + +```nginx +location = /newapp { + return 308 /newapp/; +} + +location ^~ /newapp/ { + include /etc/nginx/snippets/workmachine-proxy.conf; + proxy_pass http://127.0.0.1:5000/; +} +``` + +Validate and reload without restarting the container: + +```bash +docker compose -p workmachine exec workmachine nginx -t +docker compose -p workmachine exec workmachine nginx -s reload +``` diff --git a/tunneling/docker-compose.yml b/tunneling/docker-compose.yml new file mode 100644 index 0000000..b5f27f9 --- /dev/null +++ b/tunneling/docker-compose.yml @@ -0,0 +1,44 @@ +services: + workmachine: + build: + context: . + args: + TIMEZONE: "${TZ:-UTC}" + COKACREMOTE_REF: "${COKACREMOTE_REF:-main}" + image: "${WORKMACHINE_IMAGE:-workmachine:local}" + container_name: workmachine + hostname: workmachine + restart: unless-stopped + environment: + TZ: "${TZ:-UTC}" + MCP_PUBLIC_URL: "${MCP_PUBLIC_URL:?Set MCP_PUBLIC_URL in .env}" + MCP_ALLOWED_HOSTS: "${MCP_ALLOWED_HOSTS:-}" + MCP_OAUTH_ENABLED: "${MCP_OAUTH_ENABLED:-true}" + MCP_OAUTH_APPROVAL_KEY: "${MCP_OAUTH_APPROVAL_KEY:-}" + MCP_AUTH_TOKEN: "${MCP_AUTH_TOKEN:-}" + volumes: + - type: bind + source: "${SHARED_PATH:?Set SHARED_PATH in .env}" + target: /shared + - type: volume + source: cokacremote-state + target: /var/lib/cokacremote + stop_grace_period: 30s + + cloudflared: + image: cloudflare/cloudflared:latest + container_name: chatgpt-cloudflared + restart: unless-stopped + network_mode: "service:workmachine" + depends_on: + workmachine: + condition: service_healthy + command: + - tunnel + - --no-autoupdate + - run + - --token + - "${CLOUDFLARE_TUNNEL_TOKEN:?Set CLOUDFLARE_TUNNEL_TOKEN in .env}" + +volumes: + cokacremote-state: diff --git a/tunneling/templates/AGENTS.md b/tunneling/templates/AGENTS.md new file mode 100644 index 0000000..fa3e0da --- /dev/null +++ b/tunneling/templates/AGENTS.md @@ -0,0 +1,71 @@ +# Workmachine Instructions + +## Purpose + +- `/shared` is the persistent host-mounted workspace. +- Store projects and durable files under `/shared`. +- Treat files outside `/shared` as disposable unless documented otherwise. + +## Public Access + +- This workmachine is connected to its public domain through Cloudflare Tunnel. +- Public domain: `{{PUBLIC_DOMAIN}}` +- Public base URL: `{{PUBLIC_BASE_URL}}` +- Public MCP endpoint: `{{PUBLIC_MCP_URL}}` +- Cloudflare Tunnel forwards public requests to Nginx at `http://localhost:2999`. +- Nginx forwards the protected cokacremote paths, including `/mcp`, to `http://127.0.0.1:3000`. + +## Instruction Scope + +- Check for a nearer `AGENTS.md` before modifying a project. +- Project-specific instructions take precedence within that project. +- Preserve existing files, repositories, and uncommitted changes. + +## Reserved Workmachine Resources + +The following resources belong to the workmachine infrastructure and cokacremote. During unrelated application development, never reuse, overwrite, remove, stop, redirect, or otherwise interfere with them: + +- TCP port `2999`: Nginx gateway +- TCP port `3000`: cokacremote MCP server +- `/opt/cokacremote` +- `/var/lib/cokacremote` +- `/etc/nginx/routes.d/10-cokacremote.conf` +- Nginx and cokacremote Supervisor processes +- `/mcp` +- `/health` +- `/.well-known/*` +- `/authorize` +- `/token` +- `/register` +- `/revoke` + +Only modify these resources when the user explicitly requests maintenance of workmachine or cokacremote. + +## Application Services + +- Check listening ports before selecting an application port. +- New applications must not use ports `2999` or `3000`. +- Bind application servers to `127.0.0.1` unless instructed otherwise. +- Store application route files in `/shared/nginx/routes.d/`. +- Use one route file and one unique internal port per application. +- Do not modify `/etc/nginx/routes.d/10-cokacremote.conf`. + +## Nginx Changes + +- Validate configuration before applying it. +- Apply changes with `nginx -t && nginx -s reload`. +- Do not stop or restart Nginx when a reload is sufficient. +- Do not modify Cloudflare Tunnel settings unless explicitly requested. + +## Safety + +- Do not delete, overwrite, move, or reset user files without explicit approval. +- Do not expose secrets, tokens, `.env` files, or OAuth keys. +- Do not publish container ports directly without approval. +- Avoid destructive system and Git commands. + +## Verification + +- Use the package manager selected by the existing lockfile. +- Run relevant tests or builds after changes. +- Report changed files, allocated ports, routes, and verification results.