Skip to content

Add hosted captun.sh safety controls#20

Closed
mmkal wants to merge 17 commits into
hosted-captun-shfrom
mmkal/26/05/24/hosted-admission-module
Closed

Add hosted captun.sh safety controls#20
mmkal wants to merge 17 commits into
hosted-captun-shfrom
mmkal/26/05/24/hosted-admission-module

Conversation

@mmkal
Copy link
Copy Markdown
Contributor

@mmkal mmkal commented May 23, 2026

Summary

Adds the first hosted captun.sh safety layer on top of the initial public tunnel deployment.

This collapses the earlier stacked safety PRs into one review surface. It supersedes #17, #18, and #19.

Behavior

Hosted captun.sh now gets:

  • fixed-window rate limits for connect attempts by client IP
  • fixed-window rate limits for forwarded requests by client IP and tunnel name
  • fail-closed behavior if the hosted rate limiter Durable Object binding is missing, unless HOSTED_RATE_LIMIT_DISABLED=1 is explicitly set
  • active-session ownership tokens for anonymous hosted tunnels
  • same-owner reconnect/replace support via ownerToken
  • CLI retry loops that reuse one anonymous owner token per tunnel session
  • 409 Conflict instead of anonymous clients evicting each other from an active tunnel name
  • clearer library and CLI messages for the known hosted name-in-use conflict
  • read-only connect diagnostics, so rejection probes do not accept/replace tunnels, do not charge the connect rate-limit bucket a second time, and still surface fail-closed or recent rate-limit rejections
  • a direct-testable hosted admission module so auth/token/conflict policy is no longer embedded in the Durable Object lifecycle code

Self-hosted and secret-protected deployments keep the existing replacement/auth behavior.

User-Facing API

createCaptunTunnel now returns the anonymous hosted owner token and accepts it for intentional same-owner replacement:

const first = await createCaptunTunnel({
  name: "demo",
  fetch: () => new Response("first"),
});

const replacement = await createCaptunTunnel({
  name: "demo",
  ownerToken: first.ownerToken,
  fetch: () => new Response("replacement"),
});

A different active anonymous owner gets a conflict instead of evicting the current tunnel.

Runtime Configuration

Defaults:

  • HOSTED_RATE_LIMIT_WINDOW_SECONDS=60
  • HOSTED_CONNECTS_PER_IP_PER_WINDOW=30
  • HOSTED_REQUESTS_PER_IP_PER_WINDOW=600
  • HOSTED_REQUESTS_PER_TUNNEL_PER_WINDOW=1200

The hosted rate limiter is wired through a new HostedRateLimiter Durable Object binding and migration.

Follow-Up Left Out Intentionally

This is still intentionally lightweight. It does not add persistent reservations, auth/accounts, paid custom names, active tunnel caps, byte/response caps, observability dashboards, or Cloudflare-native edge throttles. Anonymous ownership is active-session only: once a tunnel disconnects, the name is free for another anonymous token to claim.

Verification

  • pnpm exec vitest run test/hosted-admission.test.ts test/worker.test.ts test/cli.test.ts --testNamePattern 'diagnostic|retries reuse|secret|rate limit|ownership|owner token'
  • pnpm run check
  • pnpm test
  • pnpm run build
  • CAPTUN_PUBLIC_E2E=1 pnpm vitest run test/public-hosted.test.ts was run on the earlier safety stack before the Bugbot-only fixes

Note

High Risk
Adds enforcement and throttling to the hosted tunnel connect/request path (new Durable Object rate limiter, ownership-token admission, and diagnostic probing), which can affect availability and connection behavior for production traffic. Also changes the public client API surface (ownerToken), so regressions could break existing consumers if assumptions differ.

Overview
Introduces hosted-only safety controls for captun.sh: anonymous tunnel connects now require an ownership token and reject conflicting active owners with 409 instead of evicting the current session, while self-hosted/secret-protected deployments keep prior replace/auth behavior.

Adds Durable Object–backed fixed-window rate limiting for hosted connect attempts (per IP) and forwarded requests (per IP and per tunnel), including fail-closed behavior when the limiter binding is missing (with an explicit HOSTED_RATE_LIMIT_DISABLED=1 escape hatch).

Improves client-side ergonomics and diagnostics: createCaptunTunnel now generates/returns/accepts ownerToken, performs a short read-only HTTP probe to surface WebSocket upgrade rejection details (CaptunTunnelConnectError), and the CLI reuses one token across retries and shows a clearer message for the known hosted name-in-use conflict.

Reviewed by Cursor Bugbot for commit 4377903. Bugbot is set up for automated code reviews on this repo. Configure here.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 23, 2026

Open in StackBlitz

npx https://pkg.pr.new/captun@20

commit: 4377903

@mmkal mmkal changed the title [bedtime-architecture] Extract hosted tunnel admission policy Add hosted captun.sh safety controls May 24, 2026
@mmkal mmkal changed the base branch from mmkal/26/05/24/hosted-connect-conflict-message to hosted-captun-sh May 24, 2026 06:36
Comment thread src/index.ts
Comment thread src/cli/bin.ts
Comment thread src/index.ts
Comment thread src/cli/bin.ts Outdated
Comment thread src/index.ts
Comment thread src/hosted-admission.ts
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4377903. Configure here.

Comment thread src/index.ts
() =>
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.

Comment thread src/worker.ts
windowSeconds: config.windowSeconds,
});
if (!result.ok) return hostedRateLimitedResponse(result);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Rate limit counts before rejection

Medium Severity

Hosted forwarded-request limiting runs separate HostedRateLimiter.check calls for the client IP and tunnel name in sequence. Each successful check increments its bucket before the next runs, so a request rejected on the tunnel limit still consumes IP quota, and vice versa, without the request being forwarded.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4377903. Configure here.

@mmkal
Copy link
Copy Markdown
Contributor Author

mmkal commented May 25, 2026

Superseded by the gateway-owned addressing refactor now pushed to #16. The hosted safety/rate-limit work should be rebuilt on top of the new gateway/token/connect-query protocol instead of carrying forward ownerToken/serverUrl-era API shape.

@mmkal mmkal closed this May 25, 2026
@mmkal
Copy link
Copy Markdown
Contributor Author

mmkal commented May 26, 2026

🤖 Recreated this on top of #16 as #22.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant