From c95c334fd86e3cf638b88c66d35acedab0d1b1a2 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sat, 23 May 2026 18:10:37 +0100 Subject: [PATCH 01/18] Launch hosted captun.sh tunnels --- README.md | 23 ++- src/cli/bin.ts | 337 ++++++++++++++++++++----------------- src/cli/deploy.ts | 7 +- src/index.ts | 43 ++++- src/routing.ts | 25 +++ src/worker.ts | 69 ++++++++ tasks/hosted-captun-sh.md | 34 ++++ test/e2e.test.ts | 26 +++ test/public-hosted.test.ts | 109 ++++++++++++ test/worker.test.ts | 33 ++++ 10 files changed, 533 insertions(+), 173 deletions(-) create mode 100644 tasks/hosted-captun-sh.md create mode 100644 test/public-hosted.test.ts diff --git a/README.md b/README.md index 251c66a..6ff8584 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,20 @@ Captun is a tiny reference implementation of a self-hosted ngrok or Cloudflare T ## Quick start -First deploy a captun worker to your cloudflare account. You can think of this like your own personal ngrok server, but [faster](#performance): - -`deploy` expects Cloudflare auth to already be available. Run `npx wrangler login` once, or set `CLOUDFLARE_API_TOKEN` for CI and other non-interactive shells. +Expose a local HTTP server with the hosted `captun.sh` tunnel service: ```bash -npx captun deploy +npx captun 3000 ``` -Then tunnel to it: +That prints a public URL like `https://abc123.captun.sh` and forwards requests to `localhost:3000`. + +If you want your own tunnel server, deploy a captun Worker to your Cloudflare account. You can think of this like your own personal ngrok server, but [faster](#performance): + +`deploy` expects Cloudflare auth to already be available. Run `npx wrangler login` once, or set `CLOUDFLARE_API_TOKEN` for CI and other non-interactive shells. ```bash -npx captun 3000 +npx captun deploy ``` -The deploy command will use `wrangler` under the hood to deploy an opinionated captun-tunneler-worker to your cloudflare account, and will store the server url in an XDG config file, and uses it when you tunnel to it. +The deploy command uses `wrangler` under the hood to deploy an opinionated captun Tunnel Gateway to your Cloudflare account, then stores its gateway URL and token in an XDG config file for later tunnel commands. >Server: Response - Server-->>HTTP: Response + Client->>Gateway: WebSocket RPC connect to ?captun-connect=1&captun-name=demo with fetcher as main capability + Gateway-->>Client: ready({ url }) + HTTP->>Gateway: GET /demo/report + Gateway->>Client: fetch(request) + Client-->>Gateway: Response + Gateway-->>HTTP: Response ``` See [examples/weather-reporter](./examples/weather-reporter) for a small workspace package that imports `captun` and has its own e2e tests. @@ -256,7 +258,7 @@ pnpm run build pnpm run dev ``` -Run tests with `pnpm test`. The root e2e suite uses Miniflare by default; set `CAPTUN_SERVER_URL`, with optional `CAPTUN_SECRET`, to run the same cases against a deployed Worker. +Run tests with `pnpm test`. The root e2e suite uses Miniflare by default; set `CAPTUN_GATEWAY`, with optional `CAPTUN_TOKEN`, to run the same cases against a deployed Worker. End-to-end smoke tests for build, dry-run deploy, local `wrangler dev`, tunnel, and `curl` live in [`scripts/smoke/`](./scripts/smoke) with documentation in [docs/smoke-test.md](./docs/smoke-test.md): diff --git a/docs/adr/0001-gateway-owned-tunnel-addressing.md b/docs/adr/0001-gateway-owned-tunnel-addressing.md new file mode 100644 index 0000000..a410dc7 --- /dev/null +++ b/docs/adr/0001-gateway-owned-tunnel-addressing.md @@ -0,0 +1,10 @@ +# Gateway-Owned Tunnel Addressing + +Captun clients should connect to a user-supplied `gateway` URL with Captun-owned query parameters for connect intent, tunnel name, and token; they should not construct public tunnel URLs or dial a magic `/__captun-connect` path. The Tunnel Gateway owns Tunnel Addressing and returns the active Tunnel's public URL and reusable token over the Cap'n Web session before `createCaptunTunnel` resolves. + +## Consequences + +- The public API can use one address concept: `gateway`. +- `serverUrl`, `url`, and `/__captun-connect` are legacy implementation shapes to remove before the pre-user API hardens. +- Custom-domain deployments need a stable gateway hostname inside the wildcard route by default, with that hostname reserved as a Tunnel Name. +- Gateway Policy must be configured explicitly; it must not be inferred from hostname or addressing mode. diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 50c50ea..d2bea54 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -5,7 +5,7 @@ These are pragmatic benchmark notes, not a formal benchmark suite. The goal is t The startup benchmark in [scripts/benchmark-startup.ts](../scripts/benchmark-startup.ts) measures from "start creating a tunnel" to the first successful public HTTP fetch through that tunnel: ```bash -CAPTUN_SERVER_URL=https://{name}.tunnels.example.com \ +CAPTUN_GATEWAY=https://gateway.tunnels.example.com \ PROVIDERS=captun,ngrok,cloudflared,wrangler-tunnel \ COUNTS=1 \ OUT=docs/performance/captun-startup.json \ @@ -15,7 +15,7 @@ node scripts/benchmark-startup.ts The stream benchmark in [scripts/benchmark-streams.ts](../scripts/benchmark-streams.ts) creates many named tunnels and fetches large responses through them: ```bash -CAPTUN_SERVER_URL=https://captun.example.workers.dev \ +CAPTUN_GATEWAY=https://captun.example.workers.dev \ COUNTS=100 \ BYTES=2097152 \ OUT=docs/performance/captun-streams.json \ diff --git a/docs/smoke-test.md b/docs/smoke-test.md index 671b2c9..469c532 100644 --- a/docs/smoke-test.md +++ b/docs/smoke-test.md @@ -46,12 +46,12 @@ pnpm run build npx captun deploy ``` -The CLI writes `serverUrl` and `secret` to `~/.config/captun/config.json` automatically. +The CLI writes `gateway` and `token` to `~/.config/captun/config.json` automatically. Non-interactive: ```bash -npx captun deploy --secret "$(openssl rand -base64 32 | tr -d '\n=' | tr '/+' '_-')" +npx captun deploy --token "$(openssl rand -base64 32 | tr -d '\n=' | tr '/+' '_-')" ``` Requires `CLOUDFLARE_API_TOKEN` or prior `wrangler login`. @@ -59,10 +59,10 @@ Requires `CLOUDFLARE_API_TOKEN` or prior `wrangler login`. ### B. Wildcard route deploy ```bash -npx captun deploy --route '*.tunnels.yourdomain.com/*' --zone yourdomain.com --secret "$SECRET" +npx captun deploy --route '*.tunnels.yourdomain.com/*' --zone yourdomain.com --token "$TOKEN" ``` -Config `serverUrl` becomes `https://{name}.tunnels.yourdomain.com`. +Config `gateway` becomes `https://gateway.tunnels.yourdomain.com`. The gateway returns each tunnel's public URL when the client connects. ### C. Tunnel port 3000 @@ -71,22 +71,22 @@ After deploy, config is enough: ```bash python3 -m http.server 3000 # terminal 1 npx captun 3000 --name demo # terminal 2 (reads ~/.config/captun/config.json) -curl -i "$(jq -r .serverUrl ~/.config/captun/config.json)demo/" +curl -i "https://demo.tunnels.yourdomain.com/" ``` Or step **6** against an existing Worker: ```bash -export SMOKE_SERVER_URL='https://captun..workers.dev' -export CAPTUN_SECRET='' +export SMOKE_GATEWAY='https://captun..workers.dev' +export CAPTUN_TOKEN='' ./scripts/smoke-test.sh step-6-tunnel-remote ``` Wildcard: ```bash -export SMOKE_SERVER_URL='https://{name}.tunnels.templestein.com' -export CAPTUN_SECRET='...' +export SMOKE_GATEWAY='https://gateway.tunnels.templestein.com' +export CAPTUN_TOKEN='...' ./scripts/smoke-test.sh step-6-tunnel-remote curl -i "https://smoke-test.tunnels.templestein.com/" ``` @@ -112,7 +112,7 @@ doppler run -p os -c dev_jonas -- sh -c ' npx captun deploy \ --route "*.tunnels.templestein.com/*" \ --zone templestein.com \ - --secret "$(openssl rand -base64 32 | tr -d "\n=" | tr "/+" "_-")" + --token "$(openssl rand -base64 32 | tr -d "\n=" | tr "/+" "_-")" ' # Terminal 1 @@ -125,7 +125,7 @@ npx captun 3000 --name banana curl https://banana.tunnels.templestein.com/ ``` -**Proved 2026-05-18:** deploy to `*.tunnels.templestein.com/*`, tunnel `banana` → local `:3000`, `curl` returned `200` with body `

banana tunnel works

`. Secret stored in `~/.config/captun/config.json` and set as Worker `CAPTUN_SECRET`. +**Proved 2026-05-18:** deploy to `*.tunnels.templestein.com/*`, tunnel `banana` → local `:3000`, `curl` returned `200` with body `

banana tunnel works

`. Token stored in `~/.config/captun/config.json` and set as Worker `CAPTUN_TOKEN`. `--zone` is required when Wrangler cannot infer the zone from the route pattern alone. diff --git a/examples/weather-reporter/e2e.test.ts b/examples/weather-reporter/e2e.test.ts index cffb101..f25f5dc 100644 --- a/examples/weather-reporter/e2e.test.ts +++ b/examples/weather-reporter/e2e.test.ts @@ -8,7 +8,7 @@ vi.setConfig({ testTimeout: 15_000 }); test("returns nicely formatted weather report", async () => { await using app = await createWeatherReporterFixture(); using _tunnel = await createCaptunTunnel({ - url: `${app.url}/__intercept-egress-traffic`, + gateway: `${app.url}/__intercept-egress-traffic`, fetch(request) { if (request.url === "https://wttr.in/london?format=j1") { return Response.json({ current_condition: [{ temp_C: "18" }] }); diff --git a/examples/weather-reporter/worker.ts b/examples/weather-reporter/worker.ts index 98e83c4..d120253 100644 --- a/examples/weather-reporter/worker.ts +++ b/examples/weather-reporter/worker.ts @@ -1,12 +1,12 @@ import { DurableObject } from "cloudflare:workers"; -import { acceptCaptunTunnel } from "captun"; +import { acceptFetcherCapability, type FetcherStub } from "captun"; type WeatherReporterEnv = Env & { WEATHER_REPORTER_EGRESS: DurableObjectNamespace; }; export class WeatherReporterEgressTunnel extends DurableObject { - private egressTunnel: ReturnType["tunnel"] | undefined; + private egressTunnel: FetcherStub | undefined; async fetch(request: Request) { const url = new URL(request.url); @@ -24,12 +24,15 @@ export class WeatherReporterEgressTunnel extends DurableObject { - if (this.egressTunnel === tunnel) this.egressTunnel = undefined; + if (this.egressTunnel === fetcher) this.egressTunnel = undefined; }, }); - this.egressTunnel = tunnel; + this.egressTunnel = fetcher; + queueMicrotask(() => { + void fetcher.ready({ url: new URL(request.url).origin }); + }); return response; } diff --git a/scripts/benchmark-startup.ts b/scripts/benchmark-startup.ts index 2981068..cf20a56 100644 --- a/scripts/benchmark-startup.ts +++ b/scripts/benchmark-startup.ts @@ -38,7 +38,7 @@ const providers = (process.env.PROVIDERS ?? "captun") const counts = (process.env.COUNTS ?? "1,10,100,500,1000,2000") .split(",") .map((value) => Number(value.trim())); -const captunUrl = process.env.CAPTUN_SERVER_URL ?? "https://{name}.tunnels.example.com"; +const captunGateway = process.env.CAPTUN_GATEWAY ?? "https://gateway.tunnels.example.com"; const out = process.env.OUT ?? `docs/performance/startup-${new Date().toISOString().replace(/[:.]/g, "-")}.json`; @@ -73,7 +73,7 @@ try { await mkdir("docs/performance", { recursive: true }); await writeFile( out, - `${JSON.stringify({ originUrl, captunUrl, timeoutMs, results }, null, 2)}\n`, + `${JSON.stringify({ originUrl, captunGateway, timeoutMs, results }, null, 2)}\n`, ); console.log(`wrote ${out}`); } finally { @@ -111,22 +111,20 @@ async function measure(provider: Provider, index: number, originUrl: string): Pr async function measureCaptun(index: number, originUrl: string): Promise { const name = `bench-${randomBytes(8).toString("hex")}`; - const url = tunnelUrl(captunUrl, name); const started = performance.now(); - let tunnel: Disposable | undefined; + let tunnel: Awaited> | undefined; try { tunnel = await createCaptunTunnel({ - url: `${url}/__captun-connect`, - headers: process.env.CAPTUN_SECRET - ? { authorization: `Bearer ${process.env.CAPTUN_SECRET}` } - : undefined, + gateway: captunGateway, + name, + token: process.env.CAPTUN_TOKEN, fetch: (request) => { const incoming = new URL(request.url); return fetch(`${originUrl}${incoming.pathname}${incoming.search}`, request); }, }); - await waitForFetch(url, started); - return { index, ok: true, ms: performance.now() - started, url }; + await waitForFetch(tunnel.url, started); + return { index, ok: true, ms: performance.now() - started, url: tunnel.url }; } finally { tunnel?.[Symbol.dispose](); } @@ -227,13 +225,6 @@ async function waitForFetch(url: string | URL, started: number) { throw new Error(`first fetch timed out: ${lastError}`); } -function tunnelUrl(base: string, name: string) { - if (base.includes("{name}")) return base.replaceAll("{name}", name).replace(/\/$/, ""); - const url = new URL(base); - url.pathname = `/${name}`; - return url.toString().replace(/\/$/, ""); -} - function parseCloudflareTunnelUrl(output: string) { const match = output.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i); return match ? new URL(match[0]) : undefined; diff --git a/scripts/benchmark-streams.ts b/scripts/benchmark-streams.ts index 7b52f9c..e4603ac 100644 --- a/scripts/benchmark-streams.ts +++ b/scripts/benchmark-streams.ts @@ -35,7 +35,7 @@ type Result = { const counts = (process.env.COUNTS ?? "1,10,25,50,100") .split(",") .map((value) => Number(value.trim())); -const serverUrl = process.env.CAPTUN_SERVER_URL ?? "https://captun.example.workers.dev"; +const gateway = process.env.CAPTUN_GATEWAY ?? "https://captun.example.workers.dev"; const out = process.env.OUT ?? "docs/performance/captun-streams.json"; const bytes = Number(process.env.BYTES ?? 2 * 1024 * 1024); const chunkBytes = Number(process.env.CHUNK_BYTES ?? 64 * 1024); @@ -63,8 +63,9 @@ if (warmupCount > 0) { Math.min(warmupCount, Math.max(connectConcurrency, 25)), async (index) => { const session = await createCaptunTunnel({ - url: `${tunnelUrl(`${namePrefix}-warm-${index}`)}/__captun-connect`, - headers: captunHeaders(), + gateway, + name: `${namePrefix}-warm-${index}`, + token: process.env.CAPTUN_TOKEN, fetch: () => testResponse("stream"), }); session[Symbol.dispose](); @@ -85,7 +86,7 @@ for (const count of counts) { await mkdir("docs/performance", { recursive: true }); await writeFile( out, - `${JSON.stringify({ serverUrl, bytes, chunkBytes, modes, readMode, timeoutMs, results }, null, 2)}\n`, + `${JSON.stringify({ gateway, bytes, chunkBytes, modes, readMode, timeoutMs, results }, null, 2)}\n`, ); console.log(`wrote ${out}`); @@ -127,19 +128,19 @@ async function runPool(count: number, concurrency: number, task: (index: numb async function measure(index: number, mode: string): Promise { // Deterministic names make runs reproducible and let us intentionally spread // or collide names across shards by changing NAME_PREFIX and SHARD_COUNT. - const url = tunnelUrl(`${namePrefix}-${index}`); const started = performance.now(); const cpuStarted = process.cpuUsage(); const eventLoopStarted = performance.eventLoopUtilization(); - let tunnel: Disposable | undefined; + let tunnel: Awaited> | undefined; try { tunnel = await createCaptunTunnel({ - url: `${url}/__captun-connect`, - headers: captunHeaders(), + gateway, + name: `${namePrefix}-${index}`, + token: process.env.CAPTUN_TOKEN, fetch: () => testResponse(mode), }); const connectedAt = performance.now(); - const response = await withTimeout(fetch(url), timeoutMs, "fetch timed out"); + const response = await withTimeout(fetch(tunnel.url), timeoutMs, "fetch timed out"); const responseAt = performance.now(); if (!response.ok) throw new Error(`HTTP ${response.status}`); const received = await readBytes(response); @@ -172,12 +173,6 @@ async function measure(index: number, mode: string): Promise { } } -function captunHeaders() { - return process.env.CAPTUN_SECRET - ? { authorization: `Bearer ${process.env.CAPTUN_SECRET}` } - : undefined; -} - function testResponse(mode: string) { if (mode === "bytes") { return new Response(new Uint8Array(bytes), { @@ -216,14 +211,6 @@ async function readBytes(response: Response) { return total; } -function tunnelUrl(name: string) { - if (serverUrl.includes("{name}")) return serverUrl.replaceAll("{name}", name).replace(/\/$/, ""); - const url = new URL(serverUrl); - if (url.hostname.match(/^[^.]+\.tunnels\./)) url.pathname = "/"; - else url.pathname = `/${name}`; - return url.toString().replace(/\/$/, ""); -} - async function withTimeout(promise: Promise, ms: number, message: string) { let timer: ReturnType | undefined; try { diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 69df6b7..956adb3 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -19,11 +19,11 @@ Steps (run in order for local smoke): step-3-wrangler-dev background wrangler dev (:8787) step-4-http-origin background python http.server (:3456) step-5-tunnel-local captun + curl via local wrangler dev - step-6-tunnel-remote captun + curl via SMOKE_SERVER_URL (deployed Worker) + step-6-tunnel-remote captun + curl via SMOKE_GATEWAY (deployed Worker) step-stop kill background processes Local all-in-one: ./scripts/smoke-test.sh -Remote tunnel only: SMOKE_SERVER_URL=https://... ./scripts/smoke-test.sh step-6 +Remote tunnel only: SMOKE_GATEWAY=https://... ./scripts/smoke-test.sh step-6 State/logs: $CAPTUN_SMOKE_DIR (default /tmp/captun-smoke) EOF diff --git a/scripts/smoke/step-1-deploy-dry-run-workers.sh b/scripts/smoke/step-1-deploy-dry-run-workers.sh index 9aa2415..d81c952 100755 --- a/scripts/smoke/step-1-deploy-dry-run-workers.sh +++ b/scripts/smoke/step-1-deploy-dry-run-workers.sh @@ -4,6 +4,6 @@ source "$(dirname "$0")/lib.sh" cd "$ROOT" LOG="$STATE_DIR/step-1-workers-dev-dry-run.log" log "captun deploy --dry-run (workers.dev)" -$CAPTUN_BIN deploy --dry-run --secret smoke-dry-run-secret >"$LOG" 2>&1 +$CAPTUN_BIN deploy --dry-run --token smoke-dry-run-token >"$LOG" 2>&1 grep -q "Dry run complete" "$LOG" || fail "see $LOG" pass "workers.dev dry-run (log: $LOG)" diff --git a/scripts/smoke/step-2-deploy-dry-run-wildcard.sh b/scripts/smoke/step-2-deploy-dry-run-wildcard.sh index 83af06f..6e03e41 100755 --- a/scripts/smoke/step-2-deploy-dry-run-wildcard.sh +++ b/scripts/smoke/step-2-deploy-dry-run-wildcard.sh @@ -4,7 +4,7 @@ source "$(dirname "$0")/lib.sh" cd "$ROOT" LOG="$STATE_DIR/step-2-wildcard-dry-run.log" log "captun deploy --dry-run (wildcard route)" -$CAPTUN_BIN deploy --dry-run --route '*.captun.example.com/*' --secret smoke-dry-run-secret >"$LOG" 2>&1 +$CAPTUN_BIN deploy --dry-run --route '*.captun.example.com/*' --token smoke-dry-run-token >"$LOG" 2>&1 grep -q "Dry run complete" "$LOG" || fail "see $LOG" -grep -q '{name}.captun.example.com' "$LOG" || fail "wildcard URL not inferred — see $LOG" +grep -q 'https://gateway.captun.example.com' "$LOG" || fail "wildcard gateway not inferred — see $LOG" pass "wildcard dry-run (log: $LOG)" diff --git a/scripts/smoke/step-5-tunnel-local.sh b/scripts/smoke/step-5-tunnel-local.sh index 207d66f..8f45afd 100755 --- a/scripts/smoke/step-5-tunnel-local.sh +++ b/scripts/smoke/step-5-tunnel-local.sh @@ -4,14 +4,14 @@ set -euo pipefail source "$(dirname "$0")/lib.sh" cd "$ROOT" -SERVER_URL="${SMOKE_SERVER_URL:-http://127.0.0.1:$WRANGLER_PORT}" -CURL_URL="${SERVER_URL%/}/${SMOKE_NAME}/" +GATEWAY="${SMOKE_GATEWAY:-http://127.0.0.1:$WRANGLER_PORT}" +CURL_URL="${GATEWAY%/}/${SMOKE_NAME}/" PIDFILE="$STATE_DIR/tunnel.pid" LOG="$STATE_DIR/tunnel.log" stop_pid "$PIDFILE" -log "captun $HTTP_PORT --name $SMOKE_NAME --server-url $SERVER_URL" -$CAPTUN_BIN "$HTTP_PORT" --name "$SMOKE_NAME" --server-url "$SERVER_URL" >"$LOG" 2>&1 & +log "captun $HTTP_PORT --name $SMOKE_NAME --gateway $GATEWAY" +$CAPTUN_BIN "$HTTP_PORT" --name "$SMOKE_NAME" --gateway "$GATEWAY" >"$LOG" 2>&1 & echo $! >"$PIDFILE" wait_for_log "$LOG" "Press Ctrl+C to close tunnel" 30 diff --git a/scripts/smoke/step-6-tunnel-remote.sh b/scripts/smoke/step-6-tunnel-remote.sh index b10ef29..44b3c1e 100755 --- a/scripts/smoke/step-6-tunnel-remote.sh +++ b/scripts/smoke/step-6-tunnel-remote.sh @@ -1,30 +1,26 @@ #!/usr/bin/env bash -# Tunnel to a deployed Worker (set SMOKE_SERVER_URL and optional CAPTUN_SECRET). +# Tunnel to a deployed Worker (set SMOKE_GATEWAY and optional CAPTUN_TOKEN). set -euo pipefail source "$(dirname "$0")/lib.sh" cd "$ROOT" -SERVER_URL="${SMOKE_SERVER_URL:?Set SMOKE_SERVER_URL (e.g. https://captun.account.workers.dev)}" - -if [[ "$SERVER_URL" == *"{name}"* ]]; then - CURL_URL="${SERVER_URL//\{name\}/$SMOKE_NAME}" -else - CURL_URL="${SERVER_URL%/}/${SMOKE_NAME}/" -fi -CURL_URL="${CURL_URL%/}/" +GATEWAY="${SMOKE_GATEWAY:?Set SMOKE_GATEWAY (e.g. https://captun.account.workers.dev)}" PIDFILE="$STATE_DIR/tunnel-remote.pid" LOG="$STATE_DIR/tunnel-remote.log" stop_pid "$PIDFILE" -log "captun $HTTP_PORT --name $SMOKE_NAME --server-url $SERVER_URL" -if [[ -n "${CAPTUN_SECRET:-}" ]]; then - $CAPTUN_BIN "$HTTP_PORT" --name "$SMOKE_NAME" --server-url "$SERVER_URL" --secret "$CAPTUN_SECRET" >"$LOG" 2>&1 & +log "captun $HTTP_PORT --name $SMOKE_NAME --gateway $GATEWAY" +if [[ -n "${CAPTUN_TOKEN:-}" ]]; then + $CAPTUN_BIN "$HTTP_PORT" --name "$SMOKE_NAME" --gateway "$GATEWAY" --token "$CAPTUN_TOKEN" >"$LOG" 2>&1 & else - $CAPTUN_BIN "$HTTP_PORT" --name "$SMOKE_NAME" --server-url "$SERVER_URL" >"$LOG" 2>&1 & + $CAPTUN_BIN "$HTTP_PORT" --name "$SMOKE_NAME" --gateway "$GATEWAY" >"$LOG" 2>&1 & fi echo $! >"$PIDFILE" wait_for_log "$LOG" "Press Ctrl+C to close tunnel" 30 +CURL_URL="${SMOKE_TUNNEL_URL:-$(grep -Eo 'https?://[^[:space:]]+' "$LOG" | tail -n 1 || true)}" +CURL_URL="${CURL_URL%/}/" +[[ -n "$CURL_URL" ]] || fail "could not infer tunnel URL from $LOG" log "curl $CURL_URL" BODY="$STATE_DIR/curl-remote-body.txt" diff --git a/src/cli/bin.ts b/src/cli/bin.ts index b278817..266a174 100755 --- a/src/cli/bin.ts +++ b/src/cli/bin.ts @@ -16,11 +16,7 @@ import { CliFriendlyError } from "./cli-error.js"; import { createCaptunTunnel } from "../index.js"; import { assertLocalTargetAcceptingConnections } from "./local-target.js"; import { withSpinner } from "./spinner.js"; -import { - getTunnelUrlFromServerUrl, - HOSTED_CAPTUN_SERVER_URL, - TUNNEL_URL_HEADER, -} from "../routing.js"; +import { HOSTED_CAPTUN_GATEWAY } from "../routing.js"; import { captunHealthResponse, confirmTunnelHealth, @@ -29,25 +25,24 @@ import { import { deployWorker, openInBrowser, runDeployWizard, waitForCertWithSpinner } from "./deploy.js"; export type Config = { - serverUrl: string; - secret?: string; + gateway: string; + token?: string; }; type TunnelCliInput = { target: string; name?: string; - serverUrl?: string; - secret?: string; + gateway?: string; + token?: string; requestLogs: boolean; }; export type ResolvedTunnel = { name: string; - serverUrl: string; + gateway: string; target: string; - secret?: string; + token?: string; requestLogs: boolean; - tunnel: string; }; export type TunnelReady = { @@ -99,8 +94,8 @@ export function createCaptunCliRouter(options: CaptunCliRouterOptions = {}) { .describe("Local target to expose, as a port, host:port, or URL") .meta({ positional: true }), name: z.string().optional().describe("Tunnel name"), - serverUrl: z.url().optional().describe("Tunnel Worker base URL"), - secret: z.string().optional().describe("Tunnel connection secret"), + gateway: z.url().optional().describe("Tunnel Gateway URL"), + token: z.string().optional().describe("Tunnel Gateway token"), requestLogs: z.boolean().default(true).describe("Print basic request logs"), }), ) @@ -140,10 +135,10 @@ export function createCaptunCliRouter(options: CaptunCliRouterOptions = {}) { .string() .optional() .describe("Cloudflare zone name for the route, for example example.com"), - secret: z + token: z .string() .optional() - .describe("Secret required by tunnel clients; generated when omitted"), + .describe("Token required by tunnel clients; generated when omitted"), shards: z .number() .int() @@ -158,13 +153,13 @@ export function createCaptunCliRouter(options: CaptunCliRouterOptions = {}) { ) .handler(async ({ input }) => { const wizardResult = await runDeployWizard(input, { packageRoot }); - const secret = wizardResult.secret; - const serverUrl = await deployWorker( + const token = wizardResult.token; + const gateway = await deployWorker( { name: wizardResult.name, route: wizardResult.route, zone: wizardResult.zone, - secret, + token, shards: wizardResult.shards, accountId: wizardResult.accountId, customHostname: wizardResult.customHostname, @@ -174,13 +169,13 @@ export function createCaptunCliRouter(options: CaptunCliRouterOptions = {}) { ); if (input.dryRun) { console.log("\nDry run complete (no upload, config not written)."); - console.log(`Expected server URL pattern: ${serverUrl}`); - return { serverUrl, dryRun: true }; + console.log(`Expected gateway: ${gateway}`); + return { gateway, dryRun: true }; } // Worker is live now — persist config before anything that can fail later // (e.g. cert provisioning can time out, but the deploy is already complete). - await writeCliConfig({ serverUrl, secret }); + await writeCliConfig({ gateway, token }); if (wizardResult.certWait) { try { @@ -197,25 +192,25 @@ export function createCaptunCliRouter(options: CaptunCliRouterOptions = {}) { } printDeploySummary({ - serverUrl, - workerName: wizardResult.name ?? "captun", + gateway, + workerName: wizardResult.name || "captun", route: wizardResult.route, zone: wizardResult.zone, - shards: wizardResult.shards ?? 1, + shards: wizardResult.shards || 1, }); if (process.stdin.isTTY) { await postDeploySelfTest({ - serverUrl, - secret, - workerName: wizardResult.name ?? "captun", + gateway, + token, + workerName: wizardResult.name || "captun", route: wizardResult.route, zone: wizardResult.zone, - shards: wizardResult.shards ?? 1, + shards: wizardResult.shards || 1, }); } - return { serverUrl, configPath }; + return { gateway, configPath }; }), }); } @@ -223,7 +218,7 @@ export function createCaptunCliRouter(options: CaptunCliRouterOptions = {}) { export const router = createCaptunCliRouter(); type DeployedSummary = { - serverUrl: string; + gateway: string; workerName: string; route?: string; zone?: string; @@ -235,12 +230,12 @@ function printDeploySummary(summary: DeployedSummary) { tunnelInfoRow("worker", color.cyan(summary.workerName)); if (summary.route) tunnelInfoRow("route", color.cyan(summary.route)); if (summary.zone) tunnelInfoRow("zone", color.cyan(summary.zone)); - tunnelInfoRow("server url", color.cyan(summary.serverUrl)); + tunnelInfoRow("gateway", color.cyan(summary.gateway)); tunnelInfoRow("shards", color.cyan(String(summary.shards))); tunnelInfoRow("config", color.cyan(configPath)); } -async function postDeploySelfTest(opts: DeployedSummary & { secret: string }) { +async function postDeploySelfTest(opts: DeployedSummary & { token: string }) { const server = createServer((_req, res) => { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(renderSuccessPage(opts)); @@ -259,11 +254,10 @@ async function postDeploySelfTest(opts: DeployedSummary & { secret: string }) { const name = randomName(); const tunnel: ResolvedTunnel = { name, - serverUrl: opts.serverUrl, + gateway: opts.gateway, target, - secret: opts.secret, + token: opts.token, requestLogs: true, - tunnel: getTunnelUrlFromServerUrl(opts.serverUrl, name), }; printTunnelOpening(tunnel); @@ -274,7 +268,7 @@ async function postDeploySelfTest(opts: DeployedSummary & { secret: string }) { try { await runTunnelSession(tunnel, { retries: 6, - onReady: () => openInBrowser(tunnel.tunnel), + onReady: ({ url }) => openInBrowser(url), }); } finally { await new Promise((closeResolve) => server.close(() => closeResolve())); @@ -294,7 +288,7 @@ function renderSuccessPage(opts: DeployedSummary): string { ["Worker name", opts.workerName], ...(opts.route ? [["Route", opts.route] as [string, string]] : []), ...(opts.zone ? [["Zone", opts.zone] as [string, string]] : []), - ["Server URL", opts.serverUrl], + ["Gateway", opts.gateway], ["Shards", String(opts.shards)], ["Config file", configPath], ]; @@ -440,18 +434,17 @@ function colorStatus(status: number) { } function resolveTunnel(input: TunnelCliInput, config?: Config): ResolvedTunnel { - const serverUrl = input.serverUrl || config?.serverUrl || HOSTED_CAPTUN_SERVER_URL; + const gateway = input.gateway || config?.gateway || HOSTED_CAPTUN_GATEWAY; - const name = input.name ?? randomName(); + const name = input.name || randomName(); const target = normalizeTarget(input.target); return { name, - serverUrl, + gateway, target, - secret: input.secret || config?.secret, + token: input.token || config?.token, requestLogs: input.requestLogs, - tunnel: getTunnelUrlFromServerUrl(serverUrl, name), }; } @@ -479,44 +472,41 @@ async function runTunnelSession( const startedAt = performance.now(); await assertLocalTargetAcceptingConnections(tunnel.target); - // Filled in from the `x-captun-tunnel-url` header on the first forwarded - // request — the Worker is the source of truth for the public URL. - const advertisedUrl: { current: string | undefined } = { current: undefined }; - const session = await connectTunnelWithRetry(tunnel, opts.retries ?? 0, advertisedUrl); + const session = await connectTunnelWithRetry(tunnel, opts.retries || 0); try { - await confirmTunnelHealth(tunnel.tunnel); - const tunnelUrlForDisplay = advertisedUrl.current ?? tunnel.tunnel; + await confirmTunnelHealth(session.url); console.log( `\n${color.green("Ready")} ${color.dim(`in ${Math.round(performance.now() - startedAt)}ms`)}\n`, ); - console.log(color.cyan(tunnelUrlForDisplay)); + console.log(color.cyan(session.url)); console.log(` ${color.dim("->")} ${color.cyan(tunnel.target)}`); console.log(`\n${color.dim("Press Ctrl+C to close tunnel")}\n`); - await opts.onReady?.({ url: tunnelUrlForDisplay, tunnel }); + await opts.onReady?.({ url: session.url, tunnel }); await (opts.waitForShutdown || waitForShutdown)(); } finally { session[Symbol.dispose](); } } -async function connectTunnelWithRetry( - tunnel: ResolvedTunnel, - retries: number, - advertisedUrl: { current: string | undefined }, -) { - const url = `${tunnel.tunnel}/__captun-connect`; - const headers = tunnel.secret ? { authorization: `Bearer ${tunnel.secret}` } : undefined; - const fetcher = makeTunnelFetcher(tunnel, advertisedUrl); +async function connectTunnelWithRetry(tunnel: ResolvedTunnel, retries: number) { + const fetcher = makeTunnelFetcher(tunnel); const maxAttempts = retries + 1; let delay = 2000; for (let attempt = 1; attempt <= maxAttempts; attempt++) { const label = attempt === 1 - ? `Connecting to ${tunnel.tunnel}` - : `Connecting to ${tunnel.tunnel} (retry ${attempt - 1}/${retries})`; + ? `Connecting to ${tunnel.gateway}` + : `Connecting to ${tunnel.gateway} (retry ${attempt - 1}/${retries})`; try { - return await withSpinner(label, () => createCaptunTunnel({ url, headers, fetch: fetcher })); + return await withSpinner(label, () => + createCaptunTunnel({ + gateway: tunnel.gateway, + name: tunnel.name, + token: tunnel.token, + fetch: fetcher, + }), + ); } catch (error) { if (attempt === maxAttempts) { throw tunnelConnectError(tunnel, error); @@ -528,11 +518,8 @@ async function connectTunnelWithRetry( throw new Error("unreachable"); } -function makeTunnelFetcher(tunnel: ResolvedTunnel, advertisedUrl: { current: string | undefined }) { +function makeTunnelFetcher(tunnel: ResolvedTunnel) { return async (request: Request) => { - const advertised = request.headers.get(TUNNEL_URL_HEADER); - if (advertised) advertisedUrl.current = advertised; - if (isCaptunHealthRequest(request)) return captunHealthResponse(); const url = new URL(request.url); @@ -568,9 +555,9 @@ function makeTunnelFetcher(tunnel: ResolvedTunnel, advertisedUrl: { current: str } function tunnelConnectError(tunnel: ResolvedTunnel, cause: unknown) { - const hostname = new URL(tunnel.tunnel).hostname; + const hostname = new URL(tunnel.gateway).hostname; const message = cause instanceof Error ? cause.message : String(cause); - const lines = [`Could not connect tunnel to ${color.cyan(tunnel.tunnel)} (${message}).`]; + const lines = [`Could not connect tunnel to ${color.cyan(tunnel.gateway)} (${message}).`]; if (!hostname.endsWith(".workers.dev")) { // Dropping the leftmost label gives the zone-side wildcard parent — // `tunnel.mispwoso.com` -> `mispwoso.com`, `t.captun.example.com` -> `captun.example.com`. @@ -602,10 +589,10 @@ function tunnelOpeningRows(tunnel: ResolvedTunnel) { const rows = [ { label: "target", value: color.cyan(tunnel.target) }, { label: "--name", value: color.cyan(tunnel.name) }, - { label: "--server-url", value: color.cyan(tunnel.serverUrl) }, + { label: "--gateway", value: color.cyan(tunnel.gateway) }, { - label: "--secret", - value: tunnel.secret ? color.dim(secretPreview(tunnel.secret)) : color.dim("none"), + label: "--token", + value: tunnel.token ? color.dim(tokenPreview(tunnel.token)) : color.dim("none"), }, ]; if (tunnel.requestLogs) rows.push({ label: "--request-logs", value: color.dim("true") }); @@ -616,9 +603,9 @@ function tunnelInfoRow(label: string, value: string) { console.log(` ${color.dim(label.padEnd(16))}${value}`); } -function secretPreview(secret: string, visibleChars = 6) { - if (secret.length <= visibleChars) return secret; - return `${secret.slice(0, visibleChars)}…`; +function tokenPreview(token: string, visibleChars = 6) { + if (token.length <= visibleChars) return token; + return `${token.slice(0, visibleChars)}…`; } function randomName() { diff --git a/src/cli/deploy.ts b/src/cli/deploy.ts index 214befb..b54cb03 100644 --- a/src/cli/deploy.ts +++ b/src/cli/deploy.ts @@ -30,7 +30,7 @@ export type DeployInput = { name?: string; route?: string; zone?: string; - secret?: string; + token?: string; shards?: number; dryRun?: boolean; }; @@ -40,7 +40,7 @@ export type DeployWizardResult = { route?: string; zone?: string; shards?: number; - secret: string; + token: string; accountId?: string; customHostname?: string; certWait?: { @@ -61,9 +61,9 @@ export async function runDeployWizard( route: input.route, zone: input.zone, shards: input.shards, - secret: input.secret ?? randomSecret(), - // Without this, non-interactive `--route` deploys fall through to folder - // routing while serverUrlFromRoute still hands out subdomain URLs. + token: input.token ?? randomToken(), + // Without this, non-interactive `--route` deploys would accept tunnel + // connects on the route host but still parse forwarded requests in folder mode. customHostname: input.route ? customHostnameFromRoute(input.route) : undefined, }; } @@ -211,12 +211,12 @@ export async function runDeployWizard( }); const shards = Number(shardsAnswer); - const secret = await prompts.input({ - message: "Tunnel secret (leave empty to allow anyone to create tunnels on your captun server)", - default: input.secret ?? randomSecret(), + const token = await prompts.input({ + message: "Tunnel token (leave empty to allow anyone to create tunnels on your Captun gateway)", + default: input.token ?? randomToken(), }); - return { name, route, zone, shards, certWait, secret, accountId, customHostname }; + return { name, route, zone, shards, certWait, token, accountId, customHostname }; } async function pickAccount(packageRoot: string): Promise { @@ -509,7 +509,7 @@ export async function deployWorker( name?: string; route?: string; zone?: string; - secret: string; + token: string; shards?: number; accountId?: string; customHostname?: string; @@ -522,7 +522,7 @@ export async function deployWorker( const tempDir = await mkdtemp(resolve(tmpdir(), "captun-")); const secretsFile = resolve(tempDir, "secrets.json"); try { - await writeFile(secretsFile, JSON.stringify({ CAPTUN_SECRET: input.secret }), { mode: 0o600 }); + await writeFile(secretsFile, JSON.stringify({ CAPTUN_TOKEN: input.token }), { mode: 0o600 }); const baseConfigPath = resolve(packageRoot, "wrangler.jsonc"); const baseConfig = parseJsonc(await readFile(baseConfigPath, "utf8")) as Record< @@ -551,7 +551,7 @@ export async function deployWorker( if (input.shards) args.push("--var", `SHARD_COUNT:${input.shards}`); // Always set CUSTOM_HOSTNAME (empty string = folder routing) so `--keep-vars` // doesn't leave a stale value behind when switching modes on redeploy. - args.push("--var", `CUSTOM_HOSTNAME:${input.customHostname ?? ""}`); + args.push("--var", `CUSTOM_HOSTNAME:${input.customHostname || ""}`); if (input.dryRun) args.push("--dry-run"); if (!input.dryRun) await assertWranglerAuthenticated({ cwd: packageRoot }); @@ -559,17 +559,17 @@ export async function deployWorker( const output = await runWrangler(args, { cwd: packageRoot, tty: !input.dryRun }); if (input.dryRun) { return input.route - ? serverUrlFromRoute(input.route) + ? gatewayFromRoute(input.route, input.zone) : "https://captun..workers.dev"; } - const serverUrl = input.route - ? serverUrlFromRoute(input.route) - : serverUrlFromWranglerOutput(output); - if (!serverUrl) { + const gateway = input.route + ? gatewayFromRoute(input.route, input.zone) + : gatewayFromWranglerOutput(output); + if (!gateway) { throw new Error("Wrangler deploy succeeded, but the Worker URL was not found in its output."); } - return serverUrl; + return gateway; } finally { await rm(tempDir, { force: true, recursive: true }); } @@ -596,16 +596,23 @@ export async function waitForCertWithSpinner( } } -function serverUrlFromRoute(route: string) { +function gatewayFromRoute(route: string, zone: string | undefined) { const withoutProtocol = route.replace(/^https?:\/\//, ""); const [hostPart, ...pathParts] = withoutProtocol.split("/"); - const host = hostPart?.startsWith("*.") ? `{name}.${hostPart.slice(2)}` : hostPart; - if (!host) throw new Error(`Cannot infer server URL from route: ${route}`); + const host = gatewayHostFromRouteHost(hostPart || "", zone); + if (!host) throw new Error(`Cannot infer gateway from route: ${route}`); const path = pathParts.join("/").replace(/\*.*$/, "").replace(/\/$/, ""); return `https://${host}${path ? `/${path}` : ""}`; } +function gatewayHostFromRouteHost(hostPart: string, zone: string | undefined) { + if (!hostPart.startsWith("*.")) return hostPart; + const wildcardParent = hostPart.slice(2); + const gatewayLabel = zone && wildcardParent === zone ? "captun" : "gateway"; + return `${gatewayLabel}.${wildcardParent}`; +} + /** `*.captun.example.com/*` -> `captun.example.com`; `*.example.com/*` -> `example.com`. */ function customHostnameFromRoute(route: string): string | undefined { const host = route.replace(/^https?:\/\//, "").split("/")[0]; @@ -613,7 +620,7 @@ function customHostnameFromRoute(route: string): string | undefined { return host.slice(2); } -function serverUrlFromWranglerOutput(output: string) { +function gatewayFromWranglerOutput(output: string) { // Wrangler colorizes URLs with ANSI escapes (which would attach to the match // tail) and also prints a `Worker Version Preview URL` with a literal // `-` placeholder we want to skip. @@ -621,7 +628,7 @@ function serverUrlFromWranglerOutput(output: string) { return stripped.match(/https:\/\/[^\s<>]+\.workers\.dev/)?.[0]; } -function randomSecret() { +function randomToken() { return randomBytes(32).toString("base64url"); } @@ -629,7 +636,7 @@ function randomSecret() { function parseJsonc(input: string): unknown { const withoutComments = input.replace( /("(?:[^"\\]|\\.)*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, - (_match, str: string | undefined) => str ?? "", + (_match, str: string | undefined) => str || "", ); const withoutTrailingCommas = withoutComments.replace(/,(\s*[}\]])/g, "$1"); return JSON.parse(withoutTrailingCommas); diff --git a/src/index.ts b/src/index.ts index d376754..0589490 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,77 +1,74 @@ import { newWebSocketRpcSession, RpcTarget } from "capnweb"; -import { getTunnelUrlFromServerUrl, HOSTED_CAPTUN_SERVER_URL } from "./routing.js"; +import { + CONNECT_TOKEN_QUERY_PARAM, + GATEWAY_CONNECT_QUERY_PARAM, + HOSTED_CAPTUN_GATEWAY, + TUNNEL_NAME_QUERY_PARAM, +} from "./routing.js"; /** Fetch is all you need! * - * Cap'n Web let us pass this fetcher from the - * tunnel client to the server via fetch (via websockets) - * Then the server can just fetch into the client like normal. - * This is all possible because Cap'n Web can pass Request and Response object - * across the websocket RPC boundary transparently + * Cap'n Web lets us pass this fetcher from the tunnel client to the gateway. + * The gateway can then call fetch on the client like normal, with Request and + * Response objects crossing the WebSocket RPC boundary transparently. **/ export interface Fetcher { fetch(request: Request): Response | Promise; } -// --------------------------------------------------------------------------- -// Tunnel client (formerly src/client.ts) -// --------------------------------------------------------------------------- - -/** Creates a tunnel from a public Worker URL to a local fetch implementation. - * - * Captun gives us one WebSocket RPC session. The client exposes its fetcher as - * the session's main object, and the server calls that object through a remote - * stub when forwarding HTTP requests. - * - * Cap'n Web WebSocket sessions: - * https://github.com/cloudflare/capnweb#websocket-client - */ export type CaptunTunnel = Disposable & { url: string; + token?: string; }; +export type FetcherStub = Fetcher & + Disposable & { + ready(tunnel: { url: string; token?: string }): void | Promise; + }; + +type TunnelClientCapability = Fetcher & { + ready(tunnel: { url: string; token?: string }): void | Promise; +}; + +const TUNNEL_READY_TIMEOUT_MS = 5_000; + +/** Creates a public tunnel by exposing a local fetch implementation to a Tunnel Gateway. */ export async function createCaptunTunnel( options: Fetcher & { - url?: string | URL; - serverUrl?: string; + gateway?: string | URL; name?: string; - headers?: Record; + token?: string; }, ): Promise { - const endpoint = resolveTunnelEndpoint(options); - const socket = createWebSocket({ url: endpoint.connectUrl, headers: options.headers }); - // tunnelTargetFetcher is the "main object" that comes out on the other side in acceptCaptunTunnel - // as a capnweb rpc stub that the server can just call fetch on - const tunnelTargetFetcher = new TunnelTargetFetcher({ fetch: options.fetch }); - const session = newWebSocketRpcSession(socket, tunnelTargetFetcher); - await waitUntilOpen(socket); - - return { - url: endpoint.publicUrl, - [Symbol.dispose]: () => session[Symbol.dispose](), - }; -} - -function resolveTunnelEndpoint(options: { - url?: string | URL; - serverUrl?: string; - name?: string; -}): { publicUrl: string; connectUrl: string } { - if (options.url) { - const publicUrl = publicUrlFromConnectUrl(new URL(options.url)); - return { publicUrl, connectUrl: String(options.url) }; + const connect = gatewayConnectRequest(options); + const ready = Promise.withResolvers<{ url: string; token?: string }>(); + const socket = createWebSocket(connect.url); + const fetcher = new TunnelTargetFetcher({ + fetch: options.fetch, + ready: (tunnel) => ready.resolve(tunnel), + }); + const session = newWebSocketRpcSession(socket, fetcher); + try { + await waitUntilOpen(socket); + const tunnel = await waitUntilReady(ready.promise); + return { + url: tunnel.url, + token: tunnel.token || connect.token, + [Symbol.dispose]: () => session[Symbol.dispose](), + }; + } catch (error) { + session[Symbol.dispose](); + throw error; } - - const tunnelName = options.name || randomTunnelName(); - const serverUrl = options.serverUrl || HOSTED_CAPTUN_SERVER_URL; - const publicUrl = getTunnelUrlFromServerUrl(serverUrl, tunnelName); - return { publicUrl, connectUrl: `${publicUrl}/__captun-connect` }; } -function publicUrlFromConnectUrl(connectUrl: URL) { - const publicUrl = new URL(connectUrl); - publicUrl.pathname = publicUrl.pathname.replace(/\/__captun-connect\/?$/, "") || "/"; - return publicUrl.toString().replace(/\/$/, ""); +function gatewayConnectRequest(options: { gateway?: string | URL; name?: string; token?: string }) { + const name = options.name || randomTunnelName(); + const url = new URL(options.gateway || HOSTED_CAPTUN_GATEWAY); + url.searchParams.set(GATEWAY_CONNECT_QUERY_PARAM, "1"); + url.searchParams.set(TUNNEL_NAME_QUERY_PARAM, name); + if (options.token) url.searchParams.set(CONNECT_TOKEN_QUERY_PARAM, options.token); + return { url: url.toString(), name, token: options.token }; } function randomTunnelName() { @@ -80,33 +77,32 @@ function randomTunnelName() { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } -class TunnelTargetFetcher extends RpcTarget implements Fetcher { +class TunnelTargetFetcher extends RpcTarget implements TunnelClientCapability { private fetcher: Fetcher; + private onReady: (tunnel: { url: string; token?: string }) => void; - constructor(fetcher: Fetcher) { + constructor(options: { + fetch: Fetcher["fetch"]; + ready: (tunnel: { url: string; token?: string }) => void; + }) { super(); - this.fetcher = fetcher; + this.fetcher = { fetch: options.fetch }; + this.onReady = options.ready; } fetch(request: Request) { return this.fetcher.fetch(request); } + + ready(tunnel: { url: string; token?: string }) { + this.onReady(tunnel); + } } -function createWebSocket(options: { url: string | URL; headers?: Record }) { - const connectUrl = new URL(options.url); +function createWebSocket(url: string | URL) { + const connectUrl = new URL(url); connectUrl.protocol = connectUrl.protocol === "https:" ? "wss:" : "ws:"; - // TypeScript sees the standard DOM/Workers constructor here, where the second - // argument is only WebSocket protocols. Node's CLI WebSocket runtime also - // accepts a headers init object, which we need for tunnel auth. - const WebSocketWithHeaders = WebSocket as unknown as new ( - url: string, - init?: string | string[] | { headers: Record }, - ) => WebSocket; - return new WebSocketWithHeaders( - connectUrl.href, - options.headers ? { headers: options.headers } : undefined, - ); + return new WebSocket(connectUrl.href); } async function waitUntilOpen(socket: WebSocket) { @@ -138,36 +134,50 @@ async function waitUntilOpen(socket: WebSocket) { }); } -// --------------------------------------------------------------------------- -// Tunnel server (formerly src/server.ts) -// --------------------------------------------------------------------------- +async function waitUntilReady(promise: Promise<{ url: string; token?: string }>) { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error("Timed out waiting for tunnel gateway ready message")), + TUNNEL_READY_TIMEOUT_MS, + ); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} -/** Creates a Worker WebSocket upgrade response and matching tunnel handle. */ -export function acceptCaptunTunnel(options: { onDisconnect?: () => void } = {}) { +/** Creates a Worker WebSocket upgrade response and matching fetcher stub. */ +export function acceptFetcherCapability(options: { onDisconnect?: () => void } = {}) { const pair = new WebSocketPair(); const clientSocket = pair[0]; const serverSocket = pair[1]; serverSocket.accept(); - const tunnel = acceptCaptunTunnelFromSocket(serverSocket, options); + const fetcher = acceptFetcherCapabilityFromSocket(serverSocket, options); return { - tunnel, + fetcher, response: new Response(null, { status: 101, webSocket: clientSocket }), }; } -export function acceptCaptunTunnelFromSocket( +export function acceptFetcherCapabilityFromSocket( socket: WebSocket, options: { onDisconnect?: () => void } = {}, -): Fetcher & Disposable { - // The generic describes the peer's main object; Cap'n Web still returns a - // stub with lifecycle methods like onRpcBroken() and Symbol.dispose. - const tunnelTargetFetcher = newWebSocketRpcSession(socket); - tunnelTargetFetcher.onRpcBroken(() => options.onDisconnect?.()); +): FetcherStub { + const remote = newWebSocketRpcSession(socket) as FetcherStub & { + onRpcBroken(callback: () => void): void; + }; + remote.onRpcBroken(() => options.onDisconnect?.()); return { - fetch: (request) => tunnelTargetFetcher.fetch(request), - [Symbol.dispose]: () => tunnelTargetFetcher[Symbol.dispose](), + fetch: (request) => remote.fetch(request), + ready: (tunnel) => remote.ready(tunnel), + [Symbol.dispose]: () => remote[Symbol.dispose](), }; } diff --git a/src/routing.ts b/src/routing.ts index 475e51e..bfb8b58 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -1,6 +1,9 @@ export const HOSTED_CAPTUN_HOSTNAME = "captun.sh"; -export const HOSTED_CAPTUN_SERVER_URL = "https://{name}.captun.sh"; -export const RESERVED_HOSTED_SUBDOMAINS = [ +export const HOSTED_CAPTUN_GATEWAY = "https://captun.sh"; +export const GATEWAY_CONNECT_QUERY_PARAM = "captun-connect"; +export const TUNNEL_NAME_QUERY_PARAM = "captun-name"; +export const CONNECT_TOKEN_QUERY_PARAM = "captun-token"; +export const RESERVED_TUNNEL_NAMES = [ "account", "accounts", "admin", @@ -12,6 +15,8 @@ export const RESERVED_HOSTED_SUBDOMAINS = [ "dash", "dashboard", "docs", + "gateway", + "gateways", "iterate", "login", "payment", @@ -19,6 +24,7 @@ export const RESERVED_HOSTED_SUBDOMAINS = [ "status", "support", "tunnel", + "tunnels", "www", ]; @@ -82,9 +88,9 @@ export function getTunnelNameFromUrl({ * tunnel. `reqUrl` is any URL that hits the same Worker — we only use its * protocol (and, in folder mode, its host). * - * The Worker calls this and advertises the result back to the tunnel client - * via the `x-captun-tunnel-url` header on each forwarded request, so the CLI - * doesn't have to know the routing convention to print the right URL. + * The Worker calls this before accepting a tunnel client and returns the result + * through the Cap'n Web ready callback, so the client doesn't have to know the + * gateway's routing convention to print the right URL. */ export function getTunnelUrl({ reqUrl, @@ -102,23 +108,11 @@ export function getTunnelUrl({ return `${parsed.protocol}//${parsed.host}/${encodeURIComponent(tunnelName)}`; } -export function getTunnelUrlFromServerUrl(serverUrl: string, tunnelName: string): string { - if (serverUrl.includes("{name}")) - return removeTrailingSlash(serverUrl.replaceAll("{name}", tunnelName)); - const url = new URL(serverUrl); - url.pathname = `${url.pathname.replace(/\/$/, "")}/${encodeURIComponent(tunnelName)}`; - return removeTrailingSlash(url.toString()); -} - -/** Header used by the Worker to advertise a tunnel's canonical URL to its client. */ +/** Internal header used by the top-level Worker to pass the canonical tunnel URL to the DO. */ export const TUNNEL_URL_HEADER = "x-captun-tunnel-url"; -/** Reserved path used by tunnel clients to open the WebSocket; not a tunnel name. */ -const CONNECT_PATH_SEGMENT = "__captun-connect"; - -function isValidTunnelName(name: string): boolean { +export function isValidTunnelName(name: string): boolean { if (!name) return false; - if (name === CONNECT_PATH_SEGMENT) return false; return true; } @@ -140,7 +134,3 @@ function safeDecodeURIComponent(value: string) { return undefined; } } - -function removeTrailingSlash(url: string) { - return url.replace(/\/$/, ""); -} diff --git a/src/worker.ts b/src/worker.ts index c315be7..db85036 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,17 +1,21 @@ import { DurableObject } from "cloudflare:workers"; -import { acceptCaptunTunnel, type Fetcher } from "./index.js"; +import { acceptFetcherCapability, type FetcherStub } from "./index.js"; import { captunShardName, + CONNECT_TOKEN_QUERY_PARAM, + GATEWAY_CONNECT_QUERY_PARAM, HOSTED_CAPTUN_HOSTNAME, getTunnelNameFromUrl, getTunnelUrl, - RESERVED_HOSTED_SUBDOMAINS, + isValidTunnelName, + RESERVED_TUNNEL_NAMES, + TUNNEL_NAME_QUERY_PARAM, TUNNEL_URL_HEADER, } from "./routing.js"; type CaptunEnv = { CaptunServerShard: DurableObjectNamespace; - CAPTUN_SECRET?: string; + CAPTUN_TOKEN?: string; SHARD_COUNT?: string; CUSTOM_HOSTNAME?: string; }; @@ -19,6 +23,12 @@ type CaptunEnv = { /** Set by the top-level Worker on the WebSocket-upgrade request so the DO knows the tunnel. */ const TUNNEL_NAME_HEADER = "x-captun-tunnel-name"; +type ActiveTunnel = { + url: string; + token?: string; + fetcher: FetcherStub; +}; + /** * A shard Durable Object owns many named tunnels. * @@ -28,7 +38,7 @@ const TUNNEL_NAME_HEADER = "x-captun-tunnel-name"; * aggregate throughput for lots of concurrent large responses. */ export class CaptunServerShard extends DurableObject { - private readonly tunnels = new Map(); + private readonly tunnels = new Map(); // The DO's `fetch` only handles the WebSocket upgrade. The upgrade hand-off // is special-cased by the Workers runtime around `stub.fetch(...)` — a 101 @@ -40,28 +50,36 @@ export class CaptunServerShard extends DurableObject { const tunnelName = request.headers.get(TUNNEL_NAME_HEADER); if (!tunnelName) return new Response("Missing tunnel name\n", { status: 404 }); - const expected = this.env.CAPTUN_SECRET ? `Bearer ${this.env.CAPTUN_SECRET}` : undefined; + const tunnelUrl = request.headers.get(TUNNEL_URL_HEADER); + if (!tunnelUrl) return new Response("Missing tunnel URL\n", { status: 404 }); + + const expected = this.env.CAPTUN_TOKEN; if (expected) { - // Constant-time comparison to avoid leaking the secret via timing. - const actual = new TextEncoder().encode(request.headers.get("authorization") ?? ""); + // Constant-time comparison to avoid leaking the gateway token via timing. + const actual = new TextEncoder().encode(connectToken(request) || ""); const want = new TextEncoder().encode(expected); if (actual.length !== want.length || !crypto.subtle.timingSafeEqual(actual, want)) { return new Response("Unauthorized\n", { status: 401 }); } } - this.tunnels.get(tunnelName)?.[Symbol.dispose](); - const { response, tunnel } = acceptCaptunTunnel({ + const token = expected ? connectToken(request) || undefined : undefined; + this.tunnels.get(tunnelName)?.fetcher[Symbol.dispose](); + const { response, fetcher } = acceptFetcherCapability({ onDisconnect: () => { - if (this.tunnels.get(tunnelName) === tunnel) this.tunnels.delete(tunnelName); + if (this.tunnels.get(tunnelName)?.fetcher === fetcher) this.tunnels.delete(tunnelName); }, }); + const tunnel = { url: tunnelUrl, token, fetcher }; this.tunnels.set(tunnelName, tunnel); + queueMicrotask(() => { + void fetcher.ready({ url: tunnel.url, token: tunnel.token }); + }); return response; } async forward(tunnelName: string, request: Request): Promise { - const tunnel = this.tunnels.get(tunnelName); + const tunnel = this.tunnels.get(tunnelName)?.fetcher; if (!tunnel) return new Response("No tunnel client connected\n", { status: 503 }); try { return await tunnel.fetch(request); @@ -73,6 +91,10 @@ export class CaptunServerShard extends DurableObject { export default { fetch(request: Request, env: CaptunEnv): Response | Promise { + if (isGatewayConnectRequest(request)) { + return connectTunnel(request, env); + } + const hostedResponse = hostedCaptunResponse(request, env); if (hostedResponse) return hostedResponse; @@ -88,23 +110,19 @@ export default { const url = new URL(request.url); const forwardedPath = env.CUSTOM_HOSTNAME ? url.pathname - : (url.pathname.match(/^\/[^/]+(\/.*)?$/)?.[1] ?? "/"); + : url.pathname.match(/^\/[^/]+(\/.*)?$/)?.[1] || "/"; url.pathname = forwardedPath; + if (RESERVED_TUNNEL_NAMES.includes(tunnelName)) { + return new Response("Reserved Captun tunnel name\n", { status: 404 }); + } + const shard = env.CaptunServerShard.getByName( captunShardName(tunnelName, Number(env.SHARD_COUNT || 1)), ); - const forwarded = new Request(url, request); - if (forwardedPath === "/__captun-connect") { - const headers = new Headers(forwarded.headers); - headers.set(TUNNEL_NAME_HEADER, tunnelName); - return shard.fetch(new Request(forwarded, { headers })); - } - - // Advertise the canonical tunnel URL back to the tunnel client. The CLI - // reads this so it doesn't have to mirror the Worker's routing convention. + // Keep the canonical tunnel URL attached while crossing into the DO. const tunnelUrl = getTunnelUrl({ reqUrl: request.url, customHostname: env.CUSTOM_HOSTNAME, @@ -116,6 +134,39 @@ export default { }, } satisfies ExportedHandler; +function connectTunnel(request: Request, env: CaptunEnv) { + if (request.headers.get("upgrade") !== "websocket") { + return new Response("Expected WebSocket upgrade\n", { status: 400 }); + } + + const url = new URL(request.url); + const tunnelName = url.searchParams.get(TUNNEL_NAME_QUERY_PARAM) || ""; + if (!isValidTunnelName(tunnelName) || RESERVED_TUNNEL_NAMES.includes(tunnelName)) { + return new Response("Missing tunnel name\n", { status: 404 }); + } + + const tunnelUrl = getTunnelUrl({ + reqUrl: request.url, + customHostname: env.CUSTOM_HOSTNAME, + tunnelName, + }); + const shard = env.CaptunServerShard.getByName( + captunShardName(tunnelName, Number(env.SHARD_COUNT || 1)), + ); + const headers = new Headers(request.headers); + headers.set(TUNNEL_NAME_HEADER, tunnelName); + headers.set(TUNNEL_URL_HEADER, tunnelUrl); + return shard.fetch(new Request(request, { headers })); +} + +function isGatewayConnectRequest(request: Request) { + return new URL(request.url).searchParams.get(GATEWAY_CONNECT_QUERY_PARAM) === "1"; +} + +function connectToken(request: Request) { + return new URL(request.url).searchParams.get(CONNECT_TOKEN_QUERY_PARAM); +} + function hostedCaptunResponse(request: Request, env: CaptunEnv): Response | undefined { if (env.CUSTOM_HOSTNAME !== HOSTED_CAPTUN_HOSTNAME) return undefined; @@ -135,8 +186,8 @@ function hostedCaptunResponse(request: Request, env: CaptunEnv): Response | unde if (subdomain === "www") { return wwwCaptunResponse(url); } - if (RESERVED_HOSTED_SUBDOMAINS.includes(subdomain)) { - return new Response("Reserved captun.sh subdomain\n", { status: 404 }); + if (RESERVED_TUNNEL_NAMES.includes(subdomain)) { + return new Response("Reserved Captun tunnel name\n", { status: 404 }); } } @@ -366,27 +417,42 @@ console.log(tunnel.url); const WWW_BROWSER_MODULE = `import { newWebSocketRpcSession, RpcTarget } from "https://esm.sh/capnweb@0.8.0"; export async function createCaptunTunnel(options) { - const tunnelName = options.name || randomTunnelName(); - const publicUrl = "https://" + tunnelName + ".captun.sh"; - const socket = new WebSocket("wss://" + tunnelName + ".captun.sh/__captun-connect"); - const tunnelTargetFetcher = new TunnelTargetFetcher(options.fetch); + const socket = new WebSocket(gatewayConnectUrl(options)); + const readyPromise = waitForReady(); + const tunnelTargetFetcher = new TunnelTargetFetcher(options.fetch, readyPromise.ready); const session = newWebSocketRpcSession(socket, tunnelTargetFetcher); await waitUntilOpen(socket); + const tunnel = await readyPromise.promise; return { - url: publicUrl, + url: tunnel.url, + token: tunnel.token || options.token, close: () => disposeSession(session), }; } class TunnelTargetFetcher extends RpcTarget { - constructor(fetcher) { + constructor(fetcher, ready) { super(); this.fetcher = fetcher; + this.readyCallback = ready; } fetch(request) { return this.fetcher(request); } + + ready(tunnel) { + return this.readyCallback(tunnel); + } +} + +function gatewayConnectUrl(options) { + const url = new URL(options.gateway || "https://captun.sh"); + url.protocol = url.protocol === "http:" ? "ws:" : "wss:"; + url.searchParams.set("captun-connect", "1"); + url.searchParams.set("captun-name", options.name || randomTunnelName()); + if (options.token) url.searchParams.set("captun-token", options.token); + return url; } function waitUntilOpen(socket) { @@ -407,6 +473,25 @@ function waitUntilOpen(socket) { }); } +function waitForReady() { + let timer; + let resolveReady; + let rejectReady; + const promise = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + timer = setTimeout(() => reject(new Error("Timed out waiting for tunnel gateway")), 5000); + }); + return { + promise, + ready: (tunnel) => { + clearTimeout(timer); + resolveReady(tunnel); + }, + reject: rejectReady, + }; +} + function randomTunnelName() { const bytes = new Uint8Array(8); crypto.getRandomValues(bytes); diff --git a/tasks/gateway-owned-addressing.md b/tasks/gateway-owned-addressing.md new file mode 100644 index 0000000..c7833fc --- /dev/null +++ b/tasks/gateway-owned-addressing.md @@ -0,0 +1,25 @@ +--- +status: in-progress +size: large +--- + +# Gateway-Owned Addressing Refactor + +Status summary: Implementation is complete on #16 and local verification is green. The API, Worker connect protocol, CLI/deploy config, hosted browser module, README, and tests now use gateway-owned addressing; remaining work is pushing the branch and closing/superseding #20. + +## Checklist + +- [x] Replace public `url`/`serverUrl`/`secret` options with `gateway`/`token`. _`createCaptunTunnel`, the CLI router, config file, deploy wizard, benchmarks, and smoke scripts now take `gateway` and `token`; the hosted service remains the default gateway._ +- [x] Replace `/__captun-connect` with Captun query parameters on the Gateway URL. _Clients now open the gateway URL with `captun-connect=1`, `captun-name`, and optional `captun-token`; the Worker no longer treats the old path as a connect route._ +- [x] Make the Tunnel Gateway return `{ url, token }` through the internal Cap'n Web ready callback before `createCaptunTunnel` resolves. _`createCaptunTunnel` waits for `ready(...)`, and the Worker calls it after storing the active tunnel._ +- [x] Rename low-level accept APIs to `acceptFetcherCapability` and `acceptFetcherCapabilityFromSocket`. _The public entrypoint and weather-reporter example now use Fetcher Capability/Fetcher Stub terminology._ +- [x] Update the Cloudflare Tunnel Gateway to store active Tunnels backed by Fetcher Stubs. _`CaptunServerShard` stores `{ url, token, fetcher }` ActiveTunnel records and forwards through the Fetcher Stub._ +- [x] Update CLI config, flags, deploy summary, and self-test to use `gateway` and `token`. _`--gateway`, `--token`, `CAPTUN_TOKEN`, deploy dry-runs, config writes, and post-deploy self-test all use the new names._ +- [x] Update README, Hosted Site snippets, and tests to use the new shape. _README, `www.captun.sh` snippets, the browser helper, e2e tests, worker tests, public-hosted tests, and smoke docs now describe gateway-owned tunnel URLs._ +- [ ] Close or supersede PR #20 after #16 has the new shape. + +## Implementation Notes + +- 2026-05-26: Grill-with-docs resolved the language in `CONTEXT.md` and recorded ADR-0001 for gateway-owned addressing. +- 2026-05-26: Focused tests passed: `pnpm exec vitest run test/worker.test.ts test/e2e.test.ts examples/weather-reporter/e2e.test.ts`. +- 2026-05-26: Full local checks passed: `pnpm run check`, `pnpm test`, and `pnpm run build`. diff --git a/tasks/hosted-captun-sh.md b/tasks/hosted-captun-sh.md index 7754802..aff3226 100644 --- a/tasks/hosted-captun-sh.md +++ b/tasks/hosted-captun-sh.md @@ -9,10 +9,10 @@ Status summary: Initial hosted deployment is live on `captun.sh`. The CLI, libra ## Initial public-hosted slice -- [x] Default unconfigured CLI usage to the hosted server so `npx captun 3000` uses a random `https://.captun.sh` URL. _`resolveTunnel` now falls back to `HOSTED_CAPTUN_SERVER_URL` when no config or `--server-url` is present._ -- [x] Let library users call `createCaptunTunnel({ fetch })` without passing a deployed Worker URL. _`createCaptunTunnel` now derives a cryptographic random hosted URL and returns it on `tunnel.url`._ -- [x] Add public-hosted e2e coverage for the library API and CLI router. _Added gated `CAPTUN_PUBLIC_E2E=1` tests in `test/public-hosted.test.ts`, plus local Miniflare coverage for the new server-url path._ -- [x] Deploy the initial Worker route on `*.captun.sh/*`. _Deployed `captun-public` to Iterate prd with route `*.captun.sh/*`, `CUSTOM_HOSTNAME=captun.sh`, empty `CAPTUN_SECRET`, and wildcard DNS `*.captun.sh -> 100::` proxied._ +- [x] Default unconfigured CLI usage to the hosted gateway so `npx captun 3000` uses a random `https://.captun.sh` URL. _`resolveTunnel` now falls back to `HOSTED_CAPTUN_GATEWAY` when no config or `--gateway` is present._ +- [x] Let library users call `createCaptunTunnel({ fetch })` without passing a deployed Worker URL. _`createCaptunTunnel` now connects to the default hosted gateway and resolves with the gateway-returned `tunnel.url`._ +- [x] Add public-hosted e2e coverage for the library API and CLI router. _Added gated `CAPTUN_PUBLIC_E2E=1` tests in `test/public-hosted.test.ts`, plus local Miniflare coverage for the new gateway path._ +- [x] Deploy the initial Worker route on `*.captun.sh/*`. _Deployed `captun-public` to Iterate prd with route `*.captun.sh/*`, `CUSTOM_HOSTNAME=captun.sh`, empty `CAPTUN_TOKEN`, and wildcard DNS `*.captun.sh -> 100::` proxied._ - [x] Reserve product/control-plane subdomains on hosted `captun.sh`. _The Worker now blocks `app`, `login`, `dash`, `dashboard`, `captun`, `tunnel`, and `iterate` before Durable Object dispatch._ - [x] Serve a dead-simple landing page on `www.captun.sh`. _The hosted Worker returns a static HTML string with CLI and API examples._ - [x] Redirect the apex domain to `www.captun.sh`. _Added apex DNS `captun.sh -> 100::` proxied and redeployed with route `captun.sh/*`; the Worker returns a 308 preserving path and query._ diff --git a/test/e2e.test.ts b/test/e2e.test.ts index 401b9fd..aa572a0 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -18,29 +18,30 @@ test.concurrent("forwards HTTP", async ({ task }) => { expect(await response.json()).toMatchObject({ body: "hello through tunnel" }); }); -test.concurrent("creates a named tunnel from a server URL", async ({ task }) => { +test.concurrent("creates a named tunnel from a gateway", async ({ task }) => { await using server = await createServerFixture(); const name = tunnelName(task.name); using tunnel = await createCaptunTunnel({ - serverUrl: server.url, + gateway: server.gateway, name, - headers: server.headers, + token: server.token, fetch: async (request) => { const url = new URL(request.url); return Response.json({ path: url.pathname, body: await request.text() }); }, }); - expect(tunnel).toMatchObject({ url: tunnelUrl(server.url, name) }); + expect(tunnel).toMatchObject({ token: server.token }); + if (server.tunnelUrl) expect(tunnel).toMatchObject({ url: server.tunnelUrl(name) }); - const response = await fetch(`${tunnel.url}/server-url-api`, { + const response = await fetch(`${tunnel.url}/gateway-api`, { method: "POST", - body: "hello through server url", + body: "hello through gateway", }); expect(await response.json()).toMatchObject({ - path: "/server-url-api", - body: "hello through server url", + path: "/gateway-api", + body: "hello through gateway", }); }); @@ -177,14 +178,14 @@ async function createTunnelFixture( const server = await createServerFixture(); const name = tunnelName(testName); try { - const url = tunnelUrl(server.url, name); const tunnel = await createCaptunTunnel({ - url: `${url}/__captun-connect`, - headers: server.headers, + gateway: server.gateway, + name, + token: server.token, fetch, }); return { - url, + url: tunnel.url, async [Symbol.asyncDispose]() { tunnel[Symbol.dispose](); await server[Symbol.asyncDispose](); @@ -197,20 +198,20 @@ async function createTunnelFixture( } async function createServerFixture() { - if (process.env.CAPTUN_SERVER_URL) { + if (process.env.CAPTUN_GATEWAY) { return { - url: process.env.CAPTUN_SERVER_URL, - headers: process.env.CAPTUN_SECRET - ? { authorization: `Bearer ${process.env.CAPTUN_SECRET}` } - : undefined, + gateway: process.env.CAPTUN_GATEWAY, + token: process.env.CAPTUN_TOKEN, + tunnelUrl: undefined, async [Symbol.asyncDispose]() {}, }; } const worker = await createCaptunWorkerFixture({}); return { - url: worker.origin, - headers: undefined, + gateway: worker.origin, + token: undefined, + tunnelUrl: (name: string) => `${worker.origin}/${name}`, async [Symbol.asyncDispose]() { await worker[Symbol.asyncDispose](); }, @@ -228,18 +229,6 @@ function tunnelName(testName: string) { return `${prefix}-${hash}`; } -function tunnelUrl(serverUrl: string, name: string) { - if (serverUrl.includes("{name}")) return serverUrl.replaceAll("{name}", name).replace(/\/$/, ""); - - const url = new URL(serverUrl); - if (url.hostname.match(/^[^.]+\.tunnels\./)) { - url.pathname = "/"; - } else { - url.pathname = `/${name}`; - } - return url.toString().replace(/\/$/, ""); -} - function makeBytes(size: number) { const bytes = new Uint8Array(new ArrayBuffer(size)); for (let i = 0; i < bytes.length; i++) bytes[i] = i % 251; diff --git a/test/worker.test.ts b/test/worker.test.ts index a5a541f..1926ac2 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -14,8 +14,7 @@ const tunnelNameCases: Array< > = [ // Folder routing (no customHostname): first path segment is the tunnel name. ["https://captun.account.workers.dev/my-test/hello", undefined, "my-test"], - ["https://captun.account.workers.dev/my-test/__captun-connect", undefined, "my-test"], - ["https://captun.account.workers.dev/__captun-connect", undefined, null], + ["https://captun.account.workers.dev/my-test/nested/path", undefined, "my-test"], ["https://captun.account.workers.dev/", undefined, null], ["http://localhost:8787/my-test/hello", undefined, "my-test"], ["https://captun.account.workers.dev/bad%/hello", undefined, null], @@ -23,7 +22,7 @@ const tunnelNameCases: Array< // Subdomain routing: tunnel name is the last label before customHostname. ["https://my-test.captun.example.com/hello", "captun.example.com", "my-test"], ["https://my-test.my-tunnels.com/hello", "my-tunnels.com", "my-test"], - ["https://my-test.my-tunnels.com/__captun-connect", "my-tunnels.com", "my-test"], + ["https://my-test.my-tunnels.com/nested/path", "my-tunnels.com", "my-test"], ["https://my-test.mysubdomain.mydomain.com/hello", "mysubdomain.mydomain.com", "my-test"], // Nested wildcard: the *last* label before customHostname wins. Anything to @@ -56,7 +55,7 @@ const tunnelUrlCases: Array< > = [ // Folder mode: protocol + host from reqUrl, name appended as a path segment. [ - "https://captun.acct.workers.dev/banana/__captun-connect", + "https://captun.acct.workers.dev/banana/nested/path", undefined, "banana", "https://captun.acct.workers.dev/banana", @@ -116,7 +115,8 @@ test("Captun Worker keeps a tunnel name on a stable shard", () => { test("Captun Worker forwards requests through a real Durable Object tunnel", async () => { await using fixture = await createCaptunWorkerFixture({}); using _tunnel = await createCaptunTunnel({ - url: `${fixture.origin}/demo/__captun-connect`, + gateway: fixture.origin, + name: "demo", fetch: async (request) => { const url = new URL(request.url); return Response.json({ @@ -142,7 +142,8 @@ test("Captun Worker forwards requests through a real Durable Object tunnel", asy test("Captun Worker verifies health through a connected tunnel client", async () => { await using fixture = await createCaptunWorkerFixture({}); using _tunnel = await createCaptunTunnel({ - url: `${fixture.origin}/demo/__captun-connect`, + gateway: fixture.origin, + name: "demo", fetch: (request) => { if (isCaptunHealthRequest(request)) return captunHealthResponse(); return new Response("unexpected\n", { status: 500 }); @@ -158,7 +159,8 @@ test("Captun Worker verifies health through a connected tunnel client", async () test("Captun Worker returns 502 when the tunnel client fetch throws", async () => { await using fixture = await createCaptunWorkerFixture({}); using _tunnel = await createCaptunTunnel({ - url: `${fixture.origin}/demo/__captun-connect`, + gateway: fixture.origin, + name: "demo", fetch: () => { throw new Error("local target unavailable"); }, @@ -231,7 +233,10 @@ test("Hosted Captun serves the browser demo module on www", async () => { expect(response).toMatchObject({ status: 200 }); expect(response.headers.get("content-type")).toContain("application/javascript"); - expect(await response.text()).toEqual(expect.stringContaining("createCaptunTunnel")); + const module = await response.text(); + expect(module).toEqual(expect.stringContaining("createCaptunTunnel")); + expect(module).toEqual(expect.stringContaining("captun-connect")); + expect(module).not.toEqual(expect.stringContaining("__captun-connect")); }); test("Hosted Captun landing page includes an in-browser tunnel demo", async () => { @@ -309,6 +314,8 @@ test.each([ "dash", "dashboard", "docs", + "gateway", + "gateways", "iterate", "login", "payment", @@ -316,19 +323,22 @@ test.each([ "status", "support", "tunnel", + "tunnels", ])("Hosted Captun reserves %s.captun.sh", async (subdomain) => { await using fixture = await createCaptunWorkerFixture({ CUSTOM_HOSTNAME: "captun.sh" }); const response = await fixture.worker.fetch(`https://${subdomain}.captun.sh/`); expect(response).toMatchObject({ status: 404 }); - expect(await response.text()).toBe("Reserved captun.sh subdomain\n"); + expect(await response.text()).toBe("Reserved Captun tunnel name\n"); }); test("Captun Worker rejects missing tunnel names before Durable Object dispatch", async () => { await using fixture = await createCaptunWorkerFixture({}); - const response = await fetch(`${fixture.origin}/__captun-connect`); + const response = await fixture.worker.fetch(`${fixture.origin}/?captun-connect=1`, { + headers: { upgrade: "websocket" }, + }); expect(response).toMatchObject({ status: 404 }); expect(await response.text()).toBe("Missing tunnel name\n"); @@ -343,10 +353,13 @@ test("Captun Worker rejects malformed folder tunnel names", async () => { expect(await response.text()).toBe("Missing tunnel name\n"); }); -test("Captun Worker requires the configured secret before accepting a tunnel client", async () => { - await using fixture = await createCaptunWorkerFixture({ CAPTUN_SECRET: "secret" }); +test("Captun Worker requires the configured token before accepting a tunnel client", async () => { + await using fixture = await createCaptunWorkerFixture({ CAPTUN_TOKEN: "token" }); - const response = await fetch(`${fixture.origin}/demo/__captun-connect`); + const response = await fixture.worker.fetch( + `${fixture.origin}/?captun-connect=1&captun-name=demo`, + { headers: { upgrade: "websocket" } }, + ); expect(response).toMatchObject({ status: 401 }); expect(await response.text()).toBe("Unauthorized\n"); From 48fc95210e5fa5725e70eec32af9632a054617d0 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 26 May 2026 00:25:11 +0100 Subject: [PATCH 06/18] Complete gateway-owned addressing task --- .../2026-05-26-gateway-owned-addressing.md} | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) rename tasks/{gateway-owned-addressing.md => complete/2026-05-26-gateway-owned-addressing.md} (81%) diff --git a/tasks/gateway-owned-addressing.md b/tasks/complete/2026-05-26-gateway-owned-addressing.md similarity index 81% rename from tasks/gateway-owned-addressing.md rename to tasks/complete/2026-05-26-gateway-owned-addressing.md index c7833fc..cc8bc42 100644 --- a/tasks/gateway-owned-addressing.md +++ b/tasks/complete/2026-05-26-gateway-owned-addressing.md @@ -1,11 +1,11 @@ --- -status: in-progress +status: complete size: large --- # Gateway-Owned Addressing Refactor -Status summary: Implementation is complete on #16 and local verification is green. The API, Worker connect protocol, CLI/deploy config, hosted browser module, README, and tests now use gateway-owned addressing; remaining work is pushing the branch and closing/superseding #20. +Status summary: Complete on #16. The API, Worker connect protocol, CLI/deploy config, hosted browser module, README, and tests now use gateway-owned addressing; #20 has been closed as superseded. ## Checklist @@ -16,10 +16,11 @@ Status summary: Implementation is complete on #16 and local verification is gree - [x] Update the Cloudflare Tunnel Gateway to store active Tunnels backed by Fetcher Stubs. _`CaptunServerShard` stores `{ url, token, fetcher }` ActiveTunnel records and forwards through the Fetcher Stub._ - [x] Update CLI config, flags, deploy summary, and self-test to use `gateway` and `token`. _`--gateway`, `--token`, `CAPTUN_TOKEN`, deploy dry-runs, config writes, and post-deploy self-test all use the new names._ - [x] Update README, Hosted Site snippets, and tests to use the new shape. _README, `www.captun.sh` snippets, the browser helper, e2e tests, worker tests, public-hosted tests, and smoke docs now describe gateway-owned tunnel URLs._ -- [ ] Close or supersede PR #20 after #16 has the new shape. +- [x] Close or supersede PR #20 after #16 has the new shape. _Closed #20 with a note that hosted safety/rate-limit work should be rebuilt on top of #16's gateway/token/connect-query protocol._ ## Implementation Notes - 2026-05-26: Grill-with-docs resolved the language in `CONTEXT.md` and recorded ADR-0001 for gateway-owned addressing. - 2026-05-26: Focused tests passed: `pnpm exec vitest run test/worker.test.ts test/e2e.test.ts examples/weather-reporter/e2e.test.ts`. - 2026-05-26: Full local checks passed: `pnpm run check`, `pnpm test`, and `pnpm run build`. +- 2026-05-26: Pushed #16 and closed #20 as superseded. From ce1290630752639ee480bec1a431e5bf2a026687 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 26 May 2026 00:28:43 +0100 Subject: [PATCH 07/18] Record hosted gateway deployment verification --- tasks/complete/2026-05-26-gateway-owned-addressing.md | 1 + 1 file changed, 1 insertion(+) diff --git a/tasks/complete/2026-05-26-gateway-owned-addressing.md b/tasks/complete/2026-05-26-gateway-owned-addressing.md index cc8bc42..759d1b2 100644 --- a/tasks/complete/2026-05-26-gateway-owned-addressing.md +++ b/tasks/complete/2026-05-26-gateway-owned-addressing.md @@ -24,3 +24,4 @@ Status summary: Complete on #16. The API, Worker connect protocol, CLI/deploy co - 2026-05-26: Focused tests passed: `pnpm exec vitest run test/worker.test.ts test/e2e.test.ts examples/weather-reporter/e2e.test.ts`. - 2026-05-26: Full local checks passed: `pnpm run check`, `pnpm test`, and `pnpm run build`. - 2026-05-26: Pushed #16 and closed #20 as superseded. +- 2026-05-26: Deployed `captun-public` to Iterate prd with `captun.sh/*` and `*.captun.sh/*`; live public e2e passed with `CAPTUN_PUBLIC_E2E=1 pnpm exec vitest run test/public-hosted.test.ts`. From 8e65b3ea25868630fa59f53e08e419c6dd130c34 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 26 May 2026 08:58:20 +0100 Subject: [PATCH 08/18] Fail fast on legacy hosted config --- src/cli/bin.ts | 14 +++++++++----- src/worker.ts | 3 +++ test/cli-config.test.ts | 28 ++++++++++++++++++++++++++++ test/worker.test.ts | 8 ++++++++ 4 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 test/cli-config.test.ts diff --git a/src/cli/bin.ts b/src/cli/bin.ts index 266a174..c231ef2 100755 --- a/src/cli/bin.ts +++ b/src/cli/bin.ts @@ -24,10 +24,14 @@ import { } from "./tunnel-health.js"; import { deployWorker, openInBrowser, runDeployWizard, waitForCertWithSpinner } from "./deploy.js"; -export type Config = { - gateway: string; - token?: string; -}; +export const configSchema = z + .object({ + gateway: z.url(), + token: z.string().optional(), + }) + .strict(); + +export type Config = z.infer; type TunnelCliInput = { target: string; @@ -385,7 +389,7 @@ function esc(value: string): string { async function readConfig() { try { - return JSON.parse(await readFile(configPath, "utf8")) as Config; + return configSchema.parse(JSON.parse(await readFile(configPath, "utf8"))); } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined; throw error; diff --git a/src/worker.ts b/src/worker.ts index db85036..c8945d9 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -16,6 +16,7 @@ import { type CaptunEnv = { CaptunServerShard: DurableObjectNamespace; CAPTUN_TOKEN?: string; + CAPTUN_SECRET?: string; SHARD_COUNT?: string; CUSTOM_HOSTNAME?: string; }; @@ -91,6 +92,8 @@ export class CaptunServerShard extends DurableObject { export default { fetch(request: Request, env: CaptunEnv): Response | Promise { + if ("CAPTUN_SECRET" in env) throw new Error("CAPTUN_SECRET has been renamed to CAPTUN_TOKEN"); + if (isGatewayConnectRequest(request)) { return connectTunnel(request, env); } diff --git a/test/cli-config.test.ts b/test/cli-config.test.ts new file mode 100644 index 0000000..60552f8 --- /dev/null +++ b/test/cli-config.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "vitest"; + +import { configSchema } from "../src/cli/bin.js"; + +test("CLI config requires the gateway/token shape", () => { + expect( + configSchema.parse({ gateway: "https://captun.example.workers.dev", token: "abc123" }), + ).toMatchObject({ + gateway: "https://captun.example.workers.dev", + token: "abc123", + }); +}); + +test("CLI config rejects legacy serverUrl/secret fields", () => { + const parsed = configSchema.safeParse({ + serverUrl: "https://{name}.tunnels.example.com", + secret: "abc123", + }); + + expect(parsed).toMatchObject({ + success: false, + error: { + issues: expect.arrayContaining([ + expect.objectContaining({ code: "unrecognized_keys", keys: ["serverUrl", "secret"] }), + ]), + }, + }); +}); diff --git a/test/worker.test.ts b/test/worker.test.ts index 1926ac2..7c7fa7a 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -364,3 +364,11 @@ test("Captun Worker requires the configured token before accepting a tunnel clie expect(response).toMatchObject({ status: 401 }); expect(await response.text()).toBe("Unauthorized\n"); }); + +test("Captun Worker rejects the legacy CAPTUN_SECRET binding", async () => { + await using fixture = await createCaptunWorkerFixture({ CAPTUN_SECRET: "legacy-secret" }); + + await expect(fixture.worker.fetch(`${fixture.origin}/missing/hello`)).rejects.toThrow( + "CAPTUN_SECRET has been renamed to CAPTUN_TOKEN", + ); +}); From 3a3bb4c95d57bee8c97f56f2981bb2cd6fccac2b Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 26 May 2026 09:06:05 +0100 Subject: [PATCH 09/18] Use portable token comparison --- src/worker.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/worker.ts b/src/worker.ts index c8945d9..025ec0d 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -59,7 +59,7 @@ export class CaptunServerShard extends DurableObject { // Constant-time comparison to avoid leaking the gateway token via timing. const actual = new TextEncoder().encode(connectToken(request) || ""); const want = new TextEncoder().encode(expected); - if (actual.length !== want.length || !crypto.subtle.timingSafeEqual(actual, want)) { + if (!constantTimeEqual(actual, want)) { return new Response("Unauthorized\n", { status: 401 }); } } @@ -170,6 +170,15 @@ function connectToken(request: Request) { return new URL(request.url).searchParams.get(CONNECT_TOKEN_QUERY_PARAM); } +function constantTimeEqual(actual: Uint8Array, expected: Uint8Array) { + if (actual.length !== expected.length) return false; + let diff = 0; + for (let index = 0; index < actual.length; index++) { + diff |= actual[index]! ^ expected[index]!; + } + return diff === 0; +} + function hostedCaptunResponse(request: Request, env: CaptunEnv): Response | undefined { if (env.CUSTOM_HOSTNAME !== HOSTED_CAPTUN_HOSTNAME) return undefined; From 3c100910a68910fc5087971bed2c9c45902e5a51 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 26 May 2026 09:23:48 +0100 Subject: [PATCH 10/18] Add hosted captun.sh safety controls --- src/cli/bin.ts | 34 ++- src/hosted-admission.ts | 62 +++++ src/index.ts | 73 +++++- src/routing.ts | 1 + src/token.ts | 5 + src/worker.ts | 309 ++++++++++++++++++++--- tasks/hosted-captun-sh.md | 11 +- test/cli.test.ts | 148 +++++++++++ test/hosted-admission.test.ts | 111 +++++++++ test/miniflare.ts | 1 + test/worker.test.ts | 455 +++++++++++++++++++++++++++++++++- wrangler.jsonc | 10 +- 12 files changed, 1170 insertions(+), 50 deletions(-) create mode 100644 src/hosted-admission.ts create mode 100644 src/token.ts create mode 100644 test/cli.test.ts create mode 100644 test/hosted-admission.test.ts diff --git a/src/cli/bin.ts b/src/cli/bin.ts index c231ef2..c29f790 100755 --- a/src/cli/bin.ts +++ b/src/cli/bin.ts @@ -13,10 +13,11 @@ import { createCli, yamlTableConsoleLogger } from "trpc-cli"; import { z } from "zod/v4"; import { color } from "./ansi.js"; import { CliFriendlyError } from "./cli-error.js"; -import { createCaptunTunnel } from "../index.js"; +import { CaptunTunnelConnectError, createCaptunTunnel } from "../index.js"; import { assertLocalTargetAcceptingConnections } from "./local-target.js"; import { withSpinner } from "./spinner.js"; -import { HOSTED_CAPTUN_GATEWAY } from "../routing.js"; +import { HOSTED_CAPTUN_GATEWAY, HOSTED_CAPTUN_HOSTNAME } from "../routing.js"; +import { randomConnectToken } from "../token.js"; import { captunHealthResponse, confirmTunnelHealth, @@ -57,8 +58,10 @@ export type TunnelReady = { export type CaptunCliRouterOptions = { readConfig?: () => Promise; writeConfig?: (config: Config) => Promise; + createTunnel?: typeof createCaptunTunnel; waitForShutdown?: () => Promise; onTunnelReady?: (ready: TunnelReady) => void | Promise; + tunnelRetries?: number; }; const adjectives = @@ -110,6 +113,8 @@ export function createCaptunCliRouter(options: CaptunCliRouterOptions = {}) { const tunnel = resolveTunnel(input, config); printTunnelOpening(tunnel); await runTunnelSession(tunnel, { + retries: options.tunnelRetries, + createTunnel: options.createTunnel, waitForShutdown: options.waitForShutdown, onReady: options.onTunnelReady, }); @@ -442,16 +447,22 @@ function resolveTunnel(input: TunnelCliInput, config?: Config): ResolvedTunnel { const name = input.name || randomName(); const target = normalizeTarget(input.target); + const token = input.token || config?.token || hostedGatewayToken(gateway); return { name, gateway, target, - token: input.token || config?.token, + token, requestLogs: input.requestLogs, }; } +function hostedGatewayToken(gateway: string) { + if (new URL(gateway).hostname !== HOSTED_CAPTUN_HOSTNAME) return undefined; + return randomConnectToken(); +} + function normalizeTarget(target: string) { const value = target.trim(); if (/^\d+$/.test(value)) return `http://127.0.0.1:${value}`; @@ -469,6 +480,7 @@ async function runTunnelSession( tunnel: ResolvedTunnel, opts: { retries?: number; + createTunnel?: typeof createCaptunTunnel; waitForShutdown?: () => Promise; onReady?: (ready: TunnelReady) => void | Promise; } = {}, @@ -476,7 +488,7 @@ async function runTunnelSession( const startedAt = performance.now(); await assertLocalTargetAcceptingConnections(tunnel.target); - const session = await connectTunnelWithRetry(tunnel, opts.retries || 0); + const session = await connectTunnelWithRetry(tunnel, opts.retries || 0, opts.createTunnel); try { await confirmTunnelHealth(session.url); console.log( @@ -492,7 +504,11 @@ async function runTunnelSession( } } -async function connectTunnelWithRetry(tunnel: ResolvedTunnel, retries: number) { +async function connectTunnelWithRetry( + tunnel: ResolvedTunnel, + retries: number, + createTunnel = createCaptunTunnel, +) { const fetcher = makeTunnelFetcher(tunnel); const maxAttempts = retries + 1; @@ -504,7 +520,7 @@ async function connectTunnelWithRetry(tunnel: ResolvedTunnel, retries: number) { : `Connecting to ${tunnel.gateway} (retry ${attempt - 1}/${retries})`; try { return await withSpinner(label, () => - createCaptunTunnel({ + createTunnel({ gateway: tunnel.gateway, name: tunnel.name, token: tunnel.token, @@ -562,7 +578,11 @@ function tunnelConnectError(tunnel: ResolvedTunnel, cause: unknown) { const hostname = new URL(tunnel.gateway).hostname; const message = cause instanceof Error ? cause.message : String(cause); const lines = [`Could not connect tunnel to ${color.cyan(tunnel.gateway)} (${message}).`]; - if (!hostname.endsWith(".workers.dev")) { + const connectRejection = cause instanceof CaptunTunnelConnectError ? cause.response : undefined; + const knownTunnelConflict = + connectRejection?.status === 409 && + connectRejection.body === "Tunnel name is already connected"; + if (!knownTunnelConflict && !hostname.endsWith(".workers.dev")) { // Dropping the leftmost label gives the zone-side wildcard parent — // `tunnel.mispwoso.com` -> `mispwoso.com`, `t.captun.example.com` -> `captun.example.com`. const wildcardParent = hostname.split(".").slice(1).join("."); diff --git a/src/hosted-admission.ts b/src/hosted-admission.ts new file mode 100644 index 0000000..7b907af --- /dev/null +++ b/src/hosted-admission.ts @@ -0,0 +1,62 @@ +import { CONNECT_TOKEN_QUERY_PARAM, HOSTED_CAPTUN_HOSTNAME } from "./routing.js"; + +export type HostedAdmissionEnv = { + CAPTUN_TOKEN?: string; + CUSTOM_HOSTNAME?: string; +}; + +export type TunnelAdmission = + | { ok: true; token: string | undefined } + | { ok: false; response: Response }; + +export function decideTunnelAdmission(input: { + request: Request; + env: HostedAdmissionEnv; + activeToken: string | undefined; +}): TunnelAdmission { + const configuredToken = input.env.CAPTUN_TOKEN; + const token = connectToken(input.request) || undefined; + if (configuredToken) { + if (!token || !constantTimeEqual(token, configuredToken)) { + return { ok: false, response: reject("Unauthorized\n", 401) }; + } + return { ok: true, token }; + } + + if (input.env.CUSTOM_HOSTNAME !== HOSTED_CAPTUN_HOSTNAME) return { ok: true, token: undefined }; + + if (!token) return { ok: false, response: reject("Missing tunnel token\n", 400) }; + if (!/^[a-zA-Z0-9._~-]{1,128}$/.test(token)) { + return { ok: false, response: reject("Invalid tunnel token\n", 400) }; + } + if (input.activeToken && input.activeToken !== token) { + return { ok: false, response: reject("Tunnel name is already connected\n", 409) }; + } + + return { ok: true, token }; +} + +function connectToken(request: Request) { + return new URL(request.url).searchParams.get(CONNECT_TOKEN_QUERY_PARAM); +} + +function reject(body: string, status: number) { + return new Response(body, { + status, + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-store", + }, + }); +} + +function constantTimeEqual(actual: string, expected: string) { + const actualBytes = new TextEncoder().encode(actual); + const expectedBytes = new TextEncoder().encode(expected); + if (actualBytes.length !== expectedBytes.length) return false; + let diff = 0; + for (let index = 0; index < actualBytes.length; index++) { + diff |= actualBytes[index]! ^ expectedBytes[index]!; + } + return diff === 0; +} diff --git a/src/index.ts b/src/index.ts index 0589490..4e5f564 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,8 +3,11 @@ import { CONNECT_TOKEN_QUERY_PARAM, GATEWAY_CONNECT_QUERY_PARAM, HOSTED_CAPTUN_GATEWAY, + HOSTED_CAPTUN_HOSTNAME, + TUNNEL_CONNECT_DIAGNOSTIC_HEADER, TUNNEL_NAME_QUERY_PARAM, } from "./routing.js"; +import { randomConnectToken } from "./token.js"; /** Fetch is all you need! * @@ -21,6 +24,19 @@ export type CaptunTunnel = Disposable & { token?: string; }; +export class CaptunTunnelConnectError extends Error { + response: { status: number; statusText: string; body: string } | undefined; + + constructor( + message: string, + response: { status: number; statusText: string; body: string } | undefined, + ) { + super(message); + this.name = "CaptunTunnelConnectError"; + this.response = response; + } +} + export type FetcherStub = Fetcher & Disposable & { ready(tunnel: { url: string; token?: string }): void | Promise; @@ -31,6 +47,7 @@ type TunnelClientCapability = Fetcher & { }; const TUNNEL_READY_TIMEOUT_MS = 5_000; +const WEBSOCKET_REJECTION_PROBE_TIMEOUT_MS = 500; /** Creates a public tunnel by exposing a local fetch implementation to a Tunnel Gateway. */ export async function createCaptunTunnel( @@ -49,7 +66,7 @@ export async function createCaptunTunnel( }); const session = newWebSocketRpcSession(socket, fetcher); try { - await waitUntilOpen(socket); + await waitUntilOpen(socket, connect.url); const tunnel = await waitUntilReady(ready.promise); return { url: tunnel.url, @@ -65,10 +82,15 @@ export async function createCaptunTunnel( function gatewayConnectRequest(options: { gateway?: string | URL; name?: string; token?: string }) { const name = options.name || randomTunnelName(); const url = new URL(options.gateway || HOSTED_CAPTUN_GATEWAY); + const token = options.token || (isHostedCaptunGateway(url) ? randomConnectToken() : undefined); url.searchParams.set(GATEWAY_CONNECT_QUERY_PARAM, "1"); url.searchParams.set(TUNNEL_NAME_QUERY_PARAM, name); - if (options.token) url.searchParams.set(CONNECT_TOKEN_QUERY_PARAM, options.token); - return { url: url.toString(), name, token: options.token }; + if (token) url.searchParams.set(CONNECT_TOKEN_QUERY_PARAM, token); + return { url: url.toString(), name, token }; +} + +function isHostedCaptunGateway(url: URL) { + return url.hostname === HOSTED_CAPTUN_HOSTNAME; } function randomTunnelName() { @@ -105,7 +127,7 @@ function createWebSocket(url: string | URL) { return new WebSocket(connectUrl.href); } -async function waitUntilOpen(socket: WebSocket) { +async function waitUntilOpen(socket: WebSocket, connectUrl: string) { if (socket.readyState === WebSocket.OPEN) return; if (socket.readyState !== WebSocket.CONNECTING) { throw new Error("WebSocket closed before opening"); @@ -120,20 +142,59 @@ async function waitUntilOpen(socket: WebSocket) { socket.addEventListener("open", () => settle(resolve), { signal: listeners.signal }); socket.addEventListener( "error", - () => settle(() => reject(new Error("WebSocket connection failed"))), + () => + settle(() => { + void webSocketConnectionFailedError(connectUrl).then(reject); + }), { signal: listeners.signal }, ); socket.addEventListener( "close", (event) => { listeners.abort(); - reject(new Error(`WebSocket closed before opening: ${event.code} ${event.reason}`)); + void webSocketConnectionFailedError(connectUrl).then((error) => { + reject( + error.response + ? error + : new Error(`WebSocket closed before opening: ${event.code} ${event.reason}`), + ); + }); }, { signal: listeners.signal }, ); }); } +async function webSocketConnectionFailedError(connectUrl: string) { + const response = await readWebSocketRejection(connectUrl); + if (!response) return new CaptunTunnelConnectError("WebSocket connection failed", undefined); + return new CaptunTunnelConnectError( + `WebSocket connection failed: ${response.status} ${response.statusText}: ${response.body}`.trim(), + response, + ); +} + +async function readWebSocketRejection(connectUrl: string) { + const abort = new AbortController(); + const timeout = setTimeout(() => abort.abort(), WEBSOCKET_REJECTION_PROBE_TIMEOUT_MS); + try { + const response = await fetch(connectUrl, { + headers: { [TUNNEL_CONNECT_DIAGNOSTIC_HEADER]: "1" }, + signal: abort.signal, + }); + if (response.ok) return undefined; + return { + status: response.status, + statusText: response.statusText || "Rejected", + body: (await response.text()).trim(), + }; + } catch { + return undefined; + } finally { + clearTimeout(timeout); + } +} + async function waitUntilReady(promise: Promise<{ url: string; token?: string }>) { let timeout: ReturnType | undefined; try { diff --git a/src/routing.ts b/src/routing.ts index bfb8b58..f03a8ab 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -3,6 +3,7 @@ export const HOSTED_CAPTUN_GATEWAY = "https://captun.sh"; export const GATEWAY_CONNECT_QUERY_PARAM = "captun-connect"; export const TUNNEL_NAME_QUERY_PARAM = "captun-name"; export const CONNECT_TOKEN_QUERY_PARAM = "captun-token"; +export const TUNNEL_CONNECT_DIAGNOSTIC_HEADER = "x-captun-connect-diagnostic"; export const RESERVED_TUNNEL_NAMES = [ "account", "accounts", diff --git a/src/token.ts b/src/token.ts new file mode 100644 index 0000000..af97b94 --- /dev/null +++ b/src/token.ts @@ -0,0 +1,5 @@ +export function randomConnectToken() { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/src/worker.ts b/src/worker.ts index 025ec0d..71bc878 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,24 +1,31 @@ import { DurableObject } from "cloudflare:workers"; +import { decideTunnelAdmission } from "./hosted-admission.js"; import { acceptFetcherCapability, type FetcherStub } from "./index.js"; import { captunShardName, - CONNECT_TOKEN_QUERY_PARAM, GATEWAY_CONNECT_QUERY_PARAM, HOSTED_CAPTUN_HOSTNAME, getTunnelNameFromUrl, getTunnelUrl, isValidTunnelName, RESERVED_TUNNEL_NAMES, + TUNNEL_CONNECT_DIAGNOSTIC_HEADER, TUNNEL_NAME_QUERY_PARAM, TUNNEL_URL_HEADER, } from "./routing.js"; type CaptunEnv = { CaptunServerShard: DurableObjectNamespace; + HostedRateLimiter?: DurableObjectNamespace; CAPTUN_TOKEN?: string; CAPTUN_SECRET?: string; SHARD_COUNT?: string; CUSTOM_HOSTNAME?: string; + HOSTED_RATE_LIMIT_WINDOW_SECONDS?: string; + HOSTED_CONNECTS_PER_IP_PER_WINDOW?: string; + HOSTED_REQUESTS_PER_IP_PER_WINDOW?: string; + HOSTED_REQUESTS_PER_TUNNEL_PER_WINDOW?: string; + HOSTED_RATE_LIMIT_DISABLED?: string; }; /** Set by the top-level Worker on the WebSocket-upgrade request so the DO knows the tunnel. */ @@ -30,6 +37,24 @@ type ActiveTunnel = { fetcher: FetcherStub; }; +const DEFAULT_HOSTED_RATE_LIMIT_WINDOW_SECONDS = 60; +const DEFAULT_HOSTED_CONNECTS_PER_IP_PER_WINDOW = 30; +const DEFAULT_HOSTED_REQUESTS_PER_IP_PER_WINDOW = 600; +const DEFAULT_HOSTED_REQUESTS_PER_TUNNEL_PER_WINDOW = 1200; +const HOSTED_RATE_LIMIT_DIAGNOSTIC_WINDOW_MS = 2_000; + +type HostedRateLimitKind = "connect" | "request"; + +type HostedRateLimitInput = { limit: number; windowSeconds: number }; + +type HostedRateLimitResult = { ok: true } | { ok: false; limit: number; retryAfterSeconds: number }; + +type HostedRateLimitBucket = { + count: number; + resetAt: number; + lastRejectedAt?: number; +}; + /** * A shard Durable Object owns many named tunnels. * @@ -54,24 +79,21 @@ export class CaptunServerShard extends DurableObject { const tunnelUrl = request.headers.get(TUNNEL_URL_HEADER); if (!tunnelUrl) return new Response("Missing tunnel URL\n", { status: 404 }); - const expected = this.env.CAPTUN_TOKEN; - if (expected) { - // Constant-time comparison to avoid leaking the gateway token via timing. - const actual = new TextEncoder().encode(connectToken(request) || ""); - const want = new TextEncoder().encode(expected); - if (!constantTimeEqual(actual, want)) { - return new Response("Unauthorized\n", { status: 401 }); - } - } + const activeTunnel = this.tunnels.get(tunnelName); + const admission = decideTunnelAdmission({ + request, + env: this.env, + activeToken: activeTunnel?.token, + }); + if (!admission.ok) return admission.response; - const token = expected ? connectToken(request) || undefined : undefined; - this.tunnels.get(tunnelName)?.fetcher[Symbol.dispose](); + activeTunnel?.fetcher[Symbol.dispose](); const { response, fetcher } = acceptFetcherCapability({ onDisconnect: () => { if (this.tunnels.get(tunnelName)?.fetcher === fetcher) this.tunnels.delete(tunnelName); }, }); - const tunnel = { url: tunnelUrl, token, fetcher }; + const tunnel = { url: tunnelUrl, token: admission.token, fetcher }; this.tunnels.set(tunnelName, tunnel); queueMicrotask(() => { void fetcher.ready({ url: tunnel.url, token: tunnel.token }); @@ -79,6 +101,19 @@ export class CaptunServerShard extends DurableObject { return response; } + diagnoseConnect(tunnelName: string, request: Request): Response { + const admission = decideTunnelAdmission({ + request, + env: this.env, + activeToken: this.tunnels.get(tunnelName)?.token, + }); + if (!admission.ok) return admission.response; + return new Response(null, { + status: 204, + headers: { "cache-control": "no-store" }, + }); + } + async forward(tunnelName: string, request: Request): Promise { const tunnel = this.tunnels.get(tunnelName)?.fetcher; if (!tunnel) return new Response("No tunnel client connected\n", { status: 503 }); @@ -90,8 +125,55 @@ export class CaptunServerShard extends DurableObject { } } +export class HostedRateLimiter extends DurableObject { + private bucket: HostedRateLimitBucket | undefined; + + check(input: HostedRateLimitInput): HostedRateLimitResult { + const now = Date.now(); + const bucket = this.activeBucket(now, now + input.windowSeconds * 1000); + if (bucket.count >= input.limit) { + bucket.lastRejectedAt = now; + return { + ok: false, + limit: input.limit, + retryAfterSeconds: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)), + }; + } + + bucket.count++; + return { ok: true }; + } + + diagnose(input: HostedRateLimitInput): HostedRateLimitResult { + const now = Date.now(); + const bucket = this.bucket; + if ( + bucket && + bucket.count >= input.limit && + bucket.resetAt > now && + bucket.lastRejectedAt && + now - bucket.lastRejectedAt <= HOSTED_RATE_LIMIT_DIAGNOSTIC_WINDOW_MS + ) { + return { + ok: false, + limit: input.limit, + retryAfterSeconds: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)), + }; + } + + return { ok: true }; + } + + private activeBucket(now: number, resetAt: number): HostedRateLimitBucket { + if (this.bucket && this.bucket.resetAt > now) return this.bucket; + const bucket: HostedRateLimitBucket = { count: 0, resetAt }; + this.bucket = bucket; + return bucket; + } +} + export default { - fetch(request: Request, env: CaptunEnv): Response | Promise { + async fetch(request: Request, env: CaptunEnv): Promise { if ("CAPTUN_SECRET" in env) throw new Error("CAPTUN_SECRET has been renamed to CAPTUN_TOKEN"); if (isGatewayConnectRequest(request)) { @@ -120,6 +202,14 @@ export default { return new Response("Reserved Captun tunnel name\n", { status: 404 }); } + const rateLimited = await hostedRateLimitResponse({ + env, + request, + tunnelName, + kind: "request", + }); + if (rateLimited) return rateLimited; + const shard = env.CaptunServerShard.getByName( captunShardName(tunnelName, Number(env.SHARD_COUNT || 1)), ); @@ -137,8 +227,9 @@ export default { }, } satisfies ExportedHandler; -function connectTunnel(request: Request, env: CaptunEnv) { - if (request.headers.get("upgrade") !== "websocket") { +async function connectTunnel(request: Request, env: CaptunEnv) { + const diagnostic = isConnectDiagnostic(request); + if (!diagnostic && request.headers.get("upgrade") !== "websocket") { return new Response("Expected WebSocket upgrade\n", { status: 400 }); } @@ -159,24 +250,176 @@ function connectTunnel(request: Request, env: CaptunEnv) { const headers = new Headers(request.headers); headers.set(TUNNEL_NAME_HEADER, tunnelName); headers.set(TUNNEL_URL_HEADER, tunnelUrl); - return shard.fetch(new Request(request, { headers })); + const connectRequest = new Request(request, { headers }); + + if (diagnostic) { + const rateLimited = await hostedRateLimitDiagnosticResponse({ + env, + request, + tunnelName, + kind: "connect", + }); + if (rateLimited) return rateLimited; + return shard.diagnoseConnect(tunnelName, connectRequest); + } + + const rateLimited = await hostedRateLimitResponse({ + env, + request, + tunnelName, + kind: "connect", + }); + if (rateLimited) return rateLimited; + + return shard.fetch(connectRequest); } function isGatewayConnectRequest(request: Request) { return new URL(request.url).searchParams.get(GATEWAY_CONNECT_QUERY_PARAM) === "1"; } -function connectToken(request: Request) { - return new URL(request.url).searchParams.get(CONNECT_TOKEN_QUERY_PARAM); +function isConnectDiagnostic(request: Request) { + if (request.headers.get("upgrade") === "websocket") return false; + return request.headers.get(TUNNEL_CONNECT_DIAGNOSTIC_HEADER) === "1"; } -function constantTimeEqual(actual: Uint8Array, expected: Uint8Array) { - if (actual.length !== expected.length) return false; - let diff = 0; - for (let index = 0; index < actual.length; index++) { - diff |= actual[index]! ^ expected[index]!; +async function hostedRateLimitResponse(input: { + env: CaptunEnv; + request: Request; + tunnelName: string; + kind: HostedRateLimitKind; +}): Promise { + if (input.env.CUSTOM_HOSTNAME !== HOSTED_CAPTUN_HOSTNAME) return undefined; + if (!input.env.HostedRateLimiter) { + return hostedRateLimiterMissingResponse(input.env); + } + + const config = hostedRateLimitConfig(input.env); + const checks = hostedRateLimitChecks({ + kind: input.kind, + clientKey: hostedClientKey(input.request), + tunnelName: input.tunnelName, + config, + }); + for (const check of checks) { + const limiter = input.env.HostedRateLimiter.getByName(hostedRateLimiterName(check.key)); + const result = await limiter.check({ + limit: check.limit, + windowSeconds: config.windowSeconds, + }); + if (!result.ok) return hostedRateLimitedResponse(result); } - return diff === 0; + + return undefined; +} + +async function hostedRateLimitDiagnosticResponse(input: { + env: CaptunEnv; + request: Request; + tunnelName: string; + kind: HostedRateLimitKind; +}): Promise { + if (input.env.CUSTOM_HOSTNAME !== HOSTED_CAPTUN_HOSTNAME) return undefined; + if (!input.env.HostedRateLimiter) { + return hostedRateLimiterMissingResponse(input.env); + } + + const config = hostedRateLimitConfig(input.env); + const checks = hostedRateLimitChecks({ + kind: input.kind, + clientKey: hostedClientKey(input.request), + tunnelName: input.tunnelName, + config, + }); + for (const check of checks) { + const limiter = input.env.HostedRateLimiter.getByName(hostedRateLimiterName(check.key)); + const result = await limiter.diagnose({ + limit: check.limit, + windowSeconds: config.windowSeconds, + }); + if (!result.ok) return hostedRateLimitedResponse(result); + } + + return undefined; +} + +function hostedRateLimiterMissingResponse(env: CaptunEnv) { + if (env.HOSTED_RATE_LIMIT_DISABLED === "1") return undefined; + return new Response("Hosted rate limiter is not configured\n", { + status: 503, + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-store", + }, + }); +} + +function hostedRateLimitedResponse(result: Extract) { + return new Response(`Rate limit exceeded. Try again in ${result.retryAfterSeconds}s.\n`, { + status: 429, + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-store", + "retry-after": String(result.retryAfterSeconds), + "x-captun-rate-limit": String(result.limit), + }, + }); +} + +function hostedClientKey(request: Request) { + return request.headers.get("cf-connecting-ip") || "unknown"; +} + +function hostedRateLimitChecks(input: { + kind: HostedRateLimitKind; + clientKey: string; + tunnelName: string; + config: ReturnType; +}) { + if (input.kind === "connect") { + return [{ key: `connect:ip:${input.clientKey}`, limit: input.config.connectsPerIp }]; + } + + return [ + { key: `request:ip:${input.clientKey}`, limit: input.config.requestsPerIp }, + { key: `request:tunnel:${input.tunnelName}`, limit: input.config.requestsPerTunnel }, + ]; +} + +function hostedRateLimiterName(key: string) { + let hash = 2166136261; + for (let index = 0; index < key.length; index++) { + hash ^= key.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return `bucket-${(hash >>> 0).toString(36)}`; +} + +function hostedRateLimitConfig(env: CaptunEnv) { + return { + windowSeconds: positiveInteger( + env.HOSTED_RATE_LIMIT_WINDOW_SECONDS, + DEFAULT_HOSTED_RATE_LIMIT_WINDOW_SECONDS, + ), + connectsPerIp: positiveInteger( + env.HOSTED_CONNECTS_PER_IP_PER_WINDOW, + DEFAULT_HOSTED_CONNECTS_PER_IP_PER_WINDOW, + ), + requestsPerIp: positiveInteger( + env.HOSTED_REQUESTS_PER_IP_PER_WINDOW, + DEFAULT_HOSTED_REQUESTS_PER_IP_PER_WINDOW, + ), + requestsPerTunnel: positiveInteger( + env.HOSTED_REQUESTS_PER_TUNNEL_PER_WINDOW, + DEFAULT_HOSTED_REQUESTS_PER_TUNNEL_PER_WINDOW, + ), + }; +} + +function positiveInteger(value: string | undefined, fallback: number) { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) return fallback; + return parsed; } function hostedCaptunResponse(request: Request, env: CaptunEnv): Response | undefined { @@ -429,7 +672,8 @@ console.log(tunnel.url); const WWW_BROWSER_MODULE = `import { newWebSocketRpcSession, RpcTarget } from "https://esm.sh/capnweb@0.8.0"; export async function createCaptunTunnel(options) { - const socket = new WebSocket(gatewayConnectUrl(options)); + const connect = gatewayConnectUrl(options); + const socket = new WebSocket(connect.url); const readyPromise = waitForReady(); const tunnelTargetFetcher = new TunnelTargetFetcher(options.fetch, readyPromise.ready); const session = newWebSocketRpcSession(socket, tunnelTargetFetcher); @@ -437,7 +681,7 @@ export async function createCaptunTunnel(options) { const tunnel = await readyPromise.promise; return { url: tunnel.url, - token: tunnel.token || options.token, + token: tunnel.token || connect.token, close: () => disposeSession(session), }; } @@ -461,10 +705,11 @@ class TunnelTargetFetcher extends RpcTarget { function gatewayConnectUrl(options) { const url = new URL(options.gateway || "https://captun.sh"); url.protocol = url.protocol === "http:" ? "ws:" : "wss:"; + const token = options.token || (url.hostname === "captun.sh" ? randomConnectToken() : undefined); url.searchParams.set("captun-connect", "1"); url.searchParams.set("captun-name", options.name || randomTunnelName()); - if (options.token) url.searchParams.set("captun-token", options.token); - return url; + if (token) url.searchParams.set("captun-token", token); + return { url, token }; } function waitUntilOpen(socket) { @@ -510,6 +755,12 @@ function randomTunnelName() { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } +function randomConnectToken() { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + function disposeSession(session) { const disposeSymbol = Symbol.dispose; if (disposeSymbol && typeof session[disposeSymbol] === "function") session[disposeSymbol](); diff --git a/tasks/hosted-captun-sh.md b/tasks/hosted-captun-sh.md index aff3226..576b047 100644 --- a/tasks/hosted-captun-sh.md +++ b/tasks/hosted-captun-sh.md @@ -1,11 +1,11 @@ --- -status: initial-hosted-deployed +status: hosted-safety-in-review size: medium --- # Hosted captun.sh -Status summary: Initial hosted deployment is live on `captun.sh`. The CLI, library, and browser landing-page demo can create hosted random tunnels; the main missing work is a proper free/paid control plane, tunnel ownership, and throttling. +Status summary: Initial hosted deployment is live on `captun.sh`. The follow-up PR now adds anonymous tunnel tokens, same-token reconnects, different-token `409`s, and hosted-only Durable Object rate-limit buckets. The main missing work is still a proper auth/payment control plane plus richer resource caps and observability. ## Initial public-hosted slice @@ -21,9 +21,9 @@ Status summary: Initial hosted deployment is live on `captun.sh`. The CLI, libra ## Safety and product follow-up - [ ] Use cryptographic random names for free hosted tunnels and keep friendly/custom subdomains behind auth or a paid reservation model. -- [ ] Add per-session tunnel ownership: first client claims a tunnel name, the same token can reconnect, and a different token gets `409` instead of evicting the active tunnel. -- [ ] Add Cloudflare Rate Limiting bindings for cheap edge throttles on connect attempts and forwarded requests. -- [ ] Add Durable Object backed global-ish limits for active tunnels, concurrent tunnels per IP/account, and suspicious reconnect churn. +- [x] Add per-session tunnel ownership: first client claims a tunnel name, the same token can reconnect, and a different token gets `409` instead of evicting the active tunnel. _Hosted `captun.sh` now requires a generated `captun-token`; `CaptunServerShard` keeps the active tunnel token and rejects mismatched reconnects._ +- [x] Add hosted throttles on connect attempts and forwarded requests. _Added a `HostedRateLimiter` Durable Object binding with per-IP connect/request buckets and per-tunnel request buckets for `CUSTOM_HOSTNAME=captun.sh` only._ +- [ ] Add broader Durable Object backed limits for active tunnels, concurrent tunnels per IP/account, and suspicious reconnect churn. - [ ] Add basic resource caps: max tunnel lifetime, idle timeout, in-flight request cap, request body size limit, and response streaming guardrails. - [ ] Add observability for rejected connects, `429`s, high-volume tunnel names, high-volume IPs, and top error classes. - [ ] Document an emergency shutdown path for disabling hosted `captun.sh` without affecting self-hosted deployments. @@ -34,3 +34,4 @@ Status summary: Initial hosted deployment is live on `captun.sh`. The CLI, libra - 2026-05-23: Public e2e passed against the live service with `CAPTUN_PUBLIC_E2E=1 pnpm vitest run test/public-hosted.test.ts`. - 2026-05-23: Reserved names and `www.captun.sh` verified against the live Worker. Apex redirect verified with `curl --resolve` against Cloudflare's authoritative A record while local resolver propagation was still uneven. - 2026-05-23: Browser demo deployed and manually verified with Playwriter. Clicking "create tunnel" produced a random `captun.sh` URL, and `curl` to that URL returned the browser-defined response. +- 2026-05-26: Hosted safety branch rebuilt on top of #16's gateway/token protocol. Public hosted clients now get generated tokens in the CLI, library, and browser module; self-hosted deployments without `CAPTUN_TOKEN` still permit trusted replacement. diff --git a/test/cli.test.ts b/test/cli.test.ts new file mode 100644 index 0000000..fdd4fca --- /dev/null +++ b/test/cli.test.ts @@ -0,0 +1,148 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; + +import { createRouterClient } from "@orpc/server"; +import { expect, test } from "vitest"; + +import { createCaptunCliRouter } from "../src/cli/bin.js"; +import { isCaptunHealthRequest } from "../src/cli/tunnel-health.js"; +import { CaptunTunnelConnectError } from "../src/index.js"; + +test("CLI tunnel connect errors do not blame DNS for active token conflicts", async () => { + await using target = await createTestServer(defaultTargetHandler); + + const router = createCaptunCliRouter({ + readConfig: async () => undefined, + createTunnel: async () => { + throw new CaptunTunnelConnectError( + "WebSocket connection failed: 409 Conflict: Tunnel name is already connected", + { status: 409, statusText: "Conflict", body: "Tunnel name is already connected" }, + ); + }, + }); + const client = createRouterClient(router); + + let caught: unknown; + try { + await client.tunnel({ + target: String(target.port), + gateway: "https://captun.sh", + name: "demo", + requestLogs: false, + }); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ + message: expect.stringContaining("Tunnel name is already connected"), + }); + expect(caught).not.toMatchObject({ + message: expect.stringContaining("DNS for"), + }); +}); + +test("CLI tunnel connect errors keep DNS guidance for unrelated 409 responses", async () => { + await using target = await createTestServer(defaultTargetHandler); + + const router = createCaptunCliRouter({ + readConfig: async () => undefined, + createTunnel: async () => { + throw new CaptunTunnelConnectError( + "WebSocket connection failed: 409 Conflict: Some other conflict", + { + status: 409, + statusText: "Conflict", + body: "Some other conflict", + }, + ); + }, + }); + const client = createRouterClient(router); + + let caught: unknown; + try { + await client.tunnel({ + target: String(target.port), + gateway: "https://custom.example.com", + name: "demo", + requestLogs: false, + }); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ + message: expect.stringContaining("Some other conflict"), + }); + expect(caught).toMatchObject({ + message: expect.stringContaining("DNS for"), + }); +}); + +test("CLI tunnel retries reuse the same generated hosted token", async () => { + await using target = await createTestServer(defaultTargetHandler); + const tokens: Array = []; + + const router = createCaptunCliRouter({ + readConfig: async () => undefined, + waitForShutdown: async () => {}, + tunnelRetries: 1, + createTunnel: async (options) => { + tokens.push(options.token); + if (tokens.length === 1) throw new Error("try again"); + return { + url: target.origin, + token: options.token, + [Symbol.dispose]() {}, + }; + }, + }); + const client = createRouterClient(router); + + await client.tunnel({ + target: String(target.port), + gateway: "https://captun.sh", + name: "demo", + requestLogs: false, + }); + + expect(tokens).toEqual([tokens[0], tokens[0]]); + expect(tokens[0]).toMatch(/^[a-f0-9]{32}$/); +}); + +function defaultTargetHandler(request: IncomingMessage, response: ServerResponse) { + const req = new Request(`http://127.0.0.1${request.url || "/"}`); + if (isCaptunHealthRequest(req)) { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ ok: true })); + return; + } + + response.end("ok\n"); +} + +async function createTestServer( + handler: (req: IncomingMessage, res: ServerResponse) => void | Promise, +) { + const server = createServer((req, res) => { + void Promise.resolve(handler(req, res)).catch((error: unknown) => { + res.writeHead(500, { "content-type": "text/plain" }); + res.end(error instanceof Error ? error.stack : String(error)); + }); + }); + await new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen(0, "127.0.0.1", resolveListen); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Could not determine test-server port."); + } + return { + origin: `http://127.0.0.1:${address.port}`, + port: address.port, + async [Symbol.asyncDispose]() { + await new Promise((resolveClose) => server.close(() => resolveClose())); + }, + }; +} diff --git a/test/hosted-admission.test.ts b/test/hosted-admission.test.ts new file mode 100644 index 0000000..3a6c345 --- /dev/null +++ b/test/hosted-admission.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from "vitest"; + +import { decideTunnelAdmission, type HostedAdmissionEnv } from "../src/hosted-admission.js"; + +test("hosted tunnel admission allows self-hosted connects without tokens", () => { + const admission = decideTunnelAdmission({ + request: new Request("https://captun.example.com/?captun-connect=1&captun-name=demo"), + env: { CUSTOM_HOSTNAME: "captun.example.com" }, + activeToken: undefined, + }); + + expect(admission).toMatchObject({ ok: true, token: undefined }); +}); + +test("hosted tunnel admission checks configured tokens before public-hosted policy", async () => { + const rejected = decideTunnelAdmission({ + request: new Request("https://captun.sh/?captun-connect=1&captun-name=demo"), + env: { CUSTOM_HOSTNAME: "captun.sh", CAPTUN_TOKEN: "secret" }, + activeToken: undefined, + }); + const accepted = decideTunnelAdmission({ + request: new Request( + "https://captun.sh/?captun-connect=1&captun-name=demo&captun-token=secret", + ), + env: { CUSTOM_HOSTNAME: "captun.sh", CAPTUN_TOKEN: "secret" }, + activeToken: undefined, + }); + + expect(rejected).toMatchObject({ ok: false }); + if (rejected.ok) throw new Error("expected token rejection"); + expect(rejected.response).toMatchObject({ status: 401 }); + expect(await rejected.response.text()).toBe("Unauthorized\n"); + expect(accepted).toMatchObject({ ok: true, token: "secret" }); +}); + +test("hosted tunnel admission ignores active anonymous tokens when token auth is configured", () => { + const admission = decideTunnelAdmission({ + request: new Request( + "https://captun.sh/?captun-connect=1&captun-name=demo&captun-token=secret", + ), + env: { CUSTOM_HOSTNAME: "captun.sh", CAPTUN_TOKEN: "secret" }, + activeToken: "anonymous-a", + }); + + expect(admission).toMatchObject({ ok: true, token: "secret" }); +}); + +test("hosted tunnel admission requires anonymous tokens on captun.sh", async () => { + const missing = decideTunnelAdmission({ + request: new Request("https://captun.sh/?captun-connect=1&captun-name=demo"), + env: hostedEnv(), + activeToken: undefined, + }); + const invalid = decideTunnelAdmission({ + request: new Request( + "https://captun.sh/?captun-connect=1&captun-name=demo&captun-token=no spaces", + ), + env: hostedEnv(), + activeToken: undefined, + }); + + expect(missing).toMatchObject({ ok: false }); + if (missing.ok) throw new Error("expected missing token rejection"); + expect(missing.response).toMatchObject({ status: 400 }); + expect(await missing.response.text()).toBe("Missing tunnel token\n"); + + expect(invalid).toMatchObject({ ok: false }); + if (invalid.ok) throw new Error("expected invalid token rejection"); + expect(invalid.response).toMatchObject({ status: 400 }); + expect(await invalid.response.text()).toBe("Invalid tunnel token\n"); +}); + +test("hosted tunnel admission allows first and same-token anonymous connects", () => { + const first = decideTunnelAdmission({ + request: new Request( + "https://captun.sh/?captun-connect=1&captun-name=demo&captun-token=token-a", + ), + env: hostedEnv(), + activeToken: undefined, + }); + const sameToken = decideTunnelAdmission({ + request: new Request( + "https://captun.sh/?captun-connect=1&captun-name=demo&captun-token=token-a", + ), + env: hostedEnv(), + activeToken: "token-a", + }); + + expect(first).toMatchObject({ ok: true, token: "token-a" }); + expect(sameToken).toMatchObject({ ok: true, token: "token-a" }); +}); + +test("hosted tunnel admission rejects different active anonymous tokens", async () => { + const admission = decideTunnelAdmission({ + request: new Request( + "https://captun.sh/?captun-connect=1&captun-name=demo&captun-token=token-b", + ), + env: hostedEnv(), + activeToken: "token-a", + }); + + expect(admission).toMatchObject({ ok: false }); + if (admission.ok) throw new Error("expected active-token rejection"); + expect(admission.response).toMatchObject({ status: 409 }); + expect(admission.response.headers.get("cache-control")).toBe("no-store"); + expect(await admission.response.text()).toBe("Tunnel name is already connected\n"); +}); + +function hostedEnv(): HostedAdmissionEnv { + return { CUSTOM_HOSTNAME: "captun.sh" }; +} diff --git a/test/miniflare.ts b/test/miniflare.ts index cfaabe9..6e2edce 100644 --- a/test/miniflare.ts +++ b/test/miniflare.ts @@ -52,6 +52,7 @@ export function createCaptunWorkerFixture(bindings: Record) { entryPoint: "src/worker.ts", durableObjects: { CaptunServerShard: { className: "CaptunServerShard" }, + HostedRateLimiter: { className: "HostedRateLimiter" }, }, bindings, }); diff --git a/test/worker.test.ts b/test/worker.test.ts index 7c7fa7a..efee5fe 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -1,3 +1,6 @@ +import { createServer } from "node:http"; + +import { newWebSocketRpcSession, RpcTarget } from "capnweb"; import { expect, test } from "vitest"; import { createCaptunTunnel } from "../src/index.js"; import { captunHealthResponse, isCaptunHealthRequest } from "../src/cli/tunnel-health.js"; @@ -5,9 +8,10 @@ import { captunShardName, getTunnelNameFromUrl, getTunnelUrl, + TUNNEL_CONNECT_DIAGNOSTIC_HEADER, TUNNEL_URL_HEADER, } from "../src/routing.js"; -import { createCaptunWorkerFixture } from "./miniflare.js"; +import { createCaptunWorkerFixture, createMiniflareWorkerFixture } from "./miniflare.js"; const tunnelNameCases: Array< [url: string, customHostname: string | undefined, name: string | null] @@ -236,6 +240,8 @@ test("Hosted Captun serves the browser demo module on www", async () => { const module = await response.text(); expect(module).toEqual(expect.stringContaining("createCaptunTunnel")); expect(module).toEqual(expect.stringContaining("captun-connect")); + expect(module).toEqual(expect.stringContaining("captun-token")); + expect(module).toEqual(expect.stringContaining("randomConnectToken")); expect(module).not.toEqual(expect.stringContaining("__captun-connect")); }); @@ -302,6 +308,364 @@ test("Hosted Captun landing page loads CodeMirror for the browser demo editor", ); }); +test("Hosted Captun rate limits tunnel connect attempts per client IP", async () => { + await using fixture = await createCaptunWorkerFixture({ + CUSTOM_HOSTNAME: "captun.sh", + CAPTUN_TOKEN: "secret", + HOSTED_CONNECTS_PER_IP_PER_WINDOW: "1", + }); + const headers = { upgrade: "websocket", "cf-connecting-ip": "203.0.113.10" }; + + const first = await fixture.worker.fetch("https://captun.sh/?captun-connect=1&captun-name=one", { + headers, + }); + const second = await fixture.worker.fetch("https://captun.sh/?captun-connect=1&captun-name=two", { + headers, + }); + + expect(first).toMatchObject({ status: 401 }); + expect(second).toMatchObject({ status: 429 }); + expect(second.headers.get("retry-after")).toBe("60"); + expect(second.headers.get("cache-control")).toBe("no-store"); + expect(await second.text()).toBe("Rate limit exceeded. Try again in 60s.\n"); +}); + +test("Hosted Captun rate limits forwarded requests per client IP", async () => { + await using fixture = await createCaptunWorkerFixture({ + CUSTOM_HOSTNAME: "captun.sh", + HOSTED_REQUESTS_PER_IP_PER_WINDOW: "2", + HOSTED_REQUESTS_PER_TUNNEL_PER_WINDOW: "100", + }); + const headers = { "cf-connecting-ip": "203.0.113.20" }; + + const first = await fixture.worker.fetch("https://one.captun.sh/hello", { headers }); + const second = await fixture.worker.fetch("https://two.captun.sh/hello", { headers }); + const third = await fixture.worker.fetch("https://three.captun.sh/hello", { headers }); + + expect(first).toMatchObject({ status: 503 }); + expect(second).toMatchObject({ status: 503 }); + expect(third).toMatchObject({ status: 429 }); + expect(third.headers.get("x-captun-rate-limit")).toBe("2"); +}); + +test("Hosted Captun rate limits forwarded requests per tunnel name", async () => { + await using fixture = await createCaptunWorkerFixture({ + CUSTOM_HOSTNAME: "captun.sh", + HOSTED_REQUESTS_PER_IP_PER_WINDOW: "100", + HOSTED_REQUESTS_PER_TUNNEL_PER_WINDOW: "1", + }); + + const first = await fixture.worker.fetch("https://one.captun.sh/hello", { + headers: { "cf-connecting-ip": "203.0.113.30" }, + }); + const second = await fixture.worker.fetch("https://one.captun.sh/hello", { + headers: { "cf-connecting-ip": "203.0.113.31" }, + }); + const otherTunnel = await fixture.worker.fetch("https://two.captun.sh/hello", { + headers: { "cf-connecting-ip": "203.0.113.31" }, + }); + + expect(first).toMatchObject({ status: 503 }); + expect(second).toMatchObject({ status: 429 }); + expect(otherTunnel).toMatchObject({ status: 503 }); +}); + +test("Hosted Captun rate limits do not affect self-hosted folder routing", async () => { + await using fixture = await createCaptunWorkerFixture({ + HOSTED_REQUESTS_PER_IP_PER_WINDOW: "1", + }); + const headers = { "cf-connecting-ip": "203.0.113.40" }; + + const first = await fixture.worker.fetch(`${fixture.origin}/one/hello`, { headers }); + const second = await fixture.worker.fetch(`${fixture.origin}/two/hello`, { headers }); + + expect(first).toMatchObject({ status: 503 }); + expect(second).toMatchObject({ status: 503 }); +}); + +test("Hosted Captun fails closed when the rate limiter binding is missing", async () => { + await using fixture = await createMiniflareWorkerFixture({ + entryPoint: "src/worker.ts", + durableObjects: { + CaptunServerShard: { className: "CaptunServerShard" }, + }, + bindings: { CUSTOM_HOSTNAME: "captun.sh" }, + }); + + const response = await fixture.worker.fetch("https://one.captun.sh/hello", { + headers: { "cf-connecting-ip": "203.0.113.50" }, + }); + + expect(response).toMatchObject({ status: 503 }); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.text()).toBe("Hosted rate limiter is not configured\n"); +}); + +test("Hosted Captun only bypasses a missing rate limiter binding when explicitly disabled", async () => { + await using fixture = await createMiniflareWorkerFixture({ + entryPoint: "src/worker.ts", + durableObjects: { + CaptunServerShard: { className: "CaptunServerShard" }, + }, + bindings: { + CUSTOM_HOSTNAME: "captun.sh", + HOSTED_RATE_LIMIT_DISABLED: "1", + HOSTED_REQUESTS_PER_IP_PER_WINDOW: "1", + }, + }); + + const first = await fixture.worker.fetch("https://one.captun.sh/hello", { + headers: { "cf-connecting-ip": "203.0.113.51" }, + }); + const second = await fixture.worker.fetch("https://two.captun.sh/hello", { + headers: { "cf-connecting-ip": "203.0.113.51" }, + }); + + expect(first).toMatchObject({ status: 503 }); + expect(second).toMatchObject({ status: 503 }); +}); + +test("Hosted Captun does not trust spoofable forwarded IP headers for rate limiting", async () => { + await using fixture = await createCaptunWorkerFixture({ + CUSTOM_HOSTNAME: "captun.sh", + HOSTED_REQUESTS_PER_IP_PER_WINDOW: "1", + }); + + const first = await fixture.worker.fetch("https://one.captun.sh/hello", { + headers: { "x-forwarded-for": "203.0.113.60" }, + }); + const second = await fixture.worker.fetch("https://two.captun.sh/hello", { + headers: { "x-forwarded-for": "203.0.113.61" }, + }); + + expect(first).toMatchObject({ status: 503 }); + expect(second).toMatchObject({ status: 429 }); +}); + +test("Hosted Captun rejects a different token while a tunnel is active", async () => { + await using fixture = await createCaptunWorkerFixture({ + CUSTOM_HOSTNAME: "captun.sh", + HOSTED_CONNECTS_PER_IP_PER_WINDOW: "100", + }); + using _tokenTunnel = await createDirectWorkerTunnel({ + fixture, + url: "https://captun.sh/?captun-connect=1&captun-name=demo&captun-token=token-a", + responseText: "token a\n", + clientIp: "203.0.113.70", + }); + + const conflict = await fixture.worker.fetch( + "https://captun.sh/?captun-connect=1&captun-name=demo&captun-token=token-b", + { headers: { upgrade: "websocket", "cf-connecting-ip": "203.0.113.71" } }, + ); + expect(conflict).toMatchObject({ status: 409 }); + expect(await conflict.text()).toBe("Tunnel name is already connected\n"); + + const stillOwned = await fixture.worker.fetch("https://demo.captun.sh/hello", { + headers: { "cf-connecting-ip": "203.0.113.72" }, + }); + + expect(stillOwned).toMatchObject({ status: 200 }); + expect(await stillOwned.text()).toBe("token a\n"); +}); + +test("Hosted Captun connect diagnostics do not replace active tunnels", async () => { + await using fixture = await createCaptunWorkerFixture({ + CUSTOM_HOSTNAME: "captun.sh", + HOSTED_CONNECTS_PER_IP_PER_WINDOW: "100", + }); + using _tokenTunnel = await createDirectWorkerTunnel({ + fixture, + url: "https://captun.sh/?captun-connect=1&captun-name=demo&captun-token=token-a", + responseText: "token a\n", + clientIp: "203.0.113.74", + }); + + const diagnostic = await fixture.worker.fetch( + "https://captun.sh/?captun-connect=1&captun-name=demo&captun-token=token-a", + { + headers: { + "cf-connecting-ip": "203.0.113.75", + [TUNNEL_CONNECT_DIAGNOSTIC_HEADER]: "1", + }, + }, + ); + const stillOwned = await fixture.worker.fetch("https://demo.captun.sh/hello", { + headers: { "cf-connecting-ip": "203.0.113.76" }, + }); + + expect(diagnostic).toMatchObject({ status: 204 }); + expect(stillOwned).toMatchObject({ status: 200 }); + expect(await stillOwned.text()).toBe("token a\n"); +}); + +test("Hosted Captun connect diagnostics do not spend connect rate-limit slots", async () => { + await using fixture = await createCaptunWorkerFixture({ + CUSTOM_HOSTNAME: "captun.sh", + HOSTED_CONNECTS_PER_IP_PER_WINDOW: "2", + }); + using _tokenTunnel = await createDirectWorkerTunnel({ + fixture, + url: "https://captun.sh/?captun-connect=1&captun-name=demo&captun-token=token-a", + responseText: "token a\n", + clientIp: "203.0.113.77", + }); + + const firstConflict = await fixture.worker.fetch( + "https://captun.sh/?captun-connect=1&captun-name=demo&captun-token=token-b", + { headers: { upgrade: "websocket", "cf-connecting-ip": "203.0.113.78" } }, + ); + const diagnostic = await fixture.worker.fetch( + "https://captun.sh/?captun-connect=1&captun-name=demo&captun-token=token-b", + { + headers: { + "cf-connecting-ip": "203.0.113.78", + [TUNNEL_CONNECT_DIAGNOSTIC_HEADER]: "1", + }, + }, + ); + const secondConflict = await fixture.worker.fetch( + "https://captun.sh/?captun-connect=1&captun-name=demo&captun-token=token-c", + { headers: { upgrade: "websocket", "cf-connecting-ip": "203.0.113.78" } }, + ); + + expect(firstConflict).toMatchObject({ status: 409 }); + expect(diagnostic).toMatchObject({ status: 409 }); + expect(await diagnostic.text()).toBe("Tunnel name is already connected\n"); + expect(secondConflict).toMatchObject({ status: 409 }); +}); + +test("Hosted Captun connect diagnostics surface recent connect rate limits", async () => { + await using fixture = await createCaptunWorkerFixture({ + CUSTOM_HOSTNAME: "captun.sh", + HOSTED_CONNECTS_PER_IP_PER_WINDOW: "1", + }); + using _tokenTunnel = await createDirectWorkerTunnel({ + fixture, + url: "https://captun.sh/?captun-connect=1&captun-name=demo&captun-token=token-a", + responseText: "token a\n", + clientIp: "203.0.113.79", + }); + + const rateLimited = await fixture.worker.fetch( + "https://captun.sh/?captun-connect=1&captun-name=demo&captun-token=token-b", + { headers: { upgrade: "websocket", "cf-connecting-ip": "203.0.113.79" } }, + ); + const diagnostic = await fixture.worker.fetch( + "https://captun.sh/?captun-connect=1&captun-name=demo&captun-token=token-b", + { + headers: { + "cf-connecting-ip": "203.0.113.79", + [TUNNEL_CONNECT_DIAGNOSTIC_HEADER]: "1", + }, + }, + ); + + expect(rateLimited).toMatchObject({ status: 429 }); + expect(diagnostic).toMatchObject({ status: 429 }); + expect(await diagnostic.text()).toMatch(/^Rate limit exceeded\. Try again in \d+s\.\n$/); +}); + +test("Hosted Captun connect diagnostics fail closed when rate limiter binding is missing", async () => { + await using fixture = await createMiniflareWorkerFixture({ + entryPoint: "src/worker.ts", + durableObjects: { CaptunServerShard: { className: "CaptunServerShard" } }, + bindings: { CUSTOM_HOSTNAME: "captun.sh" }, + }); + + const diagnostic = await fixture.worker.fetch( + "https://captun.sh/?captun-connect=1&captun-name=demo&captun-token=token-a", + { + headers: { + "cf-connecting-ip": "203.0.113.84", + [TUNNEL_CONNECT_DIAGNOSTIC_HEADER]: "1", + }, + }, + ); + + expect(diagnostic).toMatchObject({ status: 503 }); + expect(await diagnostic.text()).toBe("Hosted rate limiter is not configured\n"); +}); + +test("Hosted Captun lets the same token replace its active tunnel", async () => { + await using fixture = await createCaptunWorkerFixture({ + CUSTOM_HOSTNAME: "captun.sh", + HOSTED_CONNECTS_PER_IP_PER_WINDOW: "100", + }); + using _firstTunnel = await createDirectWorkerTunnel({ + fixture, + url: "https://captun.sh/?captun-connect=1&captun-name=demo&captun-token=token-a", + responseText: "first\n", + clientIp: "203.0.113.80", + }); + using _secondTunnel = await createDirectWorkerTunnel({ + fixture, + url: "https://captun.sh/?captun-connect=1&captun-name=demo&captun-token=token-a", + responseText: "second\n", + clientIp: "203.0.113.81", + }); + + const response = await fixture.worker.fetch("https://demo.captun.sh/hello", { + headers: { "cf-connecting-ip": "203.0.113.82" }, + }); + + expect(response).toMatchObject({ status: 200 }); + expect(await response.text()).toBe("second\n"); +}); + +test("Hosted Captun requires anonymous tokens for public hosted connections", async () => { + await using fixture = await createCaptunWorkerFixture({ + CUSTOM_HOSTNAME: "captun.sh", + HOSTED_CONNECTS_PER_IP_PER_WINDOW: "100", + }); + + const response = await fixture.worker.fetch( + "https://captun.sh/?captun-connect=1&captun-name=demo", + { + headers: { upgrade: "websocket", "cf-connecting-ip": "203.0.113.90" }, + }, + ); + + expect(response).toMatchObject({ status: 400 }); + expect(await response.text()).toBe("Missing tunnel token\n"); +}); + +test("createCaptunTunnel surfaces rejected WebSocket upgrade response details", async () => { + await using rejection = await createRejectedWebSocketUpgradeServer({ + status: 409, + body: "Tunnel name is already connected\n", + }); + + await expect( + createCaptunTunnel({ + gateway: rejection.origin, + name: "demo", + fetch: () => new Response("unused\n"), + }), + ).rejects.toThrow(/409 Conflict: Tunnel name is already connected/); +}); + +test("createCaptunTunnel falls back when the rejected upgrade probe does not respond", async () => { + await using rejection = await createRejectedWebSocketUpgradeServer({ + status: 409, + body: "Tunnel name is already connected\n", + neverRespondToHttp: true, + }); + + let caught: unknown; + try { + await createCaptunTunnel({ + gateway: rejection.origin, + name: "demo", + fetch: () => new Response("unused\n"), + }); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ message: "WebSocket connection failed" }); +}); + test.each([ "account", "accounts", @@ -372,3 +736,92 @@ test("Captun Worker rejects the legacy CAPTUN_SECRET binding", async () => { "CAPTUN_SECRET has been renamed to CAPTUN_TOKEN", ); }); + +async function createRejectedWebSocketUpgradeServer(options: { + status: number; + body: string; + neverRespondToHttp?: boolean; +}) { + const sockets = new Set<{ destroy: () => void }>(); + const statusText = options.status === 409 ? "Conflict" : "Rejected"; + const server = createServer((_request, response) => { + if (options.neverRespondToHttp) return; + response.writeHead(options.status, { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-store", + }); + response.end(options.body); + }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + server.on("upgrade", (_request, socket) => { + socket.write( + [ + `HTTP/1.1 ${options.status} ${statusText}`, + "Content-Type: text/plain; charset=utf-8", + "Cache-Control: no-store", + `Content-Length: ${Buffer.byteLength(options.body)}`, + "Connection: close", + "", + options.body, + ].join("\r\n"), + ); + socket.destroy(); + }); + await new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen(0, "127.0.0.1", resolveListen); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Could not start test server"); + + return { + origin: `http://127.0.0.1:${address.port}`, + async [Symbol.asyncDispose]() { + for (const socket of sockets) socket.destroy(); + await new Promise((resolveClose) => server.close(() => resolveClose())); + }, + }; +} + +async function createDirectWorkerTunnel(options: { + fixture: any; + url: string; + responseText: string; + clientIp: string; +}) { + const response = await options.fixture.worker.fetch(options.url, { + headers: { + upgrade: "websocket", + "cf-connecting-ip": options.clientIp, + }, + }); + expect(response).toMatchObject({ status: 101 }); + + const socket = response.webSocket; + socket.accept(); + const session = newWebSocketRpcSession(socket, new TestTunnelFetcher(options.responseText)); + + return { + [Symbol.dispose]() { + session[Symbol.dispose](); + }, + }; +} + +class TestTunnelFetcher extends RpcTarget { + private responseText: string; + + constructor(responseText: string) { + super(); + this.responseText = responseText; + } + + fetch() { + return new Response(this.responseText); + } + + ready() {} +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 5ca49da..b67f507 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -10,7 +10,13 @@ "head_sampling_rate": 1, }, "durable_objects": { - "bindings": [{ "name": "CaptunServerShard", "class_name": "CaptunServerShard" }], + "bindings": [ + { "name": "CaptunServerShard", "class_name": "CaptunServerShard" }, + { "name": "HostedRateLimiter", "class_name": "HostedRateLimiter" }, + ], }, - "migrations": [{ "tag": "v1", "new_sqlite_classes": ["CaptunServerShard"] }], + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["CaptunServerShard"] }, + { "tag": "v2", "new_sqlite_classes": ["HostedRateLimiter"] }, + ], } From 1a924a7c2c6ff54d2311b0b9df6f8e1517d35516 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 26 May 2026 11:25:08 +0100 Subject: [PATCH 11/18] Separate hosted service from deployable worker --- CONTEXT.md | 1 + README.md | 2 +- package.json | 3 +- src/hosted/site.ts | 336 ++++++++++++++++++++++++++++++++++++ src/hosted/worker.ts | 12 ++ src/worker.ts | 343 +------------------------------------ test/hosted-worker.test.ts | 145 ++++++++++++++++ test/miniflare.ts | 10 ++ test/worker.test.ts | 143 ---------------- wrangler.hosted.jsonc | 19 ++ 10 files changed, 527 insertions(+), 487 deletions(-) create mode 100644 src/hosted/site.ts create mode 100644 src/hosted/worker.ts create mode 100644 test/hosted-worker.test.ts create mode 100644 wrangler.hosted.jsonc diff --git a/CONTEXT.md b/CONTEXT.md index 85753ae..9a9c6ac 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -136,6 +136,7 @@ _Avoid_: Agent layer, agent product - The **Hosted Service** is currently the **Cloudflare Tunnel Gateway** running with public hosted policy and the `captun.sh` product surface. - The **Hosted Site** is part of the **Hosted Service** product surface, but it is not tunnel routing or **Tunnel Admission**. - **Hosted Site** code should not live in **Cloudflare Tunnel Gateway** core. A real browser package can wait until the demo surface needs it. +- **Hosted Service** entrypoints may compose the **Cloudflare Tunnel Gateway**, but **Cloudflare Tunnel Gateway** core should remain understandable as a **Self-Hosted Deployment** with **Trusted Gateway Policy**. - The **Control Plane** governs future **Hosted Service** accounts, reservations, billing, and policy. - The **Agent Preview Use Case** uses the **Hosted Service** and may later use the **Control Plane**. - The **Agent Preview Use Case** should not shape the current gateway/core split until **Control Plane** support exists. diff --git a/README.md b/README.md index 1b7256a..adc79e1 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ export default { } satisfies ExportedHandler; ``` -The core client/server pieces (`createCaptunTunnel`, `acceptFetcherCapability`, `acceptFetcherCapabilityFromSocket`, `Fetcher`, and `FetcherStub`) live in [src/index.ts](./src/index.ts) — small TypeScript wrappers around [Cap'n Web](https://github.com/cloudflare/capnweb). For a deployable Cloudflare Tunnel Gateway, also copy or adapt [src/worker.ts](./src/worker.ts) and the Durable Object binding in [wrangler.jsonc](./wrangler.jsonc). +The core client/server pieces (`createCaptunTunnel`, `acceptFetcherCapability`, `acceptFetcherCapabilityFromSocket`, `Fetcher`, and `FetcherStub`) live in [src/index.ts](./src/index.ts) — small TypeScript wrappers around [Cap'n Web](https://github.com/cloudflare/capnweb). For a self-hosted Cloudflare Tunnel Gateway, copy or adapt [src/worker.ts](./src/worker.ts) and the Durable Object binding in [wrangler.jsonc](./wrangler.jsonc). The Iterate-operated hosted service is separate: its product surface lives under [src/hosted](./src/hosted), with [wrangler.hosted.jsonc](./wrangler.hosted.jsonc) as its deployment config. ## Advanced CLI Usage diff --git a/package.json b/package.json index bd26d47..2bbf697 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "files": [ "dist", "README.md", - "wrangler.jsonc" + "wrangler.jsonc", + "wrangler.hosted.jsonc" ], "type": "module", "exports": { diff --git a/src/hosted/site.ts b/src/hosted/site.ts new file mode 100644 index 0000000..0828dee --- /dev/null +++ b/src/hosted/site.ts @@ -0,0 +1,336 @@ +import { HOSTED_CAPTUN_HOSTNAME, RESERVED_TUNNEL_NAMES } from "../routing.js"; + +export function hostedCaptunResponse(request: Request): Response | undefined { + const url = new URL(request.url); + if (url.hostname === HOSTED_CAPTUN_HOSTNAME) { + return Response.redirect( + `https://www.${HOSTED_CAPTUN_HOSTNAME}${url.pathname}${url.search}`, + 308, + ); + } + + const suffix = `.${HOSTED_CAPTUN_HOSTNAME}`; + if (!url.hostname.endsWith(suffix)) return undefined; + + const labels = url.hostname.slice(0, -suffix.length).split("."); + const subdomain = labels[labels.length - 1] || ""; + if (subdomain === "www") { + return wwwCaptunResponse(url); + } + if (RESERVED_TUNNEL_NAMES.includes(subdomain)) { + return new Response("Reserved Captun tunnel name\n", { status: 404 }); + } +} + +function wwwCaptunResponse(url: URL): Response { + if (url.pathname === "/captun.browser.js") { + return new Response(WWW_BROWSER_MODULE, { + headers: { + "content-type": "application/javascript; charset=utf-8", + "cache-control": "no-store", + }, + }); + } + + return new Response(WWW_LANDING_PAGE, { + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + }, + }); +} + +const WWW_LANDING_PAGE = ` + + + + + captun + + + +

captun

+

cap[nweb] tun[nel]: a tiny, fast public tunnel for local HTTP servers.

+

Run this with something listening on port 3000:

+
npx captun 3000
+

You get a URL like:

+
https://abc123.captun.sh
+

Requests to that URL are forwarded to your local server until you stop the process.

+ +

From code

+

You don't need to run a local server. Just a fetch function:

+ +
+ +

Try it in this tab

+

This works in any environment supported by capnweb, so you can run a "server" basically anywhere, even the browser.

+

Edit the fetch function, create a tunnel, then the iframe below will load the public URL.

+ +
+
+ + + idle + + +
+ +

+ + +

Bring your own Cloudflare account

+
npx captun deploy
+

Source: github.com/iterate/captun

+ + + +`; + +const WWW_BROWSER_MODULE = `import { newWebSocketRpcSession, RpcTarget } from "https://esm.sh/capnweb@0.8.0"; + +export async function createCaptunTunnel(options) { + const socket = new WebSocket(gatewayConnectUrl(options)); + const readyPromise = waitForReady(); + const tunnelTargetFetcher = new TunnelTargetFetcher(options.fetch, readyPromise.ready); + const session = newWebSocketRpcSession(socket, tunnelTargetFetcher); + await waitUntilOpen(socket); + const tunnel = await readyPromise.promise; + return { + url: tunnel.url, + token: tunnel.token || options.token, + close: () => disposeSession(session), + }; +} + +class TunnelTargetFetcher extends RpcTarget { + constructor(fetcher, ready) { + super(); + this.fetcher = fetcher; + this.readyCallback = ready; + } + + fetch(request) { + return this.fetcher(request); + } + + ready(tunnel) { + return this.readyCallback(tunnel); + } +} + +function gatewayConnectUrl(options) { + const url = new URL(options.gateway || "https://captun.sh"); + url.protocol = url.protocol === "http:" ? "ws:" : "wss:"; + url.searchParams.set("captun-connect", "1"); + url.searchParams.set("captun-name", options.name || randomTunnelName()); + if (options.token) url.searchParams.set("captun-token", options.token); + return url; +} + +function waitUntilOpen(socket) { + if (socket.readyState === WebSocket.OPEN) return Promise.resolve(); + if (socket.readyState !== WebSocket.CONNECTING) { + return Promise.reject(new Error("WebSocket closed before opening")); + } + + return new Promise((resolve, reject) => { + const listeners = new AbortController(); + const settle = (callback) => { + listeners.abort(); + callback(); + }; + socket.addEventListener("open", () => settle(resolve), { signal: listeners.signal }); + socket.addEventListener("error", () => settle(() => reject(new Error("WebSocket connection failed"))), { signal: listeners.signal }); + socket.addEventListener("close", (event) => settle(() => reject(new Error("WebSocket closed before opening: " + event.code + " " + event.reason))), { signal: listeners.signal }); + }); +} + +function waitForReady() { + let timer; + let resolveReady; + let rejectReady; + const promise = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + timer = setTimeout(() => reject(new Error("Timed out waiting for tunnel gateway")), 5000); + }); + return { + promise, + ready: (tunnel) => { + clearTimeout(timer); + resolveReady(tunnel); + }, + reject: rejectReady, + }; +} + +function randomTunnelName() { + const bytes = new Uint8Array(8); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function disposeSession(session) { + const disposeSymbol = Symbol.dispose; + if (disposeSymbol && typeof session[disposeSymbol] === "function") session[disposeSymbol](); +} +`; diff --git a/src/hosted/worker.ts b/src/hosted/worker.ts new file mode 100644 index 0000000..ab91a54 --- /dev/null +++ b/src/hosted/worker.ts @@ -0,0 +1,12 @@ +import cloudflareTunnelGateway, { CaptunServerShard, type CaptunEnv } from "../worker.js"; +import { hostedCaptunResponse } from "./site.js"; + +export { CaptunServerShard }; + +export default { + fetch(request: Request, env: CaptunEnv): Response | Promise { + const hostedResponse = hostedCaptunResponse(request); + if (hostedResponse) return hostedResponse; + return cloudflareTunnelGateway.fetch(request, env); + }, +} satisfies ExportedHandler; diff --git a/src/worker.ts b/src/worker.ts index 025ec0d..65c7bea 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -4,7 +4,6 @@ import { captunShardName, CONNECT_TOKEN_QUERY_PARAM, GATEWAY_CONNECT_QUERY_PARAM, - HOSTED_CAPTUN_HOSTNAME, getTunnelNameFromUrl, getTunnelUrl, isValidTunnelName, @@ -13,7 +12,7 @@ import { TUNNEL_URL_HEADER, } from "./routing.js"; -type CaptunEnv = { +export type CaptunEnv = { CaptunServerShard: DurableObjectNamespace; CAPTUN_TOKEN?: string; CAPTUN_SECRET?: string; @@ -98,9 +97,6 @@ export default { return connectTunnel(request, env); } - const hostedResponse = hostedCaptunResponse(request, env); - if (hostedResponse) return hostedResponse; - const tunnelName = getTunnelNameFromUrl({ customHostname: env.CUSTOM_HOSTNAME, url: request.url, @@ -178,340 +174,3 @@ function constantTimeEqual(actual: Uint8Array, expected: Uint8Array) { } return diff === 0; } - -function hostedCaptunResponse(request: Request, env: CaptunEnv): Response | undefined { - if (env.CUSTOM_HOSTNAME !== HOSTED_CAPTUN_HOSTNAME) return undefined; - - const url = new URL(request.url); - if (url.hostname === HOSTED_CAPTUN_HOSTNAME) { - return Response.redirect( - `https://www.${HOSTED_CAPTUN_HOSTNAME}${url.pathname}${url.search}`, - 308, - ); - } - - const suffix = `.${HOSTED_CAPTUN_HOSTNAME}`; - if (!url.hostname.endsWith(suffix)) return undefined; - - const labels = url.hostname.slice(0, -suffix.length).split("."); - const subdomain = labels[labels.length - 1] || ""; - if (subdomain === "www") { - return wwwCaptunResponse(url); - } - if (RESERVED_TUNNEL_NAMES.includes(subdomain)) { - return new Response("Reserved Captun tunnel name\n", { status: 404 }); - } -} - -function wwwCaptunResponse(url: URL): Response { - if (url.pathname === "/captun.browser.js") { - return new Response(WWW_BROWSER_MODULE, { - headers: { - "content-type": "application/javascript; charset=utf-8", - "cache-control": "no-store", - }, - }); - } - - return new Response(WWW_LANDING_PAGE, { - headers: { - "content-type": "text/html; charset=utf-8", - "cache-control": "no-store", - }, - }); -} - -const WWW_LANDING_PAGE = ` - - - - - captun - - - -

captun

-

cap[nweb] tun[nel]: a tiny, fast public tunnel for local HTTP servers.

-

Run this with something listening on port 3000:

-
npx captun 3000
-

You get a URL like:

-
https://abc123.captun.sh
-

Requests to that URL are forwarded to your local server until you stop the process.

- -

From code

-

You don't need to run a local server. Just a fetch function:

- -
- -

Try it in this tab

-

This works in any environment supported by capnweb, so you can run a "server" basically anywhere, even the browser.

-

Edit the fetch function, create a tunnel, then the iframe below will load the public URL.

- -
-
- - - idle - - -
- -

- - -

Bring your own Cloudflare account

-
npx captun deploy
-

Source: github.com/iterate/captun

- - - -`; - -const WWW_BROWSER_MODULE = `import { newWebSocketRpcSession, RpcTarget } from "https://esm.sh/capnweb@0.8.0"; - -export async function createCaptunTunnel(options) { - const socket = new WebSocket(gatewayConnectUrl(options)); - const readyPromise = waitForReady(); - const tunnelTargetFetcher = new TunnelTargetFetcher(options.fetch, readyPromise.ready); - const session = newWebSocketRpcSession(socket, tunnelTargetFetcher); - await waitUntilOpen(socket); - const tunnel = await readyPromise.promise; - return { - url: tunnel.url, - token: tunnel.token || options.token, - close: () => disposeSession(session), - }; -} - -class TunnelTargetFetcher extends RpcTarget { - constructor(fetcher, ready) { - super(); - this.fetcher = fetcher; - this.readyCallback = ready; - } - - fetch(request) { - return this.fetcher(request); - } - - ready(tunnel) { - return this.readyCallback(tunnel); - } -} - -function gatewayConnectUrl(options) { - const url = new URL(options.gateway || "https://captun.sh"); - url.protocol = url.protocol === "http:" ? "ws:" : "wss:"; - url.searchParams.set("captun-connect", "1"); - url.searchParams.set("captun-name", options.name || randomTunnelName()); - if (options.token) url.searchParams.set("captun-token", options.token); - return url; -} - -function waitUntilOpen(socket) { - if (socket.readyState === WebSocket.OPEN) return Promise.resolve(); - if (socket.readyState !== WebSocket.CONNECTING) { - return Promise.reject(new Error("WebSocket closed before opening")); - } - - return new Promise((resolve, reject) => { - const listeners = new AbortController(); - const settle = (callback) => { - listeners.abort(); - callback(); - }; - socket.addEventListener("open", () => settle(resolve), { signal: listeners.signal }); - socket.addEventListener("error", () => settle(() => reject(new Error("WebSocket connection failed"))), { signal: listeners.signal }); - socket.addEventListener("close", (event) => settle(() => reject(new Error("WebSocket closed before opening: " + event.code + " " + event.reason))), { signal: listeners.signal }); - }); -} - -function waitForReady() { - let timer; - let resolveReady; - let rejectReady; - const promise = new Promise((resolve, reject) => { - resolveReady = resolve; - rejectReady = reject; - timer = setTimeout(() => reject(new Error("Timed out waiting for tunnel gateway")), 5000); - }); - return { - promise, - ready: (tunnel) => { - clearTimeout(timer); - resolveReady(tunnel); - }, - reject: rejectReady, - }; -} - -function randomTunnelName() { - const bytes = new Uint8Array(8); - crypto.getRandomValues(bytes); - return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); -} - -function disposeSession(session) { - const disposeSymbol = Symbol.dispose; - if (disposeSymbol && typeof session[disposeSymbol] === "function") session[disposeSymbol](); -} -`; diff --git a/test/hosted-worker.test.ts b/test/hosted-worker.test.ts new file mode 100644 index 0000000..dc4ccde --- /dev/null +++ b/test/hosted-worker.test.ts @@ -0,0 +1,145 @@ +import { expect, test } from "vitest"; +import { createHostedCaptunWorkerFixture } from "./miniflare.js"; + +test("Hosted Captun redirects the apex hostname to www", async () => { + await using fixture = await createHostedCaptunWorkerFixture(); + + const response = await fixture.worker.fetch("https://captun.sh/docs?x=1", { + redirect: "manual", + }); + + expect(response).toMatchObject({ status: 308 }); + expect(response.headers.get("location")).toBe("https://www.captun.sh/docs?x=1"); +}); + +test("Hosted Captun serves a static landing page on www", async () => { + await using fixture = await createHostedCaptunWorkerFixture(); + + const response = await fixture.worker.fetch("https://www.captun.sh/"); + + expect(response).toMatchObject({ status: 200 }); + expect(response.headers.get("content-type")).toContain("text/html"); + expect(response.headers.get("cache-control")).toBe("no-store"); + const html = await response.text(); + + expect(html).toEqual( + expect.stringContaining( + 'cap[nweb] tun[nel]', + ), + ); + expect(html).toEqual( + expect.stringContaining('fast'), + ); + expect(html).toEqual(expect.stringContaining("Run this with something listening on port 3000:")); + expect(html).toEqual(expect.stringContaining("npx captun 3000")); + expect(html).toEqual( + expect.stringContaining("You don't need to run a local server. Just a fetch function:"), + ); +}); + +test("Hosted Captun serves the browser demo module on www", async () => { + await using fixture = await createHostedCaptunWorkerFixture(); + + const response = await fixture.worker.fetch("https://www.captun.sh/captun.browser.js"); + + expect(response).toMatchObject({ status: 200 }); + expect(response.headers.get("content-type")).toContain("application/javascript"); + const module = await response.text(); + expect(module).toEqual(expect.stringContaining("createCaptunTunnel")); + expect(module).toEqual(expect.stringContaining("captun-connect")); + expect(module).not.toEqual(expect.stringContaining("__captun-connect")); +}); + +test("Hosted Captun landing page includes an in-browser tunnel demo", async () => { + await using fixture = await createHostedCaptunWorkerFixture(); + + const response = await fixture.worker.fetch("https://www.captun.sh/"); + + const html = await response.text(); + + expect(html.indexOf("

Try it in this tab

")).toBeLessThan( + html.indexOf("

Bring your own Cloudflare account

"), + ); + expect( + html.indexOf( + 'This works in any environment supported by capnweb', + ), + ).toBeLessThan( + html.indexOf("Edit the fetch function, create a tunnel, then the iframe below will load"), + ); + expect(html).toEqual( + expect.stringContaining( + 'This works in any environment supported by capnweb', + ), + ); + expect(html).toEqual(expect.stringContaining('// your "server" is this browser tab!')); + expect(html).toEqual(expect.stringContaining("window.chatMessages")); + expect(html).toEqual(expect.stringContaining("document.cookie")); + expect(html).toEqual(expect.stringContaining("username ||= ")); + expect(html).toEqual(expect.stringContaining("function send(form)")); + expect(html).toEqual(expect.stringContaining('onsubmit="send(this); return false"')); + expect(html).toEqual(expect.stringContaining("")); + expect(html).toEqual(expect.stringContaining("Response.json({ ok: true })")); + expect(html).toEqual(expect.stringContaining(' window.chatMessages.push(await request.text()); return Response.json({ ok: true }); } - const messages = window.chatMessages.join("\\n"); + const messages = window.chatMessages.join("\\n").replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''').replace(/\`/g, '`'); return new Response(\`