put every http sidecar on the proxy factory
createSidecarProxy arrived with the wallet but nothing else moved onto it, so five sidecars still carried their own copy of the same two files: a sidecar-server.ts that remembered a port announced as `<name>:server`, and a router.ts that forwarded the subpath. Byte for byte identical once the app name was normalised away — which is exactly what the factory's own header said it existed to end. headscale, transmission, invoiceshelf, slskd and music are now wallet-shaped: create the proxy, export the router and the URL getter. 386 lines deleted against 163 added, and the five feature directories go from ~70 lines each to ~18. Two deviations were real and moved INTO the factory rather than being dropped, because both are HTTP concerns rather than app knowledge: - Range and If-None-Match are now forwarded for every sidecar. music needed both (seeking, and ETag revalidation returning a cheap 304 instead of a cover image) and slskd needed Range. Forwarding them everywhere costs nothing and removes the reason to hand-roll. - timeoutSeconds, used only by music at 1800. A from-scratch reindex holds the proxied connection open for minutes with no bytes flowing, which the 60s idle timeout would drop. It applies to the whole prefix — the proxy must not know which of a sidecar's routes are slow. The five side-effect imports in hono.ts are gone with them: the port listener now registers when createSidecarProxy runs inside the router this file already imports. Vault keeps its hand-rolled pair and its side-effect import — it is off-limits by standing instruction, and is the one sidecar this commit deliberately does not touch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import type { ServerWebSocket } from 'bun';
|
import type { ServerWebSocket } from 'bun';
|
||||||
import { getMusicServerWsUrl } from '../music/sidecar-server';
|
import { getMusicServerWsUrl } from '../music/router';
|
||||||
|
|
||||||
// Platform side of the two cliamp sockets. Both used to spawn processes here — the `cliamp` player and a
|
// Platform side of the two cliamp sockets. Both used to spawn processes here — the `cliamp` player and a
|
||||||
// `parec` capture — which put the whole local-audio pipeline inside the thin proxy. They now live in the
|
// `parec` capture — which put the whole local-audio pipeline inside the thin proxy. They now live in the
|
||||||
|
|||||||
@@ -1,50 +1,18 @@
|
|||||||
import { createRouter } from '../../create-router';
|
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||||
import { getHeadscaleServerUrl } from './sidecar-server';
|
|
||||||
|
|
||||||
// Thin reverse-proxy for /api/headscale/*. The platform's ONLY job here is AUTH + FORWARDING: this router
|
// /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge:
|
||||||
// mounts under the protected /api tree (userMiddleware upstream authenticates the owner), then forwards the
|
// this file must never grow app logic.
|
||||||
// subpath + query + body to the officer-headscale sidecar, which OWNS the Headscale contract and holds the
|
|
||||||
// admin API key.
|
|
||||||
//
|
//
|
||||||
// A catch-all with no routes of its own. Unlike /api/slskd this proxies nothing of the upstream's own
|
// The sidecar exposes only Officer-owned routes under `/_officer/` — Headscale's REST shape differs
|
||||||
// surface — the sidecar exposes only Officer-owned routes under /_officer/, because Headscale's REST shape
|
// across releases, and version handling belongs in the sidecar. It holds the admin API key; the platform
|
||||||
// differs across releases and version handling belongs in the sidecar, not the browser. The full contract
|
// does not know Headscale's URL.
|
||||||
// is documented at the top of src/servers/sidecar/headscale/index.ts. It is opaque from here: this file
|
|
||||||
// must never grow Headscale logic.
|
|
||||||
|
|
||||||
export const headscaleRouter = createRouter();
|
const proxy = createSidecarProxy({
|
||||||
|
name: 'headscale',
|
||||||
const PREFIX = '/api/headscale';
|
prefix: '/api/headscale',
|
||||||
|
|
||||||
headscaleRouter.all('/*', async (ctx) => {
|
|
||||||
const baseUrl = getHeadscaleServerUrl();
|
|
||||||
if (!baseUrl) return ctx.text('headscale 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 serve its Officer-owned routes. 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) {
|
|
||||||
console.error('[headscale] proxy fetch failed', { target, error: String(err) });
|
|
||||||
return ctx.text('headscale sidecar unreachable', 502);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const headscaleRouter = proxy.router;
|
||||||
|
|
||||||
|
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||||
|
export const getHeadscaleServerUrl = proxy.getHttpUrl;
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import * as sidecar from '@@/sidecar-registry';
|
|
||||||
|
|
||||||
// The officer-headscale sidecar starts its HTTP server on a random loopback port and reports it here on
|
|
||||||
// connect. We remember it so `/api/headscale/*` always forwards to the current sidecar. The platform holds
|
|
||||||
// NO knowledge of Headscale itself — not its URL, and emphatically not its admin API key.
|
|
||||||
|
|
||||||
let serverPort: number | null = null;
|
|
||||||
|
|
||||||
sidecar.on('headscale:server', (msg) => {
|
|
||||||
const port = (msg as { port?: number }).port;
|
|
||||||
if (typeof port !== 'number') return;
|
|
||||||
serverPort = port;
|
|
||||||
console.log(`[headscale] 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 getHeadscaleServerUrl(): string | null {
|
|
||||||
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
|
|
||||||
}
|
|
||||||
@@ -1,54 +1,16 @@
|
|||||||
import { createRouter } from '../../create-router';
|
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||||
import { getInvoiceshelfServerUrl } from './sidecar-server';
|
|
||||||
|
|
||||||
// Thin reverse-proxy for /api/invoiceshelf/*. The platform's ONLY job here is AUTH + FORWARDING: this router
|
// /api/invoiceshelf/* — auth, then forward to officer-invoiceshelf. No routes of its own and no invoiceshelf knowledge:
|
||||||
// mounts under the protected /api tree (userMiddleware upstream authenticates the owner), then forwards the
|
// this file must never grow app logic.
|
||||||
// subpath + query + body to the officer-invoiceshelf sidecar, which OWNS the InvoiceShelf contract and holds
|
|
||||||
// the API token.
|
|
||||||
//
|
//
|
||||||
// A catch-all with no routes of its own. The sidecar exposes only Officer-owned routes under /_officer/,
|
// The sidecar owns the InvoiceShelf contract and holds its credentials.
|
||||||
// against an allow-list of resources — the administrative half of InvoiceShelf's API is unreachable by
|
|
||||||
// design. The full contract is documented at the top of src/servers/sidecar/invoiceshelf/index.ts. It is
|
|
||||||
// opaque from here: this file must never grow InvoiceShelf logic.
|
|
||||||
//
|
|
||||||
// Note which headers are forwarded, and which are NOT. Origin and Referer are deliberately dropped: if either
|
|
||||||
// reaches InvoiceShelf, its statefulApi() middleware switches from token auth to session+CSRF and every
|
|
||||||
// request 419s. The browser sets them on same-origin XHR, so passing them through would break the sidecar in
|
|
||||||
// a way that looks like an auth bug.
|
|
||||||
|
|
||||||
export const invoiceshelfRouter = createRouter();
|
const proxy = createSidecarProxy({
|
||||||
|
name: 'invoiceshelf',
|
||||||
const PREFIX = '/api/invoiceshelf';
|
prefix: '/api/invoiceshelf',
|
||||||
|
|
||||||
invoiceshelfRouter.all('/*', async (ctx) => {
|
|
||||||
const baseUrl = getInvoiceshelfServerUrl();
|
|
||||||
if (!baseUrl) return ctx.text('invoiceshelf 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 serve its Officer-owned routes. 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) {
|
|
||||||
console.error('[invoiceshelf] proxy fetch failed', { target, error: String(err) });
|
|
||||||
return ctx.text('invoiceshelf sidecar unreachable', 502);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const invoiceshelfRouter = proxy.router;
|
||||||
|
|
||||||
|
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||||
|
export const getInvoiceshelfServerUrl = proxy.getHttpUrl;
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import * as sidecar from '@@/sidecar-registry';
|
|
||||||
|
|
||||||
// The officer-invoiceshelf sidecar starts its HTTP server on a random loopback port and reports it here on
|
|
||||||
// connect. We remember it so `/api/invoiceshelf/*` always forwards to the current sidecar. The platform holds
|
|
||||||
// NO knowledge of InvoiceShelf itself — not its URL, not its API token, and not which company it acts as.
|
|
||||||
|
|
||||||
let serverPort: number | null = null;
|
|
||||||
|
|
||||||
sidecar.on('invoiceshelf:server', (msg) => {
|
|
||||||
const port = (msg as { port?: number }).port;
|
|
||||||
if (typeof port !== 'number') return;
|
|
||||||
serverPort = port;
|
|
||||||
console.log(`[invoiceshelf] 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 getInvoiceshelfServerUrl(): string | null {
|
|
||||||
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
|
|
||||||
}
|
|
||||||
@@ -1,69 +1,22 @@
|
|||||||
import { createRouter } from '../../create-router';
|
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||||
import { getMusicServerUrl } from './sidecar-server';
|
|
||||||
|
|
||||||
// Thin reverse-proxy for /api/music/*. The platform's ONLY job here is AUTH + FORWARDING. userMiddleware
|
// /api/music/* — auth, then forward to officer-music. No routes of its own and no music knowledge:
|
||||||
// (upstream — this router mounts under the protected /api tree, so the media `?token=` path also works)
|
// this file must never grow app logic.
|
||||||
// authenticates; we forward the subpath + query + body + Range to the officer-music sidecar, which OWNS
|
|
||||||
// the entire /api/music/* contract: streaming, indexing, AND per-user state (favorites, now-playing,
|
|
||||||
// playlists) backed by Postgres. We inject the authenticated user id as `X-Officer-User` so the sidecar
|
|
||||||
// can serve that per-user state — the sidecar is loopback-only, so it trusts the header.
|
|
||||||
//
|
//
|
||||||
// This is a catch-all with no routes of its own: the full /api/music/* HTTP contract (paths, methods,
|
// The sidecar owns the entire /api/music/* contract: streaming, indexing, AND per-user state (favorites,
|
||||||
// SSE/response shapes) is documented at the top of the sidecar's fetch handler — src/servers/sidecar/music/index.ts.
|
// now-playing, playlists) backed by Postgres. The full HTTP contract is documented at the top of the
|
||||||
|
// sidecar's fetch handler — src/servers/sidecar/music/index.ts.
|
||||||
|
|
||||||
export const musicRouter = createRouter();
|
const proxy = createSidecarProxy({
|
||||||
|
name: 'music',
|
||||||
const PREFIX = '/api/music';
|
prefix: '/api/music',
|
||||||
|
timeoutSeconds: 1800,
|
||||||
musicRouter.all('/*', async (ctx) => {
|
|
||||||
const baseUrl = getMusicServerUrl();
|
|
||||||
if (!baseUrl) return ctx.text('Music sidecar not available', 503);
|
|
||||||
|
|
||||||
const url = new URL(ctx.req.url);
|
|
||||||
const subpath = url.pathname.slice(PREFIX.length) || '/';
|
|
||||||
const target = `${baseUrl}${subpath}${url.search}`;
|
|
||||||
|
|
||||||
// A from-scratch reindex holds this proxied connection open for minutes with no bytes flowing, which
|
|
||||||
// the main server's 60s idle timeout would drop. Extend every request under this prefix to 30 min (Bun
|
|
||||||
// passes the server as Hono's env) — the proxy must not know which of the sidecar's routes are slow, and
|
|
||||||
// the sidecar applies its own per-request extension anyway.
|
|
||||||
const server = ctx.env as { timeout?: (req: Request, seconds: number) => void } | undefined;
|
|
||||||
try {
|
|
||||||
server?.timeout?.(ctx.req.raw, 1800);
|
|
||||||
} catch {
|
|
||||||
/* older Bun / no per-request timeout — the build still completes in the background */
|
|
||||||
}
|
|
||||||
|
|
||||||
const method = ctx.req.method;
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
const range = ctx.req.header('range');
|
|
||||||
if (range) headers['Range'] = range;
|
|
||||||
const contentType = ctx.req.header('content-type');
|
|
||||||
if (contentType) headers['Content-Type'] = contentType;
|
|
||||||
// Forward conditional-request headers so the sidecar's ETag(=v) revalidation works: a `no-cache`
|
|
||||||
// artifact (cover/meta/…) gets a cheap 304 when unchanged, a fresh 200 when its version changed.
|
|
||||||
const inm = ctx.req.header('if-none-match');
|
|
||||||
if (inm) headers['If-None-Match'] = inm;
|
|
||||||
// Forward the authenticated user id so the sidecar can serve its per-user state routes (favorites /
|
|
||||||
// now-playing / playlists). The sidecar binds loopback only, so this header is trusted.
|
|
||||||
headers['X-Officer-User'] = String(ctx.get('user').id);
|
|
||||||
|
|
||||||
// Forward the request body for mutating methods (favorites/now-playing/playlist writes). Streaming +
|
|
||||||
// reindex are GET/bodyless POST, so this is a no-op there.
|
|
||||||
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) {
|
|
||||||
console.error('[music] proxy fetch failed', { target, error: String(err) });
|
|
||||||
return ctx.text('Music sidecar unreachable', 502);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass status + headers through and stream the body (206/Content-Range/X-Audio-Duration included).
|
|
||||||
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const musicRouter = proxy.router;
|
||||||
|
|
||||||
|
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||||
|
export const getMusicServerUrl = proxy.getHttpUrl;
|
||||||
|
|
||||||
|
/** The same server as a `ws://` base — the cliamp relay pipes the player socket to it. */
|
||||||
|
export const getMusicServerWsUrl = proxy.getWsUrl;
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import * as sidecar from '@@/sidecar-registry';
|
|
||||||
|
|
||||||
// The officer-music sidecar starts its audio-streaming HTTP server on a random port and reports it here
|
|
||||||
// on connect. We remember it so `/api/music/*` always proxies to the current server.
|
|
||||||
|
|
||||||
let serverPort: number | null = null;
|
|
||||||
|
|
||||||
sidecar.on('music:server', (msg) => {
|
|
||||||
const port = (msg as { port?: number }).port;
|
|
||||||
if (typeof port !== 'number') return;
|
|
||||||
serverPort = port;
|
|
||||||
console.log(`[music] sidecar audio server registered on port ${port}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Base URL of the sidecar's audio server, or null if the sidecar hasn't reported in yet. */
|
|
||||||
export function getMusicServerUrl(): string | null {
|
|
||||||
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Same server, ws:// scheme — for the cliamp sockets the platform relays (see api/cliamp/relay.ts). */
|
|
||||||
export function getMusicServerWsUrl(): string | null {
|
|
||||||
return serverPort ? `ws://127.0.0.1:${serverPort}` : null;
|
|
||||||
}
|
|
||||||
@@ -1,51 +1,18 @@
|
|||||||
import { createRouter } from '../../create-router';
|
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||||
import { getSlskdServerUrl } from './sidecar-server';
|
|
||||||
|
|
||||||
// Thin reverse-proxy for /api/slskd/*. The platform's ONLY job here is AUTH + FORWARDING: this router
|
// /api/slskd/* — auth, then forward to officer-slskd. No routes of its own and no slskd knowledge:
|
||||||
// mounts under the protected /api tree (userMiddleware upstream authenticates the owner), then forwards
|
// this file must never grow app logic.
|
||||||
// the subpath + query + body to the officer-slskd sidecar, which OWNS the slskd contract and injects the
|
|
||||||
// slskd API key. The platform holds no slskd credentials.
|
|
||||||
//
|
//
|
||||||
// This is a catch-all with no routes of its own: the full /api/slskd/* contract is documented at the top
|
// Covers both slskd's own API (/api/v0/searches, /api/v0/transfers, …) and the sidecar's Officer-owned
|
||||||
// of the sidecar's fetch handler (src/servers/sidecar/slskd/index.ts). That contract covers both slskd's
|
// routes (/_officer/*, features slskd has no concept of). Both are opaque from here; the sidecar injects
|
||||||
// own API (/api/v0/searches, /api/v0/transfers, …) and the sidecar's Officer-owned routes (/_officer/*,
|
// the slskd API key. The full contract is documented at the top of src/servers/sidecar/slskd/index.ts.
|
||||||
// features slskd has no concept of). Both are opaque from here — this file never grows Soulseek logic.
|
|
||||||
|
|
||||||
export const slskdRouter = createRouter();
|
const proxy = createSidecarProxy({
|
||||||
|
name: 'slskd',
|
||||||
const PREFIX = '/api/slskd';
|
prefix: '/api/slskd',
|
||||||
|
|
||||||
slskdRouter.all('/*', async (ctx) => {
|
|
||||||
const baseUrl = getSlskdServerUrl();
|
|
||||||
if (!baseUrl) return ctx.text('slskd 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;
|
|
||||||
const range = ctx.req.header('range');
|
|
||||||
if (range) headers['Range'] = range;
|
|
||||||
// Forward the authenticated user id so the sidecar can serve its own Officer-owned routes (/_officer/*,
|
|
||||||
// e.g. favourite peers) against Postgres. 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) {
|
|
||||||
console.error('[slskd] proxy fetch failed', { target, error: String(err) });
|
|
||||||
return ctx.text('slskd sidecar unreachable', 502);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const slskdRouter = proxy.router;
|
||||||
|
|
||||||
|
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||||
|
export const getSlskdServerUrl = proxy.getHttpUrl;
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import * as sidecar from '@@/sidecar-registry';
|
|
||||||
|
|
||||||
// The officer-slskd sidecar starts its slskd reverse-proxy on a random loopback port and reports it here
|
|
||||||
// on connect. We remember it so `/api/slskd/*` always forwards to the current sidecar. The platform holds
|
|
||||||
// NO knowledge of slskd itself (URL, API key) — only where the sidecar is listening.
|
|
||||||
|
|
||||||
let serverPort: number | null = null;
|
|
||||||
|
|
||||||
sidecar.on('slskd:server', (msg) => {
|
|
||||||
const port = (msg as { port?: number }).port;
|
|
||||||
if (typeof port !== 'number') return;
|
|
||||||
serverPort = port;
|
|
||||||
console.log(`[slskd] sidecar proxy registered on port ${port}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Base URL of the sidecar's HTTP proxy, or null if the sidecar hasn't reported in yet. */
|
|
||||||
export function getSlskdServerUrl(): string | null {
|
|
||||||
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
|
|
||||||
}
|
|
||||||
@@ -1,50 +1,17 @@
|
|||||||
import { createRouter } from '../../create-router';
|
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||||
import { getTransmissionServerUrl } from './sidecar-server';
|
|
||||||
|
|
||||||
// Thin reverse-proxy for /api/transmission/*. The platform's ONLY job here is AUTH + FORWARDING: this router
|
// /api/transmission/* — auth, then forward to officer-transmission. No routes of its own and no transmission knowledge:
|
||||||
// mounts under the protected /api tree (userMiddleware upstream authenticates the owner), then forwards the
|
// this file must never grow app logic.
|
||||||
// subpath + query + body to the officer-transmission sidecar, which OWNS the Transmission contract and holds
|
|
||||||
// the daemon credentials.
|
|
||||||
//
|
//
|
||||||
// A catch-all with no routes of its own. The sidecar exposes only Officer-owned routes under /_officer/,
|
// The sidecar owns the Transmission RPC contract, including its session-id handshake, and holds the
|
||||||
// because Transmission's RPC is a single POST endpoint guarded by a rotating CSRF token — proxying it raw
|
// credentials. The platform knows none of it.
|
||||||
// would push the handshake into the browser. The full contract is documented at the top of
|
|
||||||
// src/servers/sidecar/transmission/index.ts. It is opaque from here: this file must never grow Transmission
|
|
||||||
// logic.
|
|
||||||
|
|
||||||
export const transmissionRouter = createRouter();
|
const proxy = createSidecarProxy({
|
||||||
|
name: 'transmission',
|
||||||
const PREFIX = '/api/transmission';
|
prefix: '/api/transmission',
|
||||||
|
|
||||||
transmissionRouter.all('/*', async (ctx) => {
|
|
||||||
const baseUrl = getTransmissionServerUrl();
|
|
||||||
if (!baseUrl) return ctx.text('transmission 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 serve its Officer-owned routes. 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) {
|
|
||||||
console.error('[transmission] proxy fetch failed', { target, error: String(err) });
|
|
||||||
return ctx.text('transmission sidecar unreachable', 502);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const transmissionRouter = proxy.router;
|
||||||
|
|
||||||
|
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||||
|
export const getTransmissionServerUrl = proxy.getHttpUrl;
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import * as sidecar from '@@/sidecar-registry';
|
|
||||||
|
|
||||||
// The officer-transmission sidecar starts its HTTP server on a random loopback port and reports it here on
|
|
||||||
// connect. We remember it so `/api/transmission/*` always forwards to the current sidecar. The platform
|
|
||||||
// holds NO knowledge of Transmission itself — not its URL, and not its credentials.
|
|
||||||
|
|
||||||
let serverPort: number | null = null;
|
|
||||||
|
|
||||||
sidecar.on('transmission:server', (msg) => {
|
|
||||||
const port = (msg as { port?: number }).port;
|
|
||||||
if (typeof port !== 'number') return;
|
|
||||||
serverPort = port;
|
|
||||||
console.log(`[transmission] 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 getTransmissionServerUrl(): string | null {
|
|
||||||
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
|
|
||||||
}
|
|
||||||
+2
-5
@@ -30,12 +30,9 @@ import { vpnRouter } from './api/vpn/router';
|
|||||||
import { terminalRouter } from './api/terminal/sidecar-server';
|
import { terminalRouter } from './api/terminal/sidecar-server';
|
||||||
import { systemMonitorRouter } from './api/system-monitor/system-monitor';
|
import { systemMonitorRouter } from './api/system-monitor/system-monitor';
|
||||||
import { activityRouter } from './api/activity/router';
|
import { activityRouter } from './api/activity/router';
|
||||||
import './api/music/sidecar-server'; // side-effect: capture the officer-music audio server port
|
// Vault still hand-rolls its port capture, so it keeps a side-effect import; every other HTTP sidecar
|
||||||
|
// registers its listener when createSidecarProxy runs inside the router this file already imports.
|
||||||
import './api/vault/sidecar-server'; // side-effect: capture the officer-vault reverse-proxy port
|
import './api/vault/sidecar-server'; // side-effect: capture the officer-vault reverse-proxy port
|
||||||
import './api/slskd/sidecar-server'; // side-effect: capture the officer-slskd reverse-proxy 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/invoiceshelf/sidecar-server'; // side-effect: capture the officer-invoiceshelf server port
|
|
||||||
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';
|
||||||
import { queueRouter } from './api/queue/queue';
|
import { queueRouter } from './api/queue/queue';
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ export type SidecarProxyParams = {
|
|||||||
prefix: string;
|
prefix: string;
|
||||||
/** Extra work when the sidecar reports in. Rare — most sidecars only need the port remembered. */
|
/** Extra work when the sidecar reports in. Rare — most sidecars only need the port remembered. */
|
||||||
onRegister?: (port: number) => void;
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SidecarProxy = {
|
export type SidecarProxy = {
|
||||||
@@ -38,7 +45,7 @@ export type SidecarProxy = {
|
|||||||
getWsUrl: () => string | null;
|
getWsUrl: () => string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createSidecarProxy({ name, prefix, onRegister }: SidecarProxyParams): SidecarProxy {
|
export function createSidecarProxy({ name, prefix, onRegister, timeoutSeconds }: SidecarProxyParams): SidecarProxy {
|
||||||
let serverPort: number | null = null;
|
let serverPort: number | null = null;
|
||||||
|
|
||||||
sidecar.on(`${name}:server`, (msg) => {
|
sidecar.on(`${name}:server`, (msg) => {
|
||||||
@@ -62,10 +69,26 @@ export function createSidecarProxy({ name, prefix, onRegister }: SidecarProxyPar
|
|||||||
const subpath = url.pathname.slice(prefix.length) || '/';
|
const subpath = url.pathname.slice(prefix.length) || '/';
|
||||||
const target = `${baseUrl}${subpath}${url.search}`;
|
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 method = ctx.req.method;
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
const contentType = ctx.req.header('content-type');
|
// Transport headers, forwarded for every sidecar because they are HTTP, not app knowledge: Range so a
|
||||||
if (contentType) headers['Content-Type'] = contentType;
|
// 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
|
// 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.
|
// trusts this header — which is exactly why nothing but this proxy may set it.
|
||||||
headers['X-Officer-User'] = String(ctx.get('user').id);
|
headers['X-Officer-User'] = String(ctx.get('user').id);
|
||||||
|
|||||||
Reference in New Issue
Block a user