Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c95c334
Launch hosted captun.sh tunnels
mmkal May 23, 2026
fa1019f
Add browser tunnel demo
mmkal May 23, 2026
9dda262
Polish hosted captun demo
mmkal May 23, 2026
4ea175b
Fix npx preview execution
mmkal May 24, 2026
b6255a5
Refactor tunnel clients around gateway-owned URLs
mmkal May 25, 2026
48fc952
Complete gateway-owned addressing task
mmkal May 25, 2026
ce12906
Record hosted gateway deployment verification
mmkal May 25, 2026
8e65b3e
Fail fast on legacy hosted config
mmkal May 26, 2026
3a3bb4c
Use portable token comparison
mmkal May 26, 2026
3c10091
Add hosted captun.sh safety controls
mmkal May 26, 2026
1a924a7
Separate hosted service from deployable worker
mmkal May 26, 2026
6a72436
Merge hosted gateway split into hosted safety
mmkal May 26, 2026
ead1f28
Polish hosted landing page
mmkal May 26, 2026
93b12be
Merge remote-tracking branch 'origin/hosted-captun-sh' into mmkal/26/…
mmkal May 26, 2026
52d6ecd
Show hosted demo connect timing
mmkal May 26, 2026
45b888f
Merge remote-tracking branch 'origin/hosted-captun-sh' into mmkal/26/…
mmkal May 26, 2026
bd502c3
Fix hosted demo escape source
mmkal May 26, 2026
d41f629
Merge hosted demo escape fix
mmkal May 26, 2026
ec3a912
Merge main after hosted launch
mmkal May 26, 2026
5232f5a
Share CaptunServerShard with hosted gateway
mmkal May 26, 2026
c07fc52
Use subclassed shard policy for hosted gateway
mmkal May 26, 2026
7c77471
Strip broad hosted tunnel cookies
mmkal May 26, 2026
dea1872
Reject duplicate broad hosted cookie domains
mmkal May 26, 2026
133ffbf
Merge main after runtime adapters
mmkal May 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ _Avoid_: Agent layer, agent product
- The **Hosted Site** is part of the **Hosted Service** product surface, but it is not tunnel routing or **Tunnel Admission**.
- **Hosted Site** code should not live in **Cloudflare Tunnel Gateway** core. A real browser package can wait until the demo surface needs it.
- **Hosted Service** entrypoints may compose the **Cloudflare Tunnel Gateway**, but **Cloudflare Tunnel Gateway** core should remain understandable as a **Self-Hosted Deployment** with **Trusted Gateway Policy**.
- **Public Gateway Policy** implementation for `captun.sh` should live under `src/hosted/`; `src/worker.ts` should stay readable as the deployable **Cloudflare Tunnel Gateway** core.
- The **Control Plane** governs future **Hosted Service** accounts, reservations, billing, and policy.
- The **Agent Preview Use Case** uses the **Hosted Service** and may later use the **Control Plane**.
- The **Agent Preview Use Case** should not shape the current gateway/core split until **Control Plane** support exists.
Expand Down
34 changes: 27 additions & 7 deletions src/cli/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ import { createCli, yamlTableConsoleLogger } from "trpc-cli";
import { z } from "zod/v4";
import { color } from "./ansi.js";
import { CliFriendlyError } from "./cli-error.js";
import { createCaptunTunnel } from "../index.js";
import { CaptunTunnelConnectError, createCaptunTunnel } from "../index.js";
import { assertLocalTargetAcceptingConnections } from "./local-target.js";
import { withSpinner } from "./spinner.js";
import { HOSTED_CAPTUN_GATEWAY } from "../routing.js";
import { HOSTED_CAPTUN_GATEWAY, HOSTED_CAPTUN_HOSTNAME } from "../routing.js";
import { randomConnectToken } from "../token.js";
import {
captunHealthResponse,
confirmTunnelHealth,
Expand Down Expand Up @@ -57,8 +58,10 @@ export type TunnelReady = {
export type CaptunCliRouterOptions = {
readConfig?: () => Promise<Config | undefined>;
writeConfig?: (config: Config) => Promise<void>;
createTunnel?: typeof createCaptunTunnel;
waitForShutdown?: () => Promise<void>;
onTunnelReady?: (ready: TunnelReady) => void | Promise<void>;
tunnelRetries?: number;
};

const adjectives =
Expand Down Expand Up @@ -110,6 +113,8 @@ export function createCaptunCliRouter(options: CaptunCliRouterOptions = {}) {
const tunnel = resolveTunnel(input, config);
printTunnelOpening(tunnel);
await runTunnelSession(tunnel, {
retries: options.tunnelRetries,
createTunnel: options.createTunnel,
waitForShutdown: options.waitForShutdown,
onReady: options.onTunnelReady,
});
Expand Down Expand Up @@ -442,16 +447,22 @@ function resolveTunnel(input: TunnelCliInput, config?: Config): ResolvedTunnel {

const name = input.name || randomName();
const target = normalizeTarget(input.target);
const token = input.token || config?.token || hostedGatewayToken(gateway);

return {
name,
gateway,
target,
token: input.token || config?.token,
token,
requestLogs: input.requestLogs,
};
}

function hostedGatewayToken(gateway: string) {
if (new URL(gateway).hostname !== HOSTED_CAPTUN_HOSTNAME) return undefined;
return randomConnectToken();
}

function normalizeTarget(target: string) {
const value = target.trim();
if (/^\d+$/.test(value)) return `http://127.0.0.1:${value}`;
Expand All @@ -469,14 +480,15 @@ async function runTunnelSession(
tunnel: ResolvedTunnel,
opts: {
retries?: number;
createTunnel?: typeof createCaptunTunnel;
waitForShutdown?: () => Promise<void>;
onReady?: (ready: TunnelReady) => void | Promise<void>;
} = {},
) {
const startedAt = performance.now();
await assertLocalTargetAcceptingConnections(tunnel.target);

const session = await connectTunnelWithRetry(tunnel, opts.retries || 0);
const session = await connectTunnelWithRetry(tunnel, opts.retries || 0, opts.createTunnel);
try {
await confirmTunnelHealth(session.url);
console.log(
Expand All @@ -492,7 +504,11 @@ async function runTunnelSession(
}
}

async function connectTunnelWithRetry(tunnel: ResolvedTunnel, retries: number) {
async function connectTunnelWithRetry(
tunnel: ResolvedTunnel,
retries: number,
createTunnel = createCaptunTunnel,
) {
const fetcher = makeTunnelFetcher(tunnel);

const maxAttempts = retries + 1;
Expand All @@ -504,7 +520,7 @@ async function connectTunnelWithRetry(tunnel: ResolvedTunnel, retries: number) {
: `Connecting to ${tunnel.gateway} (retry ${attempt - 1}/${retries})`;
try {
return await withSpinner(label, () =>
createCaptunTunnel({
createTunnel({
gateway: tunnel.gateway,
name: tunnel.name,
token: tunnel.token,
Expand Down Expand Up @@ -562,7 +578,11 @@ function tunnelConnectError(tunnel: ResolvedTunnel, cause: unknown) {
const hostname = new URL(tunnel.gateway).hostname;
const message = cause instanceof Error ? cause.message : String(cause);
const lines = [`Could not connect tunnel to ${color.cyan(tunnel.gateway)} (${message}).`];
if (!hostname.endsWith(".workers.dev")) {
const connectRejection = cause instanceof CaptunTunnelConnectError ? cause.response : undefined;
const knownTunnelConflict =
connectRejection?.status === 409 &&
connectRejection.body === "Tunnel name is already connected";
if (!knownTunnelConflict && !hostname.endsWith(".workers.dev")) {
// Dropping the leftmost label gives the zone-side wildcard parent —
// `tunnel.mispwoso.com` -> `mispwoso.com`, `t.captun.example.com` -> `captun.example.com`.
const wildcardParent = hostname.split(".").slice(1).join(".");
Expand Down
212 changes: 212 additions & 0 deletions src/hosted/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { DurableObject } from "cloudflare:workers";

export type HostedRateLimitEnv = {
HostedRateLimiter?: DurableObjectNamespace<HostedRateLimiter>;
HOSTED_RATE_LIMIT_WINDOW_SECONDS?: string;
HOSTED_CONNECTS_PER_IP_PER_WINDOW?: string;
HOSTED_REQUESTS_PER_IP_PER_WINDOW?: string;
HOSTED_REQUESTS_PER_TUNNEL_PER_WINDOW?: string;
HOSTED_RATE_LIMIT_DISABLED?: string;
};

const DEFAULT_HOSTED_RATE_LIMIT_WINDOW_SECONDS = 60;
const DEFAULT_HOSTED_CONNECTS_PER_IP_PER_WINDOW = 30;
const DEFAULT_HOSTED_REQUESTS_PER_IP_PER_WINDOW = 600;
const DEFAULT_HOSTED_REQUESTS_PER_TUNNEL_PER_WINDOW = 1200;
const HOSTED_RATE_LIMIT_DIAGNOSTIC_WINDOW_MS = 2_000;

type HostedRateLimitKind = "connect" | "request";

type HostedRateLimitInput = { limit: number; windowSeconds: number };

type HostedRateLimitResult = { ok: true } | { ok: false; limit: number; retryAfterSeconds: number };

type HostedRateLimitBucket = {
count: number;
resetAt: number;
lastRejectedAt?: number;
};

export class HostedRateLimiter extends DurableObject<HostedRateLimitEnv> {
private bucket: HostedRateLimitBucket | undefined;

check(input: HostedRateLimitInput): HostedRateLimitResult {
const now = Date.now();
const bucket = this.activeBucket(now, now + input.windowSeconds * 1000);
if (bucket.count >= input.limit) {
bucket.lastRejectedAt = now;
return {
ok: false,
limit: input.limit,
retryAfterSeconds: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
};
}

bucket.count++;
return { ok: true };
}

diagnose(input: HostedRateLimitInput): HostedRateLimitResult {
const now = Date.now();
const bucket = this.bucket;
if (
bucket &&
bucket.count >= input.limit &&
bucket.resetAt > now &&
bucket.lastRejectedAt &&
now - bucket.lastRejectedAt <= HOSTED_RATE_LIMIT_DIAGNOSTIC_WINDOW_MS
) {
return {
ok: false,
limit: input.limit,
retryAfterSeconds: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
};
}

return { ok: true };
}
Comment thread
cursor[bot] marked this conversation as resolved.

private activeBucket(now: number, resetAt: number): HostedRateLimitBucket {
if (this.bucket && this.bucket.resetAt > now) return this.bucket;
const bucket: HostedRateLimitBucket = { count: 0, resetAt };
this.bucket = bucket;
return bucket;
}
}

export async function hostedRateLimitResponse(input: {
env: HostedRateLimitEnv;
request: Request;
tunnelName: string;
kind: HostedRateLimitKind;
}): Promise<Response | undefined> {
if (!input.env.HostedRateLimiter) {
return hostedRateLimiterMissingResponse(input.env);
}

const config = hostedRateLimitConfig(input.env);
const checks = hostedRateLimitChecks({
kind: input.kind,
clientKey: hostedClientKey(input.request),
tunnelName: input.tunnelName,
config,
});
for (const check of checks) {
const limiter = input.env.HostedRateLimiter.getByName(hostedRateLimiterName(check.key));
const result = await limiter.check({
limit: check.limit,
windowSeconds: config.windowSeconds,
});
if (!result.ok) return hostedRateLimitedResponse(result);
}

return undefined;
}

export async function hostedRateLimitDiagnosticResponse(input: {
env: HostedRateLimitEnv;
request: Request;
tunnelName: string;
kind: HostedRateLimitKind;
}): Promise<Response | 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: HostedRateLimitEnv) {
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<HostedRateLimitResult, { ok: false }>) {
return new Response(`Rate limit exceeded. Try again in ${result.retryAfterSeconds}s.\n`, {
status: 429,
headers: {
"content-type": "text/plain; charset=utf-8",
"cache-control": "no-store",
"retry-after": String(result.retryAfterSeconds),
"x-captun-rate-limit": String(result.limit),
},
});
}

function hostedClientKey(request: Request) {
return request.headers.get("cf-connecting-ip") || "unknown";
}

function hostedRateLimitChecks(input: {
kind: HostedRateLimitKind;
clientKey: string;
tunnelName: string;
config: ReturnType<typeof hostedRateLimitConfig>;
}) {
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: HostedRateLimitEnv) {
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;
}
16 changes: 12 additions & 4 deletions src/hosted/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,15 +265,16 @@ const WWW_FAVICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"
const WWW_BROWSER_MODULE = `import { newWebSocketRpcSession, RpcTarget } from "https://esm.sh/capnweb@0.8.0";

export async function createCaptunTunnel(options) {
const socket = new WebSocket(gatewayConnectUrl(options));
const connect = gatewayConnectUrl(options);
const socket = new WebSocket(connect.url);
const readyPromise = waitForReady();
const tunnelTargetFetcher = new TunnelTargetFetcher(options.fetch, readyPromise.ready);
const session = newWebSocketRpcSession(socket, tunnelTargetFetcher);
await waitUntilOpen(socket);
const tunnel = await readyPromise.promise;
return {
url: tunnel.url,
token: tunnel.token || options.token,
token: tunnel.token || connect.token,
close: () => disposeSession(session),
};
}
Expand All @@ -296,11 +297,12 @@ class TunnelTargetFetcher extends RpcTarget {

function gatewayConnectUrl(options) {
const url = new URL(options.gateway || "https://captun.sh");
const token = options.token || (url.hostname === "captun.sh" ? randomConnectToken() : undefined);
url.protocol = url.protocol === "http:" ? "ws:" : "wss:";
url.searchParams.set("captun-connect", "1");
url.searchParams.set("captun-name", options.name || randomTunnelName());
if (options.token) url.searchParams.set("captun-token", options.token);
return url;
if (token) url.searchParams.set("captun-token", token);
return { url, token };
}

function waitUntilOpen(socket) {
Expand Down Expand Up @@ -346,6 +348,12 @@ function randomTunnelName() {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}

function randomConnectToken() {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}

function disposeSession(session) {
const disposeSymbol = Symbol.dispose;
if (disposeSymbol && typeof session[disposeSymbol] === "function") session[disposeSymbol]();
Expand Down
Loading
Loading