From 6d037f7a08bd0b96b0cd2df139ab4732d427bc4a Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Tue, 10 Feb 2026 07:23:11 -0800 Subject: [PATCH 01/22] cloudflare option --- .env.example | 10 ++ README.md | 143 ++++++++++++++++++-- deploy/cloudflare-tunnel/docker-compose.yml | 38 ++++++ scripts/cf-dns-create-wildcard.sh | 38 ++++++ scripts/create-instance.sh | 36 +++-- scripts/provision-host.sh | 41 ++++-- scripts/smoke-provision-script.ts | 48 ++++++- src/core/cloudflareDns.ts | 85 ++++++++++++ src/core/cloudflared.ts | 61 +++++++++ src/core/deployMode.ts | 1 + src/core/dockerRun.ts | 29 ++-- src/core/provision.ts | 40 ++++-- src/core/traefik.ts | 41 ++++++ src/index.ts | 16 ++- 14 files changed, 567 insertions(+), 60 deletions(-) create mode 100644 deploy/cloudflare-tunnel/docker-compose.yml create mode 100755 scripts/cf-dns-create-wildcard.sh create mode 100644 src/core/cloudflareDns.ts create mode 100644 src/core/cloudflared.ts create mode 100644 src/core/deployMode.ts diff --git a/.env.example b/.env.example index c93eeb0..19c73c8 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,13 @@ OPENCLAW_TTYD_TTL_SECONDS=86400 # Runtime image to run for instances (build locally or use your own) OPENCLAW_RUNTIME_IMAGE=openclaw-ttyd:local +# ---- Deploy mode ---- +# "traefik" (default) or "cloudflare-tunnel" +OPENCLAW_DEPLOY_MODE=traefik + +# ---- Cloudflare Tunnel (only when OPENCLAW_DEPLOY_MODE=cloudflare-tunnel) ---- +OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN= +OPENCLAW_CLOUDFLARE_API_TOKEN= +OPENCLAW_CLOUDFLARE_ZONE_ID= +OPENCLAW_CLOUDFLARE_TUNNEL_ID= + diff --git a/README.md b/README.md index 284a60c..cb9dc96 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,15 @@ To stop everything: This is the real setup. You run one server, and each friend gets their own subdomain with HTTPS. +There are two ways to get traffic to your server. Pick whichever fits your situation: + +| | **Option A: Direct with Traefik** | **Option B: Cloudflare Tunnel** | +|---|---|---| +| **Best for** | VPS / cloud VM with a public IP | Home servers, machines behind NAT, no static IP | +| **Open ports** | Port 443 must be open | None — traffic flows through Cloudflare's network | +| **TLS certificates** | Let's Encrypt via Traefik (DNS-01 challenge) | Handled by Cloudflare automatically | +| **DNS provider** | Vercel | Cloudflare (free) | + ### How many friends? Rule of thumb: **each instance needs ~4 GB RAM**. @@ -64,7 +73,13 @@ Rule of thumb: **each instance needs ~4 GB RAM**. | 32 GB RAM | ~8 | | 64 GB RAM | ~16 | -### What you need +--- + +### Option A: Direct Server with Traefik + +Traditional setup: your server has a public IP, Traefik handles TLS with Let's Encrypt certificates. + +#### What you need 1. A Linux VM (Ubuntu/Debian) with port **443** open (Docker is installed automatically) 2. A domain with **DNS managed by [Vercel](https://vercel.com/docs/projects/domains)** (e.g. `example.com`) @@ -74,7 +89,7 @@ Rule of thumb: **each instance needs ~4 GB RAM**. ``` 4. A **[Vercel API token](https://vercel.com/account/tokens)** — Traefik uses this to automatically prove you own the domain and get wildcard SSL certificates (via DNS-01 challenge). This is why your DNS needs to be on Vercel. -### Setup +#### Setup ```bash git clone https://github.com/Agent-3-7/openclaw-host-kit @@ -96,6 +111,89 @@ Provision the server (installs Docker, starts Traefik reverse proxy): sudo ./scripts/provision-host.sh ``` +--- + +### Option B: Cloudflare Tunnel + +No public IP or open ports needed. Cloudflare handles TLS and routes traffic through an encrypted tunnel to your machine. + +#### What you need + +1. A Linux machine (Ubuntu/Debian) — Docker is installed automatically +2. A domain with **DNS on [Cloudflare](https://dash.cloudflare.com/)** (free plan works) +3. A Cloudflare account with access to [Zero Trust](https://one.dash.cloudflare.com/) + +#### 1. Create a Cloudflare Tunnel + +1. Go to [Zero Trust](https://one.dash.cloudflare.com/) → **Networks** → **Tunnels** +2. Click **Create a tunnel** → choose **Cloudflared** as the connector +3. Give it a name (e.g. `openclaw-h1`) +4. On the Install Connector page, copy the **tunnel token** (the long string after `--token`). You don't need to install the connector on your machine — the Docker setup handles that. +5. Click **Next** to skip to the Public Hostname tab, but don't add a hostname yet — we'll do that after provisioning + +Note the **Tunnel ID** shown on the tunnel overview page (a UUID like `abcd1234-...`). + +#### 2. Get your Zone ID and API token + +**Zone ID:** +- Go to the [Cloudflare dashboard](https://dash.cloudflare.com/) → select your domain +- The **Zone ID** is on the right sidebar of the Overview page + +**API Token:** +- Go to **My Profile** → [**API Tokens**](https://dash.cloudflare.com/profile/api-tokens) → **Create Token** +- Use the **Edit zone DNS** template +- Scope it to your specific domain under **Zone Resources** +- Create the token and copy it + +#### 3. Configure and provision + +```bash +git clone https://github.com/Agent-3-7/openclaw-host-kit +cd openclaw-host-kit +cp .env.example .env +``` + +Edit `.env`: +```bash +OPENCLAW_BASE_DOMAIN=example.com +OPENCLAW_HOST_SHARD=h1 +OPENCLAW_TTYD_SECRET=some_long_random_string + +OPENCLAW_DEPLOY_MODE=cloudflare-tunnel +OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN=eyJhIjoiYWJj... # from step 1 +OPENCLAW_CLOUDFLARE_API_TOKEN=your_api_token # from step 2 +OPENCLAW_CLOUDFLARE_ZONE_ID=your_zone_id # from step 2 +OPENCLAW_CLOUDFLARE_TUNNEL_ID=abcd1234-... # from step 1 +``` + +Provision the server (installs Docker, starts Traefik + cloudflared): +```bash +sudo ./scripts/provision-host.sh +``` + +#### 4. Set up DNS and tunnel routing + +Create the wildcard DNS record (CNAME pointing to your tunnel): +```bash +./scripts/cf-dns-create-wildcard.sh +``` + +Then add the public hostname in Cloudflare: +1. Go to [Zero Trust](https://one.dash.cloudflare.com/) → **Networks** → **Tunnels** → click your tunnel → **Edit** +2. Go to the **Public Hostname** tab → **Add a public hostname** +3. Fill in: + - **Subdomain:** `*.h1.openclaw` (adjust to match your shard and subdomain) + - **Domain:** `example.com` (your base domain) + - **Type:** HTTP + - **URL:** `traefik:80` +4. Save + +#### 5. Create instances + +From here it's the same as Option A — [jump to creating instances](#create-instances-for-your-friends). + +--- + ### Create instances for your friends Pick any ID you like — a name, a random string, whatever: @@ -120,6 +218,8 @@ Send your friend their terminal URL. They open it in a browser and get a full we ## How It Works +**Direct (Traefik) mode:** + ```mermaid flowchart LR U[Browser] -->|https :443| T[Traefik] @@ -128,7 +228,20 @@ flowchart LR FA -->|valid token| TT[Web terminal] ``` -- **Traefik** sits at the front, handles HTTPS and routes each subdomain to the right container +**Cloudflare Tunnel mode:** + +```mermaid +flowchart LR + U[Browser] -->|https| CF[Cloudflare edge] + CF -->|tunnel| CD[cloudflared] + CD -->|http :80| T[Traefik] + T -->|/| G[OpenClaw gateway] + T -->|/terminal| FA[Token check] + FA -->|valid token| TT[Web terminal] +``` + +- **Traefik** sits at the front, handles routing each subdomain to the right container (and TLS in direct mode) +- **cloudflared** (tunnel mode only) connects to Cloudflare's edge and forwards traffic to Traefik over the local Docker network - **Each instance** is a Docker container running the OpenClaw gateway + a web terminal ([ttyd](https://github.com/tsl0922/ttyd)) - **Terminal auth** uses HMAC tokens with a 24-hour TTL — random people can't get a shell even if they guess the hostname - **Each container** gets its own CPU, memory, and PID limits so one instance can't take down the server @@ -140,19 +253,21 @@ flowchart LR ``` scripts/ - local-up.sh # 1-command local demo - local-down.sh # tear down local demo - provision-host.sh # set up a server (installs Docker + Traefik) - create-instance.sh # spin up an instance on the server - terminal-url.sh # print terminal URL for an instance - dashboard-url.sh # print dashboard URL for an instance - terminal-token.sh # generate a terminal auth token + local-up.sh # 1-command local demo + local-down.sh # tear down local demo + provision-host.sh # set up a server (installs Docker + Traefik/cloudflared) + create-instance.sh # spin up an instance on the server + terminal-url.sh # print terminal URL for an instance + dashboard-url.sh # print dashboard URL for an instance + terminal-token.sh # generate a terminal auth token + cf-dns-create-wildcard.sh # create wildcard CNAME via Cloudflare API docker/ - openclaw-ttyd/ # runtime image (OpenClaw + web terminal) - forward-auth/ # tiny token-validation service (~90 lines of JS) + openclaw-ttyd/ # runtime image (OpenClaw + web terminal) + forward-auth/ # tiny token-validation service (~90 lines of JS) deploy/ - traefik/ # docker-compose for the reverse proxy -src/ # TypeScript library (used by the managed hosting platform) + traefik/ # docker-compose for direct mode (Traefik + Let's Encrypt) + cloudflare-tunnel/ # docker-compose for tunnel mode (Traefik + cloudflared) +src/ # TypeScript library (used by the managed hosting platform) ``` ## Notes diff --git a/deploy/cloudflare-tunnel/docker-compose.yml b/deploy/cloudflare-tunnel/docker-compose.yml new file mode 100644 index 0000000..75b3952 --- /dev/null +++ b/deploy/cloudflare-tunnel/docker-compose.yml @@ -0,0 +1,38 @@ +version: "3.9" + +services: + traefik: + image: traefik:v3.1 + container_name: traefik + restart: unless-stopped + command: + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.web.address=:80" + ports: + - "80:80" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + + openclaw-forward-auth: + build: + context: ../../docker/forward-auth + container_name: openclaw-forward-auth + restart: unless-stopped + environment: + - OPENCLAW_TTYD_SECRET=${OPENCLAW_TTYD_SECRET} + - OPENCLAW_TTYD_TTL_SECONDS=${OPENCLAW_TTYD_TTL_SECONDS:-86400} + labels: + - "traefik.enable=false" + + cloudflared: + image: cloudflare/cloudflared:latest + container_name: cloudflared + restart: unless-stopped + command: tunnel run + environment: + - TUNNEL_TOKEN=${OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN} + +networks: + default: + name: traefik_default diff --git a/scripts/cf-dns-create-wildcard.sh b/scripts/cf-dns-create-wildcard.sh new file mode 100755 index 0000000..b13858a --- /dev/null +++ b/scripts/cf-dns-create-wildcard.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +if [ -f .env ]; then + # shellcheck disable=SC1091 + source .env +fi + +: "${OPENCLAW_CLOUDFLARE_API_TOKEN:?Missing OPENCLAW_CLOUDFLARE_API_TOKEN}" +: "${OPENCLAW_CLOUDFLARE_ZONE_ID:?Missing OPENCLAW_CLOUDFLARE_ZONE_ID}" +: "${OPENCLAW_CLOUDFLARE_TUNNEL_ID:?Missing OPENCLAW_CLOUDFLARE_TUNNEL_ID}" +: "${OPENCLAW_BASE_DOMAIN:?Missing OPENCLAW_BASE_DOMAIN}" +: "${OPENCLAW_HOST_SHARD:?Missing OPENCLAW_HOST_SHARD}" + +OPENCLAW_SUBDOMAIN="${OPENCLAW_SUBDOMAIN:-openclaw}" + +WILDCARD_NAME="*.${OPENCLAW_HOST_SHARD}.${OPENCLAW_SUBDOMAIN}.${OPENCLAW_BASE_DOMAIN}" +TUNNEL_TARGET="${OPENCLAW_CLOUDFLARE_TUNNEL_ID}.cfargotunnel.com" + +echo "Creating DNS CNAME record:" +echo " ${WILDCARD_NAME} -> ${TUNNEL_TARGET}" +echo + +RESPONSE=$(curl -sS -X POST \ + "https://api.cloudflare.com/client/v4/zones/${OPENCLAW_CLOUDFLARE_ZONE_ID}/dns_records" \ + -H "Authorization: Bearer ${OPENCLAW_CLOUDFLARE_API_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "{ + \"type\": \"CNAME\", + \"name\": \"${WILDCARD_NAME}\", + \"content\": \"${TUNNEL_TARGET}\", + \"proxied\": true, + \"ttl\": 1 + }") + +echo "${RESPONSE}" diff --git a/scripts/create-instance.sh b/scripts/create-instance.sh index 3d0961e..38502be 100755 --- a/scripts/create-instance.sh +++ b/scripts/create-instance.sh @@ -22,6 +22,7 @@ fi : "${OPENCLAW_BASE_DOMAIN:?Missing OPENCLAW_BASE_DOMAIN}" : "${OPENCLAW_HOST_SHARD:?Missing OPENCLAW_HOST_SHARD}" +OPENCLAW_DEPLOY_MODE="${OPENCLAW_DEPLOY_MODE:-traefik}" OPENCLAW_SUBDOMAIN="${OPENCLAW_SUBDOMAIN:-openclaw}" OPENCLAW_RUNTIME_IMAGE="${OPENCLAW_RUNTIME_IMAGE:-openclaw-ttyd:local}" @@ -43,6 +44,27 @@ chown 1000:1000 "${DATA_DIR}" docker pull "${OPENCLAW_RUNTIME_IMAGE}" >/dev/null 2>&1 || true +# --- Select entrypoint and TLS labels based on deploy mode --- +if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then + ENTRYPOINT="web" + MAIN_TLS_LABELS=() + TERMINAL_TLS_LABELS=() +else + ENTRYPOINT="websecure" + MAIN_TLS_LABELS=( + --label "traefik.http.routers.${CONTAINER}.tls=true" + --label "traefik.http.routers.${CONTAINER}.tls.certresolver=le" + --label "traefik.http.routers.${CONTAINER}.tls.domains[0].main=${WILDCARD_DOMAIN}" + --label "traefik.http.routers.${CONTAINER}.tls.domains[0].sans=*.${WILDCARD_DOMAIN}" + ) + TERMINAL_TLS_LABELS=( + --label "traefik.http.routers.${CONTAINER}-terminal.tls=true" + --label "traefik.http.routers.${CONTAINER}-terminal.tls.certresolver=le" + --label "traefik.http.routers.${CONTAINER}-terminal.tls.domains[0].main=${WILDCARD_DOMAIN}" + --label "traefik.http.routers.${CONTAINER}-terminal.tls.domains[0].sans=*.${WILDCARD_DOMAIN}" + ) +fi + docker run -d \ --name "${CONTAINER}" \ --restart unless-stopped \ @@ -57,20 +79,14 @@ docker run -d \ --label "traefik.docker.network=${NETWORK}" \ --label "traefik.http.routers.${CONTAINER}.rule=Host(\`${HOSTNAME}\`)" \ --label "traefik.http.routers.${CONTAINER}.service=${CONTAINER}" \ - --label "traefik.http.routers.${CONTAINER}.entrypoints=websecure" \ - --label "traefik.http.routers.${CONTAINER}.tls=true" \ - --label "traefik.http.routers.${CONTAINER}.tls.certresolver=le" \ - --label "traefik.http.routers.${CONTAINER}.tls.domains[0].main=${WILDCARD_DOMAIN}" \ - --label "traefik.http.routers.${CONTAINER}.tls.domains[0].sans=*.${WILDCARD_DOMAIN}" \ + --label "traefik.http.routers.${CONTAINER}.entrypoints=${ENTRYPOINT}" \ + "${MAIN_TLS_LABELS[@]}" \ --label "traefik.http.services.${CONTAINER}.loadbalancer.server.port=18789" \ --label "traefik.http.routers.${CONTAINER}-terminal.rule=Host(\`${HOSTNAME}\`) && PathPrefix(\`/terminal\`)" \ --label "traefik.http.routers.${CONTAINER}-terminal.service=${CONTAINER}-terminal" \ --label "traefik.http.routers.${CONTAINER}-terminal.priority=100" \ - --label "traefik.http.routers.${CONTAINER}-terminal.entrypoints=websecure" \ - --label "traefik.http.routers.${CONTAINER}-terminal.tls=true" \ - --label "traefik.http.routers.${CONTAINER}-terminal.tls.certresolver=le" \ - --label "traefik.http.routers.${CONTAINER}-terminal.tls.domains[0].main=${WILDCARD_DOMAIN}" \ - --label "traefik.http.routers.${CONTAINER}-terminal.tls.domains[0].sans=*.${WILDCARD_DOMAIN}" \ + --label "traefik.http.routers.${CONTAINER}-terminal.entrypoints=${ENTRYPOINT}" \ + "${TERMINAL_TLS_LABELS[@]}" \ --label "traefik.http.middlewares.${CONTAINER}-terminal-strip.stripprefix.prefixes=/terminal" \ --label "traefik.http.middlewares.${CONTAINER}-terminal-strip.stripprefix.forceSlash=true" \ --label "traefik.http.middlewares.${CONTAINER}-inject-id.headers.customrequestheaders.X-Openclaw-Instance-Id=${INSTANCE_ID}" \ diff --git a/scripts/provision-host.sh b/scripts/provision-host.sh index 537f730..3752061 100755 --- a/scripts/provision-host.sh +++ b/scripts/provision-host.sh @@ -13,15 +13,25 @@ if [ -f .env ]; then source .env fi +OPENCLAW_DEPLOY_MODE="${OPENCLAW_DEPLOY_MODE:-traefik}" + +# --- Vars required in ALL modes --- : "${OPENCLAW_BASE_DOMAIN:?Missing OPENCLAW_BASE_DOMAIN}" : "${OPENCLAW_HOST_SHARD:?Missing OPENCLAW_HOST_SHARD}" -: "${OPENCLAW_ACME_EMAIL:?Missing OPENCLAW_ACME_EMAIL}" -: "${OPENCLAW_VERCEL_API_TOKEN:?Missing OPENCLAW_VERCEL_API_TOKEN}" : "${OPENCLAW_TTYD_SECRET:?Missing OPENCLAW_TTYD_SECRET}" OPENCLAW_SUBDOMAIN="${OPENCLAW_SUBDOMAIN:-openclaw}" export OPENCLAW_WILDCARD_DOMAIN="${OPENCLAW_HOST_SHARD}.${OPENCLAW_SUBDOMAIN}.${OPENCLAW_BASE_DOMAIN}" +# --- Mode-specific validation --- +if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then + : "${OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN:?Missing OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN}" +else + : "${OPENCLAW_ACME_EMAIL:?Missing OPENCLAW_ACME_EMAIL}" + : "${OPENCLAW_VERCEL_API_TOKEN:?Missing OPENCLAW_VERCEL_API_TOKEN}" +fi + +# --- Common: install Docker & compose plugin --- export DEBIAN_FRONTEND=noninteractive apt-get update -y apt-get install -y ca-certificates curl gnupg lsb-release @@ -36,13 +46,24 @@ if ! docker compose version >/dev/null 2>&1; then apt-get install -y docker-compose-plugin fi -mkdir -p /opt/traefik -touch /opt/traefik/acme.json -chmod 600 /opt/traefik/acme.json +# --- Mode-specific setup --- +if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then + # Ensure the network is always named `traefik_default` (matches instance labels). + docker compose -p traefik -f deploy/cloudflare-tunnel/docker-compose.yml up -d --build -# Ensure the network is always named `traefik_default` (matches instance labels). -docker compose -p traefik -f deploy/traefik/docker-compose.yml up -d --build + echo + echo "Traefik + cloudflared are up." + echo "Expected wildcard DNS (CNAME): *.${OPENCLAW_WILDCARD_DOMAIN} -> .cfargotunnel.com" + echo "Run scripts/cf-dns-create-wildcard.sh to create the DNS record via Cloudflare API." +else + mkdir -p /opt/traefik + touch /opt/traefik/acme.json + chmod 600 /opt/traefik/acme.json -echo -echo "Traefik is up." -echo "Expected wildcard DNS (A record): *.${OPENCLAW_WILDCARD_DOMAIN} -> " + # Ensure the network is always named `traefik_default` (matches instance labels). + docker compose -p traefik -f deploy/traefik/docker-compose.yml up -d --build + + echo + echo "Traefik is up." + echo "Expected wildcard DNS (A record): *.${OPENCLAW_WILDCARD_DOMAIN} -> " +fi diff --git a/scripts/smoke-provision-script.ts b/scripts/smoke-provision-script.ts index f079288..f412814 100644 --- a/scripts/smoke-provision-script.ts +++ b/scripts/smoke-provision-script.ts @@ -1,6 +1,7 @@ import { buildProvisionScript } from '../src/index'; -const script = buildProvisionScript({ +// --- Test 1: Traefik mode (existing behavior) --- +const traefikScript = buildProvisionScript({ traefikCompose: { acmeEmail: 'you@example.com', wildcardDomain: 'h1.openclaw.example.com', @@ -16,9 +17,48 @@ const script = buildProvisionScript({ composePath: '/opt/traefik/docker-compose.yml', }); -if (!script.includes('docker compose')) { - throw new Error('Expected docker compose commands in provision script'); +if (!traefikScript.includes('docker compose')) { + throw new Error('[traefik] Expected docker compose commands in provision script'); } +if (!traefikScript.includes('acme.json')) { + throw new Error('[traefik] Expected acme.json setup in traefik mode'); +} +if (!traefikScript.includes('certificatesresolvers')) { + throw new Error('[traefik] Expected certificatesresolvers in traefik mode'); +} + +console.log('OK: traefik mode'); + +// --- Test 2: Cloudflare Tunnel mode --- +const cfScript = buildProvisionScript({ + deployMode: 'cloudflare-tunnel', + cloudflareTunnelCompose: { + tunnelToken: 'test-tunnel-token', + wildcardDomain: 'h1.openclaw.example.com', + ttydSecret: 'test-secret', + ttydTtlSeconds: 86400, + traefikImage: 'traefik:v3.1', + cloudflaredImage: 'cloudflare/cloudflared:latest', + enableDashboard: false, + }, + openclawRuntimeImage: 'openclaw-ttyd:local', + composePath: '/opt/traefik/docker-compose.yml', +}); -console.log('OK'); +if (!cfScript.includes('docker compose')) { + throw new Error('[cloudflare-tunnel] Expected docker compose commands in provision script'); +} +if (!cfScript.includes('cloudflared')) { + throw new Error('[cloudflare-tunnel] Expected cloudflared in CF tunnel mode'); +} +if (!cfScript.includes('TUNNEL_TOKEN')) { + throw new Error('[cloudflare-tunnel] Expected TUNNEL_TOKEN in CF tunnel mode'); +} +if (cfScript.includes('acme')) { + throw new Error('[cloudflare-tunnel] Should NOT contain acme in CF tunnel mode'); +} +if (cfScript.includes('vercel')) { + throw new Error('[cloudflare-tunnel] Should NOT contain vercel in CF tunnel mode'); +} +console.log('OK: cloudflare-tunnel mode'); diff --git a/src/core/cloudflareDns.ts b/src/core/cloudflareDns.ts new file mode 100644 index 0000000..2c541ff --- /dev/null +++ b/src/core/cloudflareDns.ts @@ -0,0 +1,85 @@ +const CLOUDFLARE_API_BASE = 'https://api.cloudflare.com/client/v4'; + +export interface CloudflareApiConfig { + apiToken: string; + zoneId: string; +} + +export interface CloudflareApiRequest { + url: string; + method: string; + headers: Record; + body?: string; +} + +/** Returns "{tunnelId}.cfargotunnel.com" */ +export function buildTunnelCname(tunnelId: string): string { + return `${tunnelId}.cfargotunnel.com`; +} + +/** POST to /zones/{zoneId}/dns_records with CNAME payload */ +export function buildCreateDnsRecordRequest( + config: CloudflareApiConfig, + record: { name: string; target: string; proxied?: boolean; ttl?: number } +): CloudflareApiRequest { + return { + url: `${CLOUDFLARE_API_BASE}/zones/${config.zoneId}/dns_records`, + method: 'POST', + headers: { + 'Authorization': `Bearer ${config.apiToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + type: 'CNAME', + name: record.name, + content: record.target, + proxied: record.proxied ?? true, + ttl: record.ttl ?? 1, + }), + }; +} + +/** Convenience: creates "*.{wildcardDomain}" CNAME pointing to tunnel */ +export function buildCreateWildcardDnsRecordRequest( + config: CloudflareApiConfig, + wildcardDomain: string, + tunnelCname: string +): CloudflareApiRequest { + return buildCreateDnsRecordRequest(config, { + name: `*.${wildcardDomain}`, + target: tunnelCname, + proxied: true, + }); +} + +/** GET /zones/{zoneId}/dns_records with optional name filter */ +export function buildListDnsRecordsRequest( + config: CloudflareApiConfig, + nameFilter?: string +): CloudflareApiRequest { + let url = `${CLOUDFLARE_API_BASE}/zones/${config.zoneId}/dns_records`; + if (nameFilter) { + url += `?name=${nameFilter}`; + } + return { + url, + method: 'GET', + headers: { + 'Authorization': `Bearer ${config.apiToken}`, + }, + }; +} + +/** DELETE /zones/{zoneId}/dns_records/{recordId} */ +export function buildDeleteDnsRecordRequest( + config: CloudflareApiConfig, + recordId: string +): CloudflareApiRequest { + return { + url: `${CLOUDFLARE_API_BASE}/zones/${config.zoneId}/dns_records/${recordId}`, + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${config.apiToken}`, + }, + }; +} diff --git a/src/core/cloudflared.ts b/src/core/cloudflared.ts new file mode 100644 index 0000000..4c0544a --- /dev/null +++ b/src/core/cloudflared.ts @@ -0,0 +1,61 @@ +export interface CloudflareTunnelComposeInput { + tunnelToken: string; + wildcardDomain: string; + ttydSecret: string; + ttydTtlSeconds?: number; + traefikImage?: string; + cloudflaredImage?: string; + enableDashboard?: boolean; +} + +export function buildCloudflareTunnelComposeYaml(input: CloudflareTunnelComposeInput): string { + const traefikImage = input.traefikImage || 'traefik:v3.1'; + const cloudflaredImage = input.cloudflaredImage || 'cloudflare/cloudflared:latest'; + const ttydTtlSeconds = input.ttydTtlSeconds ?? 86400; + + const enableDashboard = !!input.enableDashboard; + const dashboardCmd = enableDashboard ? ' - "--api.dashboard=true"' : ''; + const dashboardPort = enableDashboard ? ' - "8080:8080"' : ''; + + return [ + 'version: "3.9"', + 'services:', + ' traefik:', + ` image: ${traefikImage}`, + ' container_name: traefik', + ' restart: unless-stopped', + ' command:', + ' - "--providers.docker=true"', + ' - "--providers.docker.exposedbydefault=false"', + ' - "--entrypoints.web.address=:80"', + dashboardCmd, + ' ports:', + ' - "80:80"', + dashboardPort, + ' volumes:', + ' - /var/run/docker.sock:/var/run/docker.sock:ro', + '', + ' openclaw-forward-auth:', + ' build:', + ' context: ../../docker/forward-auth', + ' container_name: openclaw-forward-auth', + ' restart: unless-stopped', + ' environment:', + ` - OPENCLAW_TTYD_SECRET=${input.ttydSecret}`, + ` - OPENCLAW_TTYD_TTL_SECONDS=${ttydTtlSeconds}`, + ' labels:', + ' - "traefik.enable=false"', + '', + ' cloudflared:', + ` image: ${cloudflaredImage}`, + ' container_name: cloudflared', + ' restart: unless-stopped', + ' command: tunnel run', + ' environment:', + ` - TUNNEL_TOKEN=${input.tunnelToken}`, + '', + 'networks:', + ' default:', + ' name: traefik_default', + ].filter(Boolean).join('\n'); +} diff --git a/src/core/deployMode.ts b/src/core/deployMode.ts new file mode 100644 index 0000000..c7c5ed8 --- /dev/null +++ b/src/core/deployMode.ts @@ -0,0 +1 @@ +export type DeployMode = "traefik" | "cloudflare-tunnel"; diff --git a/src/core/dockerRun.ts b/src/core/dockerRun.ts index 54dddda..6879f93 100644 --- a/src/core/dockerRun.ts +++ b/src/core/dockerRun.ts @@ -1,4 +1,5 @@ import { buildInstanceUrls, InstanceUrls } from './urls'; +import { DeployMode } from './deployMode'; export interface DockerResourceLimits { cpuLimit?: string; @@ -16,6 +17,7 @@ export interface BuildDockerRunCommandInput extends DockerResourceLimits { terminalToken: string; authUrl: string; + deployMode?: DeployMode; dockerNetwork?: string; entrypoint?: string; certResolver?: string; @@ -51,8 +53,11 @@ const DEFAULTS = { } as const; export function buildDockerRunCommand(input: BuildDockerRunCommandInput): DockerRunResult { + const deployMode = input.deployMode || 'traefik'; + const isCloudflare = deployMode === 'cloudflare-tunnel'; + const dockerNetwork = input.dockerNetwork || DEFAULTS.dockerNetwork; - const entrypoint = input.entrypoint || DEFAULTS.entrypoint; + const entrypoint = input.entrypoint || (isCloudflare ? 'web' : DEFAULTS.entrypoint); const certResolver = input.certResolver || DEFAULTS.certResolver; const containerNamePrefix = input.containerNamePrefix || DEFAULTS.containerNamePrefix; const dataDirBase = input.dataDirBase || DEFAULTS.dataDirBase; @@ -76,25 +81,29 @@ export function buildDockerRunCommand(input: BuildDockerRunCommandInput): Docker const containerName = `${containerNamePrefix}${input.instanceId}`; const dataDir = `${dataDirBase}/${input.instanceId}`; - const labels = [ + const labels: string[] = [ 'traefik.enable=true', `traefik.docker.network=${dockerNetwork}`, `traefik.http.routers.${containerName}.rule=Host(\`${urls.hostName}\`)`, `traefik.http.routers.${containerName}.service=${containerName}`, `traefik.http.routers.${containerName}.entrypoints=${entrypoint}`, - `traefik.http.routers.${containerName}.tls=true`, - `traefik.http.routers.${containerName}.tls.certresolver=${certResolver}`, - `traefik.http.routers.${containerName}.tls.domains[0].main=${urls.wildcardDomain}`, - `traefik.http.routers.${containerName}.tls.domains[0].sans=*.${urls.wildcardDomain}`, + ...(!isCloudflare ? [ + `traefik.http.routers.${containerName}.tls=true`, + `traefik.http.routers.${containerName}.tls.certresolver=${certResolver}`, + `traefik.http.routers.${containerName}.tls.domains[0].main=${urls.wildcardDomain}`, + `traefik.http.routers.${containerName}.tls.domains[0].sans=*.${urls.wildcardDomain}`, + ] : []), `traefik.http.services.${containerName}.loadbalancer.server.port=${gatewayPort}`, `traefik.http.routers.${containerName}-terminal.rule=Host(\`${urls.hostName}\`) && PathPrefix(\`/terminal\`)`, `traefik.http.routers.${containerName}-terminal.service=${containerName}-terminal`, `traefik.http.routers.${containerName}-terminal.priority=100`, `traefik.http.routers.${containerName}-terminal.entrypoints=${entrypoint}`, - `traefik.http.routers.${containerName}-terminal.tls=true`, - `traefik.http.routers.${containerName}-terminal.tls.certresolver=${certResolver}`, - `traefik.http.routers.${containerName}-terminal.tls.domains[0].main=${urls.wildcardDomain}`, - `traefik.http.routers.${containerName}-terminal.tls.domains[0].sans=*.${urls.wildcardDomain}`, + ...(!isCloudflare ? [ + `traefik.http.routers.${containerName}-terminal.tls=true`, + `traefik.http.routers.${containerName}-terminal.tls.certresolver=${certResolver}`, + `traefik.http.routers.${containerName}-terminal.tls.domains[0].main=${urls.wildcardDomain}`, + `traefik.http.routers.${containerName}-terminal.tls.domains[0].sans=*.${urls.wildcardDomain}`, + ] : []), `traefik.http.middlewares.${containerName}-terminal-strip.stripprefix.prefixes=/terminal`, `traefik.http.middlewares.${containerName}-terminal-strip.stripprefix.forceSlash=true`, `traefik.http.middlewares.${containerName}-inject-id.headers.customrequestheaders.X-Openclaw-Instance-Id=${input.instanceId}`, diff --git a/src/core/provision.ts b/src/core/provision.ts index 64283be..227c777 100644 --- a/src/core/provision.ts +++ b/src/core/provision.ts @@ -1,18 +1,32 @@ -import { buildTraefikComposeYaml } from './traefik'; +import { buildTraefikComposeYaml, TraefikComposeInput } from './traefik'; +import { buildCloudflareTunnelComposeYaml, CloudflareTunnelComposeInput } from './cloudflared'; +import { DeployMode } from './deployMode'; -export interface ProvisionScriptInput { - traefikCompose: Parameters[0]; - openclawRuntimeImage?: string; - composePath?: string; -} +export type ProvisionScriptInput = + | { + deployMode?: 'traefik'; + traefikCompose: TraefikComposeInput; + openclawRuntimeImage?: string; + composePath?: string; + } + | { + deployMode: 'cloudflare-tunnel'; + cloudflareTunnelCompose: CloudflareTunnelComposeInput; + openclawRuntimeImage?: string; + composePath?: string; + }; export function buildProvisionScript(input: ProvisionScriptInput): string { + const deployMode: DeployMode = input.deployMode || 'traefik'; const composePath = input.composePath || '/opt/traefik/docker-compose.yml'; - const composeYaml = buildTraefikComposeYaml(input.traefikCompose); const sudoLiteral = '${SUDO}'; const dockerPull = input.openclawRuntimeImage ? `${sudoLiteral} docker pull ${input.openclawRuntimeImage}` : ''; - return [ + const composeYaml = deployMode === 'cloudflare-tunnel' + ? buildCloudflareTunnelComposeYaml((input as { cloudflareTunnelCompose: CloudflareTunnelComposeInput }).cloudflareTunnelCompose) + : buildTraefikComposeYaml((input as { traefikCompose: TraefikComposeInput }).traefikCompose); + + const commonSteps = [ 'set -euo pipefail', 'export DEBIAN_FRONTEND=noninteractive', 'if [ "$(id -u)" -ne 0 ]; then SUDO="sudo -n"; else SUDO=""; fi', @@ -30,13 +44,21 @@ export function buildProvisionScript(input: ProvisionScriptInput): string { 'JSON', ' ${SUDO} systemctl restart docker', 'fi', + ]; + + const acmeSteps = deployMode === 'traefik' ? [ '${SUDO} mkdir -p /opt/traefik', '${SUDO} touch /opt/traefik/acme.json', '${SUDO} chmod 600 /opt/traefik/acme.json', + ] : []; + + const composeSteps = [ `${sudoLiteral} tee ${composePath} >/dev/null <<'YAML'`, composeYaml, 'YAML', `${sudoLiteral} docker compose -f ${composePath} up -d`, dockerPull, - ].filter(Boolean).join('\n'); + ]; + + return [...commonSteps, ...acmeSteps, ...composeSteps].filter(Boolean).join('\n'); } diff --git a/src/core/traefik.ts b/src/core/traefik.ts index 394b635..64dcf31 100644 --- a/src/core/traefik.ts +++ b/src/core/traefik.ts @@ -51,3 +51,44 @@ export function buildTraefikComposeYaml(input: TraefikComposeInput): string { ' - /opt/traefik/acme.json:/acme.json', ].filter(Boolean).join('\n'); } + +export interface TraefikHttpComposeInput { + wildcardDomain: string; + enableDashboard?: boolean; + traefikImage?: string; + entrypointName?: string; + entrypointPort?: number; +} + +export function buildTraefikHttpComposeYaml(input: TraefikHttpComposeInput): string { + const traefikImage = input.traefikImage || 'traefik:v3.1'; + const entrypointName = input.entrypointName || 'web'; + const entrypointPort = input.entrypointPort ?? 80; + + const enableDashboard = !!input.enableDashboard; + const dashboardCmd = enableDashboard ? ' - "--api.dashboard=true"' : ''; + const dashboardPort = enableDashboard ? ' - "8080:8080"' : ''; + + return [ + 'version: "3.9"', + 'services:', + ' traefik:', + ` image: ${traefikImage}`, + ' container_name: traefik', + ' restart: unless-stopped', + ' command:', + ' - "--providers.docker=true"', + ' - "--providers.docker.exposedbydefault=false"', + ` - "--entrypoints.${entrypointName}.address=:${entrypointPort}"`, + dashboardCmd, + ' ports:', + ` - "${entrypointPort}:${entrypointPort}"`, + dashboardPort, + ' volumes:', + ' - /var/run/docker.sock:/var/run/docker.sock:ro', + '', + 'networks:', + ' default:', + ' name: traefik_default', + ].filter(Boolean).join('\n'); +} diff --git a/src/index.ts b/src/index.ts index 345b798..008cd85 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export type { DeployMode } from './core/deployMode'; export { computeCapacity } from './core/capacity'; export { buildDockerRunCommand } from './core/dockerRun'; export type { BuildDockerRunCommandInput, DockerRunResult, DockerResourceLimits } from './core/dockerRun'; @@ -5,8 +6,17 @@ export { buildInstanceUrls } from './core/urls'; export type { BuildInstanceUrlsInput, InstanceUrls } from './core/urls'; export { buildProvisionScript } from './core/provision'; export type { ProvisionScriptInput } from './core/provision'; -export { buildTraefikComposeYaml } from './core/traefik'; -export type { TraefikComposeInput } from './core/traefik'; +export { buildTraefikComposeYaml, buildTraefikHttpComposeYaml } from './core/traefik'; +export type { TraefikComposeInput, TraefikHttpComposeInput } from './core/traefik'; +export { buildCloudflareTunnelComposeYaml } from './core/cloudflared'; +export type { CloudflareTunnelComposeInput } from './core/cloudflared'; +export { + buildTunnelCname, + buildCreateDnsRecordRequest, + buildCreateWildcardDnsRecordRequest, + buildListDnsRecordsRequest, + buildDeleteDnsRecordRequest, +} from './core/cloudflareDns'; +export type { CloudflareApiConfig, CloudflareApiRequest } from './core/cloudflareDns'; export { generateTerminalToken, validateTerminalToken } from './core/terminalToken'; export type { TerminalTokenOptions } from './core/terminalToken'; - From caf1247ce9bbe275841c515a3f04133071ec06c3 Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Tue, 10 Feb 2026 10:23:37 -0800 Subject: [PATCH 02/22] update readme, fix image pull, fix env file load --- README.md | 16 +++++++--------- deploy/cloudflare-tunnel/docker-compose.yml | 6 +----- deploy/traefik/docker-compose.yml | 2 -- scripts/create-instance.sh | 4 ++++ scripts/provision-host.sh | 4 ++-- scripts/smoke-provision-script.ts | 4 ++-- src/core/cloudflared.ts | 5 +---- src/core/traefik.ts | 2 -- 8 files changed, 17 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index cb9dc96..86775e6 100644 --- a/README.md +++ b/README.md @@ -125,13 +125,11 @@ No public IP or open ports needed. Cloudflare handles TLS and routes traffic thr #### 1. Create a Cloudflare Tunnel -1. Go to [Zero Trust](https://one.dash.cloudflare.com/) → **Networks** → **Tunnels** -2. Click **Create a tunnel** → choose **Cloudflared** as the connector +1. Go to [Zero Trust](https://one.dash.cloudflare.com/) → **Networks** → **Connectors** +2. Click **Create a connector** → choose **Cloudflared** 3. Give it a name (e.g. `openclaw-h1`) -4. On the Install Connector page, copy the **tunnel token** (the long string after `--token`). You don't need to install the connector on your machine — the Docker setup handles that. -5. Click **Next** to skip to the Public Hostname tab, but don't add a hostname yet — we'll do that after provisioning - -Note the **Tunnel ID** shown on the tunnel overview page (a UUID like `abcd1234-...`). +4. On the Install Connector page, copy the install command shown. Extract the **tunnel token** — the long `eyJ...` string after `--token`. You don't need to install the connector on your machine — the Docker setup handles that. +5. You can now navigate away from this page — no public hostname route is required yet (we'll add one after provisioning). Go back to **Networks** → **Connectors** and note the **Tunnel ID** (a UUID like `abcd1234-...`) listed next to your connector. #### 2. Get your Zone ID and API token @@ -140,7 +138,7 @@ Note the **Tunnel ID** shown on the tunnel overview page (a UUID like `abcd1234- - The **Zone ID** is on the right sidebar of the Overview page **API Token:** -- Go to **My Profile** → [**API Tokens**](https://dash.cloudflare.com/profile/api-tokens) → **Create Token** +- Go to **Account Home** → **Manage account** → **Account API Tokens** → **Create Token** - Use the **Edit zone DNS** template - Scope it to your specific domain under **Zone Resources** - Create the token and copy it @@ -179,8 +177,8 @@ Create the wildcard DNS record (CNAME pointing to your tunnel): ``` Then add the public hostname in Cloudflare: -1. Go to [Zero Trust](https://one.dash.cloudflare.com/) → **Networks** → **Tunnels** → click your tunnel → **Edit** -2. Go to the **Public Hostname** tab → **Add a public hostname** +1. Go to [Zero Trust](https://one.dash.cloudflare.com/) → **Networks** → **Connectors** → click your connector → **Edit** +2. Go to the **Public Application Routes** tab → **Add a public hostname** 3. Fill in: - **Subdomain:** `*.h1.openclaw` (adjust to match your shard and subdomain) - **Domain:** `example.com` (your base domain) diff --git a/deploy/cloudflare-tunnel/docker-compose.yml b/deploy/cloudflare-tunnel/docker-compose.yml index 75b3952..68a21e2 100644 --- a/deploy/cloudflare-tunnel/docker-compose.yml +++ b/deploy/cloudflare-tunnel/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.9" - services: traefik: image: traefik:v3.1 @@ -29,9 +27,7 @@ services: image: cloudflare/cloudflared:latest container_name: cloudflared restart: unless-stopped - command: tunnel run - environment: - - TUNNEL_TOKEN=${OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN} + command: tunnel --no-autoupdate run --token ${OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN} networks: default: diff --git a/deploy/traefik/docker-compose.yml b/deploy/traefik/docker-compose.yml index 87a613e..ca13efb 100644 --- a/deploy/traefik/docker-compose.yml +++ b/deploy/traefik/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.9" - services: traefik: image: traefik:v3.1 diff --git a/scripts/create-instance.sh b/scripts/create-instance.sh index 38502be..9596f63 100755 --- a/scripts/create-instance.sh +++ b/scripts/create-instance.sh @@ -43,6 +43,10 @@ mkdir -p "${DATA_DIR}" chown 1000:1000 "${DATA_DIR}" docker pull "${OPENCLAW_RUNTIME_IMAGE}" >/dev/null 2>&1 || true +if ! docker image inspect "${OPENCLAW_RUNTIME_IMAGE}" >/dev/null 2>&1; then + echo "Image ${OPENCLAW_RUNTIME_IMAGE} not found; building from docker/openclaw-ttyd …" + docker build -t "${OPENCLAW_RUNTIME_IMAGE}" docker/openclaw-ttyd +fi # --- Select entrypoint and TLS labels based on deploy mode --- if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then diff --git a/scripts/provision-host.sh b/scripts/provision-host.sh index 3752061..09ecc94 100755 --- a/scripts/provision-host.sh +++ b/scripts/provision-host.sh @@ -49,7 +49,7 @@ fi # --- Mode-specific setup --- if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then # Ensure the network is always named `traefik_default` (matches instance labels). - docker compose -p traefik -f deploy/cloudflare-tunnel/docker-compose.yml up -d --build + docker compose -p traefik --env-file .env -f deploy/cloudflare-tunnel/docker-compose.yml up -d --build echo echo "Traefik + cloudflared are up." @@ -61,7 +61,7 @@ else chmod 600 /opt/traefik/acme.json # Ensure the network is always named `traefik_default` (matches instance labels). - docker compose -p traefik -f deploy/traefik/docker-compose.yml up -d --build + docker compose -p traefik --env-file .env -f deploy/traefik/docker-compose.yml up -d --build echo echo "Traefik is up." diff --git a/scripts/smoke-provision-script.ts b/scripts/smoke-provision-script.ts index f412814..018ef70 100644 --- a/scripts/smoke-provision-script.ts +++ b/scripts/smoke-provision-script.ts @@ -51,8 +51,8 @@ if (!cfScript.includes('docker compose')) { if (!cfScript.includes('cloudflared')) { throw new Error('[cloudflare-tunnel] Expected cloudflared in CF tunnel mode'); } -if (!cfScript.includes('TUNNEL_TOKEN')) { - throw new Error('[cloudflare-tunnel] Expected TUNNEL_TOKEN in CF tunnel mode'); +if (!cfScript.includes('--token test-tunnel-token')) { + throw new Error('[cloudflare-tunnel] Expected --token flag in CF tunnel mode'); } if (cfScript.includes('acme')) { throw new Error('[cloudflare-tunnel] Should NOT contain acme in CF tunnel mode'); diff --git a/src/core/cloudflared.ts b/src/core/cloudflared.ts index 4c0544a..17dd1fa 100644 --- a/src/core/cloudflared.ts +++ b/src/core/cloudflared.ts @@ -18,7 +18,6 @@ export function buildCloudflareTunnelComposeYaml(input: CloudflareTunnelComposeI const dashboardPort = enableDashboard ? ' - "8080:8080"' : ''; return [ - 'version: "3.9"', 'services:', ' traefik:', ` image: ${traefikImage}`, @@ -50,9 +49,7 @@ export function buildCloudflareTunnelComposeYaml(input: CloudflareTunnelComposeI ` image: ${cloudflaredImage}`, ' container_name: cloudflared', ' restart: unless-stopped', - ' command: tunnel run', - ' environment:', - ` - TUNNEL_TOKEN=${input.tunnelToken}`, + ` command: tunnel --no-autoupdate run --token ${input.tunnelToken}`, '', 'networks:', ' default:', diff --git a/src/core/traefik.ts b/src/core/traefik.ts index 64dcf31..5d3ff05 100644 --- a/src/core/traefik.ts +++ b/src/core/traefik.ts @@ -21,7 +21,6 @@ export function buildTraefikComposeYaml(input: TraefikComposeInput): string { const dashboardPort = enableDashboard ? ' - "8080:8080"' : ''; return [ - 'version: "3.9"', 'services:', ' traefik:', ` image: ${traefikImage}`, @@ -70,7 +69,6 @@ export function buildTraefikHttpComposeYaml(input: TraefikHttpComposeInput): str const dashboardPort = enableDashboard ? ' - "8080:8080"' : ''; return [ - 'version: "3.9"', 'services:', ' traefik:', ` image: ${traefikImage}`, From b0e9a11b6ee570a9a23f4512c92c4accf26ca186 Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Tue, 10 Feb 2026 10:45:12 -0800 Subject: [PATCH 03/22] fix docker version mismatch --- deploy/cloudflare-tunnel/docker-compose.yml | 2 ++ deploy/traefik/docker-compose.yml | 1 + scripts/local-frontdoor-up.sh | 1 + src/core/cloudflared.ts | 2 ++ src/core/traefik.ts | 3 +++ 5 files changed, 9 insertions(+) diff --git a/deploy/cloudflare-tunnel/docker-compose.yml b/deploy/cloudflare-tunnel/docker-compose.yml index 68a21e2..a6273b1 100644 --- a/deploy/cloudflare-tunnel/docker-compose.yml +++ b/deploy/cloudflare-tunnel/docker-compose.yml @@ -7,6 +7,8 @@ services: - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--entrypoints.web.address=:80" + environment: + - DOCKER_API_VERSION=1.45 ports: - "80:80" volumes: diff --git a/deploy/traefik/docker-compose.yml b/deploy/traefik/docker-compose.yml index ca13efb..2613479 100644 --- a/deploy/traefik/docker-compose.yml +++ b/deploy/traefik/docker-compose.yml @@ -16,6 +16,7 @@ services: - "--entrypoints.websecure.http.tls.domains[0].main=${OPENCLAW_WILDCARD_DOMAIN}" - "--entrypoints.websecure.http.tls.domains[0].sans=*.${OPENCLAW_WILDCARD_DOMAIN}" environment: + - DOCKER_API_VERSION=1.45 - VERCEL_API_TOKEN=${OPENCLAW_VERCEL_API_TOKEN} - VERCEL_TEAM_ID=${OPENCLAW_VERCEL_TEAM_ID} ports: diff --git a/scripts/local-frontdoor-up.sh b/scripts/local-frontdoor-up.sh index 5d345b0..77754ba 100755 --- a/scripts/local-frontdoor-up.sh +++ b/scripts/local-frontdoor-up.sh @@ -23,6 +23,7 @@ docker run -d \ --network "${NETWORK}" \ -p "${PORT}:8080" \ -v /var/run/docker.sock:/var/run/docker.sock:ro \ + -e DOCKER_API_VERSION=1.45 \ traefik:v3.1 \ --providers.docker=true \ --providers.docker.exposedbydefault=false \ diff --git a/src/core/cloudflared.ts b/src/core/cloudflared.ts index 17dd1fa..9386891 100644 --- a/src/core/cloudflared.ts +++ b/src/core/cloudflared.ts @@ -28,6 +28,8 @@ export function buildCloudflareTunnelComposeYaml(input: CloudflareTunnelComposeI ' - "--providers.docker.exposedbydefault=false"', ' - "--entrypoints.web.address=:80"', dashboardCmd, + ' environment:', + ' - DOCKER_API_VERSION=1.45', ' ports:', ' - "80:80"', dashboardPort, diff --git a/src/core/traefik.ts b/src/core/traefik.ts index 5d3ff05..4d9bd5e 100644 --- a/src/core/traefik.ts +++ b/src/core/traefik.ts @@ -40,6 +40,7 @@ export function buildTraefikComposeYaml(input: TraefikComposeInput): string { ` - "--entrypoints.${entrypointName}.http.tls.domains[0].sans=*.${input.wildcardDomain}"`, dashboardCmd, ' environment:', + ' - DOCKER_API_VERSION=1.45', ` - VERCEL_API_TOKEN=${input.vercelApiToken}`, input.vercelTeamId ? ` - VERCEL_TEAM_ID=${input.vercelTeamId}` : '', ' ports:', @@ -79,6 +80,8 @@ export function buildTraefikHttpComposeYaml(input: TraefikHttpComposeInput): str ' - "--providers.docker.exposedbydefault=false"', ` - "--entrypoints.${entrypointName}.address=:${entrypointPort}"`, dashboardCmd, + ' environment:', + ' - DOCKER_API_VERSION=1.45', ' ports:', ` - "${entrypointPort}:${entrypointPort}"`, dashboardPort, From 3e7e1d68172e8c860d6e1e4c2444d8daafd34ac8 Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Tue, 10 Feb 2026 10:51:44 -0800 Subject: [PATCH 04/22] update example env --- .env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.example b/.env.example index 19c73c8..2e4e481 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,9 @@ OPENCLAW_TTYD_SECRET=replace_me_with_a_long_random_string # Optional (seconds) OPENCLAW_TTYD_TTL_SECONDS=86400 +# Docker API version (Traefik ↔ Docker Engine compatibility) +DOCKER_API_VERSION=1.45 + # Runtime image to run for instances (build locally or use your own) OPENCLAW_RUNTIME_IMAGE=openclaw-ttyd:local From 49bc03d756e857631d932bd961ef04815854de2d Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Tue, 10 Feb 2026 11:06:29 -0800 Subject: [PATCH 05/22] fix actual issue in ts file, revert env variable --- .env.example | 3 --- deploy/cloudflare-tunnel/docker-compose.yml | 2 -- deploy/traefik/docker-compose.yml | 1 - scripts/local-frontdoor-up.sh | 1 - scripts/provision-host.sh | 11 +++++++++++ src/core/cloudflared.ts | 2 -- src/core/traefik.ts | 3 --- 7 files changed, 11 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 2e4e481..19c73c8 100644 --- a/.env.example +++ b/.env.example @@ -14,9 +14,6 @@ OPENCLAW_TTYD_SECRET=replace_me_with_a_long_random_string # Optional (seconds) OPENCLAW_TTYD_TTL_SECONDS=86400 -# Docker API version (Traefik ↔ Docker Engine compatibility) -DOCKER_API_VERSION=1.45 - # Runtime image to run for instances (build locally or use your own) OPENCLAW_RUNTIME_IMAGE=openclaw-ttyd:local diff --git a/deploy/cloudflare-tunnel/docker-compose.yml b/deploy/cloudflare-tunnel/docker-compose.yml index a6273b1..68a21e2 100644 --- a/deploy/cloudflare-tunnel/docker-compose.yml +++ b/deploy/cloudflare-tunnel/docker-compose.yml @@ -7,8 +7,6 @@ services: - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--entrypoints.web.address=:80" - environment: - - DOCKER_API_VERSION=1.45 ports: - "80:80" volumes: diff --git a/deploy/traefik/docker-compose.yml b/deploy/traefik/docker-compose.yml index 2613479..ca13efb 100644 --- a/deploy/traefik/docker-compose.yml +++ b/deploy/traefik/docker-compose.yml @@ -16,7 +16,6 @@ services: - "--entrypoints.websecure.http.tls.domains[0].main=${OPENCLAW_WILDCARD_DOMAIN}" - "--entrypoints.websecure.http.tls.domains[0].sans=*.${OPENCLAW_WILDCARD_DOMAIN}" environment: - - DOCKER_API_VERSION=1.45 - VERCEL_API_TOKEN=${OPENCLAW_VERCEL_API_TOKEN} - VERCEL_TEAM_ID=${OPENCLAW_VERCEL_TEAM_ID} ports: diff --git a/scripts/local-frontdoor-up.sh b/scripts/local-frontdoor-up.sh index 77754ba..5d345b0 100755 --- a/scripts/local-frontdoor-up.sh +++ b/scripts/local-frontdoor-up.sh @@ -23,7 +23,6 @@ docker run -d \ --network "${NETWORK}" \ -p "${PORT}:8080" \ -v /var/run/docker.sock:/var/run/docker.sock:ro \ - -e DOCKER_API_VERSION=1.45 \ traefik:v3.1 \ --providers.docker=true \ --providers.docker.exposedbydefault=false \ diff --git a/scripts/provision-host.sh b/scripts/provision-host.sh index 09ecc94..ffd911f 100755 --- a/scripts/provision-host.sh +++ b/scripts/provision-host.sh @@ -42,6 +42,17 @@ fi systemctl enable --now docker +# Ensure the Docker daemon accepts API v1.24+ (Traefik v3.x hardcodes v1.24) +DAEMON_JSON="/etc/docker/daemon.json" +if [[ ! -s "${DAEMON_JSON}" ]] || [[ "$(tr -d ' \n\t' < "${DAEMON_JSON}" 2>/dev/null || echo '')" == "{}" ]]; then + tee "${DAEMON_JSON}" >/dev/null <<'JSON' +{ + "min-api-version": "1.24" +} +JSON + systemctl restart docker +fi + if ! docker compose version >/dev/null 2>&1; then apt-get install -y docker-compose-plugin fi diff --git a/src/core/cloudflared.ts b/src/core/cloudflared.ts index 9386891..17dd1fa 100644 --- a/src/core/cloudflared.ts +++ b/src/core/cloudflared.ts @@ -28,8 +28,6 @@ export function buildCloudflareTunnelComposeYaml(input: CloudflareTunnelComposeI ' - "--providers.docker.exposedbydefault=false"', ' - "--entrypoints.web.address=:80"', dashboardCmd, - ' environment:', - ' - DOCKER_API_VERSION=1.45', ' ports:', ' - "80:80"', dashboardPort, diff --git a/src/core/traefik.ts b/src/core/traefik.ts index 4d9bd5e..5d3ff05 100644 --- a/src/core/traefik.ts +++ b/src/core/traefik.ts @@ -40,7 +40,6 @@ export function buildTraefikComposeYaml(input: TraefikComposeInput): string { ` - "--entrypoints.${entrypointName}.http.tls.domains[0].sans=*.${input.wildcardDomain}"`, dashboardCmd, ' environment:', - ' - DOCKER_API_VERSION=1.45', ` - VERCEL_API_TOKEN=${input.vercelApiToken}`, input.vercelTeamId ? ` - VERCEL_TEAM_ID=${input.vercelTeamId}` : '', ' ports:', @@ -80,8 +79,6 @@ export function buildTraefikHttpComposeYaml(input: TraefikHttpComposeInput): str ' - "--providers.docker.exposedbydefault=false"', ` - "--entrypoints.${entrypointName}.address=:${entrypointPort}"`, dashboardCmd, - ' environment:', - ' - DOCKER_API_VERSION=1.45', ' ports:', ` - "${entrypointPort}:${entrypointPort}"`, dashboardPort, From 6489cca9fe58d0caf3ebb55b5c59e80a17e94693 Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Tue, 10 Feb 2026 11:08:17 -0800 Subject: [PATCH 06/22] fix env not loading --- scripts/terminal-token.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/terminal-token.sh b/scripts/terminal-token.sh index 419dee9..8e1f08b 100755 --- a/scripts/terminal-token.sh +++ b/scripts/terminal-token.sh @@ -3,6 +3,11 @@ set -euo pipefail cd "$(dirname "$0")/.." +if [ -f .env ]; then + # shellcheck disable=SC1091 + source .env +fi + INSTANCE_ID="${1:-}" if [ -z "${INSTANCE_ID}" ]; then echo "Usage: $0 " >&2 From a7d648e4429e13cb5fff6d143c533c384ca3796e Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Tue, 10 Feb 2026 12:00:57 -0800 Subject: [PATCH 07/22] feat: make hostShard optional in buildInstanceUrls for flat URL support Co-Authored-By: Claude Opus 4.6 --- src/core/urls.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/core/urls.ts b/src/core/urls.ts index 0563c6b..d167af9 100644 --- a/src/core/urls.ts +++ b/src/core/urls.ts @@ -1,9 +1,9 @@ export interface BuildInstanceUrlsInput { instanceId: string; - hostShard: string; baseDomain: string; - subdomain?: string; terminalToken: string; + hostShard?: string; + subdomain?: string; instancePrefix?: string; } @@ -15,10 +15,11 @@ export interface InstanceUrls { } export function buildInstanceUrls(input: BuildInstanceUrlsInput): InstanceUrls { - const subdomain = input.subdomain || 'openclaw'; const instancePrefix = input.instancePrefix || 'openclaw-'; - const wildcardDomain = `${input.hostShard}.${subdomain}.${input.baseDomain}`; + const wildcardDomain = input.hostShard + ? `${input.hostShard}.${input.subdomain || 'openclaw'}.${input.baseDomain}` + : input.baseDomain; const hostName = `${instancePrefix}${input.instanceId}.${wildcardDomain}`; return { From bdf7781cf96151a0695704de3281166fdbc309e9 Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Tue, 10 Feb 2026 12:02:21 -0800 Subject: [PATCH 08/22] feat: dockerRun only passes hostShard/subdomain in traefik mode Co-Authored-By: Claude Opus 4.6 --- src/core/dockerRun.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/core/dockerRun.ts b/src/core/dockerRun.ts index 6879f93..e8fb45f 100644 --- a/src/core/dockerRun.ts +++ b/src/core/dockerRun.ts @@ -11,7 +11,7 @@ export interface DockerResourceLimits { export interface BuildDockerRunCommandInput extends DockerResourceLimits { instanceId: string; runtimeImage: string; - hostShard: string; + hostShard?: string; baseDomain: string; subdomain?: string; terminalToken: string; @@ -71,11 +71,13 @@ export function buildDockerRunCommand(input: BuildDockerRunCommandInput): Docker const urls: InstanceUrls = buildInstanceUrls({ instanceId: input.instanceId, - hostShard: input.hostShard, baseDomain: input.baseDomain, - subdomain: input.subdomain, terminalToken: input.terminalToken, instancePrefix: containerNamePrefix, + ...(isCloudflare ? {} : { + hostShard: input.hostShard, + subdomain: input.subdomain, + }), }); const containerName = `${containerNamePrefix}${input.instanceId}`; From 22739483e93857aa852b68c757dd339658b55716 Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Tue, 10 Feb 2026 12:03:19 -0800 Subject: [PATCH 09/22] chore: remove unused wildcardDomain from CloudflareTunnelComposeInput Co-Authored-By: Claude Opus 4.6 --- src/core/cloudflared.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/cloudflared.ts b/src/core/cloudflared.ts index 17dd1fa..e922d03 100644 --- a/src/core/cloudflared.ts +++ b/src/core/cloudflared.ts @@ -1,6 +1,5 @@ export interface CloudflareTunnelComposeInput { tunnelToken: string; - wildcardDomain: string; ttydSecret: string; ttydTtlSeconds?: number; traefikImage?: string; From a309479b1021d9538427f7530ce3e5f32565cb79 Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Tue, 10 Feb 2026 12:04:49 -0800 Subject: [PATCH 10/22] feat: shell scripts construct URLs based on deploy mode Co-Authored-By: Claude Opus 4.6 --- scripts/cf-dns-create-wildcard.sh | 5 +---- scripts/create-instance.sh | 15 ++++++++++----- scripts/dashboard-url.sh | 11 ++++++++--- scripts/provision-host.sh | 10 +++++++--- scripts/terminal-url.sh | 11 ++++++++--- 5 files changed, 34 insertions(+), 18 deletions(-) diff --git a/scripts/cf-dns-create-wildcard.sh b/scripts/cf-dns-create-wildcard.sh index b13858a..9055dd8 100755 --- a/scripts/cf-dns-create-wildcard.sh +++ b/scripts/cf-dns-create-wildcard.sh @@ -12,11 +12,8 @@ fi : "${OPENCLAW_CLOUDFLARE_ZONE_ID:?Missing OPENCLAW_CLOUDFLARE_ZONE_ID}" : "${OPENCLAW_CLOUDFLARE_TUNNEL_ID:?Missing OPENCLAW_CLOUDFLARE_TUNNEL_ID}" : "${OPENCLAW_BASE_DOMAIN:?Missing OPENCLAW_BASE_DOMAIN}" -: "${OPENCLAW_HOST_SHARD:?Missing OPENCLAW_HOST_SHARD}" -OPENCLAW_SUBDOMAIN="${OPENCLAW_SUBDOMAIN:-openclaw}" - -WILDCARD_NAME="*.${OPENCLAW_HOST_SHARD}.${OPENCLAW_SUBDOMAIN}.${OPENCLAW_BASE_DOMAIN}" +WILDCARD_NAME="*.${OPENCLAW_BASE_DOMAIN}" TUNNEL_TARGET="${OPENCLAW_CLOUDFLARE_TUNNEL_ID}.cfargotunnel.com" echo "Creating DNS CNAME record:" diff --git a/scripts/create-instance.sh b/scripts/create-instance.sh index 9596f63..942456a 100755 --- a/scripts/create-instance.sh +++ b/scripts/create-instance.sh @@ -20,13 +20,18 @@ if [ -f .env ]; then fi : "${OPENCLAW_BASE_DOMAIN:?Missing OPENCLAW_BASE_DOMAIN}" -: "${OPENCLAW_HOST_SHARD:?Missing OPENCLAW_HOST_SHARD}" OPENCLAW_DEPLOY_MODE="${OPENCLAW_DEPLOY_MODE:-traefik}" -OPENCLAW_SUBDOMAIN="${OPENCLAW_SUBDOMAIN:-openclaw}" -OPENCLAW_RUNTIME_IMAGE="${OPENCLAW_RUNTIME_IMAGE:-openclaw-ttyd:local}" -WILDCARD_DOMAIN="${OPENCLAW_HOST_SHARD}.${OPENCLAW_SUBDOMAIN}.${OPENCLAW_BASE_DOMAIN}" +if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then + WILDCARD_DOMAIN="${OPENCLAW_BASE_DOMAIN}" +else + : "${OPENCLAW_HOST_SHARD:?Missing OPENCLAW_HOST_SHARD}" + OPENCLAW_SUBDOMAIN="${OPENCLAW_SUBDOMAIN:-openclaw}" + WILDCARD_DOMAIN="${OPENCLAW_HOST_SHARD}.${OPENCLAW_SUBDOMAIN}.${OPENCLAW_BASE_DOMAIN}" +fi + +OPENCLAW_RUNTIME_IMAGE="${OPENCLAW_RUNTIME_IMAGE:-openclaw-ttyd:local}" HOSTNAME="openclaw-${INSTANCE_ID}.${WILDCARD_DOMAIN}" CONTAINER="openclaw-${INSTANCE_ID}" DATA_DIR="/var/lib/openclaw/instances/${INSTANCE_ID}" @@ -103,4 +108,4 @@ docker run -d \ echo echo "Instance created: ${INSTANCE_ID}" echo "Terminal URL:" -OPENCLAW_BASE_DOMAIN="${OPENCLAW_BASE_DOMAIN}" OPENCLAW_HOST_SHARD="${OPENCLAW_HOST_SHARD}" OPENCLAW_SUBDOMAIN="${OPENCLAW_SUBDOMAIN}" OPENCLAW_TTYD_SECRET="${OPENCLAW_TTYD_SECRET:-}" ./scripts/terminal-url.sh "${INSTANCE_ID}" || true +OPENCLAW_BASE_DOMAIN="${OPENCLAW_BASE_DOMAIN}" OPENCLAW_HOST_SHARD="${OPENCLAW_HOST_SHARD:-}" OPENCLAW_SUBDOMAIN="${OPENCLAW_SUBDOMAIN:-}" OPENCLAW_DEPLOY_MODE="${OPENCLAW_DEPLOY_MODE}" OPENCLAW_TTYD_SECRET="${OPENCLAW_TTYD_SECRET:-}" ./scripts/terminal-url.sh "${INSTANCE_ID}" || true diff --git a/scripts/dashboard-url.sh b/scripts/dashboard-url.sh index 7d0c725..8bff34d 100755 --- a/scripts/dashboard-url.sh +++ b/scripts/dashboard-url.sh @@ -15,10 +15,15 @@ if [ -f .env ]; then fi : "${OPENCLAW_BASE_DOMAIN:?Missing OPENCLAW_BASE_DOMAIN}" -: "${OPENCLAW_HOST_SHARD:?Missing OPENCLAW_HOST_SHARD}" -OPENCLAW_SUBDOMAIN="${OPENCLAW_SUBDOMAIN:-openclaw}" -WILDCARD_DOMAIN="${OPENCLAW_HOST_SHARD}.${OPENCLAW_SUBDOMAIN}.${OPENCLAW_BASE_DOMAIN}" +OPENCLAW_DEPLOY_MODE="${OPENCLAW_DEPLOY_MODE:-traefik}" +if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then + WILDCARD_DOMAIN="${OPENCLAW_BASE_DOMAIN}" +else + : "${OPENCLAW_HOST_SHARD:?Missing OPENCLAW_HOST_SHARD}" + OPENCLAW_SUBDOMAIN="${OPENCLAW_SUBDOMAIN:-openclaw}" + WILDCARD_DOMAIN="${OPENCLAW_HOST_SHARD}.${OPENCLAW_SUBDOMAIN}.${OPENCLAW_BASE_DOMAIN}" +fi CONTAINER="openclaw-${INSTANCE_ID}" GATEWAY_TOKEN="$(docker exec "${CONTAINER}" openclaw config get gateway.auth.token 2>/dev/null | tr -d '[:space:]\"')" diff --git a/scripts/provision-host.sh b/scripts/provision-host.sh index ffd911f..c82f407 100755 --- a/scripts/provision-host.sh +++ b/scripts/provision-host.sh @@ -17,11 +17,15 @@ OPENCLAW_DEPLOY_MODE="${OPENCLAW_DEPLOY_MODE:-traefik}" # --- Vars required in ALL modes --- : "${OPENCLAW_BASE_DOMAIN:?Missing OPENCLAW_BASE_DOMAIN}" -: "${OPENCLAW_HOST_SHARD:?Missing OPENCLAW_HOST_SHARD}" : "${OPENCLAW_TTYD_SECRET:?Missing OPENCLAW_TTYD_SECRET}" -OPENCLAW_SUBDOMAIN="${OPENCLAW_SUBDOMAIN:-openclaw}" -export OPENCLAW_WILDCARD_DOMAIN="${OPENCLAW_HOST_SHARD}.${OPENCLAW_SUBDOMAIN}.${OPENCLAW_BASE_DOMAIN}" +if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then + export OPENCLAW_WILDCARD_DOMAIN="${OPENCLAW_BASE_DOMAIN}" +else + : "${OPENCLAW_HOST_SHARD:?Missing OPENCLAW_HOST_SHARD}" + OPENCLAW_SUBDOMAIN="${OPENCLAW_SUBDOMAIN:-openclaw}" + export OPENCLAW_WILDCARD_DOMAIN="${OPENCLAW_HOST_SHARD}.${OPENCLAW_SUBDOMAIN}.${OPENCLAW_BASE_DOMAIN}" +fi # --- Mode-specific validation --- if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then diff --git a/scripts/terminal-url.sh b/scripts/terminal-url.sh index 5bb130b..51832b1 100755 --- a/scripts/terminal-url.sh +++ b/scripts/terminal-url.sh @@ -15,10 +15,15 @@ if [ -f .env ]; then fi : "${OPENCLAW_BASE_DOMAIN:?Missing OPENCLAW_BASE_DOMAIN}" -: "${OPENCLAW_HOST_SHARD:?Missing OPENCLAW_HOST_SHARD}" -OPENCLAW_SUBDOMAIN="${OPENCLAW_SUBDOMAIN:-openclaw}" -WILDCARD_DOMAIN="${OPENCLAW_HOST_SHARD}.${OPENCLAW_SUBDOMAIN}.${OPENCLAW_BASE_DOMAIN}" +OPENCLAW_DEPLOY_MODE="${OPENCLAW_DEPLOY_MODE:-traefik}" +if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then + WILDCARD_DOMAIN="${OPENCLAW_BASE_DOMAIN}" +else + : "${OPENCLAW_HOST_SHARD:?Missing OPENCLAW_HOST_SHARD}" + OPENCLAW_SUBDOMAIN="${OPENCLAW_SUBDOMAIN:-openclaw}" + WILDCARD_DOMAIN="${OPENCLAW_HOST_SHARD}.${OPENCLAW_SUBDOMAIN}.${OPENCLAW_BASE_DOMAIN}" +fi TOKEN="$(./scripts/terminal-token.sh "${INSTANCE_ID}")" From d479894ad5741c96d76510debcac812af595194d Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Tue, 10 Feb 2026 12:05:55 -0800 Subject: [PATCH 11/22] docs: reorganize .env.example by deploy mode Co-Authored-By: Claude Opus 4.6 --- .env.example | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index 19c73c8..99d6e41 100644 --- a/.env.example +++ b/.env.example @@ -1,29 +1,24 @@ # Domain setup OPENCLAW_BASE_DOMAIN=example.com -OPENCLAW_SUBDOMAIN=openclaw -OPENCLAW_HOST_SHARD=h1 - -# Traefik wildcard TLS (DNS-01 via Vercel) -OPENCLAW_ACME_EMAIL=you@example.com -OPENCLAW_VERCEL_API_TOKEN=replace_me -# Optional -OPENCLAW_VERCEL_TEAM_ID= - -# Terminal token auth (HMAC secret) -OPENCLAW_TTYD_SECRET=replace_me_with_a_long_random_string -# Optional (seconds) -OPENCLAW_TTYD_TTL_SECONDS=86400 - -# Runtime image to run for instances (build locally or use your own) -OPENCLAW_RUNTIME_IMAGE=openclaw-ttyd:local # ---- Deploy mode ---- # "traefik" (default) or "cloudflare-tunnel" OPENCLAW_DEPLOY_MODE=traefik -# ---- Cloudflare Tunnel (only when OPENCLAW_DEPLOY_MODE=cloudflare-tunnel) ---- +# ---- Traefik mode (OPENCLAW_DEPLOY_MODE=traefik) ---- +OPENCLAW_HOST_SHARD=h1 +# OPENCLAW_SUBDOMAIN=openclaw # optional, defaults to "openclaw" +OPENCLAW_ACME_EMAIL=you@example.com +OPENCLAW_VERCEL_API_TOKEN=replace_me +# OPENCLAW_VERCEL_TEAM_ID= + +# ---- Cloudflare Tunnel (OPENCLAW_DEPLOY_MODE=cloudflare-tunnel) ---- OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN= OPENCLAW_CLOUDFLARE_API_TOKEN= OPENCLAW_CLOUDFLARE_ZONE_ID= OPENCLAW_CLOUDFLARE_TUNNEL_ID= +# ---- Shared ---- +OPENCLAW_TTYD_SECRET=replace_me_with_a_long_random_string +# OPENCLAW_TTYD_TTL_SECONDS=86400 +OPENCLAW_RUNTIME_IMAGE=openclaw-ttyd:local From fb7df2708310d7c471d529ab2a1fb43ae4346aa4 Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Tue, 10 Feb 2026 12:06:40 -0800 Subject: [PATCH 12/22] docs: update README for deploy-mode-aware URLs Co-Authored-By: Claude Opus 4.6 --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 86775e6..f42e95c 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,6 @@ cp .env.example .env Edit `.env`: ```bash OPENCLAW_BASE_DOMAIN=example.com -OPENCLAW_HOST_SHARD=h1 OPENCLAW_TTYD_SECRET=some_long_random_string OPENCLAW_DEPLOY_MODE=cloudflare-tunnel @@ -180,7 +179,7 @@ Then add the public hostname in Cloudflare: 1. Go to [Zero Trust](https://one.dash.cloudflare.com/) → **Networks** → **Connectors** → click your connector → **Edit** 2. Go to the **Public Application Routes** tab → **Add a public hostname** 3. Fill in: - - **Subdomain:** `*.h1.openclaw` (adjust to match your shard and subdomain) + - **Subdomain:** `*` - **Domain:** `example.com` (your base domain) - **Type:** HTTP - **URL:** `traefik:80` @@ -204,12 +203,13 @@ sudo ./scripts/create-instance.sh charlie Get the URLs to send them: ```bash ./scripts/terminal-url.sh alice -# https://openclaw-alice.h1.openclaw.example.com/terminal?token=... - sudo ./scripts/dashboard-url.sh alice -# https://openclaw-alice.h1.openclaw.example.com/overview?token=... ``` +The URLs depend on your deploy mode: +- **Traefik:** `https://openclaw-alice.h1.openclaw.example.com/` +- **Cloudflare Tunnel:** `https://openclaw-alice.example.com/` + Send your friend their terminal URL. They open it in a browser and get a full web terminal. The dashboard URL gives them the OpenClaw control panel. --- From 58292689cdeeb1542fbd58da9b767e89e55bd7fc Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Tue, 10 Feb 2026 12:07:26 -0800 Subject: [PATCH 13/22] test: add URL construction smoke tests for both deploy modes Co-Authored-By: Claude Opus 4.6 --- scripts/smoke-provision-script.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/scripts/smoke-provision-script.ts b/scripts/smoke-provision-script.ts index 018ef70..36ffcc6 100644 --- a/scripts/smoke-provision-script.ts +++ b/scripts/smoke-provision-script.ts @@ -1,4 +1,4 @@ -import { buildProvisionScript } from '../src/index'; +import { buildProvisionScript, buildInstanceUrls } from '../src/index'; // --- Test 1: Traefik mode (existing behavior) --- const traefikScript = buildProvisionScript({ @@ -34,7 +34,6 @@ const cfScript = buildProvisionScript({ deployMode: 'cloudflare-tunnel', cloudflareTunnelCompose: { tunnelToken: 'test-tunnel-token', - wildcardDomain: 'h1.openclaw.example.com', ttydSecret: 'test-secret', ttydTtlSeconds: 86400, traefikImage: 'traefik:v3.1', @@ -62,3 +61,25 @@ if (cfScript.includes('vercel')) { } console.log('OK: cloudflare-tunnel mode'); + +// --- Test 3: URL construction — Traefik (multi-level) --- +const traefikUrls = buildInstanceUrls({ + instanceId: 'alice', baseDomain: 'example.com', + hostShard: 'h1', terminalToken: 'tok', +}); +if (traefikUrls.hostName !== 'openclaw-alice.h1.openclaw.example.com') { + throw new Error(`Expected multi-level URL, got: ${traefikUrls.hostName}`); +} + +console.log('OK: traefik URL construction'); + +// --- Test 4: URL construction — Cloudflare (flat) --- +const cfUrls = buildInstanceUrls({ + instanceId: 'alice', baseDomain: 'example.com', + terminalToken: 'tok', +}); +if (cfUrls.hostName !== 'openclaw-alice.example.com') { + throw new Error(`Expected flat URL, got: ${cfUrls.hostName}`); +} + +console.log('OK: cloudflare URL construction'); From cfa439db419ecd5eeabe3a09b31e4a3159beb47a Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Tue, 10 Feb 2026 12:38:42 -0800 Subject: [PATCH 14/22] add wildcard cname, since tunnel doesn't add wildcards --- scripts/provision-host.sh | 24 ++++++++++++++++++++++-- scripts/smoke-provision-script.ts | 12 ++++++++++++ src/core/provision.ts | 24 +++++++++++++++++++++++- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/scripts/provision-host.sh b/scripts/provision-host.sh index c82f407..94f0659 100755 --- a/scripts/provision-host.sh +++ b/scripts/provision-host.sh @@ -30,6 +30,9 @@ fi # --- Mode-specific validation --- if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then : "${OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN:?Missing OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN}" + : "${OPENCLAW_CLOUDFLARE_API_TOKEN:?Missing OPENCLAW_CLOUDFLARE_API_TOKEN}" + : "${OPENCLAW_CLOUDFLARE_ZONE_ID:?Missing OPENCLAW_CLOUDFLARE_ZONE_ID}" + : "${OPENCLAW_CLOUDFLARE_TUNNEL_ID:?Missing OPENCLAW_CLOUDFLARE_TUNNEL_ID}" else : "${OPENCLAW_ACME_EMAIL:?Missing OPENCLAW_ACME_EMAIL}" : "${OPENCLAW_VERCEL_API_TOKEN:?Missing OPENCLAW_VERCEL_API_TOKEN}" @@ -66,10 +69,27 @@ if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then # Ensure the network is always named `traefik_default` (matches instance labels). docker compose -p traefik --env-file .env -f deploy/cloudflare-tunnel/docker-compose.yml up -d --build + # --- Create wildcard DNS record pointing to the tunnel --- + WILDCARD_NAME="*.${OPENCLAW_BASE_DOMAIN}" + TUNNEL_TARGET="${OPENCLAW_CLOUDFLARE_TUNNEL_ID}.cfargotunnel.com" + echo echo "Traefik + cloudflared are up." - echo "Expected wildcard DNS (CNAME): *.${OPENCLAW_WILDCARD_DOMAIN} -> .cfargotunnel.com" - echo "Run scripts/cf-dns-create-wildcard.sh to create the DNS record via Cloudflare API." + echo "Creating wildcard DNS CNAME: ${WILDCARD_NAME} -> ${TUNNEL_TARGET}" + + RESPONSE=$(curl -sS -X POST \ + "https://api.cloudflare.com/client/v4/zones/${OPENCLAW_CLOUDFLARE_ZONE_ID}/dns_records" \ + -H "Authorization: Bearer ${OPENCLAW_CLOUDFLARE_API_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "{ + \"type\": \"CNAME\", + \"name\": \"${WILDCARD_NAME}\", + \"content\": \"${TUNNEL_TARGET}\", + \"proxied\": true, + \"ttl\": 1 + }") + + echo "${RESPONSE}" else mkdir -p /opt/traefik touch /opt/traefik/acme.json diff --git a/scripts/smoke-provision-script.ts b/scripts/smoke-provision-script.ts index 36ffcc6..013db04 100644 --- a/scripts/smoke-provision-script.ts +++ b/scripts/smoke-provision-script.ts @@ -40,6 +40,12 @@ const cfScript = buildProvisionScript({ cloudflaredImage: 'cloudflare/cloudflared:latest', enableDashboard: false, }, + cloudflareDns: { + apiToken: 'test-api-token', + zoneId: 'test-zone-id', + tunnelId: 'test-tunnel-id', + baseDomain: 'example.com', + }, openclawRuntimeImage: 'openclaw-ttyd:local', composePath: '/opt/traefik/docker-compose.yml', }); @@ -59,6 +65,12 @@ if (cfScript.includes('acme')) { if (cfScript.includes('vercel')) { throw new Error('[cloudflare-tunnel] Should NOT contain vercel in CF tunnel mode'); } +if (!cfScript.includes('*.example.com')) { + throw new Error('[cloudflare-tunnel] Expected wildcard DNS record creation'); +} +if (!cfScript.includes('test-tunnel-id.cfargotunnel.com')) { + throw new Error('[cloudflare-tunnel] Expected tunnel CNAME target in DNS creation'); +} console.log('OK: cloudflare-tunnel mode'); diff --git a/src/core/provision.ts b/src/core/provision.ts index 227c777..f69a033 100644 --- a/src/core/provision.ts +++ b/src/core/provision.ts @@ -12,6 +12,12 @@ export type ProvisionScriptInput = | { deployMode: 'cloudflare-tunnel'; cloudflareTunnelCompose: CloudflareTunnelComposeInput; + cloudflareDns: { + apiToken: string; + zoneId: string; + tunnelId: string; + baseDomain: string; + }; openclawRuntimeImage?: string; composePath?: string; }; @@ -60,5 +66,21 @@ export function buildProvisionScript(input: ProvisionScriptInput): string { dockerPull, ]; - return [...commonSteps, ...acmeSteps, ...composeSteps].filter(Boolean).join('\n'); + const dnsSteps = deployMode === 'cloudflare-tunnel' + ? (() => { + const { apiToken, zoneId, tunnelId, baseDomain } = + (input as { cloudflareDns: { apiToken: string; zoneId: string; tunnelId: string; baseDomain: string } }).cloudflareDns; + const wildcardName = `*.${baseDomain}`; + const tunnelTarget = `${tunnelId}.cfargotunnel.com`; + return [ + `echo "Creating wildcard DNS CNAME: ${wildcardName} -> ${tunnelTarget}"`, + `curl -sS -X POST "https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records" \\`, + ` -H "Authorization: Bearer ${apiToken}" \\`, + ` -H "Content-Type: application/json" \\`, + ` --data '{"type":"CNAME","name":"${wildcardName}","content":"${tunnelTarget}","proxied":true,"ttl":1}'`, + ]; + })() + : []; + + return [...commonSteps, ...acmeSteps, ...composeSteps, ...dnsSteps].filter(Boolean).join('\n'); } From 38b8ec448dedfbdf46004c73bca8c93fbc305d17 Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Tue, 10 Feb 2026 13:02:21 -0800 Subject: [PATCH 15/22] create tunnel routes per instance to avoid paying for advance cert service --- .env.example | 1 + scripts/cf-dns-create-wildcard.sh | 35 ---------------- scripts/create-instance.sh | 46 +++++++++++++++++++++ scripts/provision-host.sh | 24 +---------- scripts/smoke-provision-script.ts | 17 ++++---- src/core/cloudflareDns.ts | 13 ------ src/core/cloudflareTunnel.ts | 67 +++++++++++++++++++++++++++++++ src/core/provision.ts | 20 ++------- src/index.ts | 7 +++- 9 files changed, 131 insertions(+), 99 deletions(-) delete mode 100755 scripts/cf-dns-create-wildcard.sh create mode 100644 src/core/cloudflareTunnel.ts diff --git a/.env.example b/.env.example index 99d6e41..9b1e901 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,7 @@ OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN= OPENCLAW_CLOUDFLARE_API_TOKEN= OPENCLAW_CLOUDFLARE_ZONE_ID= OPENCLAW_CLOUDFLARE_TUNNEL_ID= +OPENCLAW_CLOUDFLARE_ACCOUNT_ID= # ---- Shared ---- OPENCLAW_TTYD_SECRET=replace_me_with_a_long_random_string diff --git a/scripts/cf-dns-create-wildcard.sh b/scripts/cf-dns-create-wildcard.sh deleted file mode 100755 index 9055dd8..0000000 --- a/scripts/cf-dns-create-wildcard.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -cd "$(dirname "$0")/.." - -if [ -f .env ]; then - # shellcheck disable=SC1091 - source .env -fi - -: "${OPENCLAW_CLOUDFLARE_API_TOKEN:?Missing OPENCLAW_CLOUDFLARE_API_TOKEN}" -: "${OPENCLAW_CLOUDFLARE_ZONE_ID:?Missing OPENCLAW_CLOUDFLARE_ZONE_ID}" -: "${OPENCLAW_CLOUDFLARE_TUNNEL_ID:?Missing OPENCLAW_CLOUDFLARE_TUNNEL_ID}" -: "${OPENCLAW_BASE_DOMAIN:?Missing OPENCLAW_BASE_DOMAIN}" - -WILDCARD_NAME="*.${OPENCLAW_BASE_DOMAIN}" -TUNNEL_TARGET="${OPENCLAW_CLOUDFLARE_TUNNEL_ID}.cfargotunnel.com" - -echo "Creating DNS CNAME record:" -echo " ${WILDCARD_NAME} -> ${TUNNEL_TARGET}" -echo - -RESPONSE=$(curl -sS -X POST \ - "https://api.cloudflare.com/client/v4/zones/${OPENCLAW_CLOUDFLARE_ZONE_ID}/dns_records" \ - -H "Authorization: Bearer ${OPENCLAW_CLOUDFLARE_API_TOKEN}" \ - -H "Content-Type: application/json" \ - --data "{ - \"type\": \"CNAME\", - \"name\": \"${WILDCARD_NAME}\", - \"content\": \"${TUNNEL_TARGET}\", - \"proxied\": true, - \"ttl\": 1 - }") - -echo "${RESPONSE}" diff --git a/scripts/create-instance.sh b/scripts/create-instance.sh index 942456a..6618d4d 100755 --- a/scripts/create-instance.sh +++ b/scripts/create-instance.sh @@ -105,6 +105,52 @@ docker run -d \ --label "traefik.http.services.${CONTAINER}-terminal.loadbalancer.server.port=7681" \ "${OPENCLAW_RUNTIME_IMAGE}" +# --- Per-instance tunnel route + DNS (cloudflare-tunnel mode only) --- +if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then + : "${OPENCLAW_CLOUDFLARE_ACCOUNT_ID:?Missing OPENCLAW_CLOUDFLARE_ACCOUNT_ID}" + : "${OPENCLAW_CLOUDFLARE_API_TOKEN:?Missing OPENCLAW_CLOUDFLARE_API_TOKEN}" + : "${OPENCLAW_CLOUDFLARE_ZONE_ID:?Missing OPENCLAW_CLOUDFLARE_ZONE_ID}" + : "${OPENCLAW_CLOUDFLARE_TUNNEL_ID:?Missing OPENCLAW_CLOUDFLARE_TUNNEL_ID}" + + CF_API="https://api.cloudflare.com/client/v4" + AUTH_HEADER="Authorization: Bearer ${OPENCLAW_CLOUDFLARE_API_TOKEN}" + TUNNEL_CFG_URL="${CF_API}/accounts/${OPENCLAW_CLOUDFLARE_ACCOUNT_ID}/tunnels/${OPENCLAW_CLOUDFLARE_TUNNEL_ID}/configurations" + + # 1. GET current tunnel config + CURRENT_CONFIG=$(curl -sS -X GET "${TUNNEL_CFG_URL}" \ + -H "${AUTH_HEADER}") + + # 2. Add ingress rule for this instance before the catch-all + UPDATED_INGRESS=$(echo "${CURRENT_CONFIG}" | jq --arg hostname "${HOSTNAME}" --arg service "http://traefik:80" ' + .result.config.ingress + | [.[] | select(.hostname != $hostname)] + | if (.[-1].hostname // null) == null then + .[:-1] + [{"hostname": $hostname, "service": $service}] + .[-1:] + else + . + [{"hostname": $hostname, "service": $service}, {"service": "http_status:404"}] + end + ') + + UPDATED_CONFIG=$(echo "${CURRENT_CONFIG}" | jq --argjson ingress "${UPDATED_INGRESS}" ' + .result.config | .ingress = $ingress + ') + + # 3. PUT updated tunnel config + echo "Adding tunnel ingress rule for ${HOSTNAME}…" + curl -sS -X PUT "${TUNNEL_CFG_URL}" \ + -H "${AUTH_HEADER}" \ + -H "Content-Type: application/json" \ + --data "{\"config\": ${UPDATED_CONFIG}}" > /dev/null + + # 4. Create per-instance CNAME DNS record + TUNNEL_TARGET="${OPENCLAW_CLOUDFLARE_TUNNEL_ID}.cfargotunnel.com" + echo "Creating DNS CNAME: ${HOSTNAME} -> ${TUNNEL_TARGET}" + curl -sS -X POST "${CF_API}/zones/${OPENCLAW_CLOUDFLARE_ZONE_ID}/dns_records" \ + -H "${AUTH_HEADER}" \ + -H "Content-Type: application/json" \ + --data "{\"type\":\"CNAME\",\"name\":\"${HOSTNAME}\",\"content\":\"${TUNNEL_TARGET}\",\"proxied\":true,\"ttl\":1}" > /dev/null +fi + echo echo "Instance created: ${INSTANCE_ID}" echo "Terminal URL:" diff --git a/scripts/provision-host.sh b/scripts/provision-host.sh index 94f0659..9766d3d 100755 --- a/scripts/provision-host.sh +++ b/scripts/provision-host.sh @@ -30,9 +30,6 @@ fi # --- Mode-specific validation --- if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then : "${OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN:?Missing OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN}" - : "${OPENCLAW_CLOUDFLARE_API_TOKEN:?Missing OPENCLAW_CLOUDFLARE_API_TOKEN}" - : "${OPENCLAW_CLOUDFLARE_ZONE_ID:?Missing OPENCLAW_CLOUDFLARE_ZONE_ID}" - : "${OPENCLAW_CLOUDFLARE_TUNNEL_ID:?Missing OPENCLAW_CLOUDFLARE_TUNNEL_ID}" else : "${OPENCLAW_ACME_EMAIL:?Missing OPENCLAW_ACME_EMAIL}" : "${OPENCLAW_VERCEL_API_TOKEN:?Missing OPENCLAW_VERCEL_API_TOKEN}" @@ -41,7 +38,7 @@ fi # --- Common: install Docker & compose plugin --- export DEBIAN_FRONTEND=noninteractive apt-get update -y -apt-get install -y ca-certificates curl gnupg lsb-release +apt-get install -y ca-certificates curl gnupg jq lsb-release if ! command -v docker >/dev/null 2>&1; then curl -fsSL https://get.docker.com | sh @@ -69,27 +66,8 @@ if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then # Ensure the network is always named `traefik_default` (matches instance labels). docker compose -p traefik --env-file .env -f deploy/cloudflare-tunnel/docker-compose.yml up -d --build - # --- Create wildcard DNS record pointing to the tunnel --- - WILDCARD_NAME="*.${OPENCLAW_BASE_DOMAIN}" - TUNNEL_TARGET="${OPENCLAW_CLOUDFLARE_TUNNEL_ID}.cfargotunnel.com" - echo echo "Traefik + cloudflared are up." - echo "Creating wildcard DNS CNAME: ${WILDCARD_NAME} -> ${TUNNEL_TARGET}" - - RESPONSE=$(curl -sS -X POST \ - "https://api.cloudflare.com/client/v4/zones/${OPENCLAW_CLOUDFLARE_ZONE_ID}/dns_records" \ - -H "Authorization: Bearer ${OPENCLAW_CLOUDFLARE_API_TOKEN}" \ - -H "Content-Type: application/json" \ - --data "{ - \"type\": \"CNAME\", - \"name\": \"${WILDCARD_NAME}\", - \"content\": \"${TUNNEL_TARGET}\", - \"proxied\": true, - \"ttl\": 1 - }") - - echo "${RESPONSE}" else mkdir -p /opt/traefik touch /opt/traefik/acme.json diff --git a/scripts/smoke-provision-script.ts b/scripts/smoke-provision-script.ts index 013db04..236798e 100644 --- a/scripts/smoke-provision-script.ts +++ b/scripts/smoke-provision-script.ts @@ -40,12 +40,6 @@ const cfScript = buildProvisionScript({ cloudflaredImage: 'cloudflare/cloudflared:latest', enableDashboard: false, }, - cloudflareDns: { - apiToken: 'test-api-token', - zoneId: 'test-zone-id', - tunnelId: 'test-tunnel-id', - baseDomain: 'example.com', - }, openclawRuntimeImage: 'openclaw-ttyd:local', composePath: '/opt/traefik/docker-compose.yml', }); @@ -65,11 +59,14 @@ if (cfScript.includes('acme')) { if (cfScript.includes('vercel')) { throw new Error('[cloudflare-tunnel] Should NOT contain vercel in CF tunnel mode'); } -if (!cfScript.includes('*.example.com')) { - throw new Error('[cloudflare-tunnel] Expected wildcard DNS record creation'); +if (cfScript.includes('*.example.com')) { + throw new Error('[cloudflare-tunnel] Should NOT contain wildcard DNS creation'); +} +if (cfScript.includes('cfargotunnel.com')) { + throw new Error('[cloudflare-tunnel] Should NOT contain tunnel CNAME in provision script'); } -if (!cfScript.includes('test-tunnel-id.cfargotunnel.com')) { - throw new Error('[cloudflare-tunnel] Expected tunnel CNAME target in DNS creation'); +if (!cfScript.includes('jq')) { + throw new Error('[cloudflare-tunnel] Expected jq in package install'); } console.log('OK: cloudflare-tunnel mode'); diff --git a/src/core/cloudflareDns.ts b/src/core/cloudflareDns.ts index 2c541ff..8986359 100644 --- a/src/core/cloudflareDns.ts +++ b/src/core/cloudflareDns.ts @@ -39,19 +39,6 @@ export function buildCreateDnsRecordRequest( }; } -/** Convenience: creates "*.{wildcardDomain}" CNAME pointing to tunnel */ -export function buildCreateWildcardDnsRecordRequest( - config: CloudflareApiConfig, - wildcardDomain: string, - tunnelCname: string -): CloudflareApiRequest { - return buildCreateDnsRecordRequest(config, { - name: `*.${wildcardDomain}`, - target: tunnelCname, - proxied: true, - }); -} - /** GET /zones/{zoneId}/dns_records with optional name filter */ export function buildListDnsRecordsRequest( config: CloudflareApiConfig, diff --git a/src/core/cloudflareTunnel.ts b/src/core/cloudflareTunnel.ts new file mode 100644 index 0000000..3da4a4a --- /dev/null +++ b/src/core/cloudflareTunnel.ts @@ -0,0 +1,67 @@ +const CLOUDFLARE_API_BASE = 'https://api.cloudflare.com/client/v4'; + +export interface CloudflareTunnelApiConfig { + apiToken: string; + accountId: string; + tunnelId: string; +} + +export interface TunnelIngressRule { + hostname?: string; + service: string; + path?: string; +} + +// Re-use CloudflareApiRequest from cloudflareDns +import type { CloudflareApiRequest } from './cloudflareDns'; + +/** GET /accounts/{accountId}/tunnels/{tunnelId}/configurations */ +export function buildGetTunnelConfigRequest( + config: CloudflareTunnelApiConfig +): CloudflareApiRequest { + return { + url: `${CLOUDFLARE_API_BASE}/accounts/${config.accountId}/tunnels/${config.tunnelId}/configurations`, + method: 'GET', + headers: { + 'Authorization': `Bearer ${config.apiToken}`, + }, + }; +} + +/** PUT /accounts/{accountId}/tunnels/{tunnelId}/configurations */ +export function buildPutTunnelConfigRequest( + config: CloudflareTunnelApiConfig, + tunnelConfig: { ingress: TunnelIngressRule[] } +): CloudflareApiRequest { + return { + url: `${CLOUDFLARE_API_BASE}/accounts/${config.accountId}/tunnels/${config.tunnelId}/configurations`, + method: 'PUT', + headers: { + 'Authorization': `Bearer ${config.apiToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ config: tunnelConfig }), + }; +} + +/** Insert a rule before the catch-all, replacing any existing rule for the same hostname */ +export function addIngressRule( + existingRules: TunnelIngressRule[], + newRule: TunnelIngressRule +): TunnelIngressRule[] { + // Remove any existing rule with the same hostname + const filtered = existingRules.filter( + (r) => r.hostname !== newRule.hostname + ); + // The catch-all rule has no hostname — insert before it + const catchAllIndex = filtered.findIndex((r) => !r.hostname); + if (catchAllIndex === -1) { + // No catch-all found, append rule and add default catch-all + return [...filtered, newRule, { service: 'http_status:404' }]; + } + return [ + ...filtered.slice(0, catchAllIndex), + newRule, + ...filtered.slice(catchAllIndex), + ]; +} diff --git a/src/core/provision.ts b/src/core/provision.ts index f69a033..40f9c36 100644 --- a/src/core/provision.ts +++ b/src/core/provision.ts @@ -12,7 +12,7 @@ export type ProvisionScriptInput = | { deployMode: 'cloudflare-tunnel'; cloudflareTunnelCompose: CloudflareTunnelComposeInput; - cloudflareDns: { + cloudflareDns?: { apiToken: string; zoneId: string; tunnelId: string; @@ -37,7 +37,7 @@ export function buildProvisionScript(input: ProvisionScriptInput): string { 'export DEBIAN_FRONTEND=noninteractive', 'if [ "$(id -u)" -ne 0 ]; then SUDO="sudo -n"; else SUDO=""; fi', '${SUDO} apt-get update -y', - '${SUDO} apt-get install -y ca-certificates curl gnupg lsb-release', + '${SUDO} apt-get install -y ca-certificates curl gnupg jq lsb-release', 'if ! command -v docker >/dev/null 2>&1; then curl -fsSL https://get.docker.com | ${SUDO} sh; fi', '${SUDO} systemctl enable --now docker', 'if ! docker compose version >/dev/null 2>&1; then ${SUDO} apt-get install -y docker-compose-plugin; fi', @@ -66,21 +66,7 @@ export function buildProvisionScript(input: ProvisionScriptInput): string { dockerPull, ]; - const dnsSteps = deployMode === 'cloudflare-tunnel' - ? (() => { - const { apiToken, zoneId, tunnelId, baseDomain } = - (input as { cloudflareDns: { apiToken: string; zoneId: string; tunnelId: string; baseDomain: string } }).cloudflareDns; - const wildcardName = `*.${baseDomain}`; - const tunnelTarget = `${tunnelId}.cfargotunnel.com`; - return [ - `echo "Creating wildcard DNS CNAME: ${wildcardName} -> ${tunnelTarget}"`, - `curl -sS -X POST "https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records" \\`, - ` -H "Authorization: Bearer ${apiToken}" \\`, - ` -H "Content-Type: application/json" \\`, - ` --data '{"type":"CNAME","name":"${wildcardName}","content":"${tunnelTarget}","proxied":true,"ttl":1}'`, - ]; - })() - : []; + const dnsSteps: string[] = []; return [...commonSteps, ...acmeSteps, ...composeSteps, ...dnsSteps].filter(Boolean).join('\n'); } diff --git a/src/index.ts b/src/index.ts index 008cd85..6701ece 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,10 +13,15 @@ export type { CloudflareTunnelComposeInput } from './core/cloudflared'; export { buildTunnelCname, buildCreateDnsRecordRequest, - buildCreateWildcardDnsRecordRequest, buildListDnsRecordsRequest, buildDeleteDnsRecordRequest, } from './core/cloudflareDns'; export type { CloudflareApiConfig, CloudflareApiRequest } from './core/cloudflareDns'; +export { + buildGetTunnelConfigRequest, + buildPutTunnelConfigRequest, + addIngressRule, +} from './core/cloudflareTunnel'; +export type { CloudflareTunnelApiConfig, TunnelIngressRule } from './core/cloudflareTunnel'; export { generateTerminalToken, validateTerminalToken } from './core/terminalToken'; export type { TerminalTokenOptions } from './core/terminalToken'; From 84caf5f17610a3fad2e03a2847f246ad63da1427 Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Tue, 10 Feb 2026 13:24:04 -0800 Subject: [PATCH 16/22] improve logging --- scripts/create-instance.sh | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/scripts/create-instance.sh b/scripts/create-instance.sh index 6618d4d..86f8320 100755 --- a/scripts/create-instance.sh +++ b/scripts/create-instance.sh @@ -116,9 +116,19 @@ if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then AUTH_HEADER="Authorization: Bearer ${OPENCLAW_CLOUDFLARE_API_TOKEN}" TUNNEL_CFG_URL="${CF_API}/accounts/${OPENCLAW_CLOUDFLARE_ACCOUNT_ID}/tunnels/${OPENCLAW_CLOUDFLARE_TUNNEL_ID}/configurations" + cf_check() { + local response="$1" action="$2" + if [ "$(echo "$response" | jq -r '.success')" != "true" ]; then + echo "Cloudflare API error (${action}):" >&2 + echo "$response" | jq -r '.errors[] | " [\(.code)] \(.message)"' >&2 + exit 1 + fi + } + # 1. GET current tunnel config CURRENT_CONFIG=$(curl -sS -X GET "${TUNNEL_CFG_URL}" \ -H "${AUTH_HEADER}") + cf_check "$CURRENT_CONFIG" "get tunnel config" # 2. Add ingress rule for this instance before the catch-all UPDATED_INGRESS=$(echo "${CURRENT_CONFIG}" | jq --arg hostname "${HOSTNAME}" --arg service "http://traefik:80" ' @@ -137,18 +147,20 @@ if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then # 3. PUT updated tunnel config echo "Adding tunnel ingress rule for ${HOSTNAME}…" - curl -sS -X PUT "${TUNNEL_CFG_URL}" \ + PUT_RESULT=$(curl -sS -X PUT "${TUNNEL_CFG_URL}" \ -H "${AUTH_HEADER}" \ -H "Content-Type: application/json" \ - --data "{\"config\": ${UPDATED_CONFIG}}" > /dev/null + --data "{\"config\": ${UPDATED_CONFIG}}") + cf_check "$PUT_RESULT" "update tunnel config" # 4. Create per-instance CNAME DNS record TUNNEL_TARGET="${OPENCLAW_CLOUDFLARE_TUNNEL_ID}.cfargotunnel.com" echo "Creating DNS CNAME: ${HOSTNAME} -> ${TUNNEL_TARGET}" - curl -sS -X POST "${CF_API}/zones/${OPENCLAW_CLOUDFLARE_ZONE_ID}/dns_records" \ + DNS_RESULT=$(curl -sS -X POST "${CF_API}/zones/${OPENCLAW_CLOUDFLARE_ZONE_ID}/dns_records" \ -H "${AUTH_HEADER}" \ -H "Content-Type: application/json" \ - --data "{\"type\":\"CNAME\",\"name\":\"${HOSTNAME}\",\"content\":\"${TUNNEL_TARGET}\",\"proxied\":true,\"ttl\":1}" > /dev/null + --data "{\"type\":\"CNAME\",\"name\":\"${HOSTNAME}\",\"content\":\"${TUNNEL_TARGET}\",\"proxied\":true,\"ttl\":1}") + cf_check "$DNS_RESULT" "create DNS record" fi echo From 7f6cebd6773b1fa0ef323a25c6b02ef58e4c8e4f Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Tue, 10 Feb 2026 13:30:36 -0800 Subject: [PATCH 17/22] add tunnel config --- scripts/create-instance.sh | 55 ++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/scripts/create-instance.sh b/scripts/create-instance.sh index 86f8320..bf46213 100755 --- a/scripts/create-instance.sh +++ b/scripts/create-instance.sh @@ -118,32 +118,47 @@ if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then cf_check() { local response="$1" action="$2" - if [ "$(echo "$response" | jq -r '.success')" != "true" ]; then + if [ "$(echo "$response" | jq -r '.success // empty' 2>/dev/null)" != "true" ]; then echo "Cloudflare API error (${action}):" >&2 - echo "$response" | jq -r '.errors[] | " [\(.code)] \(.message)"' >&2 + echo "$response" | jq -r '.errors[]? | " [\(.code)] \(.message)"' 2>/dev/null >&2 + echo " Response: ${response}" >&2 exit 1 fi } - # 1. GET current tunnel config - CURRENT_CONFIG=$(curl -sS -X GET "${TUNNEL_CFG_URL}" \ + # 1. GET current tunnel config (404 means no config exists yet) + CFG_HTTP_CODE=$(curl -sS -o /tmp/cf_tunnel_cfg.json -w "%{http_code}" -X GET "${TUNNEL_CFG_URL}" \ -H "${AUTH_HEADER}") - cf_check "$CURRENT_CONFIG" "get tunnel config" - - # 2. Add ingress rule for this instance before the catch-all - UPDATED_INGRESS=$(echo "${CURRENT_CONFIG}" | jq --arg hostname "${HOSTNAME}" --arg service "http://traefik:80" ' - .result.config.ingress - | [.[] | select(.hostname != $hostname)] - | if (.[-1].hostname // null) == null then - .[:-1] + [{"hostname": $hostname, "service": $service}] + .[-1:] - else - . + [{"hostname": $hostname, "service": $service}, {"service": "http_status:404"}] - end - ') - - UPDATED_CONFIG=$(echo "${CURRENT_CONFIG}" | jq --argjson ingress "${UPDATED_INGRESS}" ' - .result.config | .ingress = $ingress - ') + + if [ "$CFG_HTTP_CODE" = "200" ]; then + CURRENT_CONFIG=$(cat /tmp/cf_tunnel_cfg.json) + cf_check "$CURRENT_CONFIG" "get tunnel config" + # Add ingress rule for this instance before the catch-all + UPDATED_INGRESS=$(echo "${CURRENT_CONFIG}" | jq --arg hostname "${HOSTNAME}" --arg service "http://traefik:80" ' + .result.config.ingress + | [.[] | select(.hostname != $hostname)] + | if (.[-1].hostname // null) == null then + .[:-1] + [{"hostname": $hostname, "service": $service}] + .[-1:] + else + . + [{"hostname": $hostname, "service": $service}, {"service": "http_status:404"}] + end + ') + UPDATED_CONFIG=$(echo "${CURRENT_CONFIG}" | jq --argjson ingress "${UPDATED_INGRESS}" ' + .result.config | .ingress = $ingress + ') + elif [ "$CFG_HTTP_CODE" = "404" ]; then + # No config exists yet — create a fresh one + UPDATED_CONFIG=$(jq -n --arg hostname "${HOSTNAME}" --arg service "http://traefik:80" '{ + ingress: [ + {hostname: $hostname, service: $service}, + {service: "http_status:404"} + ] + }') + else + CURRENT_CONFIG=$(cat /tmp/cf_tunnel_cfg.json) + cf_check "$CURRENT_CONFIG" "get tunnel config" + fi + rm -f /tmp/cf_tunnel_cfg.json # 3. PUT updated tunnel config echo "Adding tunnel ingress rule for ${HOSTNAME}…" From c4af6d687b9e387aa4dd382139f24c644d761790 Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Tue, 10 Feb 2026 13:50:51 -0800 Subject: [PATCH 18/22] use local config to set ingress rules --- .env.example | 2 - deploy/cloudflare-tunnel/docker-compose.yml | 4 +- scripts/create-instance.sh | 82 +++++++++------------ scripts/provision-host.sh | 34 +++++++++ scripts/smoke-provision-script.ts | 14 +++- src/core/cloudflared.ts | 54 +++++++++++++- src/core/provision.ts | 29 +++++++- src/index.ts | 4 +- 8 files changed, 164 insertions(+), 59 deletions(-) diff --git a/.env.example b/.env.example index 9b1e901..a48e0af 100644 --- a/.env.example +++ b/.env.example @@ -16,8 +16,6 @@ OPENCLAW_VERCEL_API_TOKEN=replace_me OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN= OPENCLAW_CLOUDFLARE_API_TOKEN= OPENCLAW_CLOUDFLARE_ZONE_ID= -OPENCLAW_CLOUDFLARE_TUNNEL_ID= -OPENCLAW_CLOUDFLARE_ACCOUNT_ID= # ---- Shared ---- OPENCLAW_TTYD_SECRET=replace_me_with_a_long_random_string diff --git a/deploy/cloudflare-tunnel/docker-compose.yml b/deploy/cloudflare-tunnel/docker-compose.yml index 68a21e2..718ae27 100644 --- a/deploy/cloudflare-tunnel/docker-compose.yml +++ b/deploy/cloudflare-tunnel/docker-compose.yml @@ -27,7 +27,9 @@ services: image: cloudflare/cloudflared:latest container_name: cloudflared restart: unless-stopped - command: tunnel --no-autoupdate run --token ${OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN} + command: tunnel --no-autoupdate --config /etc/cloudflared/config.yml run + volumes: + - /var/lib/openclaw/cloudflared:/etc/cloudflared:ro networks: default: diff --git a/scripts/create-instance.sh b/scripts/create-instance.sh index bf46213..d7b8dd7 100755 --- a/scripts/create-instance.sh +++ b/scripts/create-instance.sh @@ -107,14 +107,46 @@ docker run -d \ # --- Per-instance tunnel route + DNS (cloudflare-tunnel mode only) --- if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then - : "${OPENCLAW_CLOUDFLARE_ACCOUNT_ID:?Missing OPENCLAW_CLOUDFLARE_ACCOUNT_ID}" : "${OPENCLAW_CLOUDFLARE_API_TOKEN:?Missing OPENCLAW_CLOUDFLARE_API_TOKEN}" : "${OPENCLAW_CLOUDFLARE_ZONE_ID:?Missing OPENCLAW_CLOUDFLARE_ZONE_ID}" - : "${OPENCLAW_CLOUDFLARE_TUNNEL_ID:?Missing OPENCLAW_CLOUDFLARE_TUNNEL_ID}" + : "${OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN:?Missing OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN}" + INGRESS_FILE="/var/lib/openclaw/cloudflared/ingress.json" + CONFIG_FILE="/var/lib/openclaw/cloudflared/config.yml" + + # Extract tunnel ID from token + CF_TUNNEL_ID=$(echo "${OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN}" | base64 -d | jq -r '.t') + + # Upsert ingress rule (idempotent — removes existing rule for same hostname first) + echo "Adding tunnel ingress rule for ${HOSTNAME}…" + UPDATED_INGRESS=$(jq --arg hostname "${HOSTNAME}" --arg service "http://traefik:80" ' + [.[] | select(.hostname != $hostname)] + | if (.[-1].hostname // null) == null then + .[:-1] + [{"hostname": $hostname, "service": $service}] + .[-1:] + else + . + [{"hostname": $hostname, "service": $service}, {"service": "http_status:404"}] + end + ' "$INGRESS_FILE") + echo "$UPDATED_INGRESS" > "$INGRESS_FILE" + + # Regenerate config.yml from ingress.json + generate_cloudflared_config() { + local tunnel_id="$1" + { + echo "tunnel: ${tunnel_id}" + echo "credentials-file: /etc/cloudflared/credentials.json" + echo "ingress:" + jq -r '.[] | if .hostname then " - hostname: \(.hostname)\n service: \(.service)" else " - service: \(.service)" end' "$INGRESS_FILE" + } > "$CONFIG_FILE" + } + generate_cloudflared_config "$CF_TUNNEL_ID" + + docker restart cloudflared + + # Create per-instance CNAME DNS record CF_API="https://api.cloudflare.com/client/v4" AUTH_HEADER="Authorization: Bearer ${OPENCLAW_CLOUDFLARE_API_TOKEN}" - TUNNEL_CFG_URL="${CF_API}/accounts/${OPENCLAW_CLOUDFLARE_ACCOUNT_ID}/tunnels/${OPENCLAW_CLOUDFLARE_TUNNEL_ID}/configurations" + TUNNEL_TARGET="${CF_TUNNEL_ID}.cfargotunnel.com" cf_check() { local response="$1" action="$2" @@ -126,50 +158,6 @@ if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then fi } - # 1. GET current tunnel config (404 means no config exists yet) - CFG_HTTP_CODE=$(curl -sS -o /tmp/cf_tunnel_cfg.json -w "%{http_code}" -X GET "${TUNNEL_CFG_URL}" \ - -H "${AUTH_HEADER}") - - if [ "$CFG_HTTP_CODE" = "200" ]; then - CURRENT_CONFIG=$(cat /tmp/cf_tunnel_cfg.json) - cf_check "$CURRENT_CONFIG" "get tunnel config" - # Add ingress rule for this instance before the catch-all - UPDATED_INGRESS=$(echo "${CURRENT_CONFIG}" | jq --arg hostname "${HOSTNAME}" --arg service "http://traefik:80" ' - .result.config.ingress - | [.[] | select(.hostname != $hostname)] - | if (.[-1].hostname // null) == null then - .[:-1] + [{"hostname": $hostname, "service": $service}] + .[-1:] - else - . + [{"hostname": $hostname, "service": $service}, {"service": "http_status:404"}] - end - ') - UPDATED_CONFIG=$(echo "${CURRENT_CONFIG}" | jq --argjson ingress "${UPDATED_INGRESS}" ' - .result.config | .ingress = $ingress - ') - elif [ "$CFG_HTTP_CODE" = "404" ]; then - # No config exists yet — create a fresh one - UPDATED_CONFIG=$(jq -n --arg hostname "${HOSTNAME}" --arg service "http://traefik:80" '{ - ingress: [ - {hostname: $hostname, service: $service}, - {service: "http_status:404"} - ] - }') - else - CURRENT_CONFIG=$(cat /tmp/cf_tunnel_cfg.json) - cf_check "$CURRENT_CONFIG" "get tunnel config" - fi - rm -f /tmp/cf_tunnel_cfg.json - - # 3. PUT updated tunnel config - echo "Adding tunnel ingress rule for ${HOSTNAME}…" - PUT_RESULT=$(curl -sS -X PUT "${TUNNEL_CFG_URL}" \ - -H "${AUTH_HEADER}" \ - -H "Content-Type: application/json" \ - --data "{\"config\": ${UPDATED_CONFIG}}") - cf_check "$PUT_RESULT" "update tunnel config" - - # 4. Create per-instance CNAME DNS record - TUNNEL_TARGET="${OPENCLAW_CLOUDFLARE_TUNNEL_ID}.cfargotunnel.com" echo "Creating DNS CNAME: ${HOSTNAME} -> ${TUNNEL_TARGET}" DNS_RESULT=$(curl -sS -X POST "${CF_API}/zones/${OPENCLAW_CLOUDFLARE_ZONE_ID}/dns_records" \ -H "${AUTH_HEADER}" \ diff --git a/scripts/provision-host.sh b/scripts/provision-host.sh index 9766d3d..249be3e 100755 --- a/scripts/provision-host.sh +++ b/scripts/provision-host.sh @@ -62,7 +62,41 @@ if ! docker compose version >/dev/null 2>&1; then fi # --- Mode-specific setup --- +generate_cloudflared_config() { + local tunnel_id="$1" + local ingress_file="/var/lib/openclaw/cloudflared/ingress.json" + local config_file="/var/lib/openclaw/cloudflared/config.yml" + + { + echo "tunnel: ${tunnel_id}" + echo "credentials-file: /etc/cloudflared/credentials.json" + echo "ingress:" + jq -r '.[] | if .hostname then " - hostname: \(.hostname)\n service: \(.service)" else " - service: \(.service)" end' "$ingress_file" + } > "$config_file" +} + if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then + # Decode tunnel token and set up local cloudflared config + TUNNEL_JSON=$(echo "${OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN}" | base64 -d) + CF_ACCOUNT_TAG=$(echo "${TUNNEL_JSON}" | jq -r '.a') + CF_TUNNEL_ID=$(echo "${TUNNEL_JSON}" | jq -r '.t') + CF_TUNNEL_SECRET=$(echo "${TUNNEL_JSON}" | jq -r '.s') + + mkdir -p /var/lib/openclaw/cloudflared + + jq -n \ + --arg acct "$CF_ACCOUNT_TAG" \ + --arg tid "$CF_TUNNEL_ID" \ + --arg sec "$CF_TUNNEL_SECRET" \ + '{ AccountTag: $acct, TunnelID: $tid, TunnelSecret: $sec }' \ + > /var/lib/openclaw/cloudflared/credentials.json + + if [ ! -f /var/lib/openclaw/cloudflared/ingress.json ]; then + echo '[{"service":"http_status:404"}]' > /var/lib/openclaw/cloudflared/ingress.json + fi + + generate_cloudflared_config "$CF_TUNNEL_ID" + # Ensure the network is always named `traefik_default` (matches instance labels). docker compose -p traefik --env-file .env -f deploy/cloudflare-tunnel/docker-compose.yml up -d --build diff --git a/scripts/smoke-provision-script.ts b/scripts/smoke-provision-script.ts index 236798e..6f83a31 100644 --- a/scripts/smoke-provision-script.ts +++ b/scripts/smoke-provision-script.ts @@ -30,10 +30,12 @@ if (!traefikScript.includes('certificatesresolvers')) { console.log('OK: traefik mode'); // --- Test 2: Cloudflare Tunnel mode --- +const testTunnelToken = 'eyJhIjoidGVzdC1hY2NvdW50IiwidCI6InRlc3QtdHVubmVsLWlkIiwicyI6InRlc3Qtc2VjcmV0In0='; const cfScript = buildProvisionScript({ deployMode: 'cloudflare-tunnel', + tunnelToken: testTunnelToken, cloudflareTunnelCompose: { - tunnelToken: 'test-tunnel-token', + tunnelId: 'test-tunnel-id', ttydSecret: 'test-secret', ttydTtlSeconds: 86400, traefikImage: 'traefik:v3.1', @@ -50,8 +52,14 @@ if (!cfScript.includes('docker compose')) { if (!cfScript.includes('cloudflared')) { throw new Error('[cloudflare-tunnel] Expected cloudflared in CF tunnel mode'); } -if (!cfScript.includes('--token test-tunnel-token')) { - throw new Error('[cloudflare-tunnel] Expected --token flag in CF tunnel mode'); +if (!cfScript.includes('--config /etc/cloudflared/config.yml')) { + throw new Error('[cloudflare-tunnel] Expected --config flag in CF tunnel mode'); +} +if (!cfScript.includes('credentials.json')) { + throw new Error('[cloudflare-tunnel] Expected credentials.json setup in CF tunnel mode'); +} +if (!cfScript.includes('ingress.json')) { + throw new Error('[cloudflare-tunnel] Expected ingress.json setup in CF tunnel mode'); } if (cfScript.includes('acme')) { throw new Error('[cloudflare-tunnel] Should NOT contain acme in CF tunnel mode'); diff --git a/src/core/cloudflared.ts b/src/core/cloudflared.ts index e922d03..13f4a1c 100644 --- a/src/core/cloudflared.ts +++ b/src/core/cloudflared.ts @@ -1,5 +1,5 @@ export interface CloudflareTunnelComposeInput { - tunnelToken: string; + tunnelId: string; ttydSecret: string; ttydTtlSeconds?: number; traefikImage?: string; @@ -48,10 +48,60 @@ export function buildCloudflareTunnelComposeYaml(input: CloudflareTunnelComposeI ` image: ${cloudflaredImage}`, ' container_name: cloudflared', ' restart: unless-stopped', - ` command: tunnel --no-autoupdate run --token ${input.tunnelToken}`, + ' command: tunnel --no-autoupdate --config /etc/cloudflared/config.yml run', + ' volumes:', + ' - /var/lib/openclaw/cloudflared:/etc/cloudflared:ro', '', 'networks:', ' default:', ' name: traefik_default', ].filter(Boolean).join('\n'); } + +export interface TunnelCredentials { + accountTag: string; + tunnelId: string; + tunnelSecret: string; +} + +export function decodeTunnelToken(token: string): TunnelCredentials { + const json = JSON.parse(atob(token)); + return { + accountTag: json.a, + tunnelId: json.t, + tunnelSecret: json.s, + }; +} + +export function buildCredentialsJson(creds: TunnelCredentials): string { + return JSON.stringify({ + AccountTag: creds.accountTag, + TunnelID: creds.tunnelId, + TunnelSecret: creds.tunnelSecret, + }, null, 2); +} + +export interface CloudflaredIngressRule { + hostname?: string; + service: string; +} + +export function buildCloudflaredConfigYaml(opts: { + tunnelId: string; + ingress: CloudflaredIngressRule[]; +}): string { + const lines = [ + `tunnel: ${opts.tunnelId}`, + 'credentials-file: /etc/cloudflared/credentials.json', + 'ingress:', + ]; + for (const rule of opts.ingress) { + if (rule.hostname) { + lines.push(` - hostname: ${rule.hostname}`); + lines.push(` service: ${rule.service}`); + } else { + lines.push(` - service: ${rule.service}`); + } + } + return lines.join('\n'); +} diff --git a/src/core/provision.ts b/src/core/provision.ts index 40f9c36..49a02e1 100644 --- a/src/core/provision.ts +++ b/src/core/provision.ts @@ -1,5 +1,5 @@ import { buildTraefikComposeYaml, TraefikComposeInput } from './traefik'; -import { buildCloudflareTunnelComposeYaml, CloudflareTunnelComposeInput } from './cloudflared'; +import { buildCloudflareTunnelComposeYaml, CloudflareTunnelComposeInput, decodeTunnelToken, buildCredentialsJson, buildCloudflaredConfigYaml } from './cloudflared'; import { DeployMode } from './deployMode'; export type ProvisionScriptInput = @@ -12,6 +12,7 @@ export type ProvisionScriptInput = | { deployMode: 'cloudflare-tunnel'; cloudflareTunnelCompose: CloudflareTunnelComposeInput; + tunnelToken: string; cloudflareDns?: { apiToken: string; zoneId: string; @@ -58,6 +59,30 @@ export function buildProvisionScript(input: ProvisionScriptInput): string { '${SUDO} chmod 600 /opt/traefik/acme.json', ] : []; + const cloudflaredSteps: string[] = []; + if (deployMode === 'cloudflare-tunnel') { + const cfInput = input as { tunnelToken: string; cloudflareTunnelCompose: CloudflareTunnelComposeInput }; + const creds = decodeTunnelToken(cfInput.tunnelToken); + const credentialsJson = buildCredentialsJson(creds); + const initialIngress = [{ service: 'http_status:404' }]; + const configYaml = buildCloudflaredConfigYaml({ tunnelId: creds.tunnelId, ingress: initialIngress }); + + cloudflaredSteps.push( + `${sudoLiteral} mkdir -p /var/lib/openclaw/cloudflared`, + `${sudoLiteral} tee /var/lib/openclaw/cloudflared/credentials.json >/dev/null <<'CREDS'`, + credentialsJson, + 'CREDS', + `if [ ! -f /var/lib/openclaw/cloudflared/ingress.json ]; then`, + ` ${sudoLiteral} tee /var/lib/openclaw/cloudflared/ingress.json >/dev/null <<'INGRESS'`, + JSON.stringify(initialIngress, null, 2), + 'INGRESS', + 'fi', + `${sudoLiteral} tee /var/lib/openclaw/cloudflared/config.yml >/dev/null <<'CFGYML'`, + configYaml, + 'CFGYML', + ); + } + const composeSteps = [ `${sudoLiteral} tee ${composePath} >/dev/null <<'YAML'`, composeYaml, @@ -68,5 +93,5 @@ export function buildProvisionScript(input: ProvisionScriptInput): string { const dnsSteps: string[] = []; - return [...commonSteps, ...acmeSteps, ...composeSteps, ...dnsSteps].filter(Boolean).join('\n'); + return [...commonSteps, ...acmeSteps, ...cloudflaredSteps, ...composeSteps, ...dnsSteps].filter(Boolean).join('\n'); } diff --git a/src/index.ts b/src/index.ts index 6701ece..c2161db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,8 +8,8 @@ export { buildProvisionScript } from './core/provision'; export type { ProvisionScriptInput } from './core/provision'; export { buildTraefikComposeYaml, buildTraefikHttpComposeYaml } from './core/traefik'; export type { TraefikComposeInput, TraefikHttpComposeInput } from './core/traefik'; -export { buildCloudflareTunnelComposeYaml } from './core/cloudflared'; -export type { CloudflareTunnelComposeInput } from './core/cloudflared'; +export { buildCloudflareTunnelComposeYaml, decodeTunnelToken, buildCredentialsJson, buildCloudflaredConfigYaml } from './core/cloudflared'; +export type { CloudflareTunnelComposeInput, TunnelCredentials, CloudflaredIngressRule } from './core/cloudflared'; export { buildTunnelCname, buildCreateDnsRecordRequest, From 0204064a6f2633a71d4c719b81fcfe9bb9f23417 Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Tue, 10 Feb 2026 14:54:54 -0800 Subject: [PATCH 19/22] update readme, remove vars --- README.md | 39 +++++++++++++------------------------- scripts/create-instance.sh | 5 ++--- 2 files changed, 15 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index f42e95c..2ba269d 100644 --- a/README.md +++ b/README.md @@ -121,15 +121,21 @@ No public IP or open ports needed. Cloudflare handles TLS and routes traffic thr 1. A Linux machine (Ubuntu/Debian) — Docker is installed automatically 2. A domain with **DNS on [Cloudflare](https://dash.cloudflare.com/)** (free plan works) -3. A Cloudflare account with access to [Zero Trust](https://one.dash.cloudflare.com/) +3. [`cloudflared` CLI](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) installed on your local machine #### 1. Create a Cloudflare Tunnel -1. Go to [Zero Trust](https://one.dash.cloudflare.com/) → **Networks** → **Connectors** -2. Click **Create a connector** → choose **Cloudflared** -3. Give it a name (e.g. `openclaw-h1`) -4. On the Install Connector page, copy the install command shown. Extract the **tunnel token** — the long `eyJ...` string after `--token`. You don't need to install the connector on your machine — the Docker setup handles that. -5. You can now navigate away from this page — no public hostname route is required yet (we'll add one after provisioning). Go back to **Networks** → **Connectors** and note the **Tunnel ID** (a UUID like `abcd1234-...`) listed next to your connector. +```bash +cloudflared tunnel login +cloudflared tunnel create openclaw-h1 +``` + +Then grab the tunnel token: +```bash +cloudflared tunnel token openclaw-h1 +``` + +Copy the `eyJ...` string — you'll paste it into `.env` below. #### 2. Get your Zone ID and API token @@ -160,7 +166,6 @@ OPENCLAW_DEPLOY_MODE=cloudflare-tunnel OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN=eyJhIjoiYWJj... # from step 1 OPENCLAW_CLOUDFLARE_API_TOKEN=your_api_token # from step 2 OPENCLAW_CLOUDFLARE_ZONE_ID=your_zone_id # from step 2 -OPENCLAW_CLOUDFLARE_TUNNEL_ID=abcd1234-... # from step 1 ``` Provision the server (installs Docker, starts Traefik + cloudflared): @@ -168,24 +173,7 @@ Provision the server (installs Docker, starts Traefik + cloudflared): sudo ./scripts/provision-host.sh ``` -#### 4. Set up DNS and tunnel routing - -Create the wildcard DNS record (CNAME pointing to your tunnel): -```bash -./scripts/cf-dns-create-wildcard.sh -``` - -Then add the public hostname in Cloudflare: -1. Go to [Zero Trust](https://one.dash.cloudflare.com/) → **Networks** → **Connectors** → click your connector → **Edit** -2. Go to the **Public Application Routes** tab → **Add a public hostname** -3. Fill in: - - **Subdomain:** `*` - - **Domain:** `example.com` (your base domain) - - **Type:** HTTP - - **URL:** `traefik:80` -4. Save - -#### 5. Create instances +#### 4. Create instances From here it's the same as Option A — [jump to creating instances](#create-instances-for-your-friends). @@ -258,7 +246,6 @@ scripts/ terminal-url.sh # print terminal URL for an instance dashboard-url.sh # print dashboard URL for an instance terminal-token.sh # generate a terminal auth token - cf-dns-create-wildcard.sh # create wildcard CNAME via Cloudflare API docker/ openclaw-ttyd/ # runtime image (OpenClaw + web terminal) forward-auth/ # tiny token-validation service (~90 lines of JS) diff --git a/scripts/create-instance.sh b/scripts/create-instance.sh index d7b8dd7..fb32ef0 100755 --- a/scripts/create-instance.sh +++ b/scripts/create-instance.sh @@ -109,13 +109,12 @@ docker run -d \ if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then : "${OPENCLAW_CLOUDFLARE_API_TOKEN:?Missing OPENCLAW_CLOUDFLARE_API_TOKEN}" : "${OPENCLAW_CLOUDFLARE_ZONE_ID:?Missing OPENCLAW_CLOUDFLARE_ZONE_ID}" - : "${OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN:?Missing OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN}" INGRESS_FILE="/var/lib/openclaw/cloudflared/ingress.json" CONFIG_FILE="/var/lib/openclaw/cloudflared/config.yml" - # Extract tunnel ID from token - CF_TUNNEL_ID=$(echo "${OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN}" | base64 -d | jq -r '.t') + # Read tunnel ID from credentials written during provisioning + CF_TUNNEL_ID=$(jq -r '.TunnelID' /var/lib/openclaw/cloudflared/credentials.json) # Upsert ingress rule (idempotent — removes existing rule for same hostname first) echo "Adding tunnel ingress rule for ${HOSTNAME}…" From 023a2b57c613b9dc1232ba198c9f61ab2d995ca5 Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Tue, 10 Feb 2026 14:58:06 -0800 Subject: [PATCH 20/22] print both urls after instance creation --- scripts/create-instance.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/create-instance.sh b/scripts/create-instance.sh index fb32ef0..1274ef6 100755 --- a/scripts/create-instance.sh +++ b/scripts/create-instance.sh @@ -167,5 +167,9 @@ fi echo echo "Instance created: ${INSTANCE_ID}" +echo +echo "Dashboard URL:" +./scripts/dashboard-url.sh "${INSTANCE_ID}" || true +echo echo "Terminal URL:" -OPENCLAW_BASE_DOMAIN="${OPENCLAW_BASE_DOMAIN}" OPENCLAW_HOST_SHARD="${OPENCLAW_HOST_SHARD:-}" OPENCLAW_SUBDOMAIN="${OPENCLAW_SUBDOMAIN:-}" OPENCLAW_DEPLOY_MODE="${OPENCLAW_DEPLOY_MODE}" OPENCLAW_TTYD_SECRET="${OPENCLAW_TTYD_SECRET:-}" ./scripts/terminal-url.sh "${INSTANCE_ID}" || true +./scripts/terminal-url.sh "${INSTANCE_ID}" || true From f3d718e16858a2586c809bab48bff136fb0a78c7 Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Tue, 10 Feb 2026 15:00:55 -0800 Subject: [PATCH 21/22] add Makefile --- Makefile | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..642e5af --- /dev/null +++ b/Makefile @@ -0,0 +1,35 @@ +.PHONY: help typecheck smoke check local-up local-down provision create-instance terminal-url dashboard-url + +COUNT ?= 2 + +help: ## Show available targets + @grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*## "}; {printf " %-20s %s\n", $$1, $$2}' + +typecheck: ## Run TypeScript type-checking + npm run typecheck + +smoke: ## Run provision script smoke test + npm run smoke:provision-script + +check: typecheck smoke ## Run all checks (typecheck + smoke) + +local-up: ## Start local demo (COUNT=2) + ./scripts/local-up.sh $(COUNT) + +local-down: ## Tear down local demo + ./scripts/local-down.sh + +provision: ## Provision server (requires sudo) + sudo ./scripts/provision-host.sh + +create-instance: ## Create instance (ID=name) + @test -n "$(ID)" || (echo "Usage: make create-instance ID=alice" >&2; exit 1) + sudo ./scripts/create-instance.sh $(ID) + +terminal-url: ## Print terminal URL (ID=name) + @test -n "$(ID)" || (echo "Usage: make terminal-url ID=alice" >&2; exit 1) + ./scripts/terminal-url.sh $(ID) + +dashboard-url: ## Print dashboard URL (ID=name) + @test -n "$(ID)" || (echo "Usage: make dashboard-url ID=alice" >&2; exit 1) + ./scripts/dashboard-url.sh $(ID) From 21b9e3ba83b1bdea2ea5bd230f7995b5ad96924c Mon Sep 17 00:00:00 2001 From: Ben Haas Date: Tue, 10 Feb 2026 15:30:54 -0800 Subject: [PATCH 22/22] update docs --- README.md | 48 ++++++++++++++++++++++++++------------ scripts/create-instance.sh | 9 ++----- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 2ba269d..e5b6950 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This runs on your laptop using [localtest.me](http://readme.localtest.me/) (a wi ```bash git clone https://github.com/Agent-3-7/openclaw-host-kit cd openclaw-host-kit -./scripts/local-up.sh 2 +make local-up COUNT=2 ``` That's it. You'll get 2 instances with URLs like: @@ -44,11 +44,30 @@ https://openclaw-demo2.localtest.me:18090/terminal?token=def... To stop everything: ```bash -./scripts/local-down.sh +make local-down ``` --- +## Makefile + +All common operations are available as `make` targets. Run `make help` to see them: + +| Target | Usage | Description | +|--------|-------|-------------| +| `help` | `make help` | Show available targets | +| `typecheck` | `make typecheck` | Run TypeScript type-checking | +| `smoke` | `make smoke` | Run provision script smoke test | +| `check` | `make check` | Run all checks (typecheck + smoke) | +| `local-up` | `make local-up COUNT=3` | Start local demo (default COUNT=2) | +| `local-down` | `make local-down` | Tear down local demo | +| `provision` | `make provision` | Provision server (runs with sudo) | +| `create-instance` | `make create-instance ID=alice` | Create an instance | +| `terminal-url` | `make terminal-url ID=alice` | Print terminal URL | +| `dashboard-url` | `make dashboard-url ID=alice` | Print dashboard URL | + +--- + ## Deploy on a Server (Share with Friends) This is the real setup. You run one server, and each friend gets their own subdomain with HTTPS. @@ -108,7 +127,7 @@ OPENCLAW_TTYD_SECRET=some_long_random_string Provision the server (installs Docker, starts Traefik reverse proxy): ```bash -sudo ./scripts/provision-host.sh +make provision ``` --- @@ -121,7 +140,7 @@ No public IP or open ports needed. Cloudflare handles TLS and routes traffic thr 1. A Linux machine (Ubuntu/Debian) — Docker is installed automatically 2. A domain with **DNS on [Cloudflare](https://dash.cloudflare.com/)** (free plan works) -3. [`cloudflared` CLI](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) installed on your local machine +3. [`cloudflared` CLI](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) installed on the **host machine** #### 1. Create a Cloudflare Tunnel @@ -130,13 +149,13 @@ cloudflared tunnel login cloudflared tunnel create openclaw-h1 ``` -Then grab the tunnel token: +This prints the tunnel ID and creates a credentials file at `~/.cloudflared/.json`. Copy it to the openclaw config directory: + ```bash -cloudflared tunnel token openclaw-h1 +sudo mkdir -p /var/lib/openclaw/cloudflared +sudo cp ~/.cloudflared/.json /var/lib/openclaw/cloudflared/credentials.json ``` -Copy the `eyJ...` string — you'll paste it into `.env` below. - #### 2. Get your Zone ID and API token **Zone ID:** @@ -163,14 +182,13 @@ OPENCLAW_BASE_DOMAIN=example.com OPENCLAW_TTYD_SECRET=some_long_random_string OPENCLAW_DEPLOY_MODE=cloudflare-tunnel -OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN=eyJhIjoiYWJj... # from step 1 OPENCLAW_CLOUDFLARE_API_TOKEN=your_api_token # from step 2 OPENCLAW_CLOUDFLARE_ZONE_ID=your_zone_id # from step 2 ``` Provision the server (installs Docker, starts Traefik + cloudflared): ```bash -sudo ./scripts/provision-host.sh +make provision ``` #### 4. Create instances @@ -183,15 +201,15 @@ From here it's the same as Option A — [jump to creating instances](#create-ins Pick any ID you like — a name, a random string, whatever: ```bash -sudo ./scripts/create-instance.sh alice -sudo ./scripts/create-instance.sh bob -sudo ./scripts/create-instance.sh charlie +make create-instance ID=alice +make create-instance ID=bob +make create-instance ID=charlie ``` Get the URLs to send them: ```bash -./scripts/terminal-url.sh alice -sudo ./scripts/dashboard-url.sh alice +make terminal-url ID=alice +make dashboard-url ID=alice ``` The URLs depend on your deploy mode: diff --git a/scripts/create-instance.sh b/scripts/create-instance.sh index 1274ef6..d1d313a 100755 --- a/scripts/create-instance.sh +++ b/scripts/create-instance.sh @@ -126,7 +126,7 @@ if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then . + [{"hostname": $hostname, "service": $service}, {"service": "http_status:404"}] end ' "$INGRESS_FILE") - echo "$UPDATED_INGRESS" > "$INGRESS_FILE" + echo "$UPDATED_INGRESS" >"$INGRESS_FILE" # Regenerate config.yml from ingress.json generate_cloudflared_config() { @@ -136,7 +136,7 @@ if [ "${OPENCLAW_DEPLOY_MODE}" = "cloudflare-tunnel" ]; then echo "credentials-file: /etc/cloudflared/credentials.json" echo "ingress:" jq -r '.[] | if .hostname then " - hostname: \(.hostname)\n service: \(.service)" else " - service: \(.service)" end' "$INGRESS_FILE" - } > "$CONFIG_FILE" + } >"$CONFIG_FILE" } generate_cloudflared_config "$CF_TUNNEL_ID" @@ -168,8 +168,3 @@ fi echo echo "Instance created: ${INSTANCE_ID}" echo -echo "Dashboard URL:" -./scripts/dashboard-url.sh "${INSTANCE_ID}" || true -echo -echo "Terminal URL:" -./scripts/terminal-url.sh "${INSTANCE_ID}" || true