import type { Hono } from 'hono'; import type { HonoVariables } from '../create-router'; import { createRouter } from '../create-router'; import * as sidecar from '../sidecar-registry'; import { registerProxiedPrefix } from './proxied-prefixes'; // 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: the two error paths log a path, a status and byte counts taken from HEADERS, and // nothing ever reads or buffers the streams themselves. // // 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; /** * Per-request idle timeout, seconds. The main server drops a proxied connection after 60s with no bytes * flowing, which is wrong for a sidecar route that thinks before it answers (a from-scratch music * reindex holds the socket open for minutes). Applied to the whole prefix: the proxy must not know which * of a sidecar's routes are slow. */ timeoutSeconds?: number; /** * Read the request body fully before forwarding, instead of streaming it. * * OFF by default and it must stay that way: streaming is what keeps wallet passphrases, macaroons and * vault secrets out of this process's heap, and what lets a multi-gigabyte upload through at all. * * Opt in only where a sidecar's upstream answers BEFORE the body finishes arriving. When that happens * the response is lost — the far side closes while we are still writing, and the real answer is * replaced by a bodyless 400. Photos hits this because Immich judges an asset from its first few KB * and rejects immediately; see `readBodyCapped` in sidecar/photos/routes.ts for the measurements. * * Bounded by `MAX_BUFFERED_BODY`: a body larger than that is streamed anyway, so enabling this can * never put a 4 GB video in the heap. Above the cap the race is accepted, because an upload that size * only loses its ERROR MESSAGE — one Immich accepts is read to the end and never races at all. */ bufferRequestBody?: boolean; }; /** * Ceiling on `bufferRequestBody`. Kept in step with the photos sidecar's own cap so the two hops make the * same call on the same request; officer buffering is what gives the sidecar a content-length to decide by. */ const MAX_BUFFERED_BODY = 512 * 1024 * 1024; 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, timeoutSeconds, bufferRequestBody, }: SidecarProxyParams): SidecarProxy { let serverPort: number | null = null; // Tells bodyParser to keep its hands off this prefix's request bodies. Load-bearing for uploads, not an // optimisation: see proxied-prefixes.ts. registerProxiedPrefix(prefix); 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}`; if (timeoutSeconds) { // Bun passes the server as Hono's env. Older Bun has no per-request timeout; the request still // completes, it just risks the default idle drop. const server = ctx.env as { timeout?: (req: Request, seconds: number) => void } | undefined; try { server?.timeout?.(ctx.req.raw, timeoutSeconds); } catch { /* no per-request timeout available */ } } const method = ctx.req.method; const headers: Record = {}; // Transport headers, forwarded for every sidecar because they are HTTP, not app knowledge: Range so a // sidecar can answer 206 for media seeking, and If-None-Match so its ETag revalidation can return a // cheap 304 instead of the whole body. for (const header of ['content-type', 'range', 'if-none-match'] as const) { const value = ctx.req.header(header); if (value) headers[header] = value; } // 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'; // Forwarded as a STREAM, not a buffer. Buffering held the whole body in memory here on top of the copy // the sidecar holds — two copies of a 4K video per upload — and bought nothing, since this layer never // looks at the bytes. `duplex: 'half'` is required by the fetch spec whenever the body is a stream; it // is absent from TypeScript's RequestInit, hence the widened type. // This is only safe because bodyParser skips proxied prefixes: a body consumed upstream would arrive // here as an already-locked stream. // Buffered only where the sidecar opted in (see `bufferRequestBody`), and only up to the cap: an // upstream that answers before the body lands loses its own response, and streaming is what exposes // that race. Anything larger streams as it always did — a 4 GB video must not land in this heap, and // does not need to, since a file the upstream ACCEPTS is read to the end and never races. const declaredLength = Number(ctx.req.header('content-length') ?? ''); const withinBufferCap = Number.isFinite(declaredLength) && declaredLength > 0 && declaredLength <= MAX_BUFFERED_BODY; const body = hasBody ? bufferRequestBody && withinBufferCap ? await ctx.req.raw.arrayBuffer() : ctx.req.raw.body : undefined; const init: RequestInit & { duplex?: 'half' } = { method, headers, body, duplex: 'half', }; let upstream: Response; try { upstream = await fetch(target, init); } 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); } // Every rejection that passes through gets one line. This is METADATA ONLY — status and two byte // counts read from headers, never the bytes themselves — so the no-bodies rule above still holds. // // It exists because of COMMS/UPLOAD_EMPTY_400.md: photo uploads over ~16 KB failed with a 400 carrying // an EMPTY body, and nothing anywhere logged it — not Immich, not the sidecar, not here. The platform // is the only hop that sees both sides, and it said nothing, so localising the fault took an evening of // curl bisection. An error with no body is the unactionable case: the client is told "no" and given // nothing to act on, and silence here makes it invisible to the server too. // // `requestBytes` is the field that would have cracked it: it makes a size threshold legible at a glance // instead of by bisection. The subpath is logged without its query string — the 502 path above logs the // full target, but this fires on ordinary 401s and 404s too, and query strings carry tokens. if (upstream.status >= 400) { const responseBytes = upstream.headers.get('content-length'); console.warn(`[${name}] proxy ${method} ${subpath} -> ${upstream.status}`, { requestBytes: ctx.req.header('content-length') ?? 'unknown', responseBytes: responseBytes ?? 'unknown', // Called out rather than left to be inferred from `responseBytes: '0'`, because this exact shape is // the one that is impossible to diagnose from the client. ...(responseBytes === '0' ? { emptyBody: true } : {}), }); } return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) }); }); return { router, getHttpUrl, getWsUrl }; }