diff --git a/.env.example b/.env.example index c93eeb0..a48e0af 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,23 @@ # Domain setup OPENCLAW_BASE_DOMAIN=example.com -OPENCLAW_SUBDOMAIN=openclaw -OPENCLAW_HOST_SHARD=h1 -# Traefik wildcard TLS (DNS-01 via Vercel) +# ---- Deploy mode ---- +# "traefik" (default) or "cloudflare-tunnel" +OPENCLAW_DEPLOY_MODE=traefik + +# ---- 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 -# Optional -OPENCLAW_VERCEL_TEAM_ID= +# 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 +# ---- Cloudflare Tunnel (OPENCLAW_DEPLOY_MODE=cloudflare-tunnel) ---- +OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN= +OPENCLAW_CLOUDFLARE_API_TOKEN= +OPENCLAW_CLOUDFLARE_ZONE_ID= -# Runtime image to run for instances (build locally or use your own) +# ---- Shared ---- +OPENCLAW_TTYD_SECRET=replace_me_with_a_long_random_string +# OPENCLAW_TTYD_TTL_SECONDS=86400 OPENCLAW_RUNTIME_IMAGE=openclaw-ttyd:local - 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) diff --git a/README.md b/README.md index 284a60c..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,15 +44,43 @@ 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. +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 +92,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 +108,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 @@ -93,33 +127,103 @@ OPENCLAW_TTYD_SECRET=some_long_random_string Provision the server (installs Docker, starts Traefik reverse proxy): ```bash -sudo ./scripts/provision-host.sh +make provision +``` + +--- + +### 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. [`cloudflared` CLI](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) installed on the **host machine** + +#### 1. Create a Cloudflare Tunnel + +```bash +cloudflared tunnel login +cloudflared tunnel create openclaw-h1 ``` +This prints the tunnel ID and creates a credentials file at `~/.cloudflared/.json`. Copy it to the openclaw config directory: + +```bash +sudo mkdir -p /var/lib/openclaw/cloudflared +sudo cp ~/.cloudflared/.json /var/lib/openclaw/cloudflared/credentials.json +``` + +#### 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 **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 + +#### 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_TTYD_SECRET=some_long_random_string + +OPENCLAW_DEPLOY_MODE=cloudflare-tunnel +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 +make provision +``` + +#### 4. 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: ```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 -# https://openclaw-alice.h1.openclaw.example.com/terminal?token=... - -sudo ./scripts/dashboard-url.sh alice -# https://openclaw-alice.h1.openclaw.example.com/overview?token=... +make terminal-url ID=alice +make dashboard-url ID=alice ``` +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. --- ## How It Works +**Direct (Traefik) mode:** + ```mermaid flowchart LR U[Browser] -->|https :443| T[Traefik] @@ -128,7 +232,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 +257,20 @@ 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 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..718ae27 --- /dev/null +++ b/deploy/cloudflare-tunnel/docker-compose.yml @@ -0,0 +1,36 @@ +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 --no-autoupdate --config /etc/cloudflared/config.yml run + volumes: + - /var/lib/openclaw/cloudflared:/etc/cloudflared:ro + +networks: + default: + name: traefik_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 3d0961e..d1d313a 100755 --- a/scripts/create-instance.sh +++ b/scripts/create-instance.sh @@ -20,12 +20,18 @@ if [ -f .env ]; then fi : "${OPENCLAW_BASE_DOMAIN:?Missing OPENCLAW_BASE_DOMAIN}" -: "${OPENCLAW_HOST_SHARD:?Missing OPENCLAW_HOST_SHARD}" -OPENCLAW_SUBDOMAIN="${OPENCLAW_SUBDOMAIN:-openclaw}" -OPENCLAW_RUNTIME_IMAGE="${OPENCLAW_RUNTIME_IMAGE:-openclaw-ttyd:local}" +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 -WILDCARD_DOMAIN="${OPENCLAW_HOST_SHARD}.${OPENCLAW_SUBDOMAIN}.${OPENCLAW_BASE_DOMAIN}" +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}" @@ -42,6 +48,31 @@ 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 + 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}" \ @@ -57,20 +88,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}" \ @@ -80,7 +105,66 @@ 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_API_TOKEN:?Missing OPENCLAW_CLOUDFLARE_API_TOKEN}" + : "${OPENCLAW_CLOUDFLARE_ZONE_ID:?Missing OPENCLAW_CLOUDFLARE_ZONE_ID}" + + INGRESS_FILE="/var/lib/openclaw/cloudflared/ingress.json" + CONFIG_FILE="/var/lib/openclaw/cloudflared/config.yml" + + # 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}…" + 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_TARGET="${CF_TUNNEL_ID}.cfargotunnel.com" + + cf_check() { + local response="$1" action="$2" + 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>/dev/null >&2 + echo " Response: ${response}" >&2 + exit 1 + fi + } + + 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}" \ + -H "Content-Type: application/json" \ + --data "{\"type\":\"CNAME\",\"name\":\"${HOSTNAME}\",\"content\":\"${TUNNEL_TARGET}\",\"proxied\":true,\"ttl\":1}") + cf_check "$DNS_RESULT" "create DNS record" +fi + 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 +echo 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 537f730..249be3e 100755 --- a/scripts/provision-host.sh +++ b/scripts/provision-host.sh @@ -13,18 +13,32 @@ 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}" +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 + : "${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 +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 @@ -32,17 +46,71 @@ 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 -mkdir -p /opt/traefik -touch /opt/traefik/acme.json -chmod 600 /opt/traefik/acme.json +# --- 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') -# Ensure the network is always named `traefik_default` (matches instance labels). -docker compose -p traefik -f deploy/traefik/docker-compose.yml up -d --build + mkdir -p /var/lib/openclaw/cloudflared -echo -echo "Traefik is up." -echo "Expected wildcard DNS (A record): *.${OPENCLAW_WILDCARD_DOMAIN} -> " + 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 + + echo + echo "Traefik + cloudflared are up." +else + mkdir -p /opt/traefik + touch /opt/traefik/acme.json + chmod 600 /opt/traefik/acme.json + + # Ensure the network is always named `traefik_default` (matches instance labels). + docker compose -p traefik --env-file .env -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..6f83a31 100644 --- a/scripts/smoke-provision-script.ts +++ b/scripts/smoke-provision-script.ts @@ -1,6 +1,7 @@ -import { buildProvisionScript } from '../src/index'; +import { buildProvisionScript, buildInstanceUrls } 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,86 @@ 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 testTunnelToken = 'eyJhIjoidGVzdC1hY2NvdW50IiwidCI6InRlc3QtdHVubmVsLWlkIiwicyI6InRlc3Qtc2VjcmV0In0='; +const cfScript = buildProvisionScript({ + deployMode: 'cloudflare-tunnel', + tunnelToken: testTunnelToken, + cloudflareTunnelCompose: { + tunnelId: 'test-tunnel-id', + 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', +}); + +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('--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'); +} +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] 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('jq')) { + throw new Error('[cloudflare-tunnel] Expected jq in package install'); } -console.log('OK'); +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'); 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 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}")" diff --git a/src/core/cloudflareDns.ts b/src/core/cloudflareDns.ts new file mode 100644 index 0000000..8986359 --- /dev/null +++ b/src/core/cloudflareDns.ts @@ -0,0 +1,72 @@ +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, + }), + }; +} + +/** 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/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/cloudflared.ts b/src/core/cloudflared.ts new file mode 100644 index 0000000..13f4a1c --- /dev/null +++ b/src/core/cloudflared.ts @@ -0,0 +1,107 @@ +export interface CloudflareTunnelComposeInput { + tunnelId: 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 [ + '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 --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/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..e8fb45f 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; @@ -10,12 +11,13 @@ export interface DockerResourceLimits { export interface BuildDockerRunCommandInput extends DockerResourceLimits { instanceId: string; runtimeImage: string; - hostShard: string; + hostShard?: string; baseDomain: string; subdomain?: string; 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; @@ -66,35 +71,41 @@ 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}`; 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..49a02e1 100644 --- a/src/core/provision.ts +++ b/src/core/provision.ts @@ -1,23 +1,44 @@ -import { buildTraefikComposeYaml } from './traefik'; +import { buildTraefikComposeYaml, TraefikComposeInput } from './traefik'; +import { buildCloudflareTunnelComposeYaml, CloudflareTunnelComposeInput, decodeTunnelToken, buildCredentialsJson, buildCloudflaredConfigYaml } 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; + tunnelToken: string; + cloudflareDns?: { + apiToken: string; + zoneId: string; + tunnelId: string; + baseDomain: string; + }; + 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', '${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', @@ -30,13 +51,47 @@ 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 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, 'YAML', `${sudoLiteral} docker compose -f ${composePath} up -d`, dockerPull, - ].filter(Boolean).join('\n'); + ]; + + const dnsSteps: string[] = []; + + return [...commonSteps, ...acmeSteps, ...cloudflaredSteps, ...composeSteps, ...dnsSteps].filter(Boolean).join('\n'); } diff --git a/src/core/traefik.ts b/src/core/traefik.ts index 394b635..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}`, @@ -51,3 +50,43 @@ 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 [ + '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/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 { diff --git a/src/index.ts b/src/index.ts index 345b798..c2161db 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,22 @@ 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, decodeTunnelToken, buildCredentialsJson, buildCloudflaredConfigYaml } from './core/cloudflared'; +export type { CloudflareTunnelComposeInput, TunnelCredentials, CloudflaredIngressRule } from './core/cloudflared'; +export { + buildTunnelCname, + buildCreateDnsRecordRequest, + 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'; -