import { createSidecarConnector } from '@@/sidecar/connect'; import { API_URL } from '@@/officer-url.mjs'; import { getKarakeepBase, stripHopByHop, stripResponseHeaders, redactPath } from './upstream'; // The officer-karakeep sidecar: a singleton process that registers with the API server and owns a // transparent reverse-proxy to a self-hosted Karakeep. The platform API is a thin forwarder to this; // ALL knowledge of Karakeep — where it is, whether it is switched on — lives here, which is what lets // the plugin be a plugin rather than something the platform has to learn about. // // ── Pass-through, with one exception that is the point of the plugin ── // // Method, path, query, headers, status and both body streams go through verbatim. Nothing is buffered, // parsed, rewritten or cached. The one exception is the Authorization header on the AUTHENTICATED door: // the platform has already decided who is calling and swapped in that person's Karakeep token, so a // device holding an Officer session never holds a Karakeep credential. This process does not do that // swap and does not know the keys — it forwards what it is handed. See `api/router.ts`. // // ── No WebSocket ── // // Vault's sidecar proxies one, because Vaultwarden pushes notifications over SignalR. Karakeep is // request/response over HTTP — its extension and its own UI both poll — so a WebSocket path here would // be scaffolding for a protocol that does not exist. /** 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 port = probe.port; probe.stop(true); if (port == null) throw new Error('failed to acquire a free port'); return port; } const PORT = getFreePort(); Bun.serve({ port: PORT, hostname: '127.0.0.1', idleTimeout: 120, async fetch(req) { const url = new URL(req.url); /* Ours, not part of the contract. Probes the upstream so "is the sidecar up" and "can it reach Karakeep" are two answerable questions rather than one ambiguous timeout. */ if (url.pathname === '/_health') { const base = await getKarakeepBase(); if (!base) return Response.json({ ok: false, reason: 'unconfigured' }, { status: 503 }); try { const probe = await fetch(`${base}/api/health`, { signal: AbortSignal.timeout(5000), }); return Response.json({ ok: probe.ok, upstream: probe.status }); } catch { return Response.json({ ok: false, reason: 'unreachable' }, { status: 502 }); } } const base = await getKarakeepBase(); if (!base) return new Response('Karakeep is not configured', { status: 503 }); const target = `${base}${url.pathname}${url.search}`; const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; const init: RequestInit & { duplex?: 'half' } = { method: req.method, headers: stripHopByHop(req.headers), // `manual`, so a redirect is handed back to the caller exactly as Karakeep wrote it rather than // being followed here. NextAuth redirects are part of its protocol and a proxy that resolves them // silently changes what the client sees. redirect: 'manual', ...(hasBody ? { body: req.body, duplex: 'half' as const } : {}), }; let upstream: Response; try { upstream = await fetch(target, init); } catch (err) { console.error( `[karakeep] ${req.method} ${redactPath(url.pathname + url.search)} -> upstream unreachable:`, err instanceof Error ? err.message : String(err), ); return new Response('Karakeep unreachable', { status: 502 }); } return new Response(upstream.body, { status: upstream.status, headers: stripResponseHeaders(upstream.headers), }); }, }); console.log(`[karakeep] proxy listening on 127.0.0.1:${PORT}`); /* Tell the platform where to find us. The port is ephemeral and chosen fresh on every start, so it is not a value anybody configures — it is reported on connect and the platform routes to whatever the last connect said. That is what makes restarting this process invisible to the API server, and it is re-sent on every reconnect rather than once at boot, because the platform may be the side that restarted. */ const connection = createSidecarConnector({ apiUrl: `${API_URL}/api/sidecar/register`, name: 'karakeep', handles: ['karakeep'], /* Required by the connector, and deliberately empty. There is no command vocabulary here. Vault has one because it brokers a login; this sidecar only forwards bytes, and everything that decides anything — which user, which token, whether they may — happens on the platform side before a request arrives. A command channel would be a second way to reach a process whose whole value is that it does one thing. */ onCommand() {}, onConnected() { connection.send({ type: 'karakeep:server', port: PORT }); console.log(`[karakeep] reported proxy port ${PORT} to API`); }, });