From c023082975fea7ecb6650a791c5411527edfbc4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 29 Jul 2026 15:49:09 +0000 Subject: [PATCH] slskd: scaffold reverse-proxy sidecar Add officer-slskd, a singleton sidecar that reverse-proxies to a self-hosted slskd (Soulseek) instance and reports its loopback port to the API on connect. All slskd knowledge (URL + API key) lives in the sidecar; the platform is a thin auth+forward proxy for /api/slskd/* and holds no slskd credentials. Mirrors the officer-vault pattern. Co-Authored-By: Claude Opus 4.8 --- ecosystem.config.cjs | 6 + src/servers/api/slskd/router.ts | 47 ++++++++ src/servers/api/slskd/sidecar-server.ts | 19 ++++ src/servers/hono.ts | 3 + src/servers/sidecar/protocol.ts | 2 + src/servers/sidecar/slskd/index.ts | 141 ++++++++++++++++++++++++ src/servers/sidecar/slskd/upstream.ts | 53 +++++++++ 7 files changed, 271 insertions(+) create mode 100644 src/servers/api/slskd/router.ts create mode 100644 src/servers/api/slskd/sidecar-server.ts create mode 100644 src/servers/sidecar/slskd/index.ts create mode 100644 src/servers/sidecar/slskd/upstream.ts diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 03788cce..15c25a28 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -48,5 +48,11 @@ module.exports = { args: 'run src/servers/sidecar/vault/index.ts', watch: false, }, + { + name: 'officer-slskd', + script: 'bun', + args: 'run src/servers/sidecar/slskd/index.ts', + watch: false, + }, ], }; diff --git a/src/servers/api/slskd/router.ts b/src/servers/api/slskd/router.ts new file mode 100644 index 00000000..7bbcdf6d --- /dev/null +++ b/src/servers/api/slskd/router.ts @@ -0,0 +1,47 @@ +import { createRouter } from '../../create-router'; +import { getSlskdServerUrl } from './sidecar-server'; + +// Thin reverse-proxy for /api/slskd/*. The platform's ONLY job here is AUTH + FORWARDING: this router +// mounts under the protected /api tree (userMiddleware upstream authenticates the owner), then forwards +// 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 (slskd's own API, e.g. +// /api/v0/searches, /api/v0/transfers) is documented at the top of the sidecar's fetch handler — +// src/servers/sidecar/slskd/index.ts. + +export const slskdRouter = createRouter(); + +const 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 = {}; + const contentType = ctx.req.header('content-type'); + if (contentType) headers['Content-Type'] = contentType; + const range = ctx.req.header('range'); + if (range) headers['Range'] = range; + + 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) }); +}); diff --git a/src/servers/api/slskd/sidecar-server.ts b/src/servers/api/slskd/sidecar-server.ts new file mode 100644 index 00000000..bf8516f7 --- /dev/null +++ b/src/servers/api/slskd/sidecar-server.ts @@ -0,0 +1,19 @@ +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; +} diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 3be70d0a..507e4c41 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -21,10 +21,12 @@ import { taskLogsRouter } from './api/task-logs/task-logs'; import { router as fileBrowserRouter } from './api/file-browser/router'; import { musicRouter } from './api/music/router'; import { vaultRouter } from './api/vault/router'; +import { slskdRouter } from './api/slskd/router'; import { systemMonitorRouter } from './api/system-monitor/system-monitor'; import { activityRouter } from './api/activity/router'; import './api/music/sidecar-server'; // side-effect: capture the officer-music audio server 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 { devServerRouter, devServerProxyRouter } from './api/dev-server/router'; import { dockRouter } from './api/dock/dock'; import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations'; @@ -102,6 +104,7 @@ protectedRouter.route('/dashboards', dashboardsRouter); protectedRouter.route('/task-logs', taskLogsRouter); protectedRouter.route('/file-browser', fileBrowserRouter); protectedRouter.route('/music', musicRouter); +protectedRouter.route('/slskd', slskdRouter); protectedRouter.route('/system-monitor', systemMonitorRouter); protectedRouter.route('/activity', activityRouter); protectedRouter.route('/dev-server', devServerRouter); diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index bd2b4108..2386e42a 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -60,6 +60,8 @@ export type SidecarEvent = | { type: 'music:server'; port: number } // Vault — the sidecar reports where its Vaultwarden reverse-proxy HTTP/WS server is listening on connect | { type: 'vault:server'; port: number } + // slskd — the sidecar reports where its slskd reverse-proxy HTTP server is listening (random port) on connect + | { type: 'slskd:server'; port: number } // Generic | { type: 'error'; id?: string; error: string }; diff --git a/src/servers/sidecar/slskd/index.ts b/src/servers/sidecar/slskd/index.ts new file mode 100644 index 00000000..e7efa0e4 --- /dev/null +++ b/src/servers/sidecar/slskd/index.ts @@ -0,0 +1,141 @@ +import type { SidecarCommand, SidecarEvent } from '../protocol'; +import { createSidecarConnector } from '../connect'; +import { getSlskdBase, getSlskdApiKey, stripHopByHop } from './upstream'; + +// The officer-slskd sidecar. Same philosophy as officer-vault / officer-music: a singleton process that +// registers with the API server and OWNS a contract — here, a reverse-proxy to a self-hosted slskd +// (Soulseek daemon). ALL knowledge of slskd (its URL, its API key) lives here; the platform API is a +// thin, auth-gated forwarder that never sees the slskd credential. This lets the Soulseek client grow +// end-to-end without the main server accreting any slskd-specific logic. +// +// ───────────────────────────────────────────────────────────────────────────────────────────────── +// HTTP CONTRACT — the platform strips its /api/slskd mount prefix before forwarding, so requests arrive +// here as slskd-root paths (e.g. /api/v0/searches, /api/v0/transfers, /api/v0/session). We inject +// `X-API-Key` and pass method, path, query, headers, status and BOTH body streams through verbatim. +// `GET /_health` is ours (probes slskd's /health), not part of the slskd contract. The server listens on +// a random loopback port, reported to the API on connect so it can route here. +// ───────────────────────────────────────────────────────────────────────────────────────────────── +// +// TODO (next iteration): slskd streams live search results + transfer progress over SignalR hubs at +// /hub/*. Add a WebSocket upgrade path here mirroring the officer-vault notifications proxy +// (src/servers/sidecar/vault/index.ts) once the client needs real-time updates. SignalR carries its +// credential as an `?access_token=` query param on the socket, so the key injection differs from HTTP. + +const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`; + +/** Grab an ephemeral free port by briefly binding one and releasing it. */ +function getFreePort(): number { + const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); + const p = probe.port; + probe.stop(true); + if (p == null) throw new Error('failed to acquire a free port'); + return p; +} + +const port = getFreePort(); + +const server = Bun.serve({ + port, + hostname: '127.0.0.1', + idleTimeout: 255, // browse/search can idle; Bun caps this at 255s + maxRequestBodySize: 1024 * 1024 * 1024, // room for uploads / large browse responses + async fetch(req) { + const url = new URL(req.url); + const base = getSlskdBase(); + + // Reachability probe — ours, not part of the slskd contract. + if (url.pathname === '/_health') { + if (!base) return Response.json({ ok: false, error: 'SLSKD_URL not configured' }, { status: 503 }); + const started = Date.now(); + try { + const r = await fetch(`${base}/health`, { method: 'GET', signal: AbortSignal.timeout(5000) }); + return Response.json({ ok: r.ok, upstreamStatus: r.status, ms: Date.now() - started }); + } catch { + return Response.json({ ok: false, error: 'upstream unreachable', ms: Date.now() - started }, { status: 502 }); + } + } + + if (!base) return new Response('slskd upstream not configured', { status: 503 }); + + const target = `${base}${url.pathname}${url.search}`; + const method = req.method; + const hasBody = method !== 'GET' && method !== 'HEAD'; + const started = Date.now(); + + const headers = stripHopByHop(req.headers); + const apiKey = getSlskdApiKey(); + if (apiKey) headers.set('X-API-Key', apiKey); + + // Bun/undici require half-duplex to stream a request body straight through. + const init: RequestInit & { duplex?: 'half' } = { + method, + headers, + body: hasBody ? req.body : undefined, + redirect: 'manual', // a transparent proxy passes 3xx through rather than following them + }; + if (hasBody) init.duplex = 'half'; + + let upstream: Response; + try { + upstream = await fetch(target, init); + } catch { + console.error(`[slskd] ${method} ${url.pathname} -> upstream unreachable`); + return new Response('slskd upstream unreachable', { status: 502 }); + } + + console.log(`[slskd] ${method} ${url.pathname} -> ${upstream.status} ${Date.now() - started}ms`); + return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) }); + }, +}); + +console.log(`[slskd] reverse-proxy listening on 127.0.0.1:${port} -> ${getSlskdBase() ?? '(SLSKD_URL unset)'}`); + +// ── Command handlers ── + +type ReplyFn = (msg: SidecarEvent) => void; + +function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { + switch (cmd.type) { + case 'ping': + reply({ type: 'pong', id: cmd.id }); + break; + default: + reply({ + type: 'error', + id: (cmd as SidecarCommand).id, + error: `Unknown command type: ${(cmd as Record).type}`, + }); + } +} + +// ── Connect to API server ── + +const connection = createSidecarConnector({ + apiUrl: `${API_URL}/api/sidecar/register`, + name: 'slskd', + capabilities: ['slskd'], + onCommand(cmd, reply) { + handleCommand(cmd as SidecarCommand, reply as ReplyFn); + }, + onConnected() { + // Tell the API where our proxy is listening, so it can forward /api/slskd/* here. + connection.send({ type: 'slskd:server', port }); + console.log(`[slskd] reported proxy port ${port} to API`); + }, +}); + +// ── Graceful shutdown ── + +function shutdown(signal: string) { + console.log(`[slskd] ${signal} received, shutting down...`); + try { + server.stop(true); + } catch { + /* already stopped */ + } + connection.destroy(); + process.exit(0); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/src/servers/sidecar/slskd/upstream.ts b/src/servers/sidecar/slskd/upstream.ts new file mode 100644 index 00000000..d47e245a --- /dev/null +++ b/src/servers/sidecar/slskd/upstream.ts @@ -0,0 +1,53 @@ +// slskd upstream config + header hygiene for the officer-slskd sidecar. +// +// All knowledge of the slskd instance (its URL and API key) lives in the sidecar, mirroring the +// officer-vault philosophy: the platform API is a thin auth+forward proxy and holds NO slskd +// credentials. The sidecar injects the API key on every forwarded request; the platform never sees it. + +const { SLSKD_URL, SLSKD_API_KEY } = process.env; + +let warnedUnset = false; + +/** The slskd base URL (no trailing slash), or null when unconfigured (the sidecar then 503s). */ +export function getSlskdBase(): string | null { + const raw = SLSKD_URL?.trim(); + if (!raw) { + if (!warnedUnset) { + console.warn('[slskd] SLSKD_URL is unset — the sidecar will respond 503 until it is set'); + warnedUnset = true; + } + return null; + } + return raw.replace(/\/+$/, ''); +} + +/** The slskd API key, injected as `X-API-Key` on every forwarded request. Null when unconfigured. */ +export function getSlskdApiKey(): string | null { + const raw = SLSKD_API_KEY?.trim(); + return raw ? raw : null; +} + +// Hop-by-hop headers must not cross a proxy hop (RFC 7230 §6.1). `host` is dropped so the outgoing +// fetch sets the upstream authority itself; everything else — including our injected X-API-Key — passes. +const HOP_BY_HOP = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'host', +]); + +/** Copy headers, dropping only hop-by-hop / proxy-* ones. Used for both request and response directions. */ +export function stripHopByHop(src: Headers): Headers { + const out = new Headers(); + src.forEach((value, key) => { + const k = key.toLowerCase(); + if (HOP_BY_HOP.has(k) || k.startsWith('proxy-')) return; + out.set(key, value); + }); + return out; +}