From 13b7f56b0f5643f815a356f1b62ffa694dcade99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 31 Jul 2026 07:28:24 +0000 Subject: [PATCH] add a factory for sidecar proxy routers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit eight sidecars hand-rolled the same port capture — byte-identical once the app name is normalised — and six repeated the same auth-and-forward router. createSidecarProxy collapses both into one call and covers the variants the others need: a ws:// url for music and vault, an onRegister hook for opencode. the wallet adopts it first: two files become one, 54 lines of router become 16, and hono no longer needs a side-effect import to capture the port. the no-body-parsing, no-body-logging rule moves into the factory with its rationale, since that restraint is what keeps unlock passphrases and macaroons out of the platform process. costs 31 net lines today and pays back from the second adopter on. Co-Authored-By: Claude Opus 5 --- src/servers/api/wallet/router.ts | 60 +++------------- src/servers/api/wallet/sidecar-server.ts | 20 ------ src/servers/hono.ts | 1 - src/servers/sidecar/create-proxy.ts | 92 ++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 70 deletions(-) delete mode 100644 src/servers/api/wallet/sidecar-server.ts create mode 100644 src/servers/sidecar/create-proxy.ts diff --git a/src/servers/api/wallet/router.ts b/src/servers/api/wallet/router.ts index fec03aee..17f6486d 100644 --- a/src/servers/api/wallet/router.ts +++ b/src/servers/api/wallet/router.ts @@ -1,54 +1,16 @@ -import { createRouter } from '../../create-router'; -import { getWalletServerUrl } from './sidecar-server'; +import { createSidecarProxy } from '../../sidecar/create-proxy'; -// Thin reverse-proxy for /api/wallet/*. The platform's ONLY job here is AUTH + FORWARDING: this router -// mounts under the protected /api tree (userMiddleware upstream authenticates the owner), then forwards -// the subpath + query + body to the officer-wallet sidecar, which owns every wallet contract and holds -// the key material. +// /api/wallet/* — auth, then forward to officer-wallet. No routes of its own, and no wallet knowledge +// whatsoever: not a seed, not a node credential, not an xpub. The platform cannot spend and cannot read a +// balance except by asking the sidecar. // -// A catch-all with no routes of its own. This file must never grow wallet logic — and for this sidecar -// that rule carries more weight than usual. The platform process is long-lived, restarts on every -// deploy, and is the largest attack surface in the system. Keeping it structurally incapable of seeing a -// seed, a macaroon, or an unlock passphrase is the entire design. -// -// The unlock passphrase DOES transit this proxy on its way to the sidecar. That is unavoidable — the -// browser has to send it somewhere — but it is forwarded as an opaque body and never logged, never -// parsed, and never retained here. Note the deliberate absence of any body inspection below. +// The unlock passphrase DOES transit this proxy — the browser has to send it somewhere — but it is +// forwarded as an opaque body and never parsed, logged or retained. See sidecar/create-proxy.ts, which +// deliberately performs no body inspection; that restraint matters more for this sidecar than any other. -export const walletRouter = createRouter(); +const proxy = createSidecarProxy({ name: 'wallet', prefix: '/api/wallet' }); -const PREFIX = '/api/wallet'; +export const walletRouter = proxy.router; -walletRouter.all('/*', async (ctx) => { - const baseUrl = getWalletServerUrl(); - if (!baseUrl) return ctx.text('wallet sidecar not available', 503); - - const url = new URL(ctx.req.url); - const subpath = url.pathname.slice(PREFIX.length) || '/'; - const target = `${baseUrl}${subpath}${url.search}`; - - const method = ctx.req.method; - const headers: Record = {}; - const contentType = ctx.req.header('content-type'); - if (contentType) headers['Content-Type'] = contentType; - // Forward the authenticated user id so the sidecar can scope every wallet to its owner. The sidecar - // binds loopback only, so this header is trusted. - headers['X-Officer-User'] = String(ctx.get('user').id); - - const hasBody = method !== 'GET' && method !== 'HEAD'; - - let upstream: Response; - try { - upstream = await fetch(target, { - method, - headers, - body: hasBody ? await ctx.req.arrayBuffer() : undefined, - }); - } catch (err) { - // Deliberately logs the target path only — never the body, which may carry a passphrase. - console.error('[wallet] proxy fetch failed', { target, error: String(err) }); - return ctx.text('wallet sidecar unreachable', 502); - } - - return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) }); -}); +/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ +export const getWalletServerUrl = proxy.getHttpUrl; diff --git a/src/servers/api/wallet/sidecar-server.ts b/src/servers/api/wallet/sidecar-server.ts deleted file mode 100644 index d8b9e2ea..00000000 --- a/src/servers/api/wallet/sidecar-server.ts +++ /dev/null @@ -1,20 +0,0 @@ -import * as sidecar from '@@/sidecar-registry'; - -// The officer-wallet sidecar starts its HTTP server on a random loopback port and reports it here on -// connect. We remember it so `/api/wallet/*` always forwards to the current sidecar. The platform holds -// NO wallet knowledge whatsoever — not a seed, not a node credential, not an xpub. It cannot spend, and -// it cannot read a balance except by asking the sidecar. - -let serverPort: number | null = null; - -sidecar.on('wallet:server', (msg) => { - const port = (msg as { port?: number }).port; - if (typeof port !== 'number') return; - serverPort = port; - console.log(`[wallet] sidecar registered on port ${port}`); -}); - -/** Base URL of the sidecar's HTTP server, or null if the sidecar hasn't reported in yet. */ -export function getWalletServerUrl(): string | null { - return serverPort ? `http://127.0.0.1:${serverPort}` : null; -} diff --git a/src/servers/hono.ts b/src/servers/hono.ts index db08c10b..8cfb9f25 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -35,7 +35,6 @@ import './api/slskd/sidecar-server'; // side-effect: capture the officer-slskd r import './api/headscale/sidecar-server'; // side-effect: capture the officer-headscale server port import './api/transmission/sidecar-server'; // side-effect: capture the officer-transmission server port import './api/invoiceshelf/sidecar-server'; // side-effect: capture the officer-invoiceshelf server port -import './api/wallet/sidecar-server'; // side-effect: capture the officer-wallet server port import { devServerRouter, devServerProxyRouter } from './api/dev-server/router'; import { dockRouter } from './api/dock/dock'; import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations'; diff --git a/src/servers/sidecar/create-proxy.ts b/src/servers/sidecar/create-proxy.ts new file mode 100644 index 00000000..fabb2c24 --- /dev/null +++ b/src/servers/sidecar/create-proxy.ts @@ -0,0 +1,92 @@ +import type { Hono } from 'hono'; +import type { HonoVariables } from '../create-router'; +import { createRouter } from '../create-router'; +import * as sidecar from '../sidecar-registry'; + +// The shared auth-and-forward proxy every HTTP sidecar needs. +// +// A sidecar binds an EPHEMERAL loopback port and announces it over the registration socket as a +// `:server` event. The platform remembers that port and forwards `/*` to it. Before this +// factory existed each sidecar hand-rolled the same two files — eight copies of the port capture, which +// were byte-identical once the app name was normalised away, and six copies of the forwarder. +// +// WHAT THE PLATFORM DELIBERATELY DOES NOT DO HERE. It authenticates the owner (userMiddleware upstream) +// and forwards. It does not parse, inspect, log or retain request bodies. That restraint is load-bearing: +// the platform process is long-lived, restarts on every deploy and is the largest attack surface in the +// system, while the bodies passing through carry wallet unlock passphrases, macaroons and vault secrets. +// Keeping this layer structurally incapable of seeing them is the point. Note the absence of any body +// inspection below, and that the error path logs the target path ONLY. +// +// Sidecars own their own contracts. This file must never grow per-app logic — if a sidecar needs +// something extra at registration time it gets `onRegister`, not a branch in here. + +export type SidecarProxyParams = { + /** Sidecar name, e.g. 'wallet'. The registration event is `:server` and logs are tagged `[name]`. */ + name: string; + /** Public mount path, e.g. '/api/wallet'. Stripped from the path before forwarding. */ + prefix: string; + /** Extra work when the sidecar reports in. Rare — most sidecars only need the port remembered. */ + onRegister?: (port: number) => void; +}; + +export type SidecarProxy = { + /** Catch-all router to mount at `prefix`. Has no routes of its own by design. */ + router: Hono<{ Variables: HonoVariables }>; + /** `http://127.0.0.1:`, or null before the sidecar has reported in. */ + getHttpUrl: () => string | null; + /** The same server as a `ws://` base, for sidecars whose sockets the platform relays. */ + getWsUrl: () => string | null; +}; + +export function createSidecarProxy({ name, prefix, onRegister }: SidecarProxyParams): SidecarProxy { + let serverPort: number | null = null; + + sidecar.on(`${name}:server`, (msg) => { + const port = (msg as { port?: number }).port; + if (typeof port !== 'number') return; + serverPort = port; + console.log(`[${name}] sidecar registered on port ${port}`); + onRegister?.(port); + }); + + const getHttpUrl = (): string | null => (serverPort ? `http://127.0.0.1:${serverPort}` : null); + const getWsUrl = (): string | null => (serverPort ? `ws://127.0.0.1:${serverPort}` : null); + + const router = createRouter(); + + router.all('/*', async (ctx) => { + const baseUrl = getHttpUrl(); + if (!baseUrl) return ctx.text(`${name} sidecar not available`, 503); + + const url = new URL(ctx.req.url); + const subpath = url.pathname.slice(prefix.length) || '/'; + const target = `${baseUrl}${subpath}${url.search}`; + + const method = ctx.req.method; + const headers: Record = {}; + const contentType = ctx.req.header('content-type'); + if (contentType) headers['Content-Type'] = contentType; + // The authenticated owner, so the sidecar can scope its data. The sidecar binds loopback only, so it + // trusts this header — which is exactly why nothing but this proxy may set it. + headers['X-Officer-User'] = String(ctx.get('user').id); + + const hasBody = method !== 'GET' && method !== 'HEAD'; + + let upstream: Response; + try { + upstream = await fetch(target, { + method, + headers, + body: hasBody ? await ctx.req.arrayBuffer() : undefined, + }); + } catch (err) { + // Target path only — never the body, which may carry a passphrase or a credential. + console.error(`[${name}] proxy fetch failed`, { target, error: String(err) }); + return ctx.text(`${name} sidecar unreachable`, 502); + } + + return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) }); + }); + + return { router, getHttpUrl, getWsUrl }; +}