add a factory for sidecar proxy routers
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,54 +1,16 @@
|
|||||||
import { createRouter } from '../../create-router';
|
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||||
import { getWalletServerUrl } from './sidecar-server';
|
|
||||||
|
|
||||||
// Thin reverse-proxy for /api/wallet/*. The platform's ONLY job here is AUTH + FORWARDING: this router
|
// /api/wallet/* — auth, then forward to officer-wallet. No routes of its own, and no wallet knowledge
|
||||||
// mounts under the protected /api tree (userMiddleware upstream authenticates the owner), then forwards
|
// whatsoever: not a seed, not a node credential, not an xpub. The platform cannot spend and cannot read a
|
||||||
// the subpath + query + body to the officer-wallet sidecar, which owns every wallet contract and holds
|
// balance except by asking the sidecar.
|
||||||
// the key material.
|
|
||||||
//
|
//
|
||||||
// A catch-all with no routes of its own. This file must never grow wallet logic — and for this sidecar
|
// The unlock passphrase DOES transit this proxy — the browser has to send it somewhere — but it is
|
||||||
// that rule carries more weight than usual. The platform process is long-lived, restarts on every
|
// forwarded as an opaque body and never parsed, logged or retained. See sidecar/create-proxy.ts, which
|
||||||
// deploy, and is the largest attack surface in the system. Keeping it structurally incapable of seeing a
|
// deliberately performs no body inspection; that restraint matters more for this sidecar than any other.
|
||||||
// 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.
|
|
||||||
|
|
||||||
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) => {
|
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||||
const baseUrl = getWalletServerUrl();
|
export const getWalletServerUrl = proxy.getHttpUrl;
|
||||||
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<string, string> = {};
|
|
||||||
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) });
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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/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/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/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 { devServerRouter, devServerProxyRouter } from './api/dev-server/router';
|
||||||
import { dockRouter } from './api/dock/dock';
|
import { dockRouter } from './api/dock/dock';
|
||||||
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
|
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
|
||||||
|
|||||||
@@ -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
|
||||||
|
// `<name>:server` event. The platform remembers that port and forwards `<prefix>/*` 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 `<name>: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:<port>`, 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<string, string> = {};
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user