Skip to content

Migrate captun to published 0.0.3#1439

Closed
mmkal wants to merge 5 commits into
mainfrom
captun-npm-migration
Closed

Migrate captun to published 0.0.3#1439
mmkal wants to merge 5 commits into
mainfrom
captun-npm-migration

Conversation

@mmkal

@mmkal mmkal commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

apps/os was pinned to captun@https://pkg.pr.new/captun@14 — a frozen pre-merge snapshot of iterate/captun#14 from May 22. That PR merged long ago and captun@0.0.3 is now on npm, so this migrates to the published API. Future captun features (reconnect-on-drop, WebSocket passthrough for the dev-tunnel plan in tasks/switch-dev-tunnels-to-captun.md) can now be diffed against a real release instead of a stale PR build.

API migration

The client lost its headers option because browser WebSockets can't set headers — auth moved into the token option:

// before (@14)
await createCaptunTunnel({
  url: `${ingressUrl}/__iterate/intercept-project-egress`,
  headers: { Authorization: `Bearer ${adminToken}` },
  fetch,
});

// after
await createCaptunTunnel({
  gateway: `${ingressUrl}/__iterate/intercept-project-egress`,
  token: adminToken,
  fetch,
});

In captun 0.0.3 that token travelled as a ?captun-token= query param — which would have put the iterate admin secret into Cloudflare request logs (caught by @jonastemplestein below). iterate/captun#26 fixes the transport upstream: the token now rides in the Sec-WebSocket-Protocol header as captun-token.<base64url>, and the gateway echoes the captun marker subprotocol on the 101 response (strict WebSocket clients abort the handshake otherwise). This PR pins pkg.pr.new/captun@26 until that merges and 0.0.4 ships.

Server side:

  • acceptCaptunTunnel()acceptFetcherCapability(), returning {response, fetcher} instead of {response, tunnel}. The project DO passes the upgrade request in so the 101 echoes the negotiated subprotocol.
  • CAPTUN_SECRET env key → CAPTUN_TOKEN (captun throws a descriptive error if you pass the old key).
  • The project DO's egress-intercept endpoint authenticates via captun's connectTokenFromRequest (subprotocol → probe header → legacy query param), falling back to the Authorization header for non-captun callers.
  • The DO now sends the ready({url}) RPC after accepting a tunnel — captun clients block on it with a 5s timeout. The captun gateway worker does this itself, but our custom DO accept path didn't, so without it every e2e egress-intercept connect would hang and fail.

Verification

  • apps/os typecheck, repo lint, and pnpm test:project-ingress (6/6, includes the 401 unauthenticated-connect case) all pass.
  • captun#26's own suite covers the real WebSocket handshake: subprotocol echo, wrong-token 401 diagnostics, query-param back-compat, and a URL-hygiene test asserting the token never appears in the connect URL.
  • Not verifiable locally: the e2e tunnel helpers need a deployed environment — CI e2e covers them.

Follow-up (own task)

We still use adminApiSecret as the tunnel-admission token. With the transport fixed, the remaining improvement is scoping: mint a dedicated tunnel secret so a leak grants "can connect tunnels", not "can administer iterate".

History note

This change was originally committed onto stream-tui-iterate-cli (1a0401f) and has been reverted there (22d53e2); this PR is the same work cherry-picked onto main so it can be reviewed and merged independently.

🤖 Generated with Claude Code


Note

Medium Risk
Changes admin-gated WebSocket tunnel admission and connect handshake for project egress intercept; regressions could break e2e egress capture or leak/block tunnel connects, though scope is limited to captun integration paths.

Overview
Moves apps/os off the stale pkg.pr.new/captun@14 pin to a newer captun build (f1778c8, toward 0.0.3/0.0.4) and updates every integration to the renamed client/server APIs.

Tunnel clients (e2e helpers and the egress-intercept benchmark) now call createCaptunTunnel with gateway / name / token instead of url plus Authorization headers. OS worker passes CAPTUN_TOKEN into the captun gateway worker (replacing CAPTUN_SECRET).

Project egress intercept on the project DO switches from acceptCaptunTunnel to acceptFetcherCapability with the upgrade request so the 101 echoes the negotiated WebSocket subprotocol. Auth accepts captun’s connect token via connectTokenFromRequest (e.g. Sec-WebSocket-Protocol) as well as Authorization. After accept, the DO calls tunnel.ready({ url }) so clients no longer hang on connect.

Also adds an oxlint no-single-use-helpers rule, reorders imports in several scripts/preview/* tests, and records the migration in a completed task note.

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

Environment Config Lease

Lease: preview-2
Doppler config: preview_2
Type: environment-config-lease
Leased until: 2026-06-10T17:54:46.236Z

OS

Status: deployed
Commit: 7790b42
Preview: https://os.iterate-preview-2.com
Workflow run
Updated: 2026-06-10T16:56:56.660Z

mmkal and others added 2 commits June 10, 2026 13:20
Replaces the pkg.pr.new/captun@14 pin with the published package and its
renamed API (acceptCaptunTunnel -> acceptFetcherCapability etc.).

(Changes made by Misha in the working tree; committed as found so the
branch can merge latest main.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jonastemplestein

Copy link
Copy Markdown
Contributor

Unless I am mistaken, this means that the magic string that allows anyone to do ANYTHING in iterate gets logged as a query parameter in cloudflare logs?

FWIW in our own capnweb client for itx and streams there are runtime-specific connect functions so that node and workers can send a header, whereas browser relies on

If you want a single consistent API with "captun key" concept but don't want secrets to be leaked in logs, you can also do what orpc's websocket transport (and many other such systems) do and shoehorn the secret into the Sec-WebSocket-Protocol header

@mmkal

mmkal commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Unless I am mistaken, this means that the magic string that allows anyone to do ANYTHING in iterate gets logged as a query parameter in cloudflare logs?

FWIW in our own capnweb client for itx and streams there are runtime-specific connect functions so that node and workers can send a header, whereas browser relies on

If you want a single consistent API with "captun key" concept but don't want secrets to be leaked in logs, you can also do what orpc's websocket transport (and many other such systems) do and shoehorn the secret into the Sec-WebSocket-Protocol header

Yes. This is another case of my "create as a draft" guidance for clankers being too weak. I just asked to get captun up to date and for whatever reason it decided the change is ready for review.

Anyway, yeah the sec-websocket-protocol header sounds like a good idea to me

@jonastemplestein

Copy link
Copy Markdown
Contributor

FWIW i don't think we'll need the captun server library in apps/os at all any more v shortly. This whole egress fetch interception malarky will be covered entirely by the itx system

vitest e2e test runner can just do something like this

using itx = withItx({projectId})
itx.caps.provide({
  name: "egressFetch",
  cap: async (req) => {
    // intercept egress fetch here
  }
})

(or something like that)

Adopts iterate/captun#26 (pinned via pkg.pr.new/captun@26): the admin
token now rides in the Sec-WebSocket-Protocol header instead of a
?captun-token= query param, keeping it out of Cloudflare request logs.
The project DO echoes the negotiated subprotocol on the 101 response and
reads the token via captun's connectTokenFromRequest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mmkal

mmkal commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

🤖 You were right — that was the iterate admin secret going into Cloudflare request logs as a query param. Fixed with your Sec-WebSocket-Protocol suggestion, implemented upstream in captun: iterate/captun#26.

  • The client now offers ["captun", "captun-token.<base64url(token)>"] as subprotocols, and the gateway echoes captun on the 101 (browsers/undici/ws abort the handshake if the server doesn't select an offered subprotocol — base64url because RFC 6455 limits subprotocol values to HTTP token chars).
  • The connect-rejection diagnostic probe (plain fetch, can't set Sec-\* headers in browsers) sends the token via an x-captun-connect-token header instead, so wrong-token failures still diagnose as 401 without the token re-entering a URL.
  • The legacy query param is still accepted server-side for old clients, but nothing in this repo sends it anymore — the project DO reads the token via captun's new connectTokenFromRequest and passes the request to acceptFetcherCapability so the 101 echoes correctly.
  • This PR now pins pkg.pr.new/captun@26 until that merges and 0.0.4 ships.

One thing I deliberately left for a follow-up: we're still using adminApiSecret as the tunnel-admission token. Even with clean transport, a scoped tunnel secret would shrink the blast radius from "anyone can do ANYTHING in iterate" to "someone can connect tunnels" — noted in the task file as its own task.

@mmkal

mmkal commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Verified against the deployed preview (os.iterate-preview-1.com): both secret-substitution e2e tests — egress intercept and public tunnel — pass with the pkg.pr.new/captun@26 subprotocol transport, twice in a row (~20s each, dominated by the known OS-worker cold-start latency). The very first run after the preview deploy failed once on the egress-intercept test while everything was cold, then passed on every subsequent run — consistent with the pre-existing cold-start issue in apps/os/tasks/project-egress-intercept-tunnel-latency.md, not the transport change. CI's preview e2e job only runs the smoke test, so these were run manually via doppler run --config preview_1 -- pnpm e2e run -t secret-substitution.

captun#26 now has no back-compat: gateways ignore ?captun-token= entirely.
Pinned by commit (pkg.pr.new/captun@f1778c8) so the lockfile integrity
stays stable while the captun PR evolves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mmkal added a commit to iterate/captun that referenced this pull request Jun 11, 2026
…26)

`createCaptunTunnel` currently puts the Connect Token in a
`?captun-token=` query param, which means it lands in Cloudflare request
logs, logpush, and anything else that prints `request.url`. Fine for
hosted captun.sh's random per-tunnel ownership tokens; bad for
self-hosted deployments whose Gateway Secret may be a high-value
credential — flagged by @jonastemplestein on iterate/iterate#1439, where
the token was an iterate admin secret.

WebSocket clients can't set arbitrary headers (browsers especially), but
they can offer subprotocols, so this moves the token into
`Sec-WebSocket-Protocol` — the same pattern used by the Kubernetes exec
API, graphql-ws, and orpc's WS transport:

```ts
// the client now connects with
new WebSocket(connectUrl, ["captun", `captun-token.${base64url(token)}`]);
// and the gateway echoes the marker subprotocol on the 101 response
// (mandatory — browsers/undici abort the handshake if no offered subprotocol is selected)
new Response(null, { status: 101, webSocket, headers: { "sec-websocket-protocol": "captun" } });
```

- Token resolution on the server is shared: subprotocol →
`x-captun-connect-token` header. The URL is never a token transport —
`?captun-token=` is ignored entirely, and even a correct token sent that
way is rejected.
- The connect-rejection diagnostic probe sends the token via the header,
so wrong-token failures still diagnose as 401 without putting the token
back in a URL.
- Embedders calling `acceptFetcherCapability` directly pass the request
so the 101 echoes correctly, and a new
`connectTokenFromRequest(request)` export reads the token wherever it
was sent.
- base64url encoding because RFC 6455 limits subprotocol values to HTTP
token characters — this also frees tokens from the previous URL-safe
charset restriction.

Compatibility: none, deliberately. New client ↔ old gateway breaks, and
old client ↔ new gateway breaks too (`CONNECT_TOKEN_QUERY_PARAM` is
deleted; query-param tokens are ignored, so token-protected gateways 401
and hosted anonymous connects 400). Acceptable at 0.0.x — hosted deploys
from this repo in lockstep, embedded deployments pin client+worker from
one package version.

First commit is the task file with the full design; implementation
follows on this branch.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes how gateway secrets are transmitted and validated at tunnel
admission; new clients require a matching gateway deploy, though legacy
query-param clients remain supported on upgraded gateways.
> 
> **Overview**
> **Connect Tokens** no longer ride on the connect URL.
`createCaptunTunnel` opens the WebSocket with `captun` and
`captun-token.<base64url(token)>` subprotocols; rejection diagnostics
send the token via `x-captun-connect-token` instead of the query string.
> 
> Gateways and embedders share **`connectTokenFromRequest`**
(subprotocol → header → legacy `captun-token` query).
**`acceptFetcherCapability({ request })`** echoes the `captun`
subprotocol on 101 when offered; **`connectProtocolFromRequest`**
supports runtimes like Deno. Self-hosted shards treat non-upgrade
connect requests as diagnostic probes so wrong-token failures surface as
401. Hosted ownership tokens accept broader printable-ASCII charsets.
Examples and generated browser bundle follow the new handshake; six
tests cover subprotocol auth, URL hygiene, back-compat, and parsing.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
f396481. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
@mmkal

mmkal commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Closing: the itx grand cleanup (#1485, #1490) deleted the project egress-intercept tunnel this PR spent most of its diff migrating, so it's now conflicting and half-moot. The token-transport discussion above was resolved upstream in iterate/captun#26 (merged — Connect Tokens ride Sec-WebSocket-Protocol, query params rejected outright). What remains for the iterate side is a much smaller change off current main: bump captun from the frozen pkg.pr.new/captun@14 pin to the next npm release, migrate the surviving createPublicTunnel e2e helper to the gateway/token API, and rename CAPTUN_SECRETCAPTUN_TOKEN in the worker embed. Note main never had the token-in-URL leak — the 0.0.3 migration never merged, and the surviving @14 usage authenticates via an Authorization header.

@mmkal mmkal closed this Jun 11, 2026
@mmkal mmkal deleted the captun-npm-migration branch June 11, 2026 13:33
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.

2 participants