move transmission and slskd credentials into the database

both sidecars read their upstream from a new service_connections table instead of
process.env: one row per (user, service), the secret encrypted at rest, upserted
through a /_config route the app drives. transmission gains a Connection section,
soulseek gains one too, and both take over the whole app while nothing is stored.

TRANSMISSION_URL/USER/PASS/RPC_PATH and SLSKD_URL/API_KEY can come out of .env.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 18:04:22 +00:00
co-authored by Claude Opus 5
parent f20d4a300e
commit d7b775113b
26 changed files with 1235 additions and 175 deletions
+5 -9
View File
@@ -1,6 +1,6 @@
import type { BrowseDirInput, BrowseDirRow, BrowsedFile } from 'officerdb';
import { startSoulseekBrowse, finishSoulseekBrowse, failSoulseekBrowse } from 'officerdb';
import { getSlskdBase, getSlskdApiKey } from './upstream';
import { authHeaders, getSlskdConfig } from './upstream';
// Background share-tree fetches.
//
@@ -128,19 +128,15 @@ export function buildTree(dirs: BrowseDirInput[]): BrowseDirRow[] {
}
async function run(userId: number, username: string, snapshotId: number): Promise<void> {
const base = getSlskdBase();
if (!base) {
const cfg = await getSlskdConfig(userId);
if (!cfg) {
await failSoulseekBrowse(snapshotId, 'slskd upstream not configured');
return;
}
const started = Date.now();
try {
const headers = new Headers();
const apiKey = getSlskdApiKey();
if (apiKey) headers.set('X-API-Key', apiKey);
const res = await fetch(`${base}/api/v0/users/${encodeURIComponent(username)}/browse`, {
headers,
const res = await fetch(`${cfg.base}/api/v0/users/${encodeURIComponent(username)}/browse`, {
headers: authHeaders(cfg),
signal: AbortSignal.timeout(BROWSE_TIMEOUT_MS),
});
if (!res.ok) {
+114
View File
@@ -0,0 +1,114 @@
import type { SlskdConfig } from './upstream';
import { deleteServiceConnection, getServiceConnection, saveServiceConnection } from 'officerdb';
import { authHeaders, getSlskdConfig, invalidateSlskdConfig, normalizeBase } from './upstream';
// `/_config` — where the slskd daemon is and what key opens it, driven from the app rather than from a
// shell on the server.
//
// The API key is WRITE-ONLY across this boundary. The GET reports the URL and whether a key is stored; it
// has no field that could carry the key itself, masked or otherwise. The only way to change one is to send
// a new one, which is the same shape the photos and headscale registries use.
//
// ONE connection, not a registry: nobody runs two Soulseek daemons. So there is no label, no id in the
// path and no activate route — a save is an upsert over the single row, and the UI is one form.
//
// A save is validated against the live daemon before it is stored: a wrong URL or a rejected key is a 400
// with the reason, not a saved row that makes every later panel fail mysteriously.
export type ProbeResult =
| { ok: true; version: string | null; server: string | null }
| { ok: false; version: string | null; error: string };
/**
* Ask a daemon whether it is really there and whether the key works.
*
* Two calls, because they answer different questions: `/health` is unauthenticated, so a failure there means
* the URL is wrong or slskd is down, while `/api/v0/application` failing after it succeeded means the key is
* the problem. Collapsing them would report "daemon unreachable" for a mistyped key.
*/
export async function probe(cfg: SlskdConfig): Promise<ProbeResult> {
try {
const health = await fetch(`${cfg.base}/health`, { signal: AbortSignal.timeout(5000), redirect: 'manual' });
if (!health.ok) return { ok: false, version: null, error: `daemon returned ${health.status}` };
} catch (err) {
return { ok: false, version: null, error: `could not reach the daemon (${String(err)})` };
}
try {
const res = await fetch(`${cfg.base}/api/v0/application`, {
headers: { Accept: 'application/json', ...authHeaders(cfg) },
signal: AbortSignal.timeout(5000),
redirect: 'manual',
});
if (!res.ok) {
const reason = res.status === 401 || res.status === 403 ? 'API key was rejected' : 'daemon returned';
return { ok: false, version: null, error: `${reason} (${res.status})` };
}
const app = (await res.json()) as {
version?: { current?: string; full?: string };
server?: { state?: string };
};
return { ok: true, version: app.version?.full ?? app.version?.current ?? null, server: app.server?.state ?? null };
} catch (err) {
return { ok: false, version: null, error: String(err) };
}
}
/** What the browser is allowed to know about the connection. Never includes the key. */
async function connectionState(userId: number): Promise<Response> {
const connection = await getServiceConnection(userId, 'slskd');
return Response.json({ configured: !!connection, connection });
}
const bad = (error: string, status = 400) => Response.json({ error }, { status });
type ConnectionBody = { url?: unknown; apiKey?: unknown };
const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);
/** Save the connection: validated against the live daemon, then stored with the key encrypted. */
async function save(req: Request, userId: number): Promise<Response> {
const body = ((await req.json().catch(() => null)) as ConnectionBody | null) ?? {};
const url = typeof body.url === 'string' ? normalizeBase(body.url) : '';
// A blank key on an existing connection means "keep the stored one", so changing the URL does not need
// the key re-typed. `undefined` is what saveServiceConnection reads as leave-alone.
const apiKey = typeof body.apiKey === 'string' && body.apiKey.trim() ? body.apiKey.trim() : undefined;
if (!url) return bad('url is required');
if (!isHttpUrl(url)) return bad('url must start with http:// or https://');
const current = await getServiceConnection(userId, 'slskd');
if (!apiKey && !current?.hasSecret) return bad('an API key is required');
// Probing needs the actual key, so a URL-only save borrows the stored one through the normal read path
// rather than reaching for the ciphertext here.
const stored = apiKey ? null : await getSlskdConfig(userId);
const result = await probe({ base: url, apiKey: apiKey ?? stored?.apiKey ?? null });
if (!result.ok) return Response.json({ error: result.error }, { status: 400 });
const connection = await saveServiceConnection({
userId,
service: 'slskd',
url,
secret: apiKey,
version: result.version,
});
invalidateSlskdConfig(userId);
return Response.json({ connection, server: result.server });
}
/** `/_config` — GET the connection, PUT/POST to save it, DELETE to forget it. */
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
if (subpath && subpath !== '/') return bad('not found', 404);
if (req.method === 'GET') return connectionState(userId);
if (req.method === 'POST' || req.method === 'PUT') return save(req, userId);
if (req.method === 'DELETE') {
await deleteServiceConnection(userId, 'slskd');
invalidateSlskdConfig(userId);
return connectionState(userId);
}
return bad('method not allowed', 405);
}
+5 -9
View File
@@ -1,5 +1,5 @@
import { getSoulseekBrowseDownload } from 'officerdb';
import { getSlskdBase, getSlskdApiKey } from './upstream';
import { authHeaders, getSlskdConfig } from './upstream';
// Enqueueing downloads out of a cached share tree.
//
@@ -29,8 +29,8 @@ type EnqueueParams = { userId: number; username: string; path: string; file?: st
/** Queue one file, or every file beneath one folder, from the cached tree. Returns how many were sent. */
export async function enqueueFromCache({ userId, username, path, file }: EnqueueParams): Promise<number> {
const base = getSlskdBase();
if (!base) throw new DownloadError('slskd upstream not configured', 503);
const cfg = await getSlskdConfig(userId);
if (!cfg) throw new DownloadError('slskd upstream not configured', 503);
const files = await getSoulseekBrowseDownload({ userId, username, path, file });
if (!files.length) throw new DownloadError('nothing to download at that path', 404);
@@ -41,13 +41,9 @@ export async function enqueueFromCache({ userId, username, path, file }: Enqueue
);
}
const headers = new Headers({ 'content-type': 'application/json' });
const apiKey = getSlskdApiKey();
if (apiKey) headers.set('X-API-Key', apiKey);
const res = await fetch(`${base}/api/v0/transfers/downloads/${encodeURIComponent(username)}`, {
const res = await fetch(`${cfg.base}/api/v0/transfers/downloads/${encodeURIComponent(username)}`, {
method: 'POST',
headers,
headers: { 'content-type': 'application/json', ...authHeaders(cfg) },
body: JSON.stringify(files),
});
if (!res.ok) {
+46 -20
View File
@@ -1,7 +1,8 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { resetStaleSoulseekBrowses } from 'officerdb';
import { getSlskdBase, getSlskdApiKey, stripHopByHop } from './upstream';
import { getSlskdConfig, stripHopByHop } from './upstream';
import { handleConfigRoute, probe } from './config';
import { handleOfficerRoute } from './officer';
// The officer-slskd sidecar. Same philosophy as officer-vault / officer-music: a singleton process that
@@ -14,12 +15,21 @@ import { handleOfficerRoute } from './officer';
// 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.
// The server listens on a random loopback port, reported to the API on connect so it can route here.
//
// GET /_health ours (probes slskd's /health), not part of the slskd contract
// GET /_config { configured, connection } — the URL and whether a key is stored, never the key
// PUT /_config { url, apiKey? } — validated against the daemon, then stored encrypted. A blank
// apiKey on an existing connection keeps the stored one
// DEL /_config forget the connection
//
// Paths under `/_officer/` are also ours and are NOT forwarded: they're the features slskd has no concept
// of (favourite peers, …), served straight from Postgres. See officer.ts for that contract. Keeping them
// here rather than in the platform API is what lets the main server stay a pure proxy forever.
//
// Every route needs `X-Officer-User`, which the platform proxy sets after authenticating the owner. We bind
// loopback only, so its presence is the trust signal — a request without it did not come through the
// platform, and the connection is per-owner data.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
//
// TODO (next iteration): slskd streams live search results + transfer progress over SignalR hubs at
@@ -31,9 +41,9 @@ const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '50
/** 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);
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const p = probeServer.port;
probeServer.stop(true);
if (p == null) throw new Error('failed to acquire a free port');
return p;
}
@@ -47,20 +57,35 @@ const server = Bun.serve({
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();
const officerUser = req.headers.get('X-Officer-User');
const userId = Number(officerUser);
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
}
if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) {
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 });
return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length));
} catch (err) {
console.error(`[slskd] ${req.method} ${url.pathname} failed`, err);
return Response.json({ error: 'internal error' }, { status: 500 });
}
}
const cfg = await getSlskdConfig(userId);
// 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a
// configured-but-broken daemon is the whole reason the flag is on the response.
if (url.pathname === '/_health') {
if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
const started = Date.now();
const result = await probe(cfg);
const ms = Date.now() - started;
if (!result.ok) return Response.json({ ok: false, configured: true, error: result.error, ms }, { status: 502 });
return Response.json({ ok: true, configured: true, version: result.version, server: result.server, ms });
}
// Officer-owned routes — answered locally, never proxied (so they work even with slskd down).
if (url.pathname.startsWith('/_officer/')) {
try {
@@ -73,16 +98,15 @@ const server = Bun.serve({
}
}
if (!base) return new Response('slskd upstream not configured', { status: 503 });
if (!cfg) return Response.json({ error: 'slskd not connected', configured: false }, { status: 503 });
const target = `${base}${url.pathname}${url.search}`;
const target = `${cfg.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);
if (cfg.apiKey) headers.set('X-API-Key', cfg.apiKey);
// Bun/undici require half-duplex to stream a request body straight through.
const init: RequestInit & { duplex?: 'half' } = {
@@ -106,7 +130,9 @@ const server = Bun.serve({
},
});
console.log(`[slskd] reverse-proxy listening on 127.0.0.1:${port} -> ${getSlskdBase() ?? '(SLSKD_URL unset)'}`);
// No upstream in the banner: where slskd lives is now per-owner state read from the database per request,
// not a constant this process knows at boot.
console.log(`[slskd] reverse-proxy listening on 127.0.0.1:${port}`);
// A background share-tree fetch dies with this process, so anything left 'pending' from the previous life
// would spin in the UI forever. Clear it once, at boot, before serving.
+39 -20
View File
@@ -1,32 +1,51 @@
import { getServiceCredentials } from 'officerdb';
// 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.
// All knowledge of the slskd instance its URL and its API key lives here. The platform API is a thin
// auth+forward proxy and holds NO slskd credentials.
//
// The daemon is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `service_connections` (see
// databases/officer_db/src/queries/service-connections.ts). It is deliberately no longer read from the
// environment: Bun auto-loads `.env` into every process started in the platform directory, so an
// `SLSKD_API_KEY` there was also sitting in `officer`'s own process.env — a credential that drives the whole
// Soulseek daemon, held by the one process with no code to use it. Nothing in this file reads process.env.
const { SLSKD_URL, SLSKD_API_KEY } = process.env;
/** Everything needed to reach the daemon. A candidate being validated has this and nothing else yet. */
export type SlskdConfig = { base: string; apiKey: string | null };
let warnedUnset = false;
/** Trailing slashes off, so `${base}/api/v0/...` never doubles the separator. */
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
/** 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 transfers view polls every couple of seconds and every request needs the key, so the row is cached
// rather than re-read per request. Writes invalidate immediately; the TTL only covers someone editing the
// row in psql, which then takes effect within a minute instead of needing a restart.
const TTL_MS = 60_000;
const cache = new Map<number, { cfg: SlskdConfig | null; at: number }>();
/**
* The owner's slskd connection, or null when nothing is configured — the sidecar then answers 503, and the
* UI turns that into the setup form rather than a wall of empty panels.
*/
export async function getSlskdConfig(userId: number): Promise<SlskdConfig | null> {
const hit = cache.get(userId);
if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;
const creds = await getServiceCredentials(userId, 'slskd');
const cfg = creds ? { base: normalizeBase(creds.url), apiKey: creds.secret } : null;
cache.set(userId, { cfg, at: Date.now() });
return cfg;
}
/** 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;
/** Drop the cached row — called by the config routes after any save or removal. */
export function invalidateSlskdConfig(userId: number): void {
cache.delete(userId);
}
/** The auth header slskd expects, ready to spread. Empty when the daemon has no key configured. */
export const authHeaders = (cfg: SlskdConfig): Record<string, string> =>
cfg.apiKey ? { 'X-API-Key': cfg.apiKey } : {};
// 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([