import type { ServerWebSocket } from 'bun'; import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol'; import { createSidecarConnector } from '@@/sidecar/connect'; import { API_URL } from '@@/officer-url.mjs'; import { getVaultBase, getVaultWsBase, stripHopByHop, redactPath } from './upstream'; // The officer-vault sidecar: a singleton process that registers with the API server and OWNS a contract — // a transparent reverse-proxy to a self-hosted Vaultwarden (Bitwarden server). The platform API is a thin // forwarder to us; ALL knowledge of Vaultwarden (its URL, its paths, its notifications WebSocket) lives // here, which is what let the vault become a plugin without the platform learning anything new. // // HARD RULE: dumb pass-through. The vault is end-to-end encrypted — we NEVER decrypt, parse, rewrite, or // log request/response bodies. Method, path, query, headers, status and BOTH body streams pass through // verbatim; nothing is buffered or cached. // // ───────────────────────────────────────────────────────────────────────────────────────────────── // HTTP CONTRACT — this server proxies to the Vaultwarden ROOT (the platform strips its /api/vault mount // prefix before forwarding). So `/identity/*`, `/api/*`, `/notifications/*`, `/icons/*`, `/events/*` map // straight through. `/notifications/hub` upgrades to a WebSocket (SignalR); its long-poll fallback rides // the normal HTTP path. `GET /_health` is ours (probes Vaultwarden's /alive), not part of the contract. // The server listens on a random loopback port, reported to the API on connect so it can route here. // ───────────────────────────────────────────────────────────────────────────────────────────────── /** 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); if (p == null) throw new Error('failed to acquire a free port'); return p; } // ── notifications WebSocket proxy (sidecar ↔ Vaultwarden) ── type VaultWSData = { vaultWsPath: string; vaultWsProtocol?: string }; type UpstreamState = { ws: WebSocket | null; queue: (string | Uint8Array)[]; ready: boolean; closed: boolean; }; const upstreams = new Map, UpstreamState>(); // Bun hands frames over as `string | Buffer`; a Buffer is a Uint8Array at runtime, so forward as-is. const asPayload = (raw: string | Buffer): string | Uint8Array => typeof raw === 'string' ? raw : (raw as Uint8Array); const wsHandler = { // Async since the upstream base moved into the database — `open` used to be synchronous because it read // an environment variable. The state entry is registered BEFORE the first await so frames arriving // during setup are queued rather than dropped; that is the same shape the platform-side proxy already // used, for the same reason, and it is why `ws` is nullable here now. async open(ws: ServerWebSocket) { const state: UpstreamState = { ws: null, queue: [], ready: false, closed: false }; upstreams.set(ws, state); const wsBase = await getVaultWsBase(); if (!wsBase) { state.closed = true; upstreams.delete(ws); ws.close(1011, 'Vault upstream not configured'); return; } if (state.closed) return; // client hung up while we were reading the config const protocols = ws.data.vaultWsProtocol ? ws.data.vaultWsProtocol .split(',') .map((s) => s.trim()) .filter(Boolean) : undefined; const target = `${wsBase}${ws.data.vaultWsPath}`; const upstream = protocols?.length ? new WebSocket(target, protocols) : new WebSocket(target); upstream.binaryType = 'arraybuffer'; state.ws = upstream; upstream.addEventListener('open', () => { state.ready = true; for (const m of state.queue) upstream.send(m); state.queue.length = 0; }); upstream.addEventListener('message', (ev) => { try { ws.send(ev.data as string | ArrayBuffer); } catch { /* client gone */ } }); upstream.addEventListener('close', (ev) => { upstreams.delete(ws); try { ws.close(ev.code || 1000, ev.reason || ''); } catch { /* already closed */ } }); upstream.addEventListener('error', () => { upstreams.delete(ws); try { ws.close(1011, 'upstream error'); } catch { /* already closed */ } }); }, message(ws: ServerWebSocket, raw: string | Buffer) { const state = upstreams.get(ws); if (!state) return; const payload = asPayload(raw); if (state.ready && state.ws) state.ws.send(payload); else state.queue.push(payload); // buffer until the upstream socket opens }, close(ws: ServerWebSocket) { const state = upstreams.get(ws); if (state) { state.closed = true; try { state.ws?.close(); } catch { /* already closed */ } upstreams.delete(ws); } }, drain() {}, }; // ── HTTP + WS server ── const port = getFreePort(); const server = Bun.serve({ port, hostname: '127.0.0.1', idleTimeout: 255, // sync + attachments can idle; Bun caps this at 255s maxRequestBodySize: 1024 * 1024 * 1024 * 2, // attachments async fetch(req, server) { const url = new URL(req.url); /* Reachability probe — ours, never touches vault data. `/api/config`, not `/alive`. Both answer, and `/alive` is the smaller request, but it only proves that SOMETHING is listening on that address. `/api/config` proves the thing listening is actually a Vaultwarden, because it self-identifies: {"version":"2026.6.0","server":{"name":"Vaultwarden","url":...}, ...} That distinction is the whole value of the check. The failure this guards against is not "the box is down" — the owner finds that out the moment anything else fails — it is pointing the vault at the wrong port on a host that runs several things, where a 200 from an unrelated service reads as success and the real problem surfaces later as a login that cannot work. Vaultwarden does NOT implement `/api/status`, which exists in Bitwarden's official cloud API; a probe aimed there 404s against a perfectly healthy server. Recorded because it is exactly the kind of near-miss that looks like a wrong address. */ if (url.pathname === '/_health') { const base = await getVaultBase(); if (!base) return Response.json({ ok: false, error: 'no Vaultwarden URL configured' }, { status: 503 }); const started = Date.now(); try { const r = await fetch(`${base}/api/config`, { method: 'GET', signal: AbortSignal.timeout(5000) }); const ms = Date.now() - started; if (!r.ok) { return Response.json({ ok: false, upstreamStatus: r.status, ms, url: base }, { status: 502 }); } // Read only what identifies the server. This endpoint is unauthenticated and carries no vault // data, which is why it may be parsed at all — the hard rule about never reading bodies is about // everything on the proxy path, and this is not on it. const body = (await r.json().catch(() => ({}))) as Record; const server = (body.server ?? {}) as Record; const name = typeof server.name === 'string' ? server.name : null; return Response.json({ ok: true, upstreamStatus: r.status, ms, url: base, server: name, version: typeof body.version === 'string' ? body.version : null, // A 200 from something that is not a Vaultwarden is the case this probe exists to catch, so it // is reported rather than smoothed over into a plain `ok`. identified: name !== null, }); } catch { return Response.json( { ok: false, error: 'upstream unreachable', ms: Date.now() - started, url: base }, { status: 502 }, ); } } // Notifications hub: upgrade the WebSocket here (piped to Vaultwarden by wsHandler); the SignalR // long-poll fallback is not an upgrade, so it falls through to the HTTP proxy below. if (url.pathname.startsWith('/notifications/') && req.headers.get('upgrade') === 'websocket') { const ok = server.upgrade(req, { data: { vaultWsPath: url.pathname + url.search, vaultWsProtocol: req.headers.get('sec-websocket-protocol') ?? undefined, } satisfies VaultWSData, }); return ok ? undefined : new Response('Upgrade failed', { status: 500 }); } // Faithful HTTP pass-through to Vaultwarden. const base = await getVaultBase(); if (!base) return new Response('Vault upstream not configured', { status: 503 }); const target = `${base}${url.pathname}${url.search}`; const method = req.method; const hasBody = method !== 'GET' && method !== 'HEAD'; const started = Date.now(); // Bun/undici require half-duplex to stream a request body straight through (attachments can be large). const init: RequestInit & { duplex?: 'half' } = { method, headers: stripHopByHop(req.headers), body: hasBody ? req.body : undefined, redirect: 'manual', // a transparent proxy passes 3xx through rather than following them }; if (hasBody) init.duplex = 'half'; let upstream: Response; try { upstream = await fetch(target, init); } catch { console.error(`[vault] ${method} ${redactPath(url.pathname + url.search)} -> upstream unreachable`); return new Response('Vault upstream unreachable', { status: 502 }); } // Log at most method / path / status / duration — never bodies, tokens, or Authorization. console.log(`[vault] ${method} ${redactPath(url.pathname)} -> ${upstream.status} ${Date.now() - started}ms`); return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) }); }, websocket: wsHandler, }); // Fire-and-forget: the config read must not hold up the listener, and an unconfigured install is an // ordinary state that the panel exists to fix rather than something to announce as a failure. void getVaultBase().then((base) => { console.log(`[vault] reverse-proxy listening on 127.0.0.1:${port} -> ${base ?? '(no URL configured yet)'}`); }); // ── Command handlers ── 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}`, }); } } // ── Connect to API server ── const connection = createSidecarConnector({ apiUrl: `${API_URL}/api/sidecar/register`, name: 'vault', handles: ['vault'], onCommand(cmd, reply) { handleCommand(cmd as SidecarCommand, reply as ReplyFn); }, onConnected() { // Tell the API where our proxy is listening, so it can forward /api/vault/* here. connection.send({ type: 'vault:server', port }); console.log(`[vault] reported proxy port ${port} to API`); }, }); // ── Graceful shutdown ── function shutdown(signal: string) { console.log(`[vault] ${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'));