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
2 changes: 1 addition & 1 deletion .github/workflows/pkg-pr-new.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
37 changes: 34 additions & 3 deletions src/cli/bin.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -12,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 { createCaptunTunnel } from "../index.js";
import { CaptunTunnelConnectError, createCaptunTunnel, randomOwnershipToken } from "../index.js";
import { assertLocalTargetAcceptingConnections } from "./local-target.js";
import { withSpinner } from "./spinner.js";
import {
Expand Down Expand Up @@ -59,6 +60,7 @@ export type CaptunCliRouterOptions = {
writeConfig?: (config: Config) => Promise<void>;
waitForShutdown?: () => Promise<void>;
onTunnelReady?: (ready: TunnelReady) => void | Promise<void>;
tunnelRetries?: number;
};

const adjectives =
Expand Down Expand Up @@ -110,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,
});
Expand Down Expand Up @@ -505,6 +508,7 @@ async function connectTunnelWithRetry(
) {
const url = `${tunnel.tunnel}/__captun-connect`;
const headers = tunnel.secret ? { authorization: `Bearer ${tunnel.secret}` } : undefined;
const ownerToken = randomOwnershipToken();
const fetcher = makeTunnelFetcher(tunnel, advertisedUrl);

const maxAttempts = retries + 1;
Expand All @@ -515,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);
Expand Down Expand Up @@ -570,6 +576,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)) {
Comment thread
cursor[bot] marked this conversation as resolved.
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`.
Expand All @@ -588,6 +602,18 @@ function tunnelConnectError(tunnel: ResolvedTunnel, cause: unknown) {
return new CliFriendlyError(lines.join("\n"));
}

function isActiveTunnelConflict(cause: unknown) {
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);
}

function sleep(ms: number) {
return new Promise<void>((resolveSleep) => setTimeout(resolveSleep, ms));
}
Expand Down Expand Up @@ -648,5 +674,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);
}
}
74 changes: 74 additions & 0 deletions src/hosted-admission.ts
Original file line number Diff line number Diff line change
@@ -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 (ownerToken !== undefined && input.activeOwnerToken && input.activeOwnerToken !== ownerToken) {
return { ok: false, response: reject("Tunnel name is already connected\n", 409) };
}
Comment thread
cursor[bot] marked this conversation as resolved.

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;
}
125 changes: 120 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { newWebSocketRpcSession, RpcTarget } from "capnweb";
import { getTunnelUrlFromServerUrl, HOSTED_CAPTUN_SERVER_URL } from "./routing.js";
import {
getTunnelUrlFromServerUrl,
HOSTED_CAPTUN_SERVER_URL,
TUNNEL_CONNECT_DIAGNOSTIC_HEADER,
TUNNEL_OWNER_TOKEN_HEADER,
TUNNEL_OWNER_TOKEN_QUERY_PARAM,
} from "./routing.js";

/** Fetch is all you need!
*
Expand Down Expand Up @@ -28,26 +34,54 @@ export interface Fetcher {
*/
export type CaptunTunnel = Disposable & {
url: string;
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;
}
}

const WEBSOCKET_REJECTION_PROBE_TIMEOUT_MS = 500;

export async function createCaptunTunnel(
options: Fetcher & {
url?: string | URL;
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 });
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,
ownerToken: ownership.ownerToken,
[Symbol.dispose]: () => session[Symbol.dispose](),
};
}
Expand All @@ -71,15 +105,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("");
}

export 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 Expand Up @@ -109,7 +175,10 @@ function createWebSocket(options: { url: string | URL; headers?: Record<string,
);
}

async function waitUntilOpen(socket: WebSocket) {
async function waitUntilOpen(
socket: WebSocket,
options: { connectUrl: string; headers: Record<string, string> | undefined },
) {
if (socket.readyState === WebSocket.OPEN) return;
if (socket.readyState !== WebSocket.CONNECTING) {
throw new Error("WebSocket closed before opening");
Expand All @@ -124,7 +193,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);
}),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Close handler skips rejection probe

Medium Severity

In waitUntilOpen, the close listener rejects immediately with a generic close message, while the diagnostic HTTP probe that surfaces 409/rate-limit bodies runs only from the error listener. If close fires first (common when the upgrade handshake fails), CaptunTunnelConnectError and CLI active-name conflict hints are skipped.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4377903. Configure here.

{ signal: listeners.signal },
);
socket.addEventListener(
Expand All @@ -138,6 +210,49 @@ async function waitUntilOpen(socket: WebSocket) {
});
}

async function webSocketConnectionFailedError(options: {
connectUrl: string;
headers: Record<string, string> | 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<string, string> | undefined;
}) {
const abort = new AbortController();
const timeout = setTimeout(() => abort.abort(), WEBSOCKET_REJECTION_PROBE_TIMEOUT_MS);
try {
const response = await fetch(options.connectUrl, {
headers: diagnosticHeaders(options.headers),
signal: abort.signal,
});
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;
} finally {
clearTimeout(timeout);
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
}

function diagnosticHeaders(headers: Record<string, string> | undefined) {
const diagnostic = new Headers(headers);
diagnostic.set(TUNNEL_CONNECT_DIAGNOSTIC_HEADER, "1");
return diagnostic;
}

// ---------------------------------------------------------------------------
// Tunnel server (formerly src/server.ts)
// ---------------------------------------------------------------------------
Expand Down
7 changes: 7 additions & 0 deletions src/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ 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";

/** Reserved path used by tunnel clients to open the WebSocket; not a tunnel name. */
const CONNECT_PATH_SEGMENT = "__captun-connect";

Expand Down
Loading
Loading