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 }; +}