Out-of-tree port of JSS src/tunnel/index.js ("decentralized ngrok",
AGPL-3.0-only): a tunnel client connects over WebSocket, registers a name,
and the pod proxies public HTTP traffic to it.
plugins: [{ module: 'tunnel/plugin.js', prefix: '/tunnel',
config: { requestTimeout: 30000, maxMessageSize: 10485760 } }]- Control socket:
ws://host/tunnel/connect— authenticated (api.auth.getAgent: Bearer, DPoP, NIP-98, …;?token=fallback for browser clients, #528). - Public URL:
http://host/tunnel/{name}/path.
→ { type: "register", name: "myapp", passthrough?: true }
← { type: "registered", name: "myapp", url: "/tunnel/myapp/", passthrough: bool }
← { type: "request", id, method, path, headers, body?, bodyEncoding? }
→ { type: "response", id, status, headers, body, bodyEncoding? }
← { type: "error", message }
Full #530 credential model ported: by default Cookie/Authorization
are stripped inbound and Set-Cookie outbound; a registration may opt in
with passthrough: true (strict boolean), which forwards them — except
the relay's own IdP _session/_interaction cookies and
Proxy-Authorization, which never cross. 10MB message cap (#567), 30s
response timeout, name takeover only by the same agent, 308 redirect for
/tunnel/{name} → /tunnel/{name}/.
- Single mount prefix. Core owns two path roots: the control socket
at
/.tunneland traffic at/tunnel/{name}/…. The loader WAC-exempts exactly ONE prefix per plugin, so both live under the mount: control at{prefix}/connect, traffic at{prefix}/{name}/…— which makesconnecta reserved tunnel name (registration rejects it). - Raw bodies, always base64. The proxy routes sit in a scoped
pass-through content-type parser (see Findings / JSS #583), so
request.bodyis the untouched Buffer and every forwarded body isbodyEncoding: "base64". Core re-serializes parsed JSON bodies (minifying/reordering them); this port is byte-exact — within protocol, since clients must already handle both encodings. requestTimeout/maxMessageSizeare config knobs (core hardcodes 30s / 10MB).- Auth frames are queued, not dropped. Core
awaits verification before wiring the message handler, so aregistersent during a slow credential check (e.g. first did:nostr resolution) is lost. Here every message awaits the shared auth promise — ordering preserved, nothing dropped. - Agents are keyed on
api.auth.getAgent's id string (WebID ordid:nostr:DID) rather than core'swebId— same semantics, wider domain.
- The one-prefix rule forced the only real protocol change. A plugin
cannot own both
/.tunneland/tunnel/; folding the control socket into the mount ({prefix}/connect) cost a reserved name. Fine here, but any port whose core feature spans multiple path roots will hit this seam — plugins may wantprefix: [a, b]or anapi.appPaths.add(path)escape hatch. - Fastify body parsing vs. raw proxying (#583) — the known pattern
works.
api.fastifyis a real scope:register(async (scope) => { scope.removeAllContentTypeParsers(); scope.addContentTypeParser('*', { parseAs: 'buffer' }, …) })confines raw-body handling to the proxy routes without touching the host's parsers. Ergonomic enough, but every proxying plugin will re-discover it; anapi-level "give me raw bodies under this route" helper would close the gap. api.ws.routeneeded no wildcard. Core's tunnel already multiplexes all traffic over one control socket, so a single exact ws path sufficed; wildcard ws routes were never needed. (They'd be needed by a port whose clients connect to per-resource ws paths — the notifications port is the one that will test that.)- No way to pass ws server options. The loader registers
@fastify/websocketitself with defaults, so a plugin can't setmaxPayload; like core, the 10MB cap (#567) is enforced app-level after the frame is already in memory. Aws.route(path, handler, { maxPayload })passthrough would let the transport reject oversized frames early. api.auth.getAgentcovered the gate 1:1 — including the?token=browser fallback (the plugin just copies the query param into the Authorization header before calling it). No internal imports needed anywhere in this port; the only import in plugin.js isnode:crypto.- Testing an auth-gated plugin without an IdP: NIP-98 is the one
credential a test can self-mint (schnorr event over the control URL,
~20 lines with
@noble/curves); the agent falls back todid:nostr:<pubkey>. First verification may consult the external did:nostr resolver (timeout-bounded, then cached per pubkey) — the test budgets 15s for the first ack. A documented "test credential" seam in the host would make plugin test suites hermetic.
node --test --test-concurrency=1 tunnel/test.js10 tests: auth rejection, end-to-end GET through a real local target (headers both ways, query string), byte-exact POST round-trip, #530 default-strip + passthrough (incl. relay-cookie filtering), reserved and invalid names, 502 unknown tunnel, 308 trailing-slash redirect, in-flight disconnect → 502 (and 502 after), unanswered request → 504.