Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
49 changes: 47 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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!
*
Expand Down Expand Up @@ -28,6 +33,7 @@ export interface Fetcher {
*/
export type CaptunTunnel = Disposable & {
url: string;
ownerToken: string;
};

export async function createCaptunTunnel(
Expand All @@ -36,10 +42,16 @@ export async function createCaptunTunnel(
serverUrl?: string;
name?: string;
headers?: Record<string, string>;
ownerToken?: string;
},
): Promise<CaptunTunnel> {
const endpoint = resolveTunnelEndpoint(options);
const socket = createWebSocket({ url: endpoint.connectUrl, headers: options.headers });
const ownership = withAnonymousOwnershipToken({
connectUrl: endpoint.connectUrl,
headers: options.headers,
ownerToken: options.ownerToken,
});
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 });
Expand All @@ -48,6 +60,7 @@ export async function createCaptunTunnel(

return {
url: endpoint.publicUrl,
ownerToken: ownership.ownerToken,
[Symbol.dispose]: () => session[Symbol.dispose](),
};
}
Expand All @@ -71,15 +84,47 @@ 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<string, string> | undefined;
ownerToken: string | undefined;
}) {
const headerToken = getHeader(options.headers, TUNNEL_OWNER_TOKEN_HEADER);
if (headerToken) return { connectUrl: options.connectUrl, ownerToken: headerToken };

const connectUrl = new URL(options.connectUrl);
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 getHeader(headers: Record<string, string> | undefined, name: string) {
if (!headers) return undefined;
const lowerName = name.toLowerCase();
const key = Object.keys(headers).find((candidate) => candidate.toLowerCase() === lowerName);
return key ? headers[key] : undefined;
}

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;

Expand Down
4 changes: 4 additions & 0 deletions src/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
74 changes: 68 additions & 6 deletions src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -41,6 +43,11 @@ type HostedRateLimitBucket = {
resetAt: number;
};

type ActiveTunnel = {
fetcher: Fetcher & Disposable;
ownerToken?: string;
};

/**
* A shard Durable Object owns many named tunnels.
*
Expand All @@ -50,7 +57,7 @@ type HostedRateLimitBucket = {
* aggregate throughput for lots of concurrent large responses.
*/
export class CaptunServerShard extends DurableObject<CaptunEnv> {
private readonly tunnels = new Map<string, Fetcher & Disposable>();
private readonly tunnels = new Map<string, ActiveTunnel>();

// 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
Expand All @@ -72,18 +79,32 @@ export class CaptunServerShard extends DurableObject<CaptunEnv> {
}
}

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<Response> {
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);
Expand Down Expand Up @@ -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<HostedRateLimitResult, { ok: false }>) {
return new Response(`Rate limit exceeded. Try again in ${result.retryAfterSeconds}s.\n`, {
status: 429,
Expand Down Expand Up @@ -536,13 +590,15 @@ const WWW_BROWSER_MODULE = `import { newWebSocketRpcSession, RpcTarget } from "h

export async function createCaptunTunnel(options) {
const tunnelName = options.name || randomTunnelName();
const ownerToken = options.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);
return {
url: publicUrl,
ownerToken,
close: () => disposeSession(session),
};
}
Expand Down Expand Up @@ -582,6 +638,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]();
Expand Down
29 changes: 29 additions & 0 deletions tasks/complete/2026-05-24-hosted-ownership-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
status: complete
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.

## Checklist

- [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, 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

- 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: 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.
4 changes: 2 additions & 2 deletions tasks/hosted-captun-sh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions tasks/hosted-rate-limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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._
Expand Down
Loading
Loading