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 1/7] 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 6/7] 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 7/7] 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`.