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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
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) });
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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<string, unknown>).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'));
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user