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:
@@ -1,8 +1,9 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { handleConfigRoute } from './config';
|
||||
import { handleOfficerRoute } from './routes';
|
||||
import { probe } from './rpc';
|
||||
import { getTransmissionBase } from './upstream';
|
||||
import { getTransmissionConfig } from './upstream';
|
||||
|
||||
// The officer-transmission sidecar. Owns the whole Transmission contract for Officer: the daemon URL and
|
||||
// credentials, the X-Transmission-Session-Id CSRF handshake, and the translation from Transmission's
|
||||
@@ -13,6 +14,10 @@ import { getTransmissionBase } from './upstream';
|
||||
// HTTP CONTRACT — the platform strips its /api/transmission mount prefix before forwarding.
|
||||
//
|
||||
// GET /_health ours. Probes the daemon with a cheap session-get.
|
||||
// GET /_config { configured, connection } — where the daemon is, never the password
|
||||
// PUT /_config { url, username?, password?, rpcPath? } — validated with a live
|
||||
// session-get, then stored. A blank password keeps the stored one
|
||||
// DEL /_config forget the connection
|
||||
// GET /_officer/session full session settings + version + rpc-version
|
||||
// POST /_officer/session write a partial settings object (whitelisted), returns it read back
|
||||
// GET /_officer/stats session-stats: cumulative + current-session counters
|
||||
@@ -34,6 +39,10 @@ import { getTransmissionBase } from './upstream';
|
||||
// through one POST body, reports failures as `{"result": "<error>"}` inside a 200, and demands a CSRF token
|
||||
// that rotates on every daemon restart. Proxying that raw would push all three into the browser. Every
|
||||
// quirk is absorbed here — see rpc.ts.
|
||||
//
|
||||
// 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 which daemon to talk to is per-owner data.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
@@ -57,18 +66,37 @@ const server = Bun.serve({
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (url.pathname === '/_health') {
|
||||
if (!getTransmissionBase()) {
|
||||
return Response.json({ ok: false, error: 'TRANSMISSION_URL not configured' }, { status: 503 });
|
||||
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 {
|
||||
return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length));
|
||||
} catch (err) {
|
||||
console.error(`[transmission] ${req.method} ${url.pathname} failed`, err);
|
||||
return Response.json({ error: 'internal error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a
|
||||
// configured-but-unreachable daemon is the whole reason the flag is on the response.
|
||||
if (url.pathname === '/_health') {
|
||||
const cfg = await getTransmissionConfig(userId);
|
||||
if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
|
||||
const started = Date.now();
|
||||
const result = await probe();
|
||||
return Response.json({ ...result, ms: Date.now() - started }, { status: result.ok ? 200 : 502 });
|
||||
const result = await probe(cfg);
|
||||
return Response.json(
|
||||
{ ...result, configured: true, ms: Date.now() - started },
|
||||
{ status: result.ok ? 200 : 502 },
|
||||
);
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/_officer/')) {
|
||||
try {
|
||||
const res = await handleOfficerRoute(req, url);
|
||||
const res = await handleOfficerRoute(userId, req, url);
|
||||
if (res) return res;
|
||||
return Response.json({ error: 'not found' }, { status: 404 });
|
||||
} catch (err) {
|
||||
@@ -81,7 +109,9 @@ const server = Bun.serve({
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[transmission] listening on 127.0.0.1:${port} -> ${getTransmissionBase() ?? '(TRANSMISSION_URL unset)'}`);
|
||||
// No upstream in the banner: where the daemon lives is now per-owner state read from the database per
|
||||
// request, not a constant this process knows at boot.
|
||||
console.log(`[transmission] listening on 127.0.0.1:${port}`);
|
||||
|
||||
type ReplyFn = (msg: SidecarEvent) => void;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user