diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index e05f4a1f..a7931636 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -188,6 +188,8 @@ export { saveServiceConnection, deleteServiceConnection, recordServiceProbe, + getServiceInstanceUrl, + getResolvedServiceCredentials, } from './queries/service-connections'; export type { ServiceName, ServiceConnection, ServiceCredentials } from './queries/service-connections'; export { diff --git a/src/databases/officer_db/src/queries/service-connections.ts b/src/databases/officer_db/src/queries/service-connections.ts index f9e7a74a..6795842a 100644 --- a/src/databases/officer_db/src/queries/service-connections.ts +++ b/src/databases/officer_db/src/queries/service-connections.ts @@ -1,6 +1,7 @@ import { eq, and } from 'drizzle-orm'; import { db } from '../db'; import { serviceConnections } from '../schema'; +import { getOwnerUser } from './auth'; import { encryptSecret, decryptSecret } from '../crypto'; // Single-connection services (transmission, slskd) for their sidecars. Callers deal in PLAINTEXT — @@ -13,12 +14,13 @@ import { encryptSecret, decryptSecret } from '../crypto'; // moment someone forgot to strip it. /** The services that keep a connection here. Extending it is a one-line change, not a migration. */ -export type ServiceName = 'transmission' | 'slskd' | 'esplora' | 'nbxplorer' | 'memos' | 'opengist'; +export type ServiceName = 'transmission' | 'slskd' | 'esplora' | 'nbxplorer' | 'memos' | 'opengist' | 'gitea'; export type ServiceConnection = { id: number; service: string; - url: string; + /** Null on a credential-only row — see the column comment in ../schema/service-connections.ts. */ + url: string | null; username: string | null; path: string | null; /** Whether a secret is stored. The secret itself never crosses this boundary, not even masked. */ @@ -30,7 +32,8 @@ export type ServiceConnection = { export type ServiceCredentials = { id: number; - url: string; + /** Null when this row inherits the instance — resolve it with getServiceInstanceUrl. */ + url: string | null; username: string | null; secret: string | null; path: string | null; @@ -91,7 +94,12 @@ export async function getServiceCredentials(userId: number, service: ServiceName type SaveServiceConnectionParams = { userId: number; service: ServiceName; - url: string; + /** + * Null writes a CREDENTIAL row — one that inherits the instance from the owner's row on read. Only + * the inheriting kind of service does this, and only for non-owner users. See the column comment in + * ../schema/service-connections.ts. + */ + url: string | null; username?: string | null; /** Encrypted before write. Undefined leaves a stored secret alone; null clears it. */ secret?: string | null; @@ -151,3 +159,43 @@ export async function recordServiceProbe(userId: number, service: ServiceName, v .set({ version, lastSeenAt: new Date() }) .where(and(eq(serviceConnections.userId, userId), eq(serviceConnections.service, service))); } + +// ── Inherited instances ── +// +// Some services are ONE instance that several people authenticate to individually — Gitea is the first. +// The owner's row carries the URL and is the instance; everyone else's row carries only their own +// credential and leaves `url` null. See the column comment in ../schema/service-connections.ts. +// +// Resolution lives here rather than in each sidecar so there is one answer to "where is this service", +// and so a sidecar cannot accidentally use a member-supplied URL — the member has not got one to supply. + +/** + * The instance URL for a service: whoever owns the platform configured it. Null when the owner has not + * connected this service yet, which is the signal to refuse a member's setup rather than let them invent + * an instance of their own. + */ +export async function getServiceInstanceUrl(service: ServiceName): Promise { + const owner = await getOwnerUser(); + if (!owner) return null; + const [row] = await db + .select({ url: serviceConnections.url }) + .from(serviceConnections) + .where(and(eq(serviceConnections.userId, owner.id), eq(serviceConnections.service, service))); + return row?.url ?? null; +} + +/** + * This user's credentials with the base resolved: their own URL if they have one (the owner, or a + * single-daemon service), otherwise the instance the owner configured. Returns null when there is no + * usable pairing — no row, or a row that inherits from an instance nobody has set up. + */ +export async function getResolvedServiceCredentials( + userId: number, + service: ServiceName, +): Promise<(ServiceCredentials & { url: string }) | null> { + const own = await getServiceCredentials(userId, service); + if (!own) return null; + if (own.url) return { ...own, url: own.url }; + const instance = await getServiceInstanceUrl(service); + return instance ? { ...own, url: instance } : null; +} diff --git a/src/databases/officer_db/src/schema/service-connections.ts b/src/databases/officer_db/src/schema/service-connections.ts index 98c6a701..f03c5971 100644 --- a/src/databases/officer_db/src/schema/service-connections.ts +++ b/src/databases/officer_db/src/schema/service-connections.ts @@ -28,7 +28,21 @@ export const serviceConnections = pgTable( /** 'transmission' | 'slskd'. Text, not an enum: adding a service should not be a schema change. */ service: text('service').notNull(), // Normalized without a trailing slash before write, so `${url}/api/...` never doubles the separator. - url: text('url').notNull(), + // + // NULL means "inherit the instance" — the row is a CREDENTIAL, not a connection. + // + // Some services are one instance that several people authenticate to individually: Gitea is the first. + // The owner's row carries the URL and is the instance; every other user's row carries only their own + // access token and resolves the base from the owner's row at read time. A member's URL is therefore not + // stored rather than merely hidden, which is what makes "members never see the instance URL" a property + // of the schema instead of a filter somebody has to remember on every response. + // + // It also closes the obvious hole: if a member could supply a URL, the sidecar would dutifully talk to + // whatever host they named, which is a per-user SSRF hop wearing a settings form. + // + // Single-daemon services (transmission, slskd, nbxplorer) always set it. Only the inheriting kind leaves + // it null, and only for non-owner rows. + url: text('url'), /** Transmission RPC basic-auth user. Null/empty means the daemon has no auth, which is the usual case. */ username: text('username'), secret: text('secret'), // encrypted: slskd API key, or Transmission's RPC password diff --git a/src/servers/sidecar/gitea/index.ts b/src/servers/sidecar/gitea/index.ts new file mode 100644 index 00000000..e40cf175 --- /dev/null +++ b/src/servers/sidecar/gitea/index.ts @@ -0,0 +1,264 @@ +import type { SidecarCommand, SidecarEvent } from '../protocol'; +import { createSidecarConnector } from '../connect'; +import { + getServiceConnection, + saveServiceConnection, + deleteServiceConnection, + recordServiceProbe, + getServiceInstanceUrl, + getOwnerUser, +} from 'officerdb'; +import { callGitea, getGiteaConfig, invalidateGiteaConfig, normalizeBase, probe } from './upstream'; + +// The officer-gitea sidecar. Owns the whole Gitea contract: the instance URL and the personal access +// token. The platform side is a thin auth-gated forwarder holding no Gitea credentials. +// +// ───────────────────────────────────────────────────────────────────────────────────────────────── +// HTTP CONTRACT — the platform strips its /api/gitea mount prefix before forwarding. +// +// GET /_health is the instance up, and does the stored token work +// GET /_config the connection MINUS the token +// PUT/POST /_config { url, token } — validated live before it is stored +// DELETE /_config forget the connection +// ANY /_api/* forwarded to the instance's own /api/*, token attached +// +// That is the SINGLE-CONNECTION contract slskd and transmission already serve, matched deliberately so +// the UI can use the shared `useServiceConnection` / `useServiceConnectionActions` hooks rather than +// growing a fourth copy of them. Which is why PUT is accepted (the shared hook's `save` uses it), why a +// save answers `{ connection }`, and why DELETE answers the connection state rather than `{ ok }`. +// Gitea genuinely is one-of: nobody runs two. +// +// `/_api/*` is a deliberate pass-through rather than a hand-written wrapper per endpoint, for the same +// reason memos does it: Gitea's REST surface is enormous (its swagger is 850KB) and moves between minor +// versions, so re-describing it here would mean a second thing to keep in sync, and the UI is going to +// speak the upstream's shapes anyway. The allow-list below is the one piece of policy: it keeps the +// sidecar from being a general-purpose SSRF hop into whatever else is on that host. +// ───────────────────────────────────────────────────────────────────────────────────────────────── + +const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`; + +// Everything under /api/v1 the UI legitimately needs. +// +// `/api/v1/admin/*` is excluded ON PURPOSE and should stay excluded. A Gitea token minted by a site +// admin carries the admin API with it — create and delete users, rewrite anyone's repositories, mint +// tokens for other accounts. None of that is a thing a notes-and-repos panel needs, and a token pasted +// into a connection form should not silently become a remote root shell for the instance. If an admin +// screen is ever genuinely wanted it gets its own reviewed entry here, not a widened prefix. +const ALLOWED = [ + /^\/api\/v1\/version(\/|$|\?)/, + /^\/api\/v1\/user(\/|$|\?)/, + /^\/api\/v1\/users(\/|$|\?)/, + /^\/api\/v1\/orgs(\/|$|\?)/, + /^\/api\/v1\/org(\/|$|\?)/, + /^\/api\/v1\/teams(\/|$|\?)/, + /^\/api\/v1\/repos(\/|$|\?)/, + /^\/api\/v1\/repositories(\/|$|\?)/, + /^\/api\/v1\/notifications(\/|$|\?)/, + /^\/api\/v1\/issues(\/|$|\?)/, + /^\/api\/v1\/packages(\/|$|\?)/, + // Instance metadata the UI reads to match the instance's own behaviour rather than guess it — + // default branch name, whether the markdown renderer allows raw HTML, which features are enabled. + /^\/api\/v1\/settings(\/|$|\?)/, + /^\/api\/v1\/topics(\/|$|\?)/, + /^\/api\/v1\/licenses(\/|$|\?)/, + /^\/api\/v1\/gitignore(\/|$|\?)/, + /^\/api\/v1\/label(\/|$|\?)/, + // Rendering. Gitea's own web UI renders a README through these, and matching it matters: the + // instance resolves relative links, #issue references and @mentions against the repo context, which a + // client-side markdown pass cannot do. Sanitisation is the instance's too — see renderMarkdown. + /^\/api\/v1\/markdown(\/|$|\?)/, + /^\/api\/v1\/markup(\/|$|\?)/, +]; + +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 bad = (error: string, status = 400) => Response.json({ error }, { status }); + +/** Whoever owns the platform configures the instance; everyone else supplies only their own token. */ +async function isOwner(userId: number): Promise { + const owner = await getOwnerUser(); + return !!owner && owner.id === userId; +} + +// A member's row has no URL to return — it is genuinely not stored, not merely withheld — so this needs +// no per-caller filtering. `instanceConfigured` is what the UI needs instead: it distinguishes "you have +// not connected yet" from "there is nothing here to connect to", which are different screens. +async function connectionState(userId: number): Promise { + const connection = await getServiceConnection(userId, 'gitea'); + const instanceConfigured = !!(await getServiceInstanceUrl('gitea')); + return Response.json({ + configured: !!connection, + connection, + instanceConfigured, + isOwner: await isOwner(userId), + }); +} + +async function handleConfig(req: Request, userId: number): Promise { + if (req.method === 'GET') return connectionState(userId); + + if (req.method === 'DELETE') { + await deleteServiceConnection(userId, 'gitea'); + invalidateGiteaConfig(userId); + return connectionState(userId); + } + + if (req.method === 'PUT' || req.method === 'POST') { + const body = ((await req.json().catch(() => null)) as { url?: unknown; token?: unknown } | null) ?? {}; + // A blank token on a save means "keep the stored one", so the URL can be corrected without the + // token being re-pasted. There is no way to read it back out to re-send it. + const token = typeof body.token === 'string' && body.token.trim() ? body.token.trim() : null; + const owner = await isOwner(userId); + + // The instance is the owner's; the token is each person's own. Which half of the form you get is + // decided here rather than in the UI, because a UI that hides a field is a suggestion and this is a + // rule: a member who could name the URL would have a per-user SSRF hop behind a settings form. + let base: string; + if (owner) { + const url = typeof body.url === 'string' ? normalizeBase(body.url) : ''; + if (!url) return bad('url is required'); + if (!/^https?:\/\//i.test(url)) return bad('url must start with http:// or https://'); + base = url; + } else { + // Rejected, not ignored. Ignoring it would let a member believe they had pointed Gitea somewhere + // and leave them debugging a screen that is quietly talking to a different server. + if (body.url !== undefined) return bad('only the server owner can set the Gitea instance URL', 403); + const instance = await getServiceInstanceUrl('gitea'); + if (!instance) return bad('the server owner has not connected a Gitea instance yet', 409); + base = instance; + } + + const existing = await getServiceConnection(userId, 'gitea'); + if (!token && !existing?.hasSecret) return bad('an access token is required the first time'); + + // Probe with the credentials as they WILL be, so a save either stores something that works or + // fails with the reason. Storing first and discovering later makes every downstream screen fail + // mysteriously instead. For a member this also proves their token is valid against the shared + // instance, which is the only thing that can be wrong on their side. + const current = token ? null : await getGiteaConfig(userId); + const result = await probe({ id: existing?.id ?? 0, base, token: token ?? current?.token ?? null }); + if (!result.ok) return bad(result.error ?? 'could not reach the instance'); + + const connection = await saveServiceConnection({ + userId, + service: 'gitea', + // Null for anyone but the owner: their row is a credential, and inherits the instance on read. + url: owner ? base : null, + secret: token ?? undefined, + version: result.version, + }); + invalidateGiteaConfig(userId); + return Response.json({ connection, user: result.user, version: result.version }); + } + + return bad('method not allowed', 405); +} + +const server = Bun.serve({ + port, + hostname: '127.0.0.1', + // Release attachments and file uploads through the contents API. + maxRequestBodySize: 64 * 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 }); + } + + try { + if (url.pathname === '/_config') return await handleConfig(req, userId); + + const config = await getGiteaConfig(userId); + + if (url.pathname === '/_health') { + if (!config) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 }); + const started = Date.now(); + const result = await probe(config); + const ms = Date.now() - started; + if (!result.ok) { + return Response.json({ ok: false, configured: true, error: result.error, ms }, { status: 502 }); + } + await recordServiceProbe(userId, 'gitea', result.version); + return Response.json({ ok: true, configured: true, user: result.user, version: result.version, ms }); + } + + if (url.pathname.startsWith('/_api/')) { + if (!config) return Response.json({ error: 'gitea not connected', configured: false }, { status: 503 }); + const subpath = url.pathname.slice('/_api'.length) + url.search; + if (!ALLOWED.some((re) => re.test(subpath))) return bad('path not allowed', 403); + + const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; + const upstream = await callGitea(config, subpath, { + method: req.method, + headers: req.headers.get('content-type') ? { 'content-type': req.headers.get('content-type')! } : {}, + body: hasBody ? await req.arrayBuffer() : undefined, + }); + return new Response(upstream.body, { + status: upstream.status, + headers: { 'content-type': upstream.headers.get('content-type') ?? 'application/json' }, + }); + } + } catch (err) { + // Path only. A repository body is the owner's private code. + console.error(`[gitea] ${req.method} ${url.pathname} failed`, String(err)); + return Response.json({ error: 'internal error' }, { status: 500 }); + } + + return Response.json({ error: 'not found' }, { status: 404 }); + }, +}); + +console.log(`[gitea] listening on 127.0.0.1:${port} (instance configured from the UI, stored in service_connections)`); + +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: 'gitea', + capabilities: ['gitea'], + onCommand(cmd, reply) { + handleCommand(cmd as SidecarCommand, reply as ReplyFn); + }, + onConnected() { + connection.send({ type: 'gitea:server', port }); + console.log(`[gitea] reported server port ${port} to API`); + }, +}); + +function shutdown(signal: string) { + console.log(`[gitea] ${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')); diff --git a/src/servers/sidecar/gitea/upstream.ts b/src/servers/sidecar/gitea/upstream.ts new file mode 100644 index 00000000..641b07ff --- /dev/null +++ b/src/servers/sidecar/gitea/upstream.ts @@ -0,0 +1,102 @@ +import { getResolvedServiceCredentials } from 'officerdb'; + +// Where the Gitea instance is and what it takes to talk to it. The credential lives here and only here — +// the platform forwards to this sidecar and holds nothing. +// +// Gitea authenticates API calls with a personal access token minted from the instance's own +// Settings → Applications → Access Tokens. That is the OWNER'S to create and paste in; there is no way to +// derive one from a platform session, and no reason to want one. +// +// THE HEADER IS `token `, NOT `Bearer `. Gitea's own scheme, confirmed against a live 1.27.0 instance's +// swagger: `AuthorizationHeaderToken` is documented as "API tokens must be prepended with \"token\" +// followed by a space." Recent Gitea also tolerates `Bearer`, but `token` is the documented spelling and +// the one that works on every version we might be pointed at. The query-string forms (`?access_token=` / +// `?token=`) are deprecated for removal and must not be used — they leak the credential into access logs. + +export type GiteaConfig = { id: number; base: string; token: string | null }; + +/** Trailing slash stripped, so `${base}/api/v1/...` never doubles the separator. */ +export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, ''); + +const cache = new Map(); + +export function invalidateGiteaConfig(userId: number): void { + cache.delete(userId); +} + +// One Gitea instance, one token per person. The base comes from whoever owns the platform; the token is +// this caller's own, so what they see is their account — their repos, their notifications, their issues. +// +// getResolvedServiceCredentials does the inheriting: the owner's row carries the URL, everyone else's +// carries only a token and resolves the base from the owner's. It returns null when the pairing is not +// usable — no row for this user, or a row waiting on an instance the owner has not connected — and both +// cases mean the same thing to every caller here: Gitea is not available to you yet. +// +// A member's URL is never read from their own row because it is never written there. That is what stops +// a settings form from becoming a per-user SSRF hop. +export async function getGiteaConfig(userId: number): Promise { + if (cache.has(userId)) return cache.get(userId) ?? null; + const row = await getResolvedServiceCredentials(userId, 'gitea'); + const config = row ? { id: row.id, base: normalizeBase(row.url), token: row.secret } : null; + cache.set(userId, config); + return config; +} + +export const authHeaders = (config: GiteaConfig): Record => + config.token ? { Authorization: `token ${config.token}` } : {}; + +/** + * One call against the instance. Returns the raw Response so callers can stream or inspect status — + * nothing here parses a repository or an issue, because that is the owner's code and this layer has no + * business reading it. + */ +export async function callGitea(config: GiteaConfig, path: string, init?: RequestInit): Promise { + const headers = { ...authHeaders(config), ...((init?.headers as Record) ?? {}) }; + return fetch(`${config.base}${path}`, { ...init, headers, signal: init?.signal ?? AbortSignal.timeout(20_000) }); +} + +export type ProbeResult = { ok: boolean; version: string | null; user: string | null; error?: string }; + +/** + * Is the instance up, and does the stored token actually work? + * + * Both calls carry the token, which is the one place this deliberately departs from the memos sidecar's + * shape. Memos probes `/healthz` unauthenticated first so a bad URL is distinguishable from a bad token. + * Gitea has no such endpoint under a common configuration: with `REQUIRE_SIGNIN_VIEW` enabled — which the + * instance this was built against has — even `/api/v1/version` answers 403 with "Only signed in user is + * allowed to call APIs". An unauthenticated pre-flight would therefore report "unreachable" for a + * perfectly healthy server, which is the most misleading answer available. + * + * So the split is by failure mode instead, which is what the UI actually needs to tell apart: + * throw (DNS, refused, TLS, timeout) → unreachable, the URL is wrong + * 401/403 → reached it fine, the token is wrong + * 200 → both good + * Verified against the live instance: unauthenticated /version → 403, /version with a junk token → 401. + */ +export async function probe(config: GiteaConfig): Promise { + if (!config.token) return { ok: false, version: null, user: null, error: 'no access token stored' }; + + let version: string | null = null; + try { + const res = await callGitea(config, '/api/v1/version', { signal: AbortSignal.timeout(8000) }); + if (res.status === 401 || res.status === 403) { + return { ok: false, version: null, user: null, error: `token rejected (${res.status})` }; + } + if (!res.ok) return { ok: false, version: null, user: null, error: `instance returned ${res.status}` }; + const body = (await res.json().catch(() => ({}))) as { version?: string }; + version = body.version ?? null; + } catch (err) { + return { ok: false, version: null, user: null, error: `unreachable: ${String(err)}` }; + } + + // `/version` is satisfied by any valid token; `/user` is what proves the token is bound to an actual + // account and yields the identity the connection screen shows back to the owner. + try { + const me = await callGitea(config, '/api/v1/user'); + if (!me.ok) return { ok: false, version, user: null, error: `token rejected (${me.status})` }; + const body = (await me.json().catch(() => ({}))) as { login?: string; full_name?: string; username?: string }; + return { ok: true, version, user: body.login || body.username || body.full_name || null }; + } catch (err) { + return { ok: false, version, user: null, error: String(err) }; + } +} diff --git a/src/servers/sidecar/memos/upstream.ts b/src/servers/sidecar/memos/upstream.ts index b67a61a4..d9e1903a 100644 --- a/src/servers/sidecar/memos/upstream.ts +++ b/src/servers/sidecar/memos/upstream.ts @@ -21,7 +21,9 @@ export function invalidateMemosConfig(userId: number): void { export async function getMemosConfig(userId: number): Promise { if (cache.has(userId)) return cache.get(userId) ?? null; const row = await getServiceCredentials(userId, 'memos'); - const config = row ? { id: row.id, base: normalizeBase(row.url), token: row.secret } : null; + // `url` is nullable now that some services inherit an instance (see the schema). memos is a single + // daemon and always stores its own, so a null here means a malformed row, not an inheriting one. + const config = row?.url ? { id: row.id, base: normalizeBase(row.url), token: row.secret } : null; cache.set(userId, config); return config; } diff --git a/src/servers/sidecar/slskd/upstream.ts b/src/servers/sidecar/slskd/upstream.ts index 2b445663..55f302c4 100644 --- a/src/servers/sidecar/slskd/upstream.ts +++ b/src/servers/sidecar/slskd/upstream.ts @@ -32,7 +32,10 @@ export async function getSlskdConfig(userId: number): Promise