Files
platform/src/servers/sidecar/create-proxy.ts
T
pastilhasandClaude Opus 5 236572107b declaring a long timeout was giving a shorter one
Bun stores a per-request timeout in ONE BYTE. anything above 255 seconds
clamps to 255 — so a plugin declaring a generous 1800 got a SHORTER
timeout than one declaring nothing at all, which falls through to 0 and is
unlimited. the line meant to fix this is what armed it.

that is what killed every long transcription. 1800 became 255, the upload
took a second, the connection dropped at 4m16s, and whisperx went on and
finished into a dead socket. every time, all night, while three separate
fixes above it were already live and correct.

six plugins declare more than 255 and all six were getting 255:
transcribe, email and music at 1800, jellyfin at 3600, minesweeper and
photos at 600. fixed here rather than by deleting the declaration from one
router, because the other five have the same bomb.

anything Bun cannot represent becomes 0. a plugin asking for longer than
Bun's maximum is asking for "do not cut this off", and 0 is the only value
that means it.

found by the owner, after i had already looked at this file twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 22:10:41 +00:00

312 lines
17 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 { getEffectivePermissions } from '../permissions/authorize';
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 — or omitted, which now means NO timeout and is the default.
*
* The main server drops an ordinary connection after 60s with no bytes flowing, which is wrong for
* every sidecar route that thinks before it answers. A from-scratch music reindex holds the socket for
* minutes; transcribing a 42-minute recording holds it for longer than that.
*
* The owner's call on 2026-08-24, after a transcription that SUCCEEDED was thrown away by a proxy that
* gave up on it: there should be no timeouts on these requests at all, because long ones will keep
* arriving from everywhere and every ceiling is a number somebody guessed. A plugin may still name one
* if it genuinely wants a bound; nothing does by default.
*/
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;
/**
* The permission key gating this sidecar, when one does. Adds `X-Officer-Owner` and `X-Officer-Level`
* to what is forwarded, so the sidecar can make the finer decisions the platform cannot make for it —
* whose rows a read returns, which fields a level may see.
*
* The platform still decides IF the request arrives: the gate has already allowed it, and an owner-only
* path declared in the manifest was refused before reaching here. These headers are for what the plugin
* does with a request that is legitimately its own.
*/
permissionKey?: string;
};
/**
* 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 secret this sidecar announced at registration, for a producer INSIDE the platform calling it
* directly rather than through this proxy. Null until it registers.
*
* In memory on both sides and never written down — see `sidecar/notify/index.ts` for why a file would
* not have bounded the thing this exists to bound.
*/
getProducerSecret: () => string | null;
/** The same server as a `ws://` base, for sidecars whose sockets the platform relays. */
getWsUrl: () => string | null;
};
/**
* Request timeouts on sidecar proxies. Flip this line to turn them back on.
*
* `false` — no ceiling on either leg. Long requests arrive from everywhere (transcribing a 42-minute
* recording, a from-scratch music reindex) and every ceiling is a number somebody guessed.
*
* `true` — Bun's built-in fetch ceiling outbound, and the inbound drop at whatever each plugin declared.
*
* One constant in one file, so turning it on or off does not mean remembering which of eight routers to
* edit.
*/
const TIMEOUTS_ON = false;
export function createSidecarProxy({
name,
prefix,
onRegister,
timeoutSeconds,
bufferRequestBody,
permissionKey,
}: SidecarProxyParams): SidecarProxy {
let serverPort: number | null = null;
let producerSecret: string | 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, secret } = msg as { port?: number; secret?: string };
if (typeof port !== 'number') return;
serverPort = port;
// Optional: only a sidecar that guards a producer path announces one, and one that stops announcing
// (an older build after a rollback) must not leave a stale secret behind that no longer opens it.
producerSecret = typeof secret === 'string' && secret ? secret : null;
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}`;
/*
The INBOUND leg: how long Bun holds the caller's connection.
Applied to every proxied request now, not only ones that declared a number — `0` disables the idle
timeout, and no timeout is the default for anything going through a sidecar. Bun passes the server
as Hono's env; an older Bun without per-request timeouts still completes the request, it just risks
the 60s default drop.
*/
const server = ctx.env as { timeout?: (req: Request, seconds: number) => void } | undefined;
// `0` disables the idle timeout. With the switch on, an undeclared plugin falls back to the server's
// own 60s default by not being touched at all.
/*
Bun stores a per-request timeout in ONE BYTE. Anything above 255 seconds is clamped to 255 — so a
plugin declaring a generous 1800 got a SHORTER timeout than a plugin declaring nothing at all, which
falls through to 0 and is unlimited. Declaring the fix is what armed the bomb.
That is what killed every long transcription: 1800 became 255, the upload took a second, and the
connection dropped at 4m16s while whisperx went on and finished the job into a dead socket. Six
plugins declare more than 255 — transcribe and email at 1800, music at 1800, jellyfin at 3600,
minesweeper and photos at 600 — and all six were getting 255.
So anything Bun cannot represent becomes 0: a plugin asking for longer than Bun's maximum is asking
for "do not cut this off", and 0 is the only value that means it.
*/
const requested = TIMEOUTS_ON ? timeoutSeconds : (timeoutSeconds ?? 0);
const inbound = requested !== undefined && requested > 255 ? 0 : requested;
if (inbound !== undefined) {
try {
server?.timeout?.(ctx.req.raw, inbound);
} 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;
}
// ── Who is asking ──
//
// The sidecar binds loopback only and trusts these, which is exactly why nothing but this proxy may
// set them. Note the loop above: it copies a THREE-ITEM ALLOWLIST off the inbound request, so a
// client-supplied `X-Officer-Owner: 1` cannot reach a sidecar. If that is ever changed to a
// copy-everything-except, it becomes privilege escalation against every plugin at once.
//
// Facts, never the role name. A plugin branching on `Super Admin` would give every plugin repository
// an opinion about the platform's role vocabulary, and renaming a role would silently change
// authorization in code the platform does not own and cannot see. `isOwner` is user id 1, pinned by a
// CHECK constraint; it will mean the same thing in a year. A plugin wanting an "Admins too" tier
// declares a second permission and lets the owner grant it, which keeps that answer on the
// permissions page instead of compiled into an `if`.
const userId = ctx.get('user').id;
headers['X-Officer-User'] = String(userId);
// Proof that this request came THROUGH the proxy, for a sidecar that guards a path with it. Without
// it, `X-Officer-User` says who is asking but nothing says the platform is the one asking — and on a
// machine where an agent has a shell, anything on loopback can write that header itself.
if (producerSecret) headers['X-Officer-Producer'] = producerSecret;
if (permissionKey) {
// Cached by role in `authorize.ts`, and the gate has already computed this for the same request, so
// this is a map lookup rather than a query.
const { isOwner, grants } = await getEffectivePermissions(userId);
headers['X-Officer-Owner'] = isOwner ? '1' : '0';
// The owner holds every permission implicitly and appears in no grants map — reporting their level
// as the absent 'none' would invert the meaning for the one account that can do everything.
headers['X-Officer-Level'] = isOwner ? 'write' : (grants.get(permissionKey) ?? 'none');
}
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;
/*
`timeoutSeconds` has to govern BOTH hops, and it only governed one.
Above, it extends how long Bun holds the INBOUND connection open. This is the outbound leg, and it
had nothing — so it took Bun's built-in fetch ceiling of about 300s regardless of what a plugin
declared. transcribe declares 1800 for exactly this case and still gave up on a 42-minute recording
at four minutes, while the sidecar went on and finished it. The work was done and thrown away.
`timeout: false`, unconditionally. An AbortSignal is specifically NOT wanted: it cannot raise Bun's
ceiling anyway (only lower it — the trap `plugins/transcribe/sidecar/transcribe.ts` documents from
hitting it first), and it aborts the response BODY as well as the request. This proxy carries video,
and jellyfin declares 3600, so a signal would have cut a film at exactly one hour of playback. A
streaming response has its headers in milliseconds and then runs for as long as somebody watches.
*/
type BunRequestInit = RequestInit & { duplex?: 'half'; timeout?: boolean | number };
const init: BunRequestInit = {
method,
headers,
body,
duplex: 'half',
...(TIMEOUTS_ON ? {} : { timeout: false }),
};
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) });
/*
A timeout is not "unreachable", and saying so sent an hour of debugging at the wrong machine: the
sidecar had answered 200 and finished the job. One means the sidecar is not there; the other means
it is there and slower than the plugin said to wait. 504 rather than 502, and the number named, so
the next person reads "this took longer than 1800s" instead of "the thing I can see running is
not running".
*/
const timedOut = err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError');
if (timedOut) return ctx.text(`${name} sidecar did not answer in time`, 504);
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, getProducerSecret: () => producerSecret };
}