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,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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { deleteServiceConnection, getServiceConnection, saveServiceConnection } from 'officerdb';
|
||||
import { probe } from './rpc';
|
||||
import {
|
||||
basicAuth,
|
||||
getTransmissionConfig,
|
||||
invalidateTransmissionConfig,
|
||||
normalizeBase,
|
||||
normalizeRpcPath,
|
||||
} from './upstream';
|
||||
|
||||
// `/_config` — where the Transmission daemon is, driven from the app rather than from a shell on the server.
|
||||
//
|
||||
// ONE connection, not a registry: nobody runs two Transmission 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.
|
||||
//
|
||||
// The URL is the only thing usually needed. Most Transmission daemons run with no RPC auth at all, so
|
||||
// username/password are optional and an EMPTY username means "no auth" rather than "empty credentials" —
|
||||
// see basicAuth in upstream.ts, where sending a Basic header anyway makes the daemon reject the request.
|
||||
// `rpcPath` is there for the reverse-proxy case and defaults to /transmission/rpc.
|
||||
//
|
||||
// The password is WRITE-ONLY across this boundary: the GET reports whether one is stored, never its value.
|
||||
// A save is validated with a live session-get first, so a wrong URL or rejected credentials is a 400 with
|
||||
// the reason rather than a stored row that makes every later screen fail mysteriously.
|
||||
|
||||
/** What the browser is allowed to know about the connection. Never includes the password. */
|
||||
async function connectionState(userId: number): Promise<Response> {
|
||||
const connection = await getServiceConnection(userId, 'transmission');
|
||||
return Response.json({ configured: !!connection, connection });
|
||||
}
|
||||
|
||||
const bad = (error: string, status = 400) => Response.json({ error }, { status });
|
||||
|
||||
type ConnectionBody = { url?: unknown; username?: unknown; password?: unknown; rpcPath?: unknown };
|
||||
|
||||
const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);
|
||||
|
||||
const readString = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
|
||||
|
||||
/** Save the connection: validated against the live daemon, then stored. */
|
||||
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) : '';
|
||||
const username = readString(body.username);
|
||||
const rpcPath = normalizeRpcPath(readString(body.rpcPath));
|
||||
// A blank password with a username still set means "keep the stored one", so changing the URL does not
|
||||
// need the password re-typed. Clearing the username clears the auth entirely.
|
||||
const password = typeof body.password === 'string' && body.password ? body.password : null;
|
||||
|
||||
if (!url) return bad('url is required');
|
||||
if (!isHttpUrl(url)) return bad('url must start with http:// or https://');
|
||||
|
||||
const current = await getServiceConnection(userId, 'transmission');
|
||||
const keepPassword = !password && !!username && username === current?.username && current.hasSecret;
|
||||
|
||||
// Probing needs the real password, so a save that keeps the stored one borrows it through the normal read
|
||||
// path rather than reaching for the ciphertext here.
|
||||
const stored = keepPassword ? await getTransmissionConfig(userId) : null;
|
||||
const auth = keepPassword ? (stored?.auth ?? null) : basicAuth(username || null, password);
|
||||
|
||||
const result = await probe({ base: url, rpcPath, auth });
|
||||
if (!result.ok) return bad(result.error ?? 'could not reach the daemon');
|
||||
|
||||
const connection = await saveServiceConnection({
|
||||
userId,
|
||||
service: 'transmission',
|
||||
url,
|
||||
username: username || null,
|
||||
// undefined keeps the stored password; null clears it, which is what dropping the username means.
|
||||
secret: keepPassword ? undefined : (password ?? null),
|
||||
path: rpcPath,
|
||||
version: result.version ?? null,
|
||||
});
|
||||
invalidateTransmissionConfig(userId);
|
||||
return Response.json({ connection, rpcVersion: result.rpcVersion ?? null });
|
||||
}
|
||||
|
||||
/** `/_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, 'transmission');
|
||||
invalidateTransmissionConfig(userId);
|
||||
return connectionState(userId);
|
||||
}
|
||||
|
||||
return bad('method not allowed', 405);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -128,16 +128,10 @@ function pick(body: Record<string, unknown>, allowed: Set<string>): Record<strin
|
||||
/**
|
||||
* Dispatch an /_officer/* request. Returns null when nothing matches, which the caller turns into a 404.
|
||||
*
|
||||
* The platform injects X-Officer-User 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.
|
||||
* `userId` was authenticated by the platform proxy and validated at the server boundary — every call below
|
||||
* needs it, because which daemon to talk to is per-owner state read from the database.
|
||||
*/
|
||||
export async function handleOfficerRoute(req: Request, url: URL): Promise<Response | null> {
|
||||
const officerUser = req.headers.get('X-Officer-User');
|
||||
if (!officerUser) return Response.json({ error: 'missing X-Officer-User' }, { status: 401 });
|
||||
|
||||
const userId = Number(officerUser);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return badRequest('invalid X-Officer-User');
|
||||
|
||||
export async function handleOfficerRoute(userId: number, req: Request, url: URL): Promise<Response | null> {
|
||||
const segments = url.pathname.slice('/_officer/'.length).split('/').filter(Boolean);
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
@@ -171,7 +165,7 @@ async function handleSession(ctx: OfficerContext, rest: string[]): Promise<Respo
|
||||
|
||||
if (ctx.req.method === 'GET') {
|
||||
// No `fields` argument: session-get returns everything, and the settings UI reads nearly all of it.
|
||||
const session = await rpc<SessionSettings>('session-get');
|
||||
const session = await rpc<SessionSettings>(ctx.userId, 'session-get');
|
||||
return Response.json({ session });
|
||||
}
|
||||
|
||||
@@ -180,10 +174,10 @@ async function handleSession(ctx: OfficerContext, rest: string[]): Promise<Respo
|
||||
if (!body) return badRequest('expected a JSON object body');
|
||||
const args = pick(body, SESSION_SET_FIELDS);
|
||||
if (Object.keys(args).length === 0) return badRequest('no writable session fields in body');
|
||||
await rpc('session-set', args);
|
||||
await rpc(ctx.userId, 'session-set', args);
|
||||
// Read back rather than echoing the request: Transmission clamps and normalises several of these
|
||||
// (alt-speed times, queue sizes), so the request body is not what the daemon ends up holding.
|
||||
const session = await rpc<SessionSettings>('session-get');
|
||||
const session = await rpc<SessionSettings>(ctx.userId, 'session-get');
|
||||
return Response.json({ session });
|
||||
}
|
||||
|
||||
@@ -192,7 +186,7 @@ async function handleSession(ctx: OfficerContext, rest: string[]): Promise<Respo
|
||||
|
||||
async function handleStats(ctx: OfficerContext): Promise<Response> {
|
||||
if (ctx.req.method !== 'GET') return methodNotAllowed();
|
||||
const stats = await rpc<SessionStats>('session-stats');
|
||||
const stats = await rpc<SessionStats>(ctx.userId, 'session-stats');
|
||||
return Response.json({ stats });
|
||||
}
|
||||
|
||||
@@ -200,19 +194,19 @@ async function handleFreeSpace(ctx: OfficerContext): Promise<Response> {
|
||||
if (ctx.req.method !== 'GET') return methodNotAllowed();
|
||||
const path = ctx.url.searchParams.get('path');
|
||||
if (!path) return badRequest('path query parameter is required');
|
||||
const result = await rpc<{ path: string; 'size-bytes': number }>('free-space', { path });
|
||||
const result = await rpc<{ path: string; 'size-bytes': number }>(ctx.userId, 'free-space', { path });
|
||||
return Response.json({ path: result.path, bytes: result['size-bytes'] });
|
||||
}
|
||||
|
||||
async function handlePortTest(ctx: OfficerContext): Promise<Response> {
|
||||
if (ctx.req.method !== 'POST') return methodNotAllowed();
|
||||
const result = await rpc<{ 'port-is-open': boolean }>('port-test');
|
||||
const result = await rpc<{ 'port-is-open': boolean }>(ctx.userId, 'port-test');
|
||||
return Response.json({ open: result['port-is-open'] });
|
||||
}
|
||||
|
||||
async function handleBlocklistUpdate(ctx: OfficerContext): Promise<Response> {
|
||||
if (ctx.req.method !== 'POST') return methodNotAllowed();
|
||||
const result = await rpc<{ 'blocklist-size': number }>('blocklist-update');
|
||||
const result = await rpc<{ 'blocklist-size': number }>(ctx.userId, 'blocklist-update');
|
||||
return Response.json({ size: result['blocklist-size'] });
|
||||
}
|
||||
|
||||
@@ -221,7 +215,7 @@ async function handleTorrents(ctx: OfficerContext, rest: string[]): Promise<Resp
|
||||
|
||||
// GET /_officer/torrents — the list poll.
|
||||
if (rest.length === 0 && req.method === 'GET') {
|
||||
const result = await rpc<{ torrents: Torrent[] }>('torrent-get', { fields: [...LIST_FIELDS] });
|
||||
const result = await rpc<{ torrents: Torrent[] }>(ctx.userId, 'torrent-get', { fields: [...LIST_FIELDS] });
|
||||
return Response.json({ torrents: result.torrents.map(decorate) });
|
||||
}
|
||||
|
||||
@@ -231,17 +225,17 @@ async function handleTorrents(ctx: OfficerContext, rest: string[]): Promise<Resp
|
||||
if (req.method === 'POST') {
|
||||
switch (segment) {
|
||||
case 'add':
|
||||
return await addTorrent(req);
|
||||
return await addTorrent(ctx);
|
||||
case 'action':
|
||||
return await runAction(req);
|
||||
return await runAction(ctx);
|
||||
case 'set':
|
||||
return await setTorrent(req);
|
||||
return await setTorrent(ctx);
|
||||
case 'location':
|
||||
return await setLocation(req);
|
||||
return await setLocation(ctx);
|
||||
case 'rename':
|
||||
return await renamePath(req);
|
||||
return await renamePath(ctx);
|
||||
case 'remove':
|
||||
return await removeTorrents(req);
|
||||
return await removeTorrents(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +244,7 @@ async function handleTorrents(ctx: OfficerContext, rest: string[]): Promise<Resp
|
||||
if (!Number.isInteger(id) || id <= 0) return notFound();
|
||||
if (req.method !== 'GET') return methodNotAllowed();
|
||||
|
||||
const result = await rpc<{ torrents: Torrent[] }>('torrent-get', {
|
||||
const result = await rpc<{ torrents: Torrent[] }>(ctx.userId, 'torrent-get', {
|
||||
ids: [id],
|
||||
fields: [...LIST_FIELDS, ...DETAIL_FIELDS],
|
||||
});
|
||||
@@ -262,8 +256,8 @@ async function handleTorrents(ctx: OfficerContext, rest: string[]): Promise<Resp
|
||||
return null;
|
||||
}
|
||||
|
||||
async function addTorrent(req: Request): Promise<Response> {
|
||||
const body = await readJson(req);
|
||||
async function addTorrent(ctx: OfficerContext): Promise<Response> {
|
||||
const body = await readJson(ctx.req);
|
||||
if (!body) return badRequest('expected a JSON object body');
|
||||
|
||||
const args: Record<string, unknown> = {};
|
||||
@@ -281,7 +275,7 @@ async function addTorrent(req: Request): Promise<Response> {
|
||||
const result = await rpc<{
|
||||
'torrent-added'?: { id: number; name: string; hashString: string };
|
||||
'torrent-duplicate'?: { id: number; name: string; hashString: string };
|
||||
}>('torrent-add', args);
|
||||
}>(ctx.userId, 'torrent-add', args);
|
||||
|
||||
const added = result['torrent-added'];
|
||||
const duplicate = result['torrent-duplicate'];
|
||||
@@ -292,8 +286,8 @@ async function addTorrent(req: Request): Promise<Response> {
|
||||
return Response.json({ status: 'added', torrent: null });
|
||||
}
|
||||
|
||||
async function runAction(req: Request): Promise<Response> {
|
||||
const body = await readJson(req);
|
||||
async function runAction(ctx: OfficerContext): Promise<Response> {
|
||||
const body = await readJson(ctx.req);
|
||||
if (!body) return badRequest('expected a JSON object body');
|
||||
|
||||
const action = typeof body['action'] === 'string' ? body['action'] : '';
|
||||
@@ -304,12 +298,12 @@ async function runAction(req: Request): Promise<Response> {
|
||||
if (!ids) return badRequest('ids must be an array of positive integers');
|
||||
if (ids.length === 0) return Response.json({ ok: true, affected: 0 });
|
||||
|
||||
await rpc(method, { ids });
|
||||
await rpc(ctx.userId, method, { ids });
|
||||
return Response.json({ ok: true, affected: ids.length });
|
||||
}
|
||||
|
||||
async function setTorrent(req: Request): Promise<Response> {
|
||||
const body = await readJson(req);
|
||||
async function setTorrent(ctx: OfficerContext): Promise<Response> {
|
||||
const body = await readJson(ctx.req);
|
||||
if (!body) return badRequest('expected a JSON object body');
|
||||
|
||||
const ids = readIds(body);
|
||||
@@ -319,12 +313,12 @@ async function setTorrent(req: Request): Promise<Response> {
|
||||
if (Object.keys(fields).length === 0) return badRequest('no writable torrent fields in body');
|
||||
if (ids.length === 0) return Response.json({ ok: true, affected: 0 });
|
||||
|
||||
await rpc('torrent-set', { ids, ...fields });
|
||||
await rpc(ctx.userId, 'torrent-set', { ids, ...fields });
|
||||
return Response.json({ ok: true, affected: ids.length });
|
||||
}
|
||||
|
||||
async function setLocation(req: Request): Promise<Response> {
|
||||
const body = await readJson(req);
|
||||
async function setLocation(ctx: OfficerContext): Promise<Response> {
|
||||
const body = await readJson(ctx.req);
|
||||
if (!body) return badRequest('expected a JSON object body');
|
||||
|
||||
const ids = readIds(body);
|
||||
@@ -336,12 +330,12 @@ async function setLocation(req: Request): Promise<Response> {
|
||||
// `move: false` re-points the torrent at data already sitting there; `true` physically moves it. Getting
|
||||
// this backwards either loses the data or copies gigabytes unasked, so it is required, not defaulted.
|
||||
const move = body['move'] === true;
|
||||
await rpc('torrent-set-location', { ids, location, move });
|
||||
await rpc(ctx.userId, 'torrent-set-location', { ids, location, move });
|
||||
return Response.json({ ok: true, affected: ids.length });
|
||||
}
|
||||
|
||||
async function renamePath(req: Request): Promise<Response> {
|
||||
const body = await readJson(req);
|
||||
async function renamePath(ctx: OfficerContext): Promise<Response> {
|
||||
const body = await readJson(ctx.req);
|
||||
if (!body) return badRequest('expected a JSON object body');
|
||||
|
||||
const id = Number(body['id']);
|
||||
@@ -352,12 +346,12 @@ async function renamePath(req: Request): Promise<Response> {
|
||||
if (!name) return badRequest('name is required');
|
||||
|
||||
// torrent-rename-path takes ONE id — an array is accepted but the result is undefined. Enforce it.
|
||||
await rpc('torrent-rename-path', { ids: [id], path, name });
|
||||
await rpc(ctx.userId, 'torrent-rename-path', { ids: [id], path, name });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
async function removeTorrents(req: Request): Promise<Response> {
|
||||
const body = await readJson(req);
|
||||
async function removeTorrents(ctx: OfficerContext): Promise<Response> {
|
||||
const body = await readJson(ctx.req);
|
||||
if (!body) return badRequest('expected a JSON object body');
|
||||
|
||||
const ids = readIds(body);
|
||||
@@ -365,6 +359,6 @@ async function removeTorrents(req: Request): Promise<Response> {
|
||||
if (ids.length === 0) return Response.json({ ok: true, affected: 0 });
|
||||
|
||||
const deleteLocalData = body['deleteLocalData'] === true;
|
||||
await rpc('torrent-remove', { ids, 'delete-local-data': deleteLocalData });
|
||||
await rpc(ctx.userId, 'torrent-remove', { ids, 'delete-local-data': deleteLocalData });
|
||||
return Response.json({ ok: true, affected: ids.length, deletedData: deleteLocalData });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Torrent, TrackerStat } from './types';
|
||||
import { getAuthHeader, getRpcPath, getTransmissionBase } from './upstream';
|
||||
import type { TransmissionConfig } from './upstream';
|
||||
import { getTransmissionConfig } from './upstream';
|
||||
|
||||
// The Transmission RPC call layer. Every upstream request in this sidecar goes through here, so the
|
||||
// wire-level quirks are handled exactly once:
|
||||
@@ -12,7 +13,7 @@ import { getAuthHeader, getRpcPath, getTransmissionBase } from './upstream';
|
||||
// in a way that looks like an auth bug.
|
||||
// • Errors are NOT signalled by HTTP status. A perfectly successful-looking 200 carries
|
||||
// `{"result": "some error string"}`; only `result === 'success'` means it worked.
|
||||
// • Auth is HTTP Basic, and an EMPTY username must send no header at all — see getAuthHeader.
|
||||
// • Auth is HTTP Basic, and an EMPTY username must send no header at all — see basicAuth in upstream.ts.
|
||||
// • `torrent-get` with an unknown field name fails the whole call rather than ignoring the field, so
|
||||
// LIST_FIELDS/DETAIL_FIELDS are curated against the running daemon's rpc-version, not guessed.
|
||||
|
||||
@@ -29,32 +30,45 @@ export class TransmissionError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// The CSRF token for the current daemon session. Module-level because it is a property of the connection,
|
||||
// not of any one request, and every caller benefits from a refresh any one of them performs.
|
||||
let sessionId: string | null = null;
|
||||
// The CSRF token for a daemon session, keyed by endpoint rather than by owner: the token belongs to the
|
||||
// daemon, so two owners pointed at the same one share a refresh, and re-pointing at a different daemon
|
||||
// cannot carry a token that daemon never issued.
|
||||
const sessionIds = new Map<string, string>();
|
||||
|
||||
type RpcResponse<T> = { result: string; arguments?: T };
|
||||
|
||||
/**
|
||||
* Issue one RPC call. Retries exactly once on 409 after adopting the new session id; a second 409 means
|
||||
* something other than a stale token (a proxy stripping the header, most likely) and is surfaced.
|
||||
*/
|
||||
export async function rpc<T = unknown>(method: string, args: Record<string, unknown> = {}): Promise<T> {
|
||||
return call<T>(method, args, true);
|
||||
/** Issue one RPC call against the owner's configured daemon. */
|
||||
export async function rpc<T = unknown>(userId: number, method: string, args: Record<string, unknown> = {}): Promise<T> {
|
||||
const cfg = await getTransmissionConfig(userId);
|
||||
if (!cfg) throw new TransmissionError(503, 'transmission is not connected');
|
||||
return call<T>(cfg, method, args, true);
|
||||
}
|
||||
|
||||
async function call<T>(method: string, args: Record<string, unknown>, mayRetry: boolean): Promise<T> {
|
||||
const base = getTransmissionBase();
|
||||
if (!base) throw new TransmissionError(503, 'TRANSMISSION_URL is not configured');
|
||||
/** The same call against a connection that may not be stored yet — used to validate one before saving it. */
|
||||
export async function rpcWith<T = unknown>(
|
||||
cfg: TransmissionConfig,
|
||||
method: string,
|
||||
args: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
return call<T>(cfg, method, args, true);
|
||||
}
|
||||
|
||||
async function call<T>(
|
||||
cfg: TransmissionConfig,
|
||||
method: string,
|
||||
args: Record<string, unknown>,
|
||||
mayRetry: boolean,
|
||||
): Promise<T> {
|
||||
const endpoint = `${cfg.base}${cfg.rpcPath}`;
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const sessionId = sessionIds.get(endpoint);
|
||||
if (sessionId) headers['X-Transmission-Session-Id'] = sessionId;
|
||||
const auth = getAuthHeader();
|
||||
if (auth) headers['Authorization'] = auth;
|
||||
if (cfg.auth) headers['Authorization'] = cfg.auth;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${base}${getRpcPath()}`, {
|
||||
res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ method, arguments: args }),
|
||||
@@ -68,8 +82,8 @@ async function call<T>(method: string, args: Record<string, unknown>, mayRetry:
|
||||
if (res.status === 409) {
|
||||
const fresh = res.headers.get('X-Transmission-Session-Id');
|
||||
if (fresh && mayRetry) {
|
||||
sessionId = fresh;
|
||||
return call<T>(method, args, false);
|
||||
sessionIds.set(endpoint, fresh);
|
||||
return call<T>(cfg, method, args, false);
|
||||
}
|
||||
throw new TransmissionError(502, 'transmission rejected the session id handshake');
|
||||
}
|
||||
@@ -85,10 +99,12 @@ async function call<T>(method: string, args: Record<string, unknown>, mayRetry:
|
||||
return (body.arguments ?? {}) as T;
|
||||
}
|
||||
|
||||
/** Reachability + credential probe, used by /_health. */
|
||||
export async function probe(): Promise<{ ok: boolean; version?: string; rpcVersion?: number; error?: string }> {
|
||||
/** Reachability + credential probe, used by /_health and before a connection is saved. */
|
||||
export async function probe(
|
||||
cfg: TransmissionConfig,
|
||||
): Promise<{ ok: boolean; version?: string; rpcVersion?: number; error?: string }> {
|
||||
try {
|
||||
const args = await rpc<{ version: string; 'rpc-version': number }>('session-get', {
|
||||
const args = await rpcWith<{ version: string; 'rpc-version': number }>(cfg, 'session-get', {
|
||||
fields: ['version', 'rpc-version'],
|
||||
});
|
||||
return { ok: true, version: args.version, rpcVersion: args['rpc-version'] };
|
||||
|
||||
@@ -1,46 +1,72 @@
|
||||
import { getServiceCredentials } from 'officerdb';
|
||||
|
||||
// Transmission upstream config for the officer-transmission sidecar.
|
||||
//
|
||||
// All knowledge of the Transmission daemon (its URL, its RPC path and its credentials) lives here,
|
||||
// mirroring the officer-slskd/officer-vault philosophy: the platform API is a thin auth+forward proxy and
|
||||
// holds NO Transmission credentials.
|
||||
// All knowledge of the daemon — its URL, its RPC path and its credentials — lives here. The platform API is
|
||||
// a thin auth+forward proxy and holds NO Transmission credentials.
|
||||
//
|
||||
// The daemon is CONFIGURED BY THE OWNER FROM THE UI and stored in `service_connections` (see
|
||||
// databases/officer_db/src/queries/service-connections.ts), no longer read from the environment: Bun
|
||||
// auto-loads `.env` into every process started in the platform directory, so TRANSMISSION_* was also
|
||||
// sitting in `officer`'s own process.env, and pointing Officer at a daemon meant editing a file on the
|
||||
// server. Nothing in this file reads process.env.
|
||||
|
||||
const { TRANSMISSION_URL, TRANSMISSION_USER, TRANSMISSION_PASS, TRANSMISSION_RPC_PATH } = process.env;
|
||||
/** Everything needed to make one RPC call. A candidate being validated has this and nothing else yet. */
|
||||
export type TransmissionConfig = { base: string; rpcPath: string; auth: string | null };
|
||||
|
||||
let warnedUnset = false;
|
||||
|
||||
/** The Transmission base URL (no trailing slash), or null when unconfigured (the sidecar then 503s). */
|
||||
export function getTransmissionBase(): string | null {
|
||||
const raw = TRANSMISSION_URL?.trim();
|
||||
if (!raw) {
|
||||
if (!warnedUnset) {
|
||||
console.warn('[transmission] TRANSMISSION_URL is unset — the sidecar will respond 503 until it is set');
|
||||
warnedUnset = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return raw.replace(/\/+$/, '');
|
||||
}
|
||||
/** Trailing slashes off, so `${base}${rpcPath}` never doubles the separator. */
|
||||
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
|
||||
|
||||
/**
|
||||
* The RPC endpoint path. Transmission serves it at /transmission/rpc by default, but a reverse proxy can
|
||||
* mount it anywhere, so it is configurable rather than hardcoded.
|
||||
* mount it anywhere, so it is stored per connection rather than hardcoded.
|
||||
*/
|
||||
export function getRpcPath(): string {
|
||||
const raw = TRANSMISSION_RPC_PATH?.trim();
|
||||
if (!raw) return '/transmission/rpc';
|
||||
return raw.startsWith('/') ? raw : `/${raw}`;
|
||||
}
|
||||
export const normalizeRpcPath = (raw: string | null | undefined): string => {
|
||||
const path = raw?.trim();
|
||||
if (!path) return '/transmission/rpc';
|
||||
return path.startsWith('/') ? path : `/${path}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* The `Authorization: Basic …` header value, or null when the daemon has no auth configured.
|
||||
*
|
||||
* Transmission treats an empty username as "no authentication required" — sending an empty Basic header in
|
||||
* that case is not merely useless, it makes the daemon reject the request. So this returns null unless a
|
||||
* username is actually set.
|
||||
* username is actually set, which is why the stored username is nullable rather than defaulting to ''.
|
||||
*/
|
||||
export function getAuthHeader(): string | null {
|
||||
const user = TRANSMISSION_USER?.trim();
|
||||
export function basicAuth(username: string | null, password: string | null): string | null {
|
||||
const user = username?.trim();
|
||||
if (!user) return null;
|
||||
const pass = TRANSMISSION_PASS ?? '';
|
||||
return `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`;
|
||||
return `Basic ${Buffer.from(`${user}:${password ?? ''}`).toString('base64')}`;
|
||||
}
|
||||
|
||||
// The torrent list polls every couple of seconds, 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: TransmissionConfig | null; at: number }>();
|
||||
|
||||
/**
|
||||
* The owner's Transmission connection, or null when nothing is configured — the sidecar then answers 503,
|
||||
* and the UI turns that into the setup form rather than an empty torrent list.
|
||||
*/
|
||||
export async function getTransmissionConfig(userId: number): Promise<TransmissionConfig | null> {
|
||||
const hit = cache.get(userId);
|
||||
if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;
|
||||
|
||||
const creds = await getServiceCredentials(userId, 'transmission');
|
||||
const cfg = creds
|
||||
? {
|
||||
base: normalizeBase(creds.url),
|
||||
rpcPath: normalizeRpcPath(creds.path),
|
||||
auth: basicAuth(creds.username, creds.secret),
|
||||
}
|
||||
: null;
|
||||
cache.set(userId, { cfg, at: Date.now() });
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** Drop the cached row — called by the config routes after any save or removal. */
|
||||
export function invalidateTransmissionConfig(userId: number): void {
|
||||
cache.delete(userId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user