An upload over a few MB came back as a 400 with an empty body, no message anywhere, and nothing logged by Immich, the sidecar or officer. It was a race, not a size limit. Immich judges an asset from its first few KB and rejects immediately, then closes; both our hops were still writing the body; Node treats the leftover bytes as a protocol violation and replaces the application's answer with a bodyless `400 Bad Request` + `Connection: close`. The real message never reached the wire. Measured before the change: streamed lost the message 1/4 at 8 MB and 4/4 at 32 MB — probability rising with size, which is why small photos usually worked and a phone's video never did. Both hops needed it. Fixing only the sidecar took 32 MB from 4/4 failing to 2/4, because the platform proxy was losing it one hop up. Bounded at 512 MB, above which the body streams exactly as before. That ceiling is not a refusal and is deliberately not a 413: a file Immich ACCEPTS is read to the end and never races, so a 4 GB video is unaffected. All that is given up above the cap is the error message on a file that was going to be rejected anyway. A first attempt refused over-cap uploads outright and would have broken the working 4 GB case to improve diagnosis of the doomed one. `bufferRequestBody` is opt-in and off by default: the vault and wallet proxies must keep streaming so a passphrase or macaroon never lands in the platform's heap. Also adds the proxy error logging that made this findable at all — status and two byte counts from headers, never the bodies. `responseBytes: "unknown"` is what exposed the stripped response. Verified live at 32/256 MB (buffered) and 640 MB (streamed, passes through). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
195 lines
9.9 KiB
TypeScript
195 lines
9.9 KiB
TypeScript
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
|
|
// `<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: 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 `<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;
|
|
/**
|
|
* 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:<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,
|
|
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<string, string> = {};
|
|
// 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 };
|
|
}
|