import type { SidecarCommand, SidecarEvent } from '../protocol'; import { createSidecarConnector } from '../connect'; import { handleConfigRoute } from './config'; import { handleOfficerRoute } from './routes'; import { probe } from './rpc'; import { getTransmissionConfig } from './upstream'; import { API_URL } from '../../officer-url.mjs'; // 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 // single-endpoint RPC vocabulary into real REST paths. The platform API is a thin auth-gated forwarder // (src/servers/api/transmission/router.ts) holding no Transmission credentials. // // ───────────────────────────────────────────────────────────────────────────────────────────────── // 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 // GET /_officer/torrents the list poll — LIST_FIELDS for every torrent, decorated // GET /_officer/torrents/:id one torrent with files/peers/trackers (DETAIL_FIELDS) // POST /_officer/torrents/add {metainfo|filename, downloadDir?, labels?, paused?, …} // → {status:'added'|'duplicate', torrent} // POST /_officer/torrents/action {ids, action} — start|start-now|stop|verify|reannounce|queue-* // POST /_officer/torrents/set {ids, …whitelisted torrent-set fields} // POST /_officer/torrents/location {ids, location, move} // POST /_officer/torrents/rename {id, path, name} // POST /_officer/torrents/remove {ids, deleteLocalData} // GET /_officer/free-space?path= free space at a path // GET /_officer/port-test is the peer port reachable from outside // POST /_officer/blocklist-update refresh the blocklist, returns the new size // anything else 404 // // There is deliberately NO transparent /transmission/rpc passthrough. Transmission multiplexes every verb // through one POST body, reports failures as `{"result": ""}` 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. // ───────────────────────────────────────────────────────────────────────────────────────────────── /** Grab an ephemeral free port by briefly binding one and releasing it. */ function getFreePort(): number { 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; } const port = getFreePort(); const server = Bun.serve({ port, hostname: '127.0.0.1', // A base64 .torrent for a large multi-file release travels in the request body of /torrents/add. maxRequestBodySize: 256 * 1024 * 1024, async fetch(req) { const url = new URL(req.url); 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(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(userId, req, url); if (res) return res; return Response.json({ error: 'not found' }, { status: 404 }); } catch (err) { console.error(`[transmission] ${req.method} ${url.pathname} failed`, err); return Response.json({ error: 'internal error' }, { status: 500 }); } } return Response.json({ error: 'not found' }, { status: 404 }); }, }); // 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; 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}`, }); } } const connection = createSidecarConnector({ apiUrl: `${API_URL}/api/sidecar/register`, name: 'transmission', capabilities: ['transmission'], onCommand(cmd, reply) { handleCommand(cmd as SidecarCommand, reply as ReplyFn); }, onConnected() { connection.send({ type: 'transmission:server', port }); console.log(`[transmission] reported server port ${port} to API`); }, }); function shutdown(signal: string) { console.log(`[transmission] ${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'));