From 3ca4f7edb696573d547262ba21850506f38f411f Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sat, 23 May 2026 23:57:22 +0100 Subject: [PATCH 01/16] Specify hosted rate limiting task --- tasks/hosted-rate-limits.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tasks/hosted-rate-limits.md diff --git a/tasks/hosted-rate-limits.md b/tasks/hosted-rate-limits.md new file mode 100644 index 0000000..ca16076 --- /dev/null +++ b/tasks/hosted-rate-limits.md @@ -0,0 +1,30 @@ +--- +status: in-progress +size: medium +--- + +# Hosted captun.sh rate limits + +Status summary: Spec is being carved into a first reviewable slice. The intended first PR adds Worker-level hosted throttles with tests; ownership, paid/custom names, and deeper abuse controls remain follow-up work. + +## First hosted throttling slice + +- [ ] Add a hosted-only rate-limiter Durable Object. _Keep self-hosted deployments unaffected unless they opt into the hosted `CUSTOM_HOSTNAME=captun.sh` path._ +- [ ] Limit tunnel connect attempts per client IP. _Repeated `__captun-connect` upgrades from the same IP should eventually return `429` before reaching a shard._ +- [ ] Limit forwarded HTTP requests per client IP and per tunnel name. _A noisy tunnel or source IP should receive `429` without breaking unrelated tunnels._ +- [ ] Return useful `429` responses. _Include `Retry-After`, plain text body, and conservative no-store headers._ +- [ ] Make limits configurable by Worker vars. _Use safe defaults for the public deployment and allow tests to set tiny limits._ +- [ ] Cover limits in Miniflare tests. _Exercise connect throttles, forwarded-request throttles, hosted-only behavior, and reset behavior._ +- [ ] Deploy to `captun-public` after merge-ready checks. _Verify hosted public e2e still passes._ + +## Follow-up safety work + +- [ ] Add tunnel ownership tokens so a different anonymous client cannot evict an active tunnel. _This should return `409` for conflicting reconnects rather than silently replacing the active client._ +- [ ] Add active tunnel caps and reconnect-churn limits. _Likely needs a global-ish Durable Object keyed separately from the shard count._ +- [ ] Add request body, response, and in-flight request caps. _Protect against tunnels used for bulk transfer or resource exhaustion._ +- [ ] Add Cloudflare-native Rate Limiting bindings where available. _Use edge throttles for cheaper rejection before Durable Objects wake up._ +- [ ] Add observability for 429s, high-volume IPs, high-volume tunnel names, and emergency shutdowns. _Needed before the public hosted service is advertised._ + +## Implementation Notes + +- 2026-05-23: Initial unsafe hosted service is intentionally live but obscure. This task starts the first throttling layer before publicising `captun.sh`. From e3b61b46ab294c137485676c1c9461f52f041d6a Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sun, 24 May 2026 00:01:16 +0100 Subject: [PATCH 02/16] Add hosted rate limiter --- src/worker.ts | 155 +++++++++++++++++++++++++++++++++++- tasks/hosted-rate-limits.md | 20 ++--- test/miniflare.ts | 1 + test/worker.test.ts | 75 +++++++++++++++++ wrangler.jsonc | 10 ++- 5 files changed, 249 insertions(+), 12 deletions(-) diff --git a/src/worker.ts b/src/worker.ts index c315be7..44d0e93 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -11,14 +11,40 @@ import { type CaptunEnv = { CaptunServerShard: DurableObjectNamespace; + HostedRateLimiter?: DurableObjectNamespace; 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; }; /** 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"; +const HOSTED_RATE_LIMITER_NAME = "global"; +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; + +type HostedRateLimitKind = "connect" | "request"; + +type HostedRateLimitInput = { + kind: HostedRateLimitKind; + clientKey: string; + tunnelName: string; +}; + +type HostedRateLimitResult = { ok: true } | { ok: false; limit: number; retryAfterSeconds: number }; + +type HostedRateLimitBucket = { + count: number; + resetAt: number; +}; + /** * A shard Durable Object owns many named tunnels. * @@ -71,8 +97,55 @@ export class CaptunServerShard extends DurableObject { } } +export class HostedRateLimiter extends DurableObject { + private readonly buckets = new Map(); + + check(input: HostedRateLimitInput): HostedRateLimitResult { + const config = hostedRateLimitConfig(this.env); + const now = Date.now(); + const resetAt = now + config.windowSeconds * 1000; + const checks = + input.kind === "connect" + ? [{ key: `connect:ip:${input.clientKey}`, limit: config.connectsPerIp }] + : [ + { key: `request:ip:${input.clientKey}`, limit: config.requestsPerIp }, + { key: `request:tunnel:${input.tunnelName}`, limit: config.requestsPerTunnel }, + ]; + + const blocked = checks + .map((check) => ({ ...check, bucket: this.activeBucket(check.key, now, resetAt) })) + .find((check) => check.bucket.count >= check.limit); + if (blocked) { + return { + ok: false, + limit: blocked.limit, + retryAfterSeconds: Math.max(1, Math.ceil((blocked.bucket.resetAt - now) / 1000)), + }; + } + + for (const check of checks) this.activeBucket(check.key, now, resetAt).count++; + this.cleanupExpiredBuckets(now); + return { ok: true }; + } + + private activeBucket(key: string, now: number, resetAt: number) { + const existing = this.buckets.get(key); + if (existing && existing.resetAt > now) return existing; + const bucket = { count: 0, resetAt }; + this.buckets.set(key, bucket); + return bucket; + } + + private cleanupExpiredBuckets(now: number) { + if (this.buckets.size < 10_000) return; + for (const [key, bucket] of this.buckets) { + if (bucket.resetAt <= now) this.buckets.delete(key); + } + } +} + export default { - fetch(request: Request, env: CaptunEnv): Response | Promise { + async fetch(request: Request, env: CaptunEnv): Promise { const hostedResponse = hostedCaptunResponse(request, env); if (hostedResponse) return hostedResponse; @@ -98,11 +171,27 @@ export default { const forwarded = new Request(url, request); if (forwardedPath === "/__captun-connect") { + const rateLimited = await hostedRateLimitResponse({ + env, + request, + tunnelName, + kind: "connect", + }); + if (rateLimited) return rateLimited; + const headers = new Headers(forwarded.headers); headers.set(TUNNEL_NAME_HEADER, tunnelName); return shard.fetch(new Request(forwarded, { headers })); } + const rateLimited = await hostedRateLimitResponse({ + env, + request, + tunnelName, + kind: "request", + }); + if (rateLimited) return rateLimited; + // 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. const tunnelUrl = getTunnelUrl({ @@ -116,6 +205,70 @@ export default { }, } satisfies ExportedHandler; +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 undefined; + + const limiter = input.env.HostedRateLimiter.getByName(HOSTED_RATE_LIMITER_NAME); + const result = await limiter.check({ + kind: input.kind, + clientKey: hostedClientKey(input.request), + tunnelName: input.tunnelName, + }); + if (result.ok) return undefined; + + 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") || + request.headers.get("x-real-ip") || + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + "unknown" + ); +} + +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 { if (env.CUSTOM_HOSTNAME !== HOSTED_CAPTUN_HOSTNAME) return undefined; diff --git a/tasks/hosted-rate-limits.md b/tasks/hosted-rate-limits.md index ca16076..c8fb31e 100644 --- a/tasks/hosted-rate-limits.md +++ b/tasks/hosted-rate-limits.md @@ -1,21 +1,21 @@ --- -status: in-progress +status: review size: medium --- # Hosted captun.sh rate limits -Status summary: Spec is being carved into a first reviewable slice. The intended first PR adds Worker-level hosted throttles with tests; ownership, paid/custom names, and deeper abuse controls remain follow-up work. +Status summary: First hosted throttling slice is implemented and locally verified. It adds hosted-only connect and forwarded-request limits with configurable Worker vars; ownership, paid/custom names, and deeper abuse controls remain follow-up work. ## First hosted throttling slice -- [ ] Add a hosted-only rate-limiter Durable Object. _Keep self-hosted deployments unaffected unless they opt into the hosted `CUSTOM_HOSTNAME=captun.sh` path._ -- [ ] Limit tunnel connect attempts per client IP. _Repeated `__captun-connect` upgrades from the same IP should eventually return `429` before reaching a shard._ -- [ ] Limit forwarded HTTP requests per client IP and per tunnel name. _A noisy tunnel or source IP should receive `429` without breaking unrelated tunnels._ -- [ ] Return useful `429` responses. _Include `Retry-After`, plain text body, and conservative no-store headers._ -- [ ] Make limits configurable by Worker vars. _Use safe defaults for the public deployment and allow tests to set tiny limits._ -- [ ] Cover limits in Miniflare tests. _Exercise connect throttles, forwarded-request throttles, hosted-only behavior, and reset behavior._ -- [ ] Deploy to `captun-public` after merge-ready checks. _Verify hosted public e2e still passes._ +- [x] Add a hosted-only rate-limiter Durable Object. _`HostedRateLimiter` is bound in `wrangler.jsonc` and only consulted when `CUSTOM_HOSTNAME=captun.sh`._ +- [x] Limit tunnel connect attempts per client IP. _`__captun-connect` requests check `connect:ip:` before shard dispatch._ +- [x] Limit forwarded HTTP requests per client IP and per tunnel name. _Forwarded hosted requests check both `request:ip:` and `request:tunnel:` buckets._ +- [x] Return useful `429` responses. _Hosted throttles return plain text with `Retry-After`, `cache-control: no-store`, and `x-captun-rate-limit`._ +- [x] Make limits configurable by Worker vars. _Window and connect/IP/tunnel limits are controlled by `HOSTED_*_PER_WINDOW` vars with public-service defaults._ +- [x] Cover limits in Miniflare tests. _`test/worker.test.ts` covers connect, per-IP request, per-tunnel request, and self-hosted bypass behavior._ +- [ ] Deploy to `captun-public` after merge-ready checks. _Not deployed yet; this stacked PR should deploy after review or on explicit request._ ## Follow-up safety work @@ -28,3 +28,5 @@ Status summary: Spec is being carved into a first reviewable slice. The intended ## Implementation Notes - 2026-05-23: Initial unsafe hosted service is intentionally live but obscure. This task starts the first throttling layer before publicising `captun.sh`. +- 2026-05-24: Implemented fixed-window in-memory buckets inside a single hosted rate-limiter Durable Object named `global`. This is intentionally a first abuse guardrail, not billing-grade quota accounting. +- 2026-05-24: Verified with `pnpm run check`, `pnpm test`, and `pnpm run build`. 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 a5a541f..2584993 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -325,6 +325,81 @@ test.each([ expect(await response.text()).toBe("Reserved captun.sh subdomain\n"); }); +test("Hosted Captun rate limits tunnel connect attempts per client IP", async () => { + await using fixture = await createCaptunWorkerFixture({ + CUSTOM_HOSTNAME: "captun.sh", + CAPTUN_SECRET: "secret", + HOSTED_CONNECTS_PER_IP_PER_WINDOW: "1", + }); + const headers = { "cf-connecting-ip": "203.0.113.10" }; + + const first = await fixture.worker.fetch("https://one.captun.sh/__captun-connect", { + headers, + }); + const second = await fixture.worker.fetch("https://two.captun.sh/__captun-connect", { + 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("Captun Worker rejects missing tunnel names before Durable Object dispatch", async () => { await using fixture = await createCaptunWorkerFixture({}); 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 cac2726485879cddcb75c14def2bb718b5690dab Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 23:01:47 +0000 Subject: [PATCH 03/16] [autofix.ci] apply automated fixes --- tasks/hosted-rate-limits.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks/hosted-rate-limits.md b/tasks/hosted-rate-limits.md index c8fb31e..89bc46f 100644 --- a/tasks/hosted-rate-limits.md +++ b/tasks/hosted-rate-limits.md @@ -13,7 +13,7 @@ Status summary: First hosted throttling slice is implemented and locally verifie - [x] Limit tunnel connect attempts per client IP. _`__captun-connect` requests check `connect:ip:` before shard dispatch._ - [x] Limit forwarded HTTP requests per client IP and per tunnel name. _Forwarded hosted requests check both `request:ip:` and `request:tunnel:` buckets._ - [x] Return useful `429` responses. _Hosted throttles return plain text with `Retry-After`, `cache-control: no-store`, and `x-captun-rate-limit`._ -- [x] Make limits configurable by Worker vars. _Window and connect/IP/tunnel limits are controlled by `HOSTED_*_PER_WINDOW` vars with public-service defaults._ +- [x] Make limits configurable by Worker vars. _Window and connect/IP/tunnel limits are controlled by `HOSTED_\*_PER_WINDOW` vars with public-service defaults._ - [x] Cover limits in Miniflare tests. _`test/worker.test.ts` covers connect, per-IP request, per-tunnel request, and self-hosted bypass behavior._ - [ ] Deploy to `captun-public` after merge-ready checks. _Not deployed yet; this stacked PR should deploy after review or on explicit request._ From a92a0a56505336305cb12ba2f07adabdff39753b Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sun, 24 May 2026 00:07:57 +0100 Subject: [PATCH 04/16] Harden hosted rate limiter --- src/worker.ts | 108 +++++++++++++++++++++--------------- tasks/hosted-rate-limits.md | 7 ++- test/worker.test.ts | 61 +++++++++++++++++++- 3 files changed, 126 insertions(+), 50 deletions(-) diff --git a/src/worker.ts b/src/worker.ts index 44d0e93..32c7223 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -19,12 +19,12 @@ type CaptunEnv = { 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. */ const TUNNEL_NAME_HEADER = "x-captun-tunnel-name"; -const HOSTED_RATE_LIMITER_NAME = "global"; 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; @@ -32,11 +32,7 @@ const DEFAULT_HOSTED_REQUESTS_PER_TUNNEL_PER_WINDOW = 1200; type HostedRateLimitKind = "connect" | "request"; -type HostedRateLimitInput = { - kind: HostedRateLimitKind; - clientKey: string; - tunnelName: string; -}; +type HostedRateLimitInput = { limit: number; windowSeconds: number }; type HostedRateLimitResult = { ok: true } | { ok: false; limit: number; retryAfterSeconds: number }; @@ -98,50 +94,29 @@ export class CaptunServerShard extends DurableObject { } export class HostedRateLimiter extends DurableObject { - private readonly buckets = new Map(); + private bucket: HostedRateLimitBucket | undefined; check(input: HostedRateLimitInput): HostedRateLimitResult { - const config = hostedRateLimitConfig(this.env); const now = Date.now(); - const resetAt = now + config.windowSeconds * 1000; - const checks = - input.kind === "connect" - ? [{ key: `connect:ip:${input.clientKey}`, limit: config.connectsPerIp }] - : [ - { key: `request:ip:${input.clientKey}`, limit: config.requestsPerIp }, - { key: `request:tunnel:${input.tunnelName}`, limit: config.requestsPerTunnel }, - ]; - - const blocked = checks - .map((check) => ({ ...check, bucket: this.activeBucket(check.key, now, resetAt) })) - .find((check) => check.bucket.count >= check.limit); - if (blocked) { + const bucket = this.activeBucket(now, now + input.windowSeconds * 1000); + if (bucket.count >= input.limit) { return { ok: false, - limit: blocked.limit, - retryAfterSeconds: Math.max(1, Math.ceil((blocked.bucket.resetAt - now) / 1000)), + limit: input.limit, + retryAfterSeconds: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)), }; } - for (const check of checks) this.activeBucket(check.key, now, resetAt).count++; - this.cleanupExpiredBuckets(now); + bucket.count++; return { ok: true }; } - private activeBucket(key: string, now: number, resetAt: number) { - const existing = this.buckets.get(key); - if (existing && existing.resetAt > now) return existing; + private activeBucket(now: number, resetAt: number) { + if (this.bucket && this.bucket.resetAt > now) return this.bucket; const bucket = { count: 0, resetAt }; - this.buckets.set(key, bucket); + this.bucket = bucket; return bucket; } - - private cleanupExpiredBuckets(now: number) { - if (this.buckets.size < 10_000) return; - for (const [key, bucket] of this.buckets) { - if (bucket.resetAt <= now) this.buckets.delete(key); - } - } } export default { @@ -212,16 +187,37 @@ async function hostedRateLimitResponse(input: { kind: HostedRateLimitKind; }): Promise { if (input.env.CUSTOM_HOSTNAME !== HOSTED_CAPTUN_HOSTNAME) return undefined; - if (!input.env.HostedRateLimiter) return undefined; + if (!input.env.HostedRateLimiter) { + if (input.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", + }, + }); + } - const limiter = input.env.HostedRateLimiter.getByName(HOSTED_RATE_LIMITER_NAME); - const result = await limiter.check({ + const config = hostedRateLimitConfig(input.env); + const checks = hostedRateLimitChecks({ kind: input.kind, clientKey: hostedClientKey(input.request), tunnelName: input.tunnelName, + config, }); - if (result.ok) return undefined; + 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 undefined; +} +function hostedRateLimitedResponse(result: Extract) { return new Response(`Rate limit exceeded. Try again in ${result.retryAfterSeconds}s.\n`, { status: 429, headers: { @@ -234,12 +230,32 @@ async function hostedRateLimitResponse(input: { } function hostedClientKey(request: Request) { - return ( - request.headers.get("cf-connecting-ip") || - request.headers.get("x-real-ip") || - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - "unknown" - ); + 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) { diff --git a/tasks/hosted-rate-limits.md b/tasks/hosted-rate-limits.md index c8fb31e..a66850a 100644 --- a/tasks/hosted-rate-limits.md +++ b/tasks/hosted-rate-limits.md @@ -13,7 +13,7 @@ Status summary: First hosted throttling slice is implemented and locally verifie - [x] Limit tunnel connect attempts per client IP. _`__captun-connect` requests check `connect:ip:` before shard dispatch._ - [x] Limit forwarded HTTP requests per client IP and per tunnel name. _Forwarded hosted requests check both `request:ip:` and `request:tunnel:` buckets._ - [x] Return useful `429` responses. _Hosted throttles return plain text with `Retry-After`, `cache-control: no-store`, and `x-captun-rate-limit`._ -- [x] Make limits configurable by Worker vars. _Window and connect/IP/tunnel limits are controlled by `HOSTED_*_PER_WINDOW` vars with public-service defaults._ +- [x] Make limits configurable by Worker vars. _Window and connect/IP/tunnel limits are controlled by `HOSTED_\*_PER_WINDOW` vars with public-service defaults._ - [x] Cover limits in Miniflare tests. _`test/worker.test.ts` covers connect, per-IP request, per-tunnel request, and self-hosted bypass behavior._ - [ ] Deploy to `captun-public` after merge-ready checks. _Not deployed yet; this stacked PR should deploy after review or on explicit request._ @@ -28,5 +28,6 @@ Status summary: First hosted throttling slice is implemented and locally verifie ## Implementation Notes - 2026-05-23: Initial unsafe hosted service is intentionally live but obscure. This task starts the first throttling layer before publicising `captun.sh`. -- 2026-05-24: Implemented fixed-window in-memory buckets inside a single hosted rate-limiter Durable Object named `global`. This is intentionally a first abuse guardrail, not billing-grade quota accounting. -- 2026-05-24: Verified with `pnpm run check`, `pnpm test`, and `pnpm run build`. +- 2026-05-24: Implemented fixed-window in-memory buckets in hosted rate-limiter Durable Objects named by hashed bucket key. This is intentionally a first abuse guardrail, not billing-grade quota accounting. +- 2026-05-24: Review follow-up changed the limiter to fail closed when the binding is missing, added an explicit `HOSTED_RATE_LIMIT_DISABLED=1` escape hatch, and stopped trusting spoofable forwarded-IP headers. +- 2026-05-24: Verified with `pnpm run check`, `pnpm test`, `pnpm run build`, and `CAPTUN_PUBLIC_E2E=1 pnpm vitest run test/public-hosted.test.ts` after retrying one transient live WebSocket-open failure. diff --git a/test/worker.test.ts b/test/worker.test.ts index 2584993..127afa8 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -7,7 +7,7 @@ import { getTunnelUrl, 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] @@ -400,6 +400,65 @@ test("Hosted Captun rate limits do not affect self-hosted folder routing", async 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("Captun Worker rejects missing tunnel names before Durable Object dispatch", async () => { await using fixture = await createCaptunWorkerFixture({}); From 9d43030fec4605e0ae60a792bc2f52f848a6b9fb Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sun, 24 May 2026 00:10:43 +0100 Subject: [PATCH 05/16] Specify hosted ownership token task --- tasks/hosted-ownership-tokens.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tasks/hosted-ownership-tokens.md diff --git a/tasks/hosted-ownership-tokens.md b/tasks/hosted-ownership-tokens.md new file mode 100644 index 0000000..16386de --- /dev/null +++ b/tasks/hosted-ownership-tokens.md @@ -0,0 +1,23 @@ +status: in-progress +size: medium + +# Hosted Anonymous Tunnel Ownership Tokens + +Status summary: Spec commit only. This PR should add an anonymous ownership token for hosted `captun.sh` tunnel connections so the first active client owns a tunnel name until it disconnects; implementation and tests are still pending. + +## Checklist + +- [ ] Add hosted-only ownership-token parsing to the tunnel connect path. +- [ ] Let the first successful hosted connection for a tunnel name claim its token while active. +- [ ] Let a reconnect with the same token replace its own active connection. +- [ ] Reject a different token with `409 Conflict` while the tunnel is already active. +- [ ] Keep self-hosted and secret-protected tunnel behavior compatible. +- [ ] Generate and send a client-side anonymous token for hosted CLI/API/browser clients. +- [ ] Cover the hosted ownership behavior with integration-style tests. +- [ ] Run the focused tests and full project checks. + +## Notes + +- Scope is intentionally narrower than authenticated accounts: this is only an eviction guard for anonymous hosted tunnels. +- Tokens do not identify users and do not grant paid/custom subdomain rights. They only prove that a reconnect is from the same anonymous client instance. +- The hosted path is `captun.sh`; self-hosted Workers should keep the existing "last connection wins" behavior unless they opt into equivalent behavior later. From fe19c918c6ccb4dc19560bcb6dd3d7a5c65a2afc Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sun, 24 May 2026 00:17:13 +0100 Subject: [PATCH 06/16] Protect hosted tunnels with owner tokens --- src/index.ts | 40 +++++- src/routing.ts | 4 + src/worker.ts | 73 ++++++++++- tasks/hosted-captun-sh.md | 4 +- tasks/hosted-ownership-tokens.md | 25 ++-- tasks/hosted-rate-limits.md | 4 +- test/worker.test.ts | 210 ++++++++++++++++++++++++++++++- 7 files changed, 337 insertions(+), 23 deletions(-) diff --git a/src/index.ts b/src/index.ts index d376754..c0162d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,10 @@ import { newWebSocketRpcSession, RpcTarget } from "capnweb"; -import { getTunnelUrlFromServerUrl, HOSTED_CAPTUN_SERVER_URL } from "./routing.js"; +import { + getTunnelUrlFromServerUrl, + HOSTED_CAPTUN_SERVER_URL, + TUNNEL_OWNER_TOKEN_HEADER, + TUNNEL_OWNER_TOKEN_QUERY_PARAM, +} from "./routing.js"; /** Fetch is all you need! * @@ -39,7 +44,11 @@ export async function createCaptunTunnel( }, ): Promise { const endpoint = resolveTunnelEndpoint(options); - const socket = createWebSocket({ url: endpoint.connectUrl, headers: options.headers }); + const connectUrl = withAnonymousOwnershipToken({ + connectUrl: endpoint.connectUrl, + headers: options.headers, + }); + const socket = createWebSocket({ url: 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 }); @@ -71,15 +80,42 @@ function resolveTunnelEndpoint(options: { function publicUrlFromConnectUrl(connectUrl: URL) { const publicUrl = new URL(connectUrl); publicUrl.pathname = publicUrl.pathname.replace(/\/__captun-connect\/?$/, "") || "/"; + publicUrl.search = ""; + publicUrl.hash = ""; return publicUrl.toString().replace(/\/$/, ""); } +function withAnonymousOwnershipToken(options: { + connectUrl: string; + headers: Record | undefined; +}) { + if (hasHeader(options.headers, TUNNEL_OWNER_TOKEN_HEADER)) return options.connectUrl; + + const connectUrl = new URL(options.connectUrl); + if (!connectUrl.searchParams.has(TUNNEL_OWNER_TOKEN_QUERY_PARAM)) { + connectUrl.searchParams.set(TUNNEL_OWNER_TOKEN_QUERY_PARAM, randomOwnershipToken()); + } + return connectUrl.toString(); +} + +function hasHeader(headers: Record | undefined, name: string) { + if (!headers) return false; + const lowerName = name.toLowerCase(); + return Object.keys(headers).some((key) => key.toLowerCase() === lowerName); +} + function randomTunnelName() { const bytes = new Uint8Array(8); crypto.getRandomValues(bytes); return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } +function randomOwnershipToken() { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + class TunnelTargetFetcher extends RpcTarget implements Fetcher { private fetcher: Fetcher; diff --git a/src/routing.ts b/src/routing.ts index 475e51e..d739bdd 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -113,6 +113,10 @@ export function getTunnelUrlFromServerUrl(serverUrl: string, tunnelName: string) /** Header used by the Worker to advertise a tunnel's canonical URL to its client. */ export const TUNNEL_URL_HEADER = "x-captun-tunnel-url"; +/** Anonymous hosted clients use this token to prove they own an active tunnel name. */ +export const TUNNEL_OWNER_TOKEN_QUERY_PARAM = "captun-owner-token"; +export const TUNNEL_OWNER_TOKEN_HEADER = "x-captun-owner-token"; + /** Reserved path used by tunnel clients to open the WebSocket; not a tunnel name. */ const CONNECT_PATH_SEGMENT = "__captun-connect"; diff --git a/src/worker.ts b/src/worker.ts index 32c7223..b4bc9d9 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -6,6 +6,8 @@ import { getTunnelNameFromUrl, getTunnelUrl, RESERVED_HOSTED_SUBDOMAINS, + TUNNEL_OWNER_TOKEN_HEADER, + TUNNEL_OWNER_TOKEN_QUERY_PARAM, TUNNEL_URL_HEADER, } from "./routing.js"; @@ -41,6 +43,11 @@ type HostedRateLimitBucket = { resetAt: number; }; +type ActiveTunnel = { + fetcher: Fetcher & Disposable; + ownerToken?: string; +}; + /** * A shard Durable Object owns many named tunnels. * @@ -50,7 +57,7 @@ type HostedRateLimitBucket = { * 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 @@ -72,18 +79,32 @@ export class CaptunServerShard extends DurableObject { } } - this.tunnels.get(tunnelName)?.[Symbol.dispose](); + const ownerToken = hostedAnonymousOwnerToken(request, this.env); + if (ownerToken instanceof Response) return ownerToken; + + const activeTunnel = this.tunnels.get(tunnelName); + if (activeTunnel?.ownerToken && activeTunnel.ownerToken !== ownerToken) { + return new Response("Tunnel name is already connected\n", { + status: 409, + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-store", + }, + }); + } + + activeTunnel?.fetcher[Symbol.dispose](); const { response, tunnel } = acceptCaptunTunnel({ onDisconnect: () => { - if (this.tunnels.get(tunnelName) === tunnel) this.tunnels.delete(tunnelName); + if (this.tunnels.get(tunnelName)?.fetcher === tunnel) this.tunnels.delete(tunnelName); }, }); - this.tunnels.set(tunnelName, tunnel); + this.tunnels.set(tunnelName, { fetcher: tunnel, ownerToken }); 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); @@ -217,6 +238,39 @@ async function hostedRateLimitResponse(input: { return undefined; } +function hostedAnonymousOwnerToken( + request: Request, + env: CaptunEnv, +): string | Response | undefined { + if (env.CUSTOM_HOSTNAME !== HOSTED_CAPTUN_HOSTNAME) return undefined; + if (env.CAPTUN_SECRET) return undefined; + + const token = + request.headers.get(TUNNEL_OWNER_TOKEN_HEADER) || + new URL(request.url).searchParams.get(TUNNEL_OWNER_TOKEN_QUERY_PARAM) || + ""; + if (!token) { + return new Response("Missing tunnel ownership token\n", { + status: 400, + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-store", + }, + }); + } + if (!/^[a-zA-Z0-9._~-]{1,128}$/.test(token)) { + return new Response("Invalid tunnel ownership token\n", { + status: 400, + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-store", + }, + }); + } + + return token; +} + function hostedRateLimitedResponse(result: Extract) { return new Response(`Rate limit exceeded. Try again in ${result.retryAfterSeconds}s.\n`, { status: 429, @@ -536,8 +590,9 @@ const WWW_BROWSER_MODULE = `import { newWebSocketRpcSession, RpcTarget } from "h export async function createCaptunTunnel(options) { const tunnelName = options.name || randomTunnelName(); + const ownerToken = randomOwnershipToken(); const publicUrl = "https://" + tunnelName + ".captun.sh"; - const socket = new WebSocket("wss://" + tunnelName + ".captun.sh/__captun-connect"); + const socket = new WebSocket("wss://" + tunnelName + ".captun.sh/__captun-connect?captun-owner-token=" + ownerToken); const tunnelTargetFetcher = new TunnelTargetFetcher(options.fetch); const session = newWebSocketRpcSession(socket, tunnelTargetFetcher); await waitUntilOpen(socket); @@ -582,6 +637,12 @@ function randomTunnelName() { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } +function randomOwnershipToken() { + 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 7754802..36e2f40 100644 --- a/tasks/hosted-captun-sh.md +++ b/tasks/hosted-captun-sh.md @@ -5,7 +5,7 @@ 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 CLI, library, and browser landing-page demo can create hosted random tunnels; the main missing work is a proper free/paid control plane, deeper resource caps, and observability. ## Initial public-hosted slice @@ -21,7 +21,7 @@ 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. +- [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. _The stacked hosted-ownership-tokens PR stores anonymous active-owner tokens in `CaptunServerShard` and sends generated connect tokens from CLI/API/browser clients._ - [ ] 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. - [ ] Add basic resource caps: max tunnel lifetime, idle timeout, in-flight request cap, request body size limit, and response streaming guardrails. diff --git a/tasks/hosted-ownership-tokens.md b/tasks/hosted-ownership-tokens.md index 16386de..665f21d 100644 --- a/tasks/hosted-ownership-tokens.md +++ b/tasks/hosted-ownership-tokens.md @@ -1,23 +1,28 @@ -status: in-progress +status: review size: medium # Hosted Anonymous Tunnel Ownership Tokens -Status summary: Spec commit only. This PR should add an anonymous ownership token for hosted `captun.sh` tunnel connections so the first active client owns a tunnel name until it disconnects; implementation and tests are still pending. +Status summary: Implementation is complete and locally verified. Hosted anonymous tunnel connections now require an ownership token, conflicting active owners get `409`, same-owner reconnects can replace themselves, and self-hosted replacement behavior remains compatible. ## Checklist -- [ ] Add hosted-only ownership-token parsing to the tunnel connect path. -- [ ] Let the first successful hosted connection for a tunnel name claim its token while active. -- [ ] Let a reconnect with the same token replace its own active connection. -- [ ] Reject a different token with `409 Conflict` while the tunnel is already active. -- [ ] Keep self-hosted and secret-protected tunnel behavior compatible. -- [ ] Generate and send a client-side anonymous token for hosted CLI/API/browser clients. -- [ ] Cover the hosted ownership behavior with integration-style tests. -- [ ] Run the focused tests and full project checks. +- [x] Add hosted-only ownership-token parsing to the tunnel connect path. _`CaptunServerShard.fetch` now reads `captun-owner-token` or `x-captun-owner-token` only for anonymous `CUSTOM_HOSTNAME=captun.sh` connects._ +- [x] Let the first successful hosted connection for a tunnel name claim its token while active. _Active shard entries now store the accepted owner token beside the Cap'n Web fetcher._ +- [x] Let a reconnect with the same token replace its own active connection. _Same-token hosted connects still dispose and replace the previous active fetcher._ +- [x] Reject a different token with `409 Conflict` while the tunnel is already active. _Different-token hosted connects return `Tunnel name is already connected` without touching the active tunnel._ +- [x] Keep self-hosted and secret-protected tunnel behavior compatible. _Ownership enforcement is skipped outside hosted `captun.sh` and when `CAPTUN_SECRET` is configured; tests cover self-hosted replacement._ +- [x] Generate and send a client-side anonymous token for hosted CLI/API/browser clients. _`createCaptunTunnel` appends a generated query token to the WebSocket URL, and `/captun.browser.js` does the same for browser clients._ +- [x] Cover the hosted ownership behavior with integration-style tests. _`test/worker.test.ts` covers conflict, same-token replacement, header token parsing, missing-token rejection, client token generation, and self-hosted compatibility._ +- [x] Run the focused tests and full project checks. _Verified with focused worker tests, full `pnpm test`, `pnpm run check`, and `pnpm run build`._ ## Notes - Scope is intentionally narrower than authenticated accounts: this is only an eviction guard for anonymous hosted tunnels. - Tokens do not identify users and do not grant paid/custom subdomain rights. They only prove that a reconnect is from the same anonymous client instance. - The hosted path is `captun.sh`; self-hosted Workers should keep the existing "last connection wins" behavior unless they opt into equivalent behavior later. + +## Implementation Notes + +- 2026-05-24: Tokens are intentionally passed on the connect request only, using a browser-compatible query parameter by default. The Worker also accepts an equivalent header for non-browser clients and tests. +- 2026-05-24: This does not persist ownership after disconnect. Once the active Cap'n Web session breaks, the tunnel name is free for another anonymous token to claim. diff --git a/tasks/hosted-rate-limits.md b/tasks/hosted-rate-limits.md index a66850a..831042d 100644 --- a/tasks/hosted-rate-limits.md +++ b/tasks/hosted-rate-limits.md @@ -5,7 +5,7 @@ size: medium # Hosted captun.sh rate limits -Status summary: First hosted throttling slice is implemented and locally verified. It adds hosted-only connect and forwarded-request limits with configurable Worker vars; ownership, paid/custom names, and deeper abuse controls remain follow-up work. +Status summary: First hosted throttling slice is implemented and locally verified. It adds hosted-only connect and forwarded-request limits with configurable Worker vars; this stacked branch also covers anonymous active-tunnel ownership, while paid/custom names and deeper abuse controls remain follow-up work. ## First hosted throttling slice @@ -19,7 +19,7 @@ Status summary: First hosted throttling slice is implemented and locally verifie ## Follow-up safety work -- [ ] Add tunnel ownership tokens so a different anonymous client cannot evict an active tunnel. _This should return `409` for conflicting reconnects rather than silently replacing the active client._ +- [x] Add tunnel ownership tokens so a different anonymous client cannot evict an active tunnel. _The stacked hosted-ownership-tokens PR returns `409` for a conflicting active token while allowing same-token replacement._ - [ ] Add active tunnel caps and reconnect-churn limits. _Likely needs a global-ish Durable Object keyed separately from the shard count._ - [ ] Add request body, response, and in-flight request caps. _Protect against tunnels used for bulk transfer or resource exhaustion._ - [ ] Add Cloudflare-native Rate Limiting bindings where available. _Use edge throttles for cheaper rejection before Durable Objects wake up._ diff --git a/test/worker.test.ts b/test/worker.test.ts index 127afa8..b056e07 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -1,4 +1,7 @@ import { expect, test } from "vitest"; +import { createHash } from "node:crypto"; +import { createServer } from "node:http"; +import { newWebSocketRpcSession, RpcTarget } from "capnweb"; import { createCaptunTunnel } from "../src/index.js"; import { captunHealthResponse, isCaptunHealthRequest } from "../src/cli/tunnel-health.js"; import { @@ -139,6 +142,23 @@ test("Captun Worker forwards requests through a real Durable Object tunnel", asy }); }); +test("Captun Worker still lets self-hosted tunnels replace a name without ownership", async () => { + await using fixture = await createCaptunWorkerFixture({}); + using _firstTunnel = await createCaptunTunnel({ + url: `${fixture.origin}/demo/__captun-connect`, + fetch: () => new Response("first\n"), + }); + using _secondTunnel = await createCaptunTunnel({ + url: `${fixture.origin}/demo/__captun-connect`, + fetch: () => new Response("second\n"), + }); + + const response = await fetch(`${fixture.origin}/demo/hello`); + + expect(response).toMatchObject({ status: 200 }); + expect(await response.text()).toBe("second\n"); +}); + test("Captun Worker verifies health through a connected tunnel client", async () => { await using fixture = await createCaptunWorkerFixture({}); using _tunnel = await createCaptunTunnel({ @@ -231,7 +251,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 source = await response.text(); + + expect(source).toEqual(expect.stringContaining("createCaptunTunnel")); + expect(source).toEqual(expect.stringContaining("captun-owner-token")); }); test("Hosted Captun landing page includes an in-browser tunnel demo", async () => { @@ -459,6 +482,107 @@ test("Hosted Captun does not trust spoofable forwarded IP headers for rate limit expect(second).toMatchObject({ status: 429 }); }); +test("Hosted Captun rejects a different ownership token while a tunnel is active", async () => { + await using fixture = await createCaptunWorkerFixture({ + CUSTOM_HOSTNAME: "captun.sh", + HOSTED_CONNECTS_PER_IP_PER_WINDOW: "100", + }); + using _ownerTunnel = await createDirectWorkerTunnel({ + fixture, + url: "https://demo.captun.sh/__captun-connect?captun-owner-token=owner-a", + responseText: "owner a\n", + clientIp: "203.0.113.70", + }); + + const conflict = await fixture.worker.fetch( + "https://demo.captun.sh/__captun-connect?captun-owner-token=owner-b", + { headers: { "cf-connecting-ip": "203.0.113.71" } }, + ); + const stillOwned = await fixture.worker.fetch("https://demo.captun.sh/hello", { + headers: { "cf-connecting-ip": "203.0.113.72" }, + }); + + expect(conflict).toMatchObject({ status: 409 }); + expect(await conflict.text()).toBe("Tunnel name is already connected\n"); + expect(stillOwned).toMatchObject({ status: 200 }); + expect(await stillOwned.text()).toBe("owner a\n"); +}); + +test("Hosted Captun lets the same ownership 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://demo.captun.sh/__captun-connect?captun-owner-token=owner-a", + responseText: "first\n", + clientIp: "203.0.113.80", + }); + using _secondTunnel = await createDirectWorkerTunnel({ + fixture, + url: "https://demo.captun.sh/__captun-connect?captun-owner-token=owner-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 accepts an ownership token header", async () => { + await using fixture = await createCaptunWorkerFixture({ + CUSTOM_HOSTNAME: "captun.sh", + HOSTED_CONNECTS_PER_IP_PER_WINDOW: "100", + }); + using _tunnel = await createDirectWorkerTunnel({ + fixture, + url: "https://header.captun.sh/__captun-connect", + responseText: "header token\n", + clientIp: "203.0.113.85", + headers: { "x-captun-owner-token": "owner-from-header" }, + }); + + const response = await fixture.worker.fetch("https://header.captun.sh/hello", { + headers: { "cf-connecting-ip": "203.0.113.86" }, + }); + + expect(response).toMatchObject({ status: 200 }); + expect(await response.text()).toBe("header token\n"); +}); + +test("Hosted Captun requires anonymous ownership 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://demo.captun.sh/__captun-connect", { + headers: { "cf-connecting-ip": "203.0.113.90" }, + }); + + expect(response).toMatchObject({ status: 400 }); + expect(await response.text()).toBe("Missing tunnel ownership token\n"); +}); + +test("Captun clients send an anonymous ownership token on the tunnel WebSocket URL", async () => { + await using recorder = await createWebSocketUpgradeRecorder(); + + using _tunnel = await createCaptunTunnel({ + url: `${recorder.origin}/demo/__captun-connect`, + fetch: () => new Response("ok\n"), + }); + + const upgradeUrl = new URL(recorder.upgradeUrl.current || "", recorder.origin); + + expect(upgradeUrl).toMatchObject({ pathname: "/demo/__captun-connect" }); + expect(upgradeUrl.searchParams.get("captun-owner-token")).toMatch(/^[a-f0-9]{32}$/); +}); + test("Captun Worker rejects missing tunnel names before Durable Object dispatch", async () => { await using fixture = await createCaptunWorkerFixture({}); @@ -485,3 +609,87 @@ test("Captun Worker requires the configured secret before accepting a tunnel cli expect(response).toMatchObject({ status: 401 }); expect(await response.text()).toBe("Unauthorized\n"); }); + +async function createWebSocketUpgradeRecorder() { + const upgradeUrl = { current: "" }; + const sockets = new Set<{ destroy: () => void }>(); + const server = createServer(); + server.on("upgrade", (request, socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + upgradeUrl.current = request.url || ""; + const key = request.headers["sec-websocket-key"]; + if (typeof key !== "string") { + socket.destroy(); + return; + } + const accept = createHash("sha1") + .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest("base64"); + socket.write( + [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${accept}`, + "", + "", + ].join("\r\n"), + ); + }); + 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}`, + upgradeUrl, + 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; + headers?: Record; +}) { + const response = await options.fixture.worker.fetch(options.url, { + headers: { + upgrade: "websocket", + "cf-connecting-ip": options.clientIp, + ...options.headers, + }, + }); + 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); + } +} From fd12c6a714d0b1c26b905cd456fd43d73fe51e8b Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sun, 24 May 2026 00:17:51 +0100 Subject: [PATCH 07/16] Complete hosted ownership token task --- .../2026-05-24-hosted-ownership-tokens.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tasks/{hosted-ownership-tokens.md => complete/2026-05-24-hosted-ownership-tokens.md} (99%) diff --git a/tasks/hosted-ownership-tokens.md b/tasks/complete/2026-05-24-hosted-ownership-tokens.md similarity index 99% rename from tasks/hosted-ownership-tokens.md rename to tasks/complete/2026-05-24-hosted-ownership-tokens.md index 665f21d..23d010a 100644 --- a/tasks/hosted-ownership-tokens.md +++ b/tasks/complete/2026-05-24-hosted-ownership-tokens.md @@ -1,4 +1,4 @@ -status: review +status: complete size: medium # Hosted Anonymous Tunnel Ownership Tokens From 0937d555276e8046c02019a3897e62fd6d441c9c Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sun, 24 May 2026 00:26:15 +0100 Subject: [PATCH 08/16] Expose hosted tunnel owner tokens --- src/index.ts | 29 ++++++++++++------- src/worker.ts | 3 +- .../2026-05-24-hosted-ownership-tokens.md | 7 +++-- test/worker.test.ts | 28 ++++++++++++++++++ 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/src/index.ts b/src/index.ts index c0162d4..8e1c512 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,7 @@ export interface Fetcher { */ export type CaptunTunnel = Disposable & { url: string; + ownerToken: string; }; export async function createCaptunTunnel( @@ -41,14 +42,16 @@ export async function createCaptunTunnel( serverUrl?: string; name?: string; headers?: Record; + ownerToken?: string; }, ): Promise { const endpoint = resolveTunnelEndpoint(options); - const connectUrl = withAnonymousOwnershipToken({ + const ownership = withAnonymousOwnershipToken({ connectUrl: endpoint.connectUrl, headers: options.headers, + ownerToken: options.ownerToken, }); - const socket = createWebSocket({ url: connectUrl, headers: options.headers }); + const socket = createWebSocket({ url: ownership.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 }); @@ -57,6 +60,7 @@ export async function createCaptunTunnel( return { url: endpoint.publicUrl, + ownerToken: ownership.ownerToken, [Symbol.dispose]: () => session[Symbol.dispose](), }; } @@ -88,20 +92,25 @@ function publicUrlFromConnectUrl(connectUrl: URL) { function withAnonymousOwnershipToken(options: { connectUrl: string; headers: Record | undefined; + ownerToken: string | undefined; }) { - if (hasHeader(options.headers, TUNNEL_OWNER_TOKEN_HEADER)) return options.connectUrl; + const headerToken = getHeader(options.headers, TUNNEL_OWNER_TOKEN_HEADER); + if (headerToken) return { connectUrl: options.connectUrl, ownerToken: headerToken }; const connectUrl = new URL(options.connectUrl); - if (!connectUrl.searchParams.has(TUNNEL_OWNER_TOKEN_QUERY_PARAM)) { - connectUrl.searchParams.set(TUNNEL_OWNER_TOKEN_QUERY_PARAM, randomOwnershipToken()); - } - return connectUrl.toString(); + const ownerToken = + options.ownerToken || + connectUrl.searchParams.get(TUNNEL_OWNER_TOKEN_QUERY_PARAM) || + randomOwnershipToken(); + connectUrl.searchParams.set(TUNNEL_OWNER_TOKEN_QUERY_PARAM, ownerToken); + return { connectUrl: connectUrl.toString(), ownerToken }; } -function hasHeader(headers: Record | undefined, name: string) { - if (!headers) return false; +function getHeader(headers: Record | undefined, name: string) { + if (!headers) return undefined; const lowerName = name.toLowerCase(); - return Object.keys(headers).some((key) => key.toLowerCase() === lowerName); + const key = Object.keys(headers).find((candidate) => candidate.toLowerCase() === lowerName); + return key ? headers[key] : undefined; } function randomTunnelName() { diff --git a/src/worker.ts b/src/worker.ts index b4bc9d9..f830b7f 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -590,7 +590,7 @@ const WWW_BROWSER_MODULE = `import { newWebSocketRpcSession, RpcTarget } from "h export async function createCaptunTunnel(options) { const tunnelName = options.name || randomTunnelName(); - const ownerToken = randomOwnershipToken(); + const ownerToken = options.ownerToken || randomOwnershipToken(); const publicUrl = "https://" + tunnelName + ".captun.sh"; const socket = new WebSocket("wss://" + tunnelName + ".captun.sh/__captun-connect?captun-owner-token=" + ownerToken); const tunnelTargetFetcher = new TunnelTargetFetcher(options.fetch); @@ -598,6 +598,7 @@ export async function createCaptunTunnel(options) { await waitUntilOpen(socket); return { url: publicUrl, + ownerToken, close: () => disposeSession(session), }; } diff --git a/tasks/complete/2026-05-24-hosted-ownership-tokens.md b/tasks/complete/2026-05-24-hosted-ownership-tokens.md index 23d010a..3a223e5 100644 --- a/tasks/complete/2026-05-24-hosted-ownership-tokens.md +++ b/tasks/complete/2026-05-24-hosted-ownership-tokens.md @@ -3,7 +3,7 @@ size: medium # Hosted Anonymous Tunnel Ownership Tokens -Status summary: Implementation is complete and locally verified. Hosted anonymous tunnel connections now require an ownership token, conflicting active owners get `409`, same-owner reconnects can replace themselves, and self-hosted replacement behavior remains compatible. +Status summary: Implementation is complete and locally verified. Hosted anonymous tunnel connections now require an ownership token, conflicting active owners get `409`, same-owner reconnects can replace themselves through the public API, and self-hosted replacement behavior remains compatible. ## Checklist @@ -12,8 +12,8 @@ Status summary: Implementation is complete and locally verified. Hosted anonymou - [x] Let a reconnect with the same token replace its own active connection. _Same-token hosted connects still dispose and replace the previous active fetcher._ - [x] Reject a different token with `409 Conflict` while the tunnel is already active. _Different-token hosted connects return `Tunnel name is already connected` without touching the active tunnel._ - [x] Keep self-hosted and secret-protected tunnel behavior compatible. _Ownership enforcement is skipped outside hosted `captun.sh` and when `CAPTUN_SECRET` is configured; tests cover self-hosted replacement._ -- [x] Generate and send a client-side anonymous token for hosted CLI/API/browser clients. _`createCaptunTunnel` appends a generated query token to the WebSocket URL, and `/captun.browser.js` does the same for browser clients._ -- [x] Cover the hosted ownership behavior with integration-style tests. _`test/worker.test.ts` covers conflict, same-token replacement, header token parsing, missing-token rejection, client token generation, and self-hosted compatibility._ +- [x] Generate and send a client-side anonymous token for hosted CLI/API/browser clients. _`createCaptunTunnel` appends a generated query token to the WebSocket URL, returns it on `tunnel.ownerToken`, accepts it back as `ownerToken`, and `/captun.browser.js` follows the same shape._ +- [x] Cover the hosted ownership behavior with integration-style tests. _`test/worker.test.ts` covers conflict, same-token replacement, header token parsing, missing-token rejection, client token generation/reuse, and self-hosted compatibility._ - [x] Run the focused tests and full project checks. _Verified with focused worker tests, full `pnpm test`, `pnpm run check`, and `pnpm run build`._ ## Notes @@ -25,4 +25,5 @@ Status summary: Implementation is complete and locally verified. Hosted anonymou ## Implementation Notes - 2026-05-24: Tokens are intentionally passed on the connect request only, using a browser-compatible query parameter by default. The Worker also accepts an equivalent header for non-browser clients and tests. +- 2026-05-24: Review follow-up exposed `ownerToken` on the returned tunnel and added `ownerToken` as an explicit create option so exported API callers can exercise same-owner replacement without manually editing query strings. - 2026-05-24: This does not persist ownership after disconnect. Once the active Cap'n Web session breaks, the tunnel name is free for another anonymous token to claim. diff --git a/test/worker.test.ts b/test/worker.test.ts index b056e07..0c067b6 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -581,6 +581,31 @@ test("Captun clients send an anonymous ownership token on the tunnel WebSocket U expect(upgradeUrl).toMatchObject({ pathname: "/demo/__captun-connect" }); expect(upgradeUrl.searchParams.get("captun-owner-token")).toMatch(/^[a-f0-9]{32}$/); + expect(_tunnel).toMatchObject({ + ownerToken: upgradeUrl.searchParams.get("captun-owner-token"), + }); +}); + +test("Captun clients can reuse a returned anonymous ownership token", async () => { + await using recorder = await createWebSocketUpgradeRecorder(); + + using firstTunnel = await createCaptunTunnel({ + url: `${recorder.origin}/demo/__captun-connect`, + fetch: () => new Response("first\n"), + }); + using secondTunnel = await createCaptunTunnel({ + url: `${recorder.origin}/demo/__captun-connect`, + ownerToken: firstTunnel.ownerToken, + fetch: () => new Response("second\n"), + }); + + const upgradeUrls = recorder.upgradeUrls.map((url) => new URL(url, recorder.origin)); + + expect(secondTunnel).toMatchObject({ ownerToken: firstTunnel.ownerToken }); + expect(upgradeUrls.map((url) => url.searchParams.get("captun-owner-token"))).toEqual([ + firstTunnel.ownerToken, + firstTunnel.ownerToken, + ]); }); test("Captun Worker rejects missing tunnel names before Durable Object dispatch", async () => { @@ -612,12 +637,14 @@ test("Captun Worker requires the configured secret before accepting a tunnel cli async function createWebSocketUpgradeRecorder() { const upgradeUrl = { current: "" }; + const upgradeUrls: string[] = []; const sockets = new Set<{ destroy: () => void }>(); const server = createServer(); server.on("upgrade", (request, socket) => { sockets.add(socket); socket.once("close", () => sockets.delete(socket)); upgradeUrl.current = request.url || ""; + upgradeUrls.push(upgradeUrl.current); const key = request.headers["sec-websocket-key"]; if (typeof key !== "string") { socket.destroy(); @@ -647,6 +674,7 @@ async function createWebSocketUpgradeRecorder() { return { origin: `http://127.0.0.1:${address.port}`, upgradeUrl, + upgradeUrls, async [Symbol.asyncDispose]() { for (const socket of sockets) socket.destroy(); await new Promise((resolveClose) => server.close(() => resolveClose())); From babe3a5a57096b8c5681fcb8988d3218ea6ecd05 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sun, 24 May 2026 00:28:48 +0100 Subject: [PATCH 09/16] Specify hosted connect conflict messages --- tasks/hosted-connect-conflict-message.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tasks/hosted-connect-conflict-message.md diff --git a/tasks/hosted-connect-conflict-message.md b/tasks/hosted-connect-conflict-message.md new file mode 100644 index 0000000..35db9f6 --- /dev/null +++ b/tasks/hosted-connect-conflict-message.md @@ -0,0 +1,23 @@ +status: ready +size: small + +# Hosted Connect Conflict Messages + +Status summary: Spec is ready. Implementation should keep the stacked PR narrow: surface hosted WebSocket upgrade rejection details in library/CLI errors and avoid presenting DNS setup as the primary explanation for active-owner conflicts. + +## Checklist + +- [ ] Add a regression test for a rejected WebSocket upgrade body. _Pending; should show `createCaptunTunnel` callers see the Worker rejection text instead of only `WebSocket connection failed`._ +- [ ] Improve library connect errors for pre-open WebSocket failures. _Pending; include deterministic status/body details when the runtime exposes them, or a clear hosted conflict message when it does not._ +- [ ] Improve CLI tunnel connect messaging. _Pending; active-owner conflicts should mention the tunnel name is already connected or in use and should not lead with DNS hints._ +- [ ] Run focused and full verification. _Pending; run focused tests plus `pnpm run check`, `pnpm test`, and `pnpm run build`._ + +## Notes + +- This is stacked on `mmkal/26/05/24/hosted-ownership-tokens`. +- The target conflict body from the Worker is `Tunnel name is already connected`. +- Keep self-hosted DNS/certificate guidance for ordinary connection failures. + +## Implementation Notes + +- 2026-05-24: Created as a follow-up to the hosted ownership-token PR after review found anonymous active-owner conflicts were being reported as generic WebSocket/DNS failures. From e0475bae84cd8642dbe2a26f70f29d2cc544d64f Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sun, 24 May 2026 00:32:40 +0100 Subject: [PATCH 10/16] Surface hosted connect rejection details --- src/cli/bin.ts | 16 ++- src/index.ts | 60 ++++++++++- ...6-05-24-hosted-connect-conflict-message.md | 24 +++++ tasks/hosted-connect-conflict-message.md | 23 ---- test/cli.test.ts | 101 ++++++++++++++++++ test/worker.test.ts | 56 ++++++++++ 6 files changed, 253 insertions(+), 27 deletions(-) create mode 100644 tasks/complete/2026-05-24-hosted-connect-conflict-message.md delete mode 100644 tasks/hosted-connect-conflict-message.md create mode 100644 test/cli.test.ts diff --git a/src/cli/bin.ts b/src/cli/bin.ts index 4450353..50c5566 100755 --- a/src/cli/bin.ts +++ b/src/cli/bin.ts @@ -12,7 +12,7 @@ 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 { @@ -570,6 +570,14 @@ function tunnelConnectError(tunnel: ResolvedTunnel, cause: unknown) { const hostname = new URL(tunnel.tunnel).hostname; const message = cause instanceof Error ? cause.message : String(cause); const lines = [`Could not connect tunnel to ${color.cyan(tunnel.tunnel)} (${message}).`]; + if (isActiveTunnelConflict(cause)) { + lines.push( + ``, + `The tunnel name appears to be in use by another active anonymous client.`, + `Pick a different ${color.cyan("--name")} or stop the existing tunnel and retry.`, + ); + return new CliFriendlyError(lines.join("\n")); + } 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`. @@ -588,6 +596,12 @@ function tunnelConnectError(tunnel: ResolvedTunnel, cause: unknown) { return new CliFriendlyError(lines.join("\n")); } +function isActiveTunnelConflict(cause: unknown) { + if (cause instanceof CaptunTunnelConnectError && cause.response?.status === 409) return true; + const message = cause instanceof Error ? cause.message : String(cause); + return /tunnel name is already connected|tunnel name .*in use/i.test(message); +} + function sleep(ms: number) { return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); } diff --git a/src/index.ts b/src/index.ts index 8e1c512..8c7b399 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,19 @@ export type CaptunTunnel = Disposable & { ownerToken: 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 async function createCaptunTunnel( options: Fetcher & { url?: string | URL; @@ -56,7 +69,12 @@ export async function createCaptunTunnel( // 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); + try { + await waitUntilOpen(socket, { connectUrl: ownership.connectUrl, headers: options.headers }); + } catch (error) { + session[Symbol.dispose](); + throw error; + } return { url: endpoint.publicUrl, @@ -154,7 +172,10 @@ function createWebSocket(options: { url: string | URL; headers?: Record | undefined }, +) { if (socket.readyState === WebSocket.OPEN) return; if (socket.readyState !== WebSocket.CONNECTING) { throw new Error("WebSocket closed before opening"); @@ -169,7 +190,10 @@ 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(options).then(reject); + }), { signal: listeners.signal }, ); socket.addEventListener( @@ -183,6 +207,36 @@ async function waitUntilOpen(socket: WebSocket) { }); } +async function webSocketConnectionFailedError(options: { + connectUrl: string; + headers: Record | undefined; +}) { + const response = await readWebSocketRejection(options); + 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(options: { + connectUrl: string; + headers: Record | undefined; +}) { + try { + const response = await fetch(options.connectUrl, { headers: options.headers }); + if (response.ok) return undefined; + const body = (await response.text()).trim(); + return { + status: response.status, + statusText: response.statusText || "Rejected", + body: body || "No response body", + }; + } catch { + return undefined; + } +} + // --------------------------------------------------------------------------- // Tunnel server (formerly src/server.ts) // --------------------------------------------------------------------------- diff --git a/tasks/complete/2026-05-24-hosted-connect-conflict-message.md b/tasks/complete/2026-05-24-hosted-connect-conflict-message.md new file mode 100644 index 0000000..b514815 --- /dev/null +++ b/tasks/complete/2026-05-24-hosted-connect-conflict-message.md @@ -0,0 +1,24 @@ +status: complete +size: small + +# Hosted Connect Conflict Messages + +Status summary: Complete and locally verified. `createCaptunTunnel` now surfaces deterministic HTTP rejection details when available, and the CLI treats hosted active-owner conflicts as name-in-use errors instead of DNS setup failures. + +## Checklist + +- [x] Add a regression test for a rejected WebSocket upgrade body. _`test/worker.test.ts` covers a 409 connect rejection and asserts `createCaptunTunnel` reports `Tunnel name is already connected`._ +- [x] Improve library connect errors for pre-open WebSocket failures. _`src/index.ts` now probes the connect URL after a pre-open WebSocket error and throws `CaptunTunnelConnectError` with status/body details when the server exposes them._ +- [x] Improve CLI tunnel connect messaging. _`src/cli/bin.ts` detects 409/name-in-use connect failures and prints an active anonymous client explanation instead of DNS guidance._ +- [x] Run focused and full verification. _Verified with focused Vitest files, `pnpm run check`, `pnpm test`, and `pnpm run build`._ + +## Notes + +- This is stacked on `mmkal/26/05/24/hosted-ownership-tokens`. +- The target conflict body from the Worker is `Tunnel name is already connected`. +- Keep self-hosted DNS/certificate guidance for ordinary connection failures. + +## Implementation Notes + +- 2026-05-24: Created as a follow-up to the hosted ownership-token PR after review found anonymous active-owner conflicts were being reported as generic WebSocket/DNS failures. +- 2026-05-24: Node's WebSocket `ErrorEvent` does not expose the rejected upgrade status/body directly, so the library performs a follow-up `fetch` to the same connect URL. This is deterministic for the hosted Worker conflict because the Worker returns `409` before creating the WebSocket upgrade response. diff --git a/tasks/hosted-connect-conflict-message.md b/tasks/hosted-connect-conflict-message.md deleted file mode 100644 index 35db9f6..0000000 --- a/tasks/hosted-connect-conflict-message.md +++ /dev/null @@ -1,23 +0,0 @@ -status: ready -size: small - -# Hosted Connect Conflict Messages - -Status summary: Spec is ready. Implementation should keep the stacked PR narrow: surface hosted WebSocket upgrade rejection details in library/CLI errors and avoid presenting DNS setup as the primary explanation for active-owner conflicts. - -## Checklist - -- [ ] Add a regression test for a rejected WebSocket upgrade body. _Pending; should show `createCaptunTunnel` callers see the Worker rejection text instead of only `WebSocket connection failed`._ -- [ ] Improve library connect errors for pre-open WebSocket failures. _Pending; include deterministic status/body details when the runtime exposes them, or a clear hosted conflict message when it does not._ -- [ ] Improve CLI tunnel connect messaging. _Pending; active-owner conflicts should mention the tunnel name is already connected or in use and should not lead with DNS hints._ -- [ ] Run focused and full verification. _Pending; run focused tests plus `pnpm run check`, `pnpm test`, and `pnpm run build`._ - -## Notes - -- This is stacked on `mmkal/26/05/24/hosted-ownership-tokens`. -- The target conflict body from the Worker is `Tunnel name is already connected`. -- Keep self-hosted DNS/certificate guidance for ordinary connection failures. - -## Implementation Notes - -- 2026-05-24: Created as a follow-up to the hosted ownership-token PR after review found anonymous active-owner conflicts were being reported as generic WebSocket/DNS failures. diff --git a/test/cli.test.ts b/test/cli.test.ts new file mode 100644 index 0000000..1e3dc89 --- /dev/null +++ b/test/cli.test.ts @@ -0,0 +1,101 @@ +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"; + +test("CLI tunnel connect errors do not blame DNS for active-owner conflicts", async () => { + await using target = await createTestServer((_request, response) => { + response.end("ok\n"); + }); + await using rejection = await createRejectedTunnelServer("Tunnel name is already connected\n"); + + const router = createCaptunCliRouter({ readConfig: async () => undefined }); + const client = createRouterClient(router); + + let caught: unknown; + try { + await client.tunnel({ + target: String(target.port), + serverUrl: rejection.origin, + 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"), + }); +}); + +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 { + port: address.port, + async [Symbol.asyncDispose]() { + await new Promise((resolveClose) => server.close(() => resolveClose())); + }, + }; +} + +async function createRejectedTunnelServer(body: string) { + const sockets = new Set<{ destroy: () => void }>(); + const server = createServer((_request, response) => { + response.writeHead(409, { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-store", + }); + response.end(body); + }); + server.on("upgrade", (_request, socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + socket.write( + [ + "HTTP/1.1 409 Conflict", + "Content-Type: text/plain; charset=utf-8", + "Cache-Control: no-store", + `Content-Length: ${Buffer.byteLength(body)}`, + "Connection: close", + "", + 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())); + }, + }; +} diff --git a/test/worker.test.ts b/test/worker.test.ts index 0c067b6..a5d86c7 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -608,6 +608,20 @@ test("Captun clients can reuse a returned anonymous ownership token", async () = ]); }); +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({ + url: `${rejection.origin}/demo/__captun-connect`, + fetch: () => new Response("unused\n"), + }), + ).rejects.toThrow(/409 Conflict: Tunnel name is already connected/); +}); + test("Captun Worker rejects missing tunnel names before Durable Object dispatch", async () => { await using fixture = await createCaptunWorkerFixture({}); @@ -682,6 +696,48 @@ async function createWebSocketUpgradeRecorder() { }; } +async function createRejectedWebSocketUpgradeServer(options: { status: number; body: string }) { + const sockets = new Set<{ destroy: () => void }>(); + const statusText = options.status === 409 ? "Conflict" : "Rejected"; + const server = createServer((_request, response) => { + response.writeHead(options.status, { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-store", + }); + response.end(options.body); + }); + server.on("upgrade", (_request, socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(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; From 04328e3f409ded241ccbadf8cfbaa122381f3901 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sun, 24 May 2026 00:37:30 +0100 Subject: [PATCH 11/16] Harden hosted connect rejection diagnostics --- src/cli/bin.ts | 8 ++++- src/index.ts | 11 ++++++- ...6-05-24-hosted-connect-conflict-message.md | 8 +++-- test/cli.test.ts | 32 +++++++++++++++++++ test/worker.test.ts | 31 ++++++++++++++++-- 5 files changed, 83 insertions(+), 7 deletions(-) diff --git a/src/cli/bin.ts b/src/cli/bin.ts index 50c5566..da59b02 100755 --- a/src/cli/bin.ts +++ b/src/cli/bin.ts @@ -597,8 +597,14 @@ function tunnelConnectError(tunnel: ResolvedTunnel, cause: unknown) { } function isActiveTunnelConflict(cause: unknown) { - if (cause instanceof CaptunTunnelConnectError && cause.response?.status === 409) return true; + if (cause instanceof CaptunTunnelConnectError && cause.response) { + return cause.response.status === 409 && isActiveTunnelConflictMessage(cause.response.body); + } const message = cause instanceof Error ? cause.message : String(cause); + return isActiveTunnelConflictMessage(message); +} + +function isActiveTunnelConflictMessage(message: string) { return /tunnel name is already connected|tunnel name .*in use/i.test(message); } diff --git a/src/index.ts b/src/index.ts index 8c7b399..89fdd0b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,8 @@ export class CaptunTunnelConnectError extends Error { } } +const WEBSOCKET_REJECTION_PROBE_TIMEOUT_MS = 500; + export async function createCaptunTunnel( options: Fetcher & { url?: string | URL; @@ -223,8 +225,13 @@ async function readWebSocketRejection(options: { connectUrl: string; headers: Record | undefined; }) { + const abort = new AbortController(); + const timeout = setTimeout(() => abort.abort(), WEBSOCKET_REJECTION_PROBE_TIMEOUT_MS); try { - const response = await fetch(options.connectUrl, { headers: options.headers }); + const response = await fetch(options.connectUrl, { + headers: options.headers, + signal: abort.signal, + }); if (response.ok) return undefined; const body = (await response.text()).trim(); return { @@ -234,6 +241,8 @@ async function readWebSocketRejection(options: { }; } catch { return undefined; + } finally { + clearTimeout(timeout); } } diff --git a/tasks/complete/2026-05-24-hosted-connect-conflict-message.md b/tasks/complete/2026-05-24-hosted-connect-conflict-message.md index b514815..da59af9 100644 --- a/tasks/complete/2026-05-24-hosted-connect-conflict-message.md +++ b/tasks/complete/2026-05-24-hosted-connect-conflict-message.md @@ -3,13 +3,14 @@ size: small # Hosted Connect Conflict Messages -Status summary: Complete and locally verified. `createCaptunTunnel` now surfaces deterministic HTTP rejection details when available, and the CLI treats hosted active-owner conflicts as name-in-use errors instead of DNS setup failures. +Status summary: Complete and locally verified. `createCaptunTunnel` now surfaces deterministic HTTP rejection details when available without hanging on the diagnostic probe, and the CLI treats only Captun active-owner conflicts as name-in-use errors instead of DNS setup failures. ## Checklist - [x] Add a regression test for a rejected WebSocket upgrade body. _`test/worker.test.ts` covers a 409 connect rejection and asserts `createCaptunTunnel` reports `Tunnel name is already connected`._ -- [x] Improve library connect errors for pre-open WebSocket failures. _`src/index.ts` now probes the connect URL after a pre-open WebSocket error and throws `CaptunTunnelConnectError` with status/body details when the server exposes them._ -- [x] Improve CLI tunnel connect messaging. _`src/cli/bin.ts` detects 409/name-in-use connect failures and prints an active anonymous client explanation instead of DNS guidance._ +- [x] Improve library connect errors for pre-open WebSocket failures. _`src/index.ts` now probes the connect URL after a pre-open WebSocket error, aborts the probe after a short timeout, and throws `CaptunTunnelConnectError` with status/body details when the server exposes them._ +- [x] Improve CLI tunnel connect messaging. _`src/cli/bin.ts` detects the known Captun 409/name-in-use body and prints an active anonymous client explanation instead of DNS guidance._ +- [x] Add review regression coverage. _`test/worker.test.ts` covers an unresponsive diagnostic probe; `test/cli.test.ts` covers unrelated 409 responses retaining DNS guidance._ - [x] Run focused and full verification. _Verified with focused Vitest files, `pnpm run check`, `pnpm test`, and `pnpm run build`._ ## Notes @@ -22,3 +23,4 @@ Status summary: Complete and locally verified. `createCaptunTunnel` now surfaces - 2026-05-24: Created as a follow-up to the hosted ownership-token PR after review found anonymous active-owner conflicts were being reported as generic WebSocket/DNS failures. - 2026-05-24: Node's WebSocket `ErrorEvent` does not expose the rejected upgrade status/body directly, so the library performs a follow-up `fetch` to the same connect URL. This is deterministic for the hosted Worker conflict because the Worker returns `409` before creating the WebSocket upgrade response. +- 2026-05-24: Review follow-up added a timeout around the diagnostic HTTP probe and tightened CLI conflict classification so arbitrary `409` responses keep the generic troubleshooting path. diff --git a/test/cli.test.ts b/test/cli.test.ts index 1e3dc89..86f1da9 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -34,6 +34,38 @@ test("CLI tunnel connect errors do not blame DNS for active-owner conflicts", as }); }); +test("CLI tunnel connect errors keep DNS guidance for unrelated 409 responses", async () => { + await using target = await createTestServer((_request, response) => { + response.end("ok\n"); + }); + await using rejection = await createRejectedTunnelServer("Some other conflict\n"); + + const router = createCaptunCliRouter({ readConfig: async () => undefined }); + const client = createRouterClient(router); + + let caught: unknown; + try { + await client.tunnel({ + target: String(target.port), + serverUrl: rejection.origin, + 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"), + }); + expect(caught).not.toMatchObject({ + message: expect.stringContaining("active anonymous client"), + }); +}); + async function createTestServer( handler: (req: IncomingMessage, res: ServerResponse) => void | Promise, ) { diff --git a/test/worker.test.ts b/test/worker.test.ts index a5d86c7..ef36827 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -622,6 +622,26 @@ test("createCaptunTunnel surfaces rejected WebSocket upgrade response details", ).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({ + url: `${rejection.origin}/demo/__captun-connect`, + fetch: () => new Response("unused\n"), + }); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ message: "WebSocket connection failed" }); +}); + test("Captun Worker rejects missing tunnel names before Durable Object dispatch", async () => { await using fixture = await createCaptunWorkerFixture({}); @@ -696,19 +716,26 @@ async function createWebSocketUpgradeRecorder() { }; } -async function createRejectedWebSocketUpgradeServer(options: { status: number; body: string }) { +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("upgrade", (_request, socket) => { + 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}`, From 5cfbeab73e33f2b64e3e4e209b3c9083280bb931 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sun, 24 May 2026 00:41:03 +0100 Subject: [PATCH 12/16] Specify hosted admission module task --- tasks/hosted-admission-module.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 tasks/hosted-admission-module.md diff --git a/tasks/hosted-admission-module.md b/tasks/hosted-admission-module.md new file mode 100644 index 0000000..e435d9c --- /dev/null +++ b/tasks/hosted-admission-module.md @@ -0,0 +1,24 @@ +status: ready +size: small + +# Hosted Admission Module + +Status summary: Spec only. Extract the hosted anonymous tunnel admission decision out of `CaptunServerShard` so ownership-token policy has a small direct test surface. No product behavior should change. + +## Checklist + +- [ ] Extract a hosted tunnel admission module. _Move secret auth, hosted anonymous owner-token parsing, token validation, and active-owner conflict decisions behind one function._ +- [ ] Keep the Durable Object focused on active tunnel state and WebSocket acceptance. _`CaptunServerShard.fetch` should ask the admission module for allow/reject and then replace/store the tunnel._ +- [ ] Add direct tests for the admission decision. _Cover self-hosted bypass, secret auth, missing/invalid hosted token, same-owner replace, and different-owner conflict without needing a full tunnel connection._ +- [ ] Keep integration coverage passing. _Existing Worker ownership tests should still cover the Durable Object wiring._ +- [ ] Run focused and full verification. _Use focused Vitest, `pnpm run check`, `pnpm test`, and `pnpm run build`._ + +## Assumptions + +- This is stacked on `mmkal/26/05/24/hosted-connect-conflict-message`. +- This is an architecture-only change; hosted behavior, response status codes, and response bodies should stay byte-for-byte compatible where practical. +- The module interface should be small enough that future hosted safety checks can be added there without growing `CaptunServerShard.fetch`. + +## Implementation Notes + +- 2026-05-24: Nightly architecture pass recommended this because ownership-token safety policy is currently embedded in the Durable Object implementation, forcing integration setup for pure admission-policy cases. From 36aad1597d1f51d5c72147657292ea2466dd9a2f Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sun, 24 May 2026 00:44:24 +0100 Subject: [PATCH 13/16] Extract hosted tunnel admission policy --- src/hosted-admission.ts | 74 +++++++++++++++ src/worker.ts | 66 ++----------- .../2026-05-24-hosted-admission-module.md | 25 +++++ tasks/hosted-admission-module.md | 24 ----- test/hosted-admission.test.ts | 93 +++++++++++++++++++ test/worker.test.ts | 7 +- 6 files changed, 204 insertions(+), 85 deletions(-) create mode 100644 src/hosted-admission.ts create mode 100644 tasks/complete/2026-05-24-hosted-admission-module.md delete mode 100644 tasks/hosted-admission-module.md create mode 100644 test/hosted-admission.test.ts diff --git a/src/hosted-admission.ts b/src/hosted-admission.ts new file mode 100644 index 0000000..8c54ba2 --- /dev/null +++ b/src/hosted-admission.ts @@ -0,0 +1,74 @@ +import { + HOSTED_CAPTUN_HOSTNAME, + TUNNEL_OWNER_TOKEN_HEADER, + TUNNEL_OWNER_TOKEN_QUERY_PARAM, +} from "./routing.js"; + +export type HostedAdmissionEnv = { + CAPTUN_SECRET?: string; + CUSTOM_HOSTNAME?: string; +}; + +export type TunnelAdmission = + | { ok: true; ownerToken: string | undefined } + | { ok: false; response: Response }; + +export function decideTunnelAdmission(input: { + request: Request; + env: HostedAdmissionEnv; + activeOwnerToken: string | undefined; +}): TunnelAdmission { + const expected = input.env.CAPTUN_SECRET ? `Bearer ${input.env.CAPTUN_SECRET}` : undefined; + if (expected && !constantTimeEqual(input.request.headers.get("authorization") || "", expected)) { + return { ok: false, response: new Response("Unauthorized\n", { status: 401 }) }; + } + + const ownerToken = hostedAnonymousOwnerToken(input.request, input.env); + if (ownerToken instanceof Response) return { ok: false, response: ownerToken }; + + if (input.activeOwnerToken && input.activeOwnerToken !== ownerToken) { + return { ok: false, response: reject("Tunnel name is already connected\n", 409) }; + } + + return { ok: true, ownerToken }; +} + +function hostedAnonymousOwnerToken( + request: Request, + env: HostedAdmissionEnv, +): string | Response | undefined { + if (env.CUSTOM_HOSTNAME !== HOSTED_CAPTUN_HOSTNAME) return undefined; + if (env.CAPTUN_SECRET) return undefined; + + const token = + request.headers.get(TUNNEL_OWNER_TOKEN_HEADER) || + new URL(request.url).searchParams.get(TUNNEL_OWNER_TOKEN_QUERY_PARAM) || + ""; + if (!token) return reject("Missing tunnel ownership token\n", 400); + if (!/^[a-zA-Z0-9._~-]{1,128}$/.test(token)) { + return reject("Invalid tunnel ownership token\n", 400); + } + + return token; +} + +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); + let diff = actualBytes.length ^ expectedBytes.length; + const length = Math.max(actualBytes.length, expectedBytes.length); + for (let index = 0; index < length; index++) { + diff |= (actualBytes[index] || 0) ^ (expectedBytes[index] || 0); + } + return diff === 0; +} diff --git a/src/worker.ts b/src/worker.ts index f830b7f..5e3eef3 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,4 +1,5 @@ import { DurableObject } from "cloudflare:workers"; +import { decideTunnelAdmission } from "./hosted-admission.js"; import { acceptCaptunTunnel, type Fetcher } from "./index.js"; import { captunShardName, @@ -6,8 +7,6 @@ import { getTunnelNameFromUrl, getTunnelUrl, RESERVED_HOSTED_SUBDOMAINS, - TUNNEL_OWNER_TOKEN_HEADER, - TUNNEL_OWNER_TOKEN_QUERY_PARAM, TUNNEL_URL_HEADER, } from "./routing.js"; @@ -69,29 +68,13 @@ 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; - if (expected) { - // Constant-time comparison to avoid leaking the secret via timing. - const actual = new TextEncoder().encode(request.headers.get("authorization") ?? ""); - const want = new TextEncoder().encode(expected); - if (actual.length !== want.length || !crypto.subtle.timingSafeEqual(actual, want)) { - return new Response("Unauthorized\n", { status: 401 }); - } - } - - const ownerToken = hostedAnonymousOwnerToken(request, this.env); - if (ownerToken instanceof Response) return ownerToken; - const activeTunnel = this.tunnels.get(tunnelName); - if (activeTunnel?.ownerToken && activeTunnel.ownerToken !== ownerToken) { - return new Response("Tunnel name is already connected\n", { - status: 409, - headers: { - "content-type": "text/plain; charset=utf-8", - "cache-control": "no-store", - }, - }); - } + const admission = decideTunnelAdmission({ + request, + env: this.env, + activeOwnerToken: activeTunnel?.ownerToken, + }); + if (!admission.ok) return admission.response; activeTunnel?.fetcher[Symbol.dispose](); const { response, tunnel } = acceptCaptunTunnel({ @@ -99,7 +82,7 @@ export class CaptunServerShard extends DurableObject { if (this.tunnels.get(tunnelName)?.fetcher === tunnel) this.tunnels.delete(tunnelName); }, }); - this.tunnels.set(tunnelName, { fetcher: tunnel, ownerToken }); + this.tunnels.set(tunnelName, { fetcher: tunnel, ownerToken: admission.ownerToken }); return response; } @@ -238,39 +221,6 @@ async function hostedRateLimitResponse(input: { return undefined; } -function hostedAnonymousOwnerToken( - request: Request, - env: CaptunEnv, -): string | Response | undefined { - if (env.CUSTOM_HOSTNAME !== HOSTED_CAPTUN_HOSTNAME) return undefined; - if (env.CAPTUN_SECRET) return undefined; - - const token = - request.headers.get(TUNNEL_OWNER_TOKEN_HEADER) || - new URL(request.url).searchParams.get(TUNNEL_OWNER_TOKEN_QUERY_PARAM) || - ""; - if (!token) { - return new Response("Missing tunnel ownership token\n", { - status: 400, - headers: { - "content-type": "text/plain; charset=utf-8", - "cache-control": "no-store", - }, - }); - } - if (!/^[a-zA-Z0-9._~-]{1,128}$/.test(token)) { - return new Response("Invalid tunnel ownership token\n", { - status: 400, - headers: { - "content-type": "text/plain; charset=utf-8", - "cache-control": "no-store", - }, - }); - } - - return token; -} - function hostedRateLimitedResponse(result: Extract) { return new Response(`Rate limit exceeded. Try again in ${result.retryAfterSeconds}s.\n`, { status: 429, diff --git a/tasks/complete/2026-05-24-hosted-admission-module.md b/tasks/complete/2026-05-24-hosted-admission-module.md new file mode 100644 index 0000000..32a6e6e --- /dev/null +++ b/tasks/complete/2026-05-24-hosted-admission-module.md @@ -0,0 +1,25 @@ +status: complete +size: small + +# Hosted Admission Module + +Status summary: Complete and locally verified. Hosted anonymous tunnel admission now lives in a small direct-testable module, while `CaptunServerShard` only supplies active tunnel state and performs WebSocket acceptance. + +## Checklist + +- [x] Extract a hosted tunnel admission module. _`src/hosted-admission.ts` now owns secret auth, hosted anonymous owner-token parsing, token validation, and active-owner conflict decisions._ +- [x] Keep the Durable Object focused on active tunnel state and WebSocket acceptance. _`CaptunServerShard.fetch` now calls `decideTunnelAdmission` with the current active owner token, then disposes/replaces the tunnel when admitted._ +- [x] Add direct tests for the admission decision. _`test/hosted-admission.test.ts` covers self-hosted bypass, secret auth, missing/invalid hosted tokens, same-owner replace, and different-owner conflict._ +- [x] Keep integration coverage passing. _Existing Worker ownership tests still cover Durable Object wiring; the conflict test now reads the rejection body before the follow-up forwarded request to avoid Miniflare response-body flakiness._ +- [x] Run focused and full verification. _Verified with focused Vitest, `pnpm run check`, `pnpm test`, and `pnpm run build`._ + +## Assumptions + +- This is stacked on `mmkal/26/05/24/hosted-connect-conflict-message`. +- This is an architecture-only change; hosted behavior, response status codes, and response bodies should stay compatible. +- The module interface should be small enough that future hosted safety checks can be added there without growing `CaptunServerShard.fetch`. + +## Implementation Notes + +- 2026-05-24: Nightly architecture pass recommended this because ownership-token safety policy was embedded in the Durable Object implementation, forcing integration setup for pure admission-policy cases. +- 2026-05-24: Replaced the Worker-specific `crypto.subtle.timingSafeEqual` call with a local constant-time string comparison so the admission module can be tested directly in Node while retaining fixed-work secret comparison behavior. diff --git a/tasks/hosted-admission-module.md b/tasks/hosted-admission-module.md deleted file mode 100644 index e435d9c..0000000 --- a/tasks/hosted-admission-module.md +++ /dev/null @@ -1,24 +0,0 @@ -status: ready -size: small - -# Hosted Admission Module - -Status summary: Spec only. Extract the hosted anonymous tunnel admission decision out of `CaptunServerShard` so ownership-token policy has a small direct test surface. No product behavior should change. - -## Checklist - -- [ ] Extract a hosted tunnel admission module. _Move secret auth, hosted anonymous owner-token parsing, token validation, and active-owner conflict decisions behind one function._ -- [ ] Keep the Durable Object focused on active tunnel state and WebSocket acceptance. _`CaptunServerShard.fetch` should ask the admission module for allow/reject and then replace/store the tunnel._ -- [ ] Add direct tests for the admission decision. _Cover self-hosted bypass, secret auth, missing/invalid hosted token, same-owner replace, and different-owner conflict without needing a full tunnel connection._ -- [ ] Keep integration coverage passing. _Existing Worker ownership tests should still cover the Durable Object wiring._ -- [ ] Run focused and full verification. _Use focused Vitest, `pnpm run check`, `pnpm test`, and `pnpm run build`._ - -## Assumptions - -- This is stacked on `mmkal/26/05/24/hosted-connect-conflict-message`. -- This is an architecture-only change; hosted behavior, response status codes, and response bodies should stay byte-for-byte compatible where practical. -- The module interface should be small enough that future hosted safety checks can be added there without growing `CaptunServerShard.fetch`. - -## Implementation Notes - -- 2026-05-24: Nightly architecture pass recommended this because ownership-token safety policy is currently embedded in the Durable Object implementation, forcing integration setup for pure admission-policy cases. diff --git a/test/hosted-admission.test.ts b/test/hosted-admission.test.ts new file mode 100644 index 0000000..0683b8c --- /dev/null +++ b/test/hosted-admission.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from "vitest"; + +import { decideTunnelAdmission, type HostedAdmissionEnv } from "../src/hosted-admission.js"; + +test("hosted tunnel admission allows self-hosted connects without owner tokens", () => { + const admission = decideTunnelAdmission({ + request: new Request("https://captun.example.com/demo/__captun-connect"), + env: { CUSTOM_HOSTNAME: "captun.example.com" }, + activeOwnerToken: undefined, + }); + + expect(admission).toMatchObject({ ok: true, ownerToken: undefined }); +}); + +test("hosted tunnel admission checks configured secrets before owner-token policy", async () => { + const rejected = decideTunnelAdmission({ + request: new Request("https://demo.captun.sh/__captun-connect"), + env: { CUSTOM_HOSTNAME: "captun.sh", CAPTUN_SECRET: "secret" }, + activeOwnerToken: undefined, + }); + const accepted = decideTunnelAdmission({ + request: new Request("https://demo.captun.sh/__captun-connect", { + headers: { authorization: "Bearer secret" }, + }), + env: { CUSTOM_HOSTNAME: "captun.sh", CAPTUN_SECRET: "secret" }, + activeOwnerToken: undefined, + }); + + expect(rejected).toMatchObject({ ok: false }); + if (rejected.ok) throw new Error("expected secret rejection"); + expect(rejected.response).toMatchObject({ status: 401 }); + expect(await rejected.response.text()).toBe("Unauthorized\n"); + expect(accepted).toMatchObject({ ok: true, ownerToken: undefined }); +}); + +test("hosted tunnel admission requires anonymous owner tokens on captun.sh", async () => { + const missing = decideTunnelAdmission({ + request: new Request("https://demo.captun.sh/__captun-connect"), + env: hostedEnv(), + activeOwnerToken: undefined, + }); + const invalid = decideTunnelAdmission({ + request: new Request("https://demo.captun.sh/__captun-connect?captun-owner-token=no spaces"), + env: hostedEnv(), + activeOwnerToken: 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 ownership 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 ownership token\n"); +}); + +test("hosted tunnel admission allows first and same-owner anonymous connects", () => { + const first = decideTunnelAdmission({ + request: new Request("https://demo.captun.sh/__captun-connect?captun-owner-token=owner-a"), + env: hostedEnv(), + activeOwnerToken: undefined, + }); + const sameOwner = decideTunnelAdmission({ + request: new Request("https://demo.captun.sh/__captun-connect", { + headers: { "x-captun-owner-token": "owner-a" }, + }), + env: hostedEnv(), + activeOwnerToken: "owner-a", + }); + + expect(first).toMatchObject({ ok: true, ownerToken: "owner-a" }); + expect(sameOwner).toMatchObject({ ok: true, ownerToken: "owner-a" }); +}); + +test("hosted tunnel admission rejects different active anonymous owners", async () => { + const admission = decideTunnelAdmission({ + request: new Request("https://demo.captun.sh/__captun-connect?captun-owner-token=owner-b"), + env: hostedEnv(), + activeOwnerToken: "owner-a", + }); + + expect(admission).toMatchObject({ ok: false }); + if (admission.ok) throw new Error("expected active owner 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/worker.test.ts b/test/worker.test.ts index ef36827..2ccef96 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -498,13 +498,14 @@ test("Hosted Captun rejects a different ownership token while a tunnel is active "https://demo.captun.sh/__captun-connect?captun-owner-token=owner-b", { headers: { "cf-connecting-ip": "203.0.113.71" } }, ); + expect({ status: conflict.status }).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(conflict).toMatchObject({ status: 409 }); - expect(await conflict.text()).toBe("Tunnel name is already connected\n"); - expect(stillOwned).toMatchObject({ status: 200 }); + expect({ status: stillOwned.status }).toMatchObject({ status: 200 }); expect(await stillOwned.text()).toBe("owner a\n"); }); From 0859820bbf4ffe89788d58988b0d5e62f602dc49 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sun, 24 May 2026 08:15:25 +0100 Subject: [PATCH 14/16] Fix npx preview execution --- .github/workflows/pkg-pr-new.yml | 2 +- src/cli/bin.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index dbe1d35..ada908e 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -15,4 +15,4 @@ jobs: - run: corepack enable - run: pnpm install --frozen-lockfile - run: pnpm run build - - run: pnpm dlx pkg-pr-new publish --pnpm + - run: pnpm dlx pkg-pr-new publish --pnpm --bin diff --git a/src/cli/bin.ts b/src/cli/bin.ts index da59b02..d7cda9a 100755 --- a/src/cli/bin.ts +++ b/src/cli/bin.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { realpathSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { createServer, type Server } from "node:http"; import { homedir } from "node:os"; @@ -668,5 +669,10 @@ if (isMainModule()) { function isMainModule() { const entry = process.argv[1]; - return Boolean(entry && resolve(entry) === fileURLToPath(import.meta.url)); + if (!entry) return false; + try { + return realpathSync(entry) === fileURLToPath(import.meta.url); + } catch { + return resolve(entry) === fileURLToPath(import.meta.url); + } } From b77ca3ff38df5938262f33b8007bb15e19f6e994 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sun, 24 May 2026 13:45:24 +0100 Subject: [PATCH 15/16] Fix hosted connect diagnostics --- src/cli/bin.ts | 13 ++- src/index.ts | 9 +- src/routing.ts | 3 + src/worker.ts | 29 +++++- ...6-05-24-hosted-connect-conflict-message.md | 5 +- .../2026-05-24-hosted-ownership-tokens.md | 5 +- test/cli.test.ts | 93 +++++++++++++++++++ test/worker.test.ts | 67 +++++++++++++ 8 files changed, 215 insertions(+), 9 deletions(-) diff --git a/src/cli/bin.ts b/src/cli/bin.ts index d7cda9a..c64dd25 100755 --- a/src/cli/bin.ts +++ b/src/cli/bin.ts @@ -60,6 +60,7 @@ export type CaptunCliRouterOptions = { writeConfig?: (config: Config) => Promise; waitForShutdown?: () => Promise; onTunnelReady?: (ready: TunnelReady) => void | Promise; + tunnelRetries?: number; }; const adjectives = @@ -111,6 +112,7 @@ export function createCaptunCliRouter(options: CaptunCliRouterOptions = {}) { const tunnel = resolveTunnel(input, config); printTunnelOpening(tunnel); await runTunnelSession(tunnel, { + retries: options.tunnelRetries, waitForShutdown: options.waitForShutdown, onReady: options.onTunnelReady, }); @@ -506,6 +508,7 @@ async function connectTunnelWithRetry( ) { const url = `${tunnel.tunnel}/__captun-connect`; const headers = tunnel.secret ? { authorization: `Bearer ${tunnel.secret}` } : undefined; + const ownerToken = randomOwnerToken(); const fetcher = makeTunnelFetcher(tunnel, advertisedUrl); const maxAttempts = retries + 1; @@ -516,7 +519,9 @@ async function connectTunnelWithRetry( ? `Connecting to ${tunnel.tunnel}` : `Connecting to ${tunnel.tunnel} (retry ${attempt - 1}/${retries})`; try { - return await withSpinner(label, () => createCaptunTunnel({ url, headers, fetch: fetcher })); + return await withSpinner(label, () => + createCaptunTunnel({ url, headers, ownerToken, fetch: fetcher }), + ); } catch (error) { if (attempt === maxAttempts) { throw tunnelConnectError(tunnel, error); @@ -645,6 +650,12 @@ function randomName() { return [pick(adjectives), pick(speeds), pick(things)].join("-"); } +function randomOwnerToken() { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + function pick(words: string[]) { const word = words[Math.floor(Math.random() * words.length)]; if (!word) throw new Error("Cannot pick from an empty word list"); diff --git a/src/index.ts b/src/index.ts index 89fdd0b..6127047 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { newWebSocketRpcSession, RpcTarget } from "capnweb"; import { getTunnelUrlFromServerUrl, HOSTED_CAPTUN_SERVER_URL, + TUNNEL_CONNECT_DIAGNOSTIC_HEADER, TUNNEL_OWNER_TOKEN_HEADER, TUNNEL_OWNER_TOKEN_QUERY_PARAM, } from "./routing.js"; @@ -229,7 +230,7 @@ async function readWebSocketRejection(options: { const timeout = setTimeout(() => abort.abort(), WEBSOCKET_REJECTION_PROBE_TIMEOUT_MS); try { const response = await fetch(options.connectUrl, { - headers: options.headers, + headers: diagnosticHeaders(options.headers), signal: abort.signal, }); if (response.ok) return undefined; @@ -246,6 +247,12 @@ async function readWebSocketRejection(options: { } } +function diagnosticHeaders(headers: Record | undefined) { + const diagnostic = new Headers(headers); + diagnostic.set(TUNNEL_CONNECT_DIAGNOSTIC_HEADER, "1"); + return diagnostic; +} + // --------------------------------------------------------------------------- // Tunnel server (formerly src/server.ts) // --------------------------------------------------------------------------- diff --git a/src/routing.ts b/src/routing.ts index d739bdd..095d3a6 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -113,6 +113,9 @@ export function getTunnelUrlFromServerUrl(serverUrl: string, tunnelName: string) /** Header used by the Worker to advertise a tunnel's canonical URL to its client. */ export const TUNNEL_URL_HEADER = "x-captun-tunnel-url"; +/** Header used by clients to ask for read-only connect rejection details. */ +export const TUNNEL_CONNECT_DIAGNOSTIC_HEADER = "x-captun-connect-diagnostic"; + /** Anonymous hosted clients use this token to prove they own an active tunnel name. */ export const TUNNEL_OWNER_TOKEN_QUERY_PARAM = "captun-owner-token"; export const TUNNEL_OWNER_TOKEN_HEADER = "x-captun-owner-token"; diff --git a/src/worker.ts b/src/worker.ts index 5e3eef3..aea6bf1 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -7,6 +7,7 @@ import { getTunnelNameFromUrl, getTunnelUrl, RESERVED_HOSTED_SUBDOMAINS, + TUNNEL_CONNECT_DIAGNOSTIC_HEADER, TUNNEL_URL_HEADER, } from "./routing.js"; @@ -86,6 +87,19 @@ export class CaptunServerShard extends DurableObject { return response; } + diagnoseConnect(tunnelName: string, request: Request): Response { + const admission = decideTunnelAdmission({ + request, + env: this.env, + activeOwnerToken: this.tunnels.get(tunnelName)?.ownerToken, + }); + 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 }); @@ -150,6 +164,12 @@ export default { const forwarded = new Request(url, request); if (forwardedPath === "/__captun-connect") { + const headers = new Headers(forwarded.headers); + headers.set(TUNNEL_NAME_HEADER, tunnelName); + const connectRequest = new Request(forwarded, { headers }); + if (isConnectDiagnostic(connectRequest)) + return shard.diagnoseConnect(tunnelName, connectRequest); + const rateLimited = await hostedRateLimitResponse({ env, request, @@ -158,9 +178,7 @@ export default { }); if (rateLimited) return rateLimited; - const headers = new Headers(forwarded.headers); - headers.set(TUNNEL_NAME_HEADER, tunnelName); - return shard.fetch(new Request(forwarded, { headers })); + return shard.fetch(connectRequest); } const rateLimited = await hostedRateLimitResponse({ @@ -184,6 +202,11 @@ export default { }, } satisfies ExportedHandler; +function isConnectDiagnostic(request: Request) { + if (request.headers.get("upgrade") === "websocket") return false; + return request.headers.get(TUNNEL_CONNECT_DIAGNOSTIC_HEADER) === "1"; +} + async function hostedRateLimitResponse(input: { env: CaptunEnv; request: Request; diff --git a/tasks/complete/2026-05-24-hosted-connect-conflict-message.md b/tasks/complete/2026-05-24-hosted-connect-conflict-message.md index da59af9..c7682a8 100644 --- a/tasks/complete/2026-05-24-hosted-connect-conflict-message.md +++ b/tasks/complete/2026-05-24-hosted-connect-conflict-message.md @@ -3,12 +3,12 @@ size: small # Hosted Connect Conflict Messages -Status summary: Complete and locally verified. `createCaptunTunnel` now surfaces deterministic HTTP rejection details when available without hanging on the diagnostic probe, and the CLI treats only Captun active-owner conflicts as name-in-use errors instead of DNS setup failures. +Status summary: Complete and locally verified. `createCaptunTunnel` now surfaces deterministic HTTP rejection details when available without hanging or mutating tunnel state during the diagnostic probe, and the CLI treats only Captun active-owner conflicts as name-in-use errors instead of DNS setup failures. ## Checklist - [x] Add a regression test for a rejected WebSocket upgrade body. _`test/worker.test.ts` covers a 409 connect rejection and asserts `createCaptunTunnel` reports `Tunnel name is already connected`._ -- [x] Improve library connect errors for pre-open WebSocket failures. _`src/index.ts` now probes the connect URL after a pre-open WebSocket error, aborts the probe after a short timeout, and throws `CaptunTunnelConnectError` with status/body details when the server exposes them._ +- [x] Improve library connect errors for pre-open WebSocket failures. _`src/index.ts` now probes the connect URL after a pre-open WebSocket error with a read-only diagnostic header, aborts the probe after a short timeout, and throws `CaptunTunnelConnectError` with status/body details when the server exposes them._ - [x] Improve CLI tunnel connect messaging. _`src/cli/bin.ts` detects the known Captun 409/name-in-use body and prints an active anonymous client explanation instead of DNS guidance._ - [x] Add review regression coverage. _`test/worker.test.ts` covers an unresponsive diagnostic probe; `test/cli.test.ts` covers unrelated 409 responses retaining DNS guidance._ - [x] Run focused and full verification. _Verified with focused Vitest files, `pnpm run check`, `pnpm test`, and `pnpm run build`._ @@ -24,3 +24,4 @@ Status summary: Complete and locally verified. `createCaptunTunnel` now surfaces - 2026-05-24: Created as a follow-up to the hosted ownership-token PR after review found anonymous active-owner conflicts were being reported as generic WebSocket/DNS failures. - 2026-05-24: Node's WebSocket `ErrorEvent` does not expose the rejected upgrade status/body directly, so the library performs a follow-up `fetch` to the same connect URL. This is deterministic for the hosted Worker conflict because the Worker returns `409` before creating the WebSocket upgrade response. - 2026-05-24: Review follow-up added a timeout around the diagnostic HTTP probe and tightened CLI conflict classification so arbitrary `409` responses keep the generic troubleshooting path. +- 2026-05-24: Bugbot follow-up made the diagnostic probe explicitly read-only with `x-captun-connect-diagnostic`, so it does not accept/replace tunnel sessions and does not charge the hosted connect-attempt rate-limit bucket a second time. diff --git a/tasks/complete/2026-05-24-hosted-ownership-tokens.md b/tasks/complete/2026-05-24-hosted-ownership-tokens.md index 3a223e5..7653007 100644 --- a/tasks/complete/2026-05-24-hosted-ownership-tokens.md +++ b/tasks/complete/2026-05-24-hosted-ownership-tokens.md @@ -3,7 +3,7 @@ size: medium # Hosted Anonymous Tunnel Ownership Tokens -Status summary: Implementation is complete and locally verified. Hosted anonymous tunnel connections now require an ownership token, conflicting active owners get `409`, same-owner reconnects can replace themselves through the public API, and self-hosted replacement behavior remains compatible. +Status summary: Implementation is complete and locally verified. Hosted anonymous tunnel connections now require an ownership token, conflicting active owners get `409`, same-owner reconnects and CLI retries reuse one owner token, and self-hosted replacement behavior remains compatible. ## Checklist @@ -12,7 +12,7 @@ Status summary: Implementation is complete and locally verified. Hosted anonymou - [x] Let a reconnect with the same token replace its own active connection. _Same-token hosted connects still dispose and replace the previous active fetcher._ - [x] Reject a different token with `409 Conflict` while the tunnel is already active. _Different-token hosted connects return `Tunnel name is already connected` without touching the active tunnel._ - [x] Keep self-hosted and secret-protected tunnel behavior compatible. _Ownership enforcement is skipped outside hosted `captun.sh` and when `CAPTUN_SECRET` is configured; tests cover self-hosted replacement._ -- [x] Generate and send a client-side anonymous token for hosted CLI/API/browser clients. _`createCaptunTunnel` appends a generated query token to the WebSocket URL, returns it on `tunnel.ownerToken`, accepts it back as `ownerToken`, and `/captun.browser.js` follows the same shape._ +- [x] Generate and send a client-side anonymous token for hosted CLI/API/browser clients. _`createCaptunTunnel` appends a generated query token to the WebSocket URL, returns it on `tunnel.ownerToken`, accepts it back as `ownerToken`, CLI retry loops reuse one generated token, and `/captun.browser.js` follows the same shape._ - [x] Cover the hosted ownership behavior with integration-style tests. _`test/worker.test.ts` covers conflict, same-token replacement, header token parsing, missing-token rejection, client token generation/reuse, and self-hosted compatibility._ - [x] Run the focused tests and full project checks. _Verified with focused worker tests, full `pnpm test`, `pnpm run check`, and `pnpm run build`._ @@ -27,3 +27,4 @@ Status summary: Implementation is complete and locally verified. Hosted anonymou - 2026-05-24: Tokens are intentionally passed on the connect request only, using a browser-compatible query parameter by default. The Worker also accepts an equivalent header for non-browser clients and tests. - 2026-05-24: Review follow-up exposed `ownerToken` on the returned tunnel and added `ownerToken` as an explicit create option so exported API callers can exercise same-owner replacement without manually editing query strings. - 2026-05-24: This does not persist ownership after disconnect. Once the active Cap'n Web session breaks, the tunnel name is free for another anonymous token to claim. +- 2026-05-24: Bugbot follow-up moved CLI retries to one owner token per tunnel session so a partially successful retry cannot conflict with its own previous attempt. diff --git a/test/cli.test.ts b/test/cli.test.ts index 86f1da9..eada5a6 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { createRouterClient } from "@orpc/server"; @@ -66,6 +67,32 @@ test("CLI tunnel connect errors keep DNS guidance for unrelated 409 responses", }); }); +test("CLI tunnel retries reuse the same anonymous owner token", async () => { + await using target = await createTestServer((_request, response) => { + response.end("ok\n"); + }); + await using tunnelServer = await createFlakyTunnelServer(); + + const router = createCaptunCliRouter({ + readConfig: async () => undefined, + waitForShutdown: async () => {}, + tunnelRetries: 1, + }); + const client = createRouterClient(router); + + await client.tunnel({ + target: String(target.port), + serverUrl: tunnelServer.origin, + name: "demo", + requestLogs: false, + }); + + const upgradeTokens = tunnelServer.upgradeUrls.map((url) => + new URL(url, tunnelServer.origin).searchParams.get("captun-owner-token"), + ); + expect(upgradeTokens).toEqual([upgradeTokens[0], upgradeTokens[0]]); +}); + async function createTestServer( handler: (req: IncomingMessage, res: ServerResponse) => void | Promise, ) { @@ -91,6 +118,72 @@ async function createTestServer( }; } +async function createFlakyTunnelServer() { + const upgradeUrls: string[] = []; + const sockets = new Set<{ destroy: () => void }>(); + const server = createServer((request, response) => { + if (request.url === "/demo/__captun/health") { + response.end("ok\n"); + return; + } + response.writeHead(503, { "content-type": "text/plain; charset=utf-8" }); + response.end("try again\n"); + }); + server.on("upgrade", (request, socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + upgradeUrls.push(request.url || ""); + if (upgradeUrls.length === 1) { + socket.write( + [ + "HTTP/1.1 503 Service Unavailable", + "Content-Type: text/plain; charset=utf-8", + "Content-Length: 10", + "Connection: close", + "", + "try again\n", + ].join("\r\n"), + ); + socket.destroy(); + return; + } + + const key = request.headers["sec-websocket-key"]; + if (typeof key !== "string") { + socket.destroy(); + return; + } + const accept = createHash("sha1") + .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest("base64"); + socket.write( + [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${accept}`, + "", + "", + ].join("\r\n"), + ); + }); + 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}`, + upgradeUrls, + async [Symbol.asyncDispose]() { + for (const socket of sockets) socket.destroy(); + await new Promise((resolveClose) => server.close(() => resolveClose())); + }, + }; +} + async function createRejectedTunnelServer(body: string) { const sockets = new Set<{ destroy: () => void }>(); const server = createServer((_request, response) => { diff --git a/test/worker.test.ts b/test/worker.test.ts index 2ccef96..46cda26 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -8,6 +8,7 @@ import { captunShardName, getTunnelNameFromUrl, getTunnelUrl, + TUNNEL_CONNECT_DIAGNOSTIC_HEADER, TUNNEL_URL_HEADER, } from "../src/routing.js"; import { createCaptunWorkerFixture, createMiniflareWorkerFixture } from "./miniflare.js"; @@ -509,6 +510,72 @@ test("Hosted Captun rejects a different ownership token while a tunnel is active expect(await stillOwned.text()).toBe("owner 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 _ownerTunnel = await createDirectWorkerTunnel({ + fixture, + url: "https://demo.captun.sh/__captun-connect?captun-owner-token=owner-a", + responseText: "owner a\n", + clientIp: "203.0.113.74", + }); + + const diagnostic = await fixture.worker.fetch( + "https://demo.captun.sh/__captun-connect?captun-owner-token=owner-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({ status: diagnostic.status }).toMatchObject({ status: 204 }); + expect({ status: stillOwned.status }).toMatchObject({ status: 200 }); + expect(await stillOwned.text()).toBe("owner 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 _ownerTunnel = await createDirectWorkerTunnel({ + fixture, + url: "https://demo.captun.sh/__captun-connect?captun-owner-token=owner-a", + responseText: "owner a\n", + clientIp: "203.0.113.77", + }); + + const firstConflict = await fixture.worker.fetch( + "https://demo.captun.sh/__captun-connect?captun-owner-token=owner-b", + { headers: { "cf-connecting-ip": "203.0.113.78" } }, + ); + const diagnostic = await fixture.worker.fetch( + "https://demo.captun.sh/__captun-connect?captun-owner-token=owner-b", + { + headers: { + "cf-connecting-ip": "203.0.113.78", + [TUNNEL_CONNECT_DIAGNOSTIC_HEADER]: "1", + }, + }, + ); + const secondConflict = await fixture.worker.fetch( + "https://demo.captun.sh/__captun-connect?captun-owner-token=owner-c", + { headers: { "cf-connecting-ip": "203.0.113.78" } }, + ); + + expect({ status: firstConflict.status }).toMatchObject({ status: 409 }); + expect({ status: diagnostic.status }).toMatchObject({ status: 409 }); + expect(await diagnostic.text()).toBe("Tunnel name is already connected\n"); + expect({ status: secondConflict.status }).toMatchObject({ status: 409 }); +}); + test("Hosted Captun lets the same ownership token replace its active tunnel", async () => { await using fixture = await createCaptunWorkerFixture({ CUSTOM_HOSTNAME: "captun.sh", From 4377903d3097007a865b500f922560dcd8853b4e Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sun, 24 May 2026 13:53:24 +0100 Subject: [PATCH 16/16] Address hosted diagnostics review --- src/cli/bin.ts | 10 +-- src/hosted-admission.ts | 2 +- src/index.ts | 2 +- src/worker.ts | 87 ++++++++++++++++--- .../2026-05-24-hosted-admission-module.md | 1 + ...6-05-24-hosted-connect-conflict-message.md | 1 + .../2026-05-24-hosted-ownership-tokens.md | 1 + test/hosted-admission.test.ts | 12 +++ test/worker.test.ts | 52 +++++++++++ 9 files changed, 147 insertions(+), 21 deletions(-) diff --git a/src/cli/bin.ts b/src/cli/bin.ts index c64dd25..adff41d 100755 --- a/src/cli/bin.ts +++ b/src/cli/bin.ts @@ -13,7 +13,7 @@ import { createCli, yamlTableConsoleLogger } from "trpc-cli"; import { z } from "zod/v4"; import { color } from "./ansi.js"; import { CliFriendlyError } from "./cli-error.js"; -import { CaptunTunnelConnectError, createCaptunTunnel } from "../index.js"; +import { CaptunTunnelConnectError, createCaptunTunnel, randomOwnershipToken } from "../index.js"; import { assertLocalTargetAcceptingConnections } from "./local-target.js"; import { withSpinner } from "./spinner.js"; import { @@ -508,7 +508,7 @@ async function connectTunnelWithRetry( ) { const url = `${tunnel.tunnel}/__captun-connect`; const headers = tunnel.secret ? { authorization: `Bearer ${tunnel.secret}` } : undefined; - const ownerToken = randomOwnerToken(); + const ownerToken = randomOwnershipToken(); const fetcher = makeTunnelFetcher(tunnel, advertisedUrl); const maxAttempts = retries + 1; @@ -650,12 +650,6 @@ function randomName() { return [pick(adjectives), pick(speeds), pick(things)].join("-"); } -function randomOwnerToken() { - const bytes = new Uint8Array(16); - crypto.getRandomValues(bytes); - return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); -} - function pick(words: string[]) { const word = words[Math.floor(Math.random() * words.length)]; if (!word) throw new Error("Cannot pick from an empty word list"); diff --git a/src/hosted-admission.ts b/src/hosted-admission.ts index 8c54ba2..cf1341c 100644 --- a/src/hosted-admission.ts +++ b/src/hosted-admission.ts @@ -26,7 +26,7 @@ export function decideTunnelAdmission(input: { const ownerToken = hostedAnonymousOwnerToken(input.request, input.env); if (ownerToken instanceof Response) return { ok: false, response: ownerToken }; - if (input.activeOwnerToken && input.activeOwnerToken !== ownerToken) { + if (ownerToken !== undefined && input.activeOwnerToken && input.activeOwnerToken !== ownerToken) { return { ok: false, response: reject("Tunnel name is already connected\n", 409) }; } diff --git a/src/index.ts b/src/index.ts index 6127047..b8162f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -140,7 +140,7 @@ function randomTunnelName() { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } -function randomOwnershipToken() { +export function randomOwnershipToken() { 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 aea6bf1..a1b703c 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -31,6 +31,7 @@ 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"; @@ -41,6 +42,7 @@ type HostedRateLimitResult = { ok: true } | { ok: false; limit: number; retryAft type HostedRateLimitBucket = { count: number; resetAt: number; + lastRejectedAt?: number; }; type ActiveTunnel = { @@ -118,6 +120,7 @@ export class HostedRateLimiter extends DurableObject { 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, @@ -129,9 +132,29 @@ export class HostedRateLimiter extends DurableObject { return { ok: true }; } - private activeBucket(now: number, resetAt: number) { + 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 = { count: 0, resetAt }; + const bucket: HostedRateLimitBucket = { count: 0, resetAt }; this.bucket = bucket; return bucket; } @@ -167,8 +190,16 @@ export default { const headers = new Headers(forwarded.headers); headers.set(TUNNEL_NAME_HEADER, tunnelName); const connectRequest = new Request(forwarded, { headers }); - if (isConnectDiagnostic(connectRequest)) + if (isConnectDiagnostic(connectRequest)) { + const rateLimited = await hostedRateLimitDiagnosticResponse({ + env, + request, + tunnelName, + kind: "connect", + }); + if (rateLimited) return rateLimited; return shard.diagnoseConnect(tunnelName, connectRequest); + } const rateLimited = await hostedRateLimitResponse({ env, @@ -215,14 +246,7 @@ async function hostedRateLimitResponse(input: { }): Promise { if (input.env.CUSTOM_HOSTNAME !== HOSTED_CAPTUN_HOSTNAME) return undefined; if (!input.env.HostedRateLimiter) { - if (input.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", - }, - }); + return hostedRateLimiterMissingResponse(input.env); } const config = hostedRateLimitConfig(input.env); @@ -244,6 +268,47 @@ async function hostedRateLimitResponse(input: { 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, diff --git a/tasks/complete/2026-05-24-hosted-admission-module.md b/tasks/complete/2026-05-24-hosted-admission-module.md index 32a6e6e..c4b0321 100644 --- a/tasks/complete/2026-05-24-hosted-admission-module.md +++ b/tasks/complete/2026-05-24-hosted-admission-module.md @@ -23,3 +23,4 @@ Status summary: Complete and locally verified. Hosted anonymous tunnel admission - 2026-05-24: Nightly architecture pass recommended this because ownership-token safety policy was embedded in the Durable Object implementation, forcing integration setup for pure admission-policy cases. - 2026-05-24: Replaced the Worker-specific `crypto.subtle.timingSafeEqual` call with a local constant-time string comparison so the admission module can be tested directly in Node while retaining fixed-work secret comparison behavior. +- 2026-05-24: Bugbot follow-up made secret-auth hosted admission ignore stale anonymous owner tokens, since setting `CAPTUN_SECRET` disables anonymous ownership policy. diff --git a/tasks/complete/2026-05-24-hosted-connect-conflict-message.md b/tasks/complete/2026-05-24-hosted-connect-conflict-message.md index c7682a8..08c00c1 100644 --- a/tasks/complete/2026-05-24-hosted-connect-conflict-message.md +++ b/tasks/complete/2026-05-24-hosted-connect-conflict-message.md @@ -25,3 +25,4 @@ Status summary: Complete and locally verified. `createCaptunTunnel` now surfaces - 2026-05-24: Node's WebSocket `ErrorEvent` does not expose the rejected upgrade status/body directly, so the library performs a follow-up `fetch` to the same connect URL. This is deterministic for the hosted Worker conflict because the Worker returns `409` before creating the WebSocket upgrade response. - 2026-05-24: Review follow-up added a timeout around the diagnostic HTTP probe and tightened CLI conflict classification so arbitrary `409` responses keep the generic troubleshooting path. - 2026-05-24: Bugbot follow-up made the diagnostic probe explicitly read-only with `x-captun-connect-diagnostic`, so it does not accept/replace tunnel sessions and does not charge the hosted connect-attempt rate-limit bucket a second time. +- 2026-05-24: Second Bugbot follow-up taught diagnostics to report fail-closed limiter config and recent real connect rate-limit rejections without incrementing the bucket. diff --git a/tasks/complete/2026-05-24-hosted-ownership-tokens.md b/tasks/complete/2026-05-24-hosted-ownership-tokens.md index 7653007..94995db 100644 --- a/tasks/complete/2026-05-24-hosted-ownership-tokens.md +++ b/tasks/complete/2026-05-24-hosted-ownership-tokens.md @@ -28,3 +28,4 @@ Status summary: Implementation is complete and locally verified. Hosted anonymou - 2026-05-24: Review follow-up exposed `ownerToken` on the returned tunnel and added `ownerToken` as an explicit create option so exported API callers can exercise same-owner replacement without manually editing query strings. - 2026-05-24: This does not persist ownership after disconnect. Once the active Cap'n Web session breaks, the tunnel name is free for another anonymous token to claim. - 2026-05-24: Bugbot follow-up moved CLI retries to one owner token per tunnel session so a partially successful retry cannot conflict with its own previous attempt. +- 2026-05-24: Second Bugbot follow-up reused the library's exported `randomOwnershipToken` in the CLI instead of maintaining a duplicate token generator. diff --git a/test/hosted-admission.test.ts b/test/hosted-admission.test.ts index 0683b8c..3e5ebe9 100644 --- a/test/hosted-admission.test.ts +++ b/test/hosted-admission.test.ts @@ -33,6 +33,18 @@ test("hosted tunnel admission checks configured secrets before owner-token polic expect(accepted).toMatchObject({ ok: true, ownerToken: undefined }); }); +test("hosted tunnel admission ignores active anonymous owners when secret auth is configured", () => { + const admission = decideTunnelAdmission({ + request: new Request("https://demo.captun.sh/__captun-connect", { + headers: { authorization: "Bearer secret" }, + }), + env: { CUSTOM_HOSTNAME: "captun.sh", CAPTUN_SECRET: "secret" }, + activeOwnerToken: "owner-a", + }); + + expect(admission).toMatchObject({ ok: true, ownerToken: undefined }); +}); + test("hosted tunnel admission requires anonymous owner tokens on captun.sh", async () => { const missing = decideTunnelAdmission({ request: new Request("https://demo.captun.sh/__captun-connect"), diff --git a/test/worker.test.ts b/test/worker.test.ts index 46cda26..744c7eb 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -576,6 +576,58 @@ test("Hosted Captun connect diagnostics do not spend connect rate-limit slots", expect({ status: secondConflict.status }).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 _ownerTunnel = await createDirectWorkerTunnel({ + fixture, + url: "https://demo.captun.sh/__captun-connect?captun-owner-token=owner-a", + responseText: "owner a\n", + clientIp: "203.0.113.79", + }); + + const rateLimited = await fixture.worker.fetch( + "https://demo.captun.sh/__captun-connect?captun-owner-token=owner-b", + { headers: { "cf-connecting-ip": "203.0.113.79" } }, + ); + const diagnostic = await fixture.worker.fetch( + "https://demo.captun.sh/__captun-connect?captun-owner-token=owner-b", + { + headers: { + "cf-connecting-ip": "203.0.113.79", + [TUNNEL_CONNECT_DIAGNOSTIC_HEADER]: "1", + }, + }, + ); + + expect({ status: rateLimited.status }).toMatchObject({ status: 429 }); + expect({ status: diagnostic.status }).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://demo.captun.sh/__captun-connect?captun-owner-token=owner-a", + { + headers: { + "cf-connecting-ip": "203.0.113.84", + [TUNNEL_CONNECT_DIAGNOSTIC_HEADER]: "1", + }, + }, + ); + + expect({ status: diagnostic.status }).toMatchObject({ status: 503 }); + expect(await diagnostic.text()).toBe("Hosted rate limiter is not configured\n"); +}); + test("Hosted Captun lets the same ownership token replace its active tunnel", async () => { await using fixture = await createCaptunWorkerFixture({ CUSTOM_HOSTNAME: "captun.sh",