From fcf67158442a5fa9d9aa5f040093866411ce78ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 29 Jul 2026 01:23:23 +0000 Subject: [PATCH] vault: Vaultwarden reverse-proxy as the officer-vault sidecar Transparent pass-through fronting a self-hosted Vaultwarden so the OffVault (Bitwarden-SDK) app reaches it through the platform's per-app origin gate. True out-of-process sidecar (officer-vault): it owns all Vaultwarden knowledge (URL, paths, notifications WebSocket) on a random loopback port and registers via the sidecar connector; the platform is a thin origin-gated forwarder that knows only the sidecar's port. Never decrypts/parses/rewrites/logs bodies. - sidecar/vault: HTTP + notifications-WS proxy to VAULTWARDEN_URL, /_health - api/vault: sidecar-port discovery + thin forwarder + WS pipe + origin gate - origin: OFFICER_VAULT_ORIGIN allow-listed, scoped to /api/vault - mounted top-level (not protected) so the Bitwarden bearer token isn't 401'd - protocol: vault:server event; ecosystem: officer-vault app Co-Authored-By: Claude Opus 4.8 --- ecosystem.config.cjs | 6 + src/server.tsx | 9 + src/servers/_middlewares/origin-validation.ts | 9 +- src/servers/api/vault/proxy-util.ts | 29 +++ src/servers/api/vault/router.ts | 49 ++++ src/servers/api/vault/sidecar-server.ts | 24 ++ src/servers/api/vault/websocket.ts | 120 +++++++++ src/servers/hono.ts | 7 + src/servers/sidecar/protocol.ts | 2 + src/servers/sidecar/vault/index.ts | 237 ++++++++++++++++++ src/servers/sidecar/vault/upstream.ts | 58 +++++ 11 files changed, 549 insertions(+), 1 deletion(-) create mode 100644 src/servers/api/vault/proxy-util.ts create mode 100644 src/servers/api/vault/router.ts create mode 100644 src/servers/api/vault/sidecar-server.ts create mode 100644 src/servers/api/vault/websocket.ts create mode 100644 src/servers/sidecar/vault/index.ts create mode 100644 src/servers/sidecar/vault/upstream.ts diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index f197da01..03788cce 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -42,5 +42,11 @@ module.exports = { args: 'run src/servers/sidecar/music/index.ts', watch: false, }, + { + name: 'officer-vault', + script: 'bun', + args: 'run src/servers/sidecar/vault/index.ts', + watch: false, + }, ], }; diff --git a/src/server.tsx b/src/server.tsx index 8c66487d..2fca4e2b 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -11,6 +11,7 @@ import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor'; import { cliampWebsocket } from './servers/api/cliamp/websocket'; import { cliampAudioWebsocket } from './servers/api/cliamp/audio-ws'; import { desktopWebsocket } from './servers/api/desktop/websocket'; +import { vaultWebsocket, upgradeVaultWs } from './servers/api/vault/websocket'; import { findEntryByProxyId, touchEntry } from './servers/api/dev-server/router'; import officerWeb from './apps/officer-web/index.gen.html'; import { startBrowserRelay } from './servers/api/browser/relay'; @@ -42,6 +43,7 @@ type WSData = { | 'cliamp' | 'cliamp-audio' | 'desktop' + | 'vault' | 'sidecar'; sessionId?: string; cwd?: string; @@ -145,6 +147,7 @@ const handlers: Record = { cliamp: cliampWebsocket, 'cliamp-audio': cliampAudioWebsocket, desktop: desktopWebsocket, + vault: vaultWebsocket, sidecar: sidecarWebsocket, }; @@ -315,6 +318,12 @@ const server = serve({ if (req.headers.get('upgrade') === 'websocket') return upgradeDevServerWs(req, server); return honoServer.fetch(req, server); }, + // Vaultwarden notifications hub: upgrade the WebSocket here (proxied to upstream by vaultWebsocket); + // everything else on this path (SignalR long-poll negotiate/poll) falls through to the HTTP proxy. + '/api/vault/notifications/*': (req, server) => { + if (req.headers.get('upgrade') === 'websocket') return upgradeVaultWs(req, server); + return honoServer.fetch(req, server); + }, '/api/sidecar/register': (req: Request, server: any) => { const ok = server.upgrade(req, { data: { provider: 'sidecar', userId: 0, email: '', username: '' }, diff --git a/src/servers/_middlewares/origin-validation.ts b/src/servers/_middlewares/origin-validation.ts index cfe5ec34..f6856485 100644 --- a/src/servers/_middlewares/origin-validation.ts +++ b/src/servers/_middlewares/origin-validation.ts @@ -4,7 +4,7 @@ import { IS_DEV_BUILD } from '../build-env'; import { verify } from '../jwt'; import { isSuperAdmin } from '../super-admin'; -const { PUBLIC_URL, OFFICER_APP_ORIGIN, MUSIC_APP_ORIGIN } = process.env; +const { PUBLIC_URL, OFFICER_APP_ORIGIN, MUSIC_APP_ORIGIN, OFFICER_VAULT_ORIGIN } = process.env; // The allowed production web origin comes from PUBLIC_URL in .env (e.g. https://officer.pastilhas.dev), // not a hardcoded domain. @@ -33,6 +33,10 @@ const APP_ORIGINS: string[] = [ // Standalone officer-music app — its own custom-scheme origin. Allowlisted so it can authenticate // and stream; SCOPED_ORIGINS below restricts it to /api/auth + /api/music only. MUSIC_APP_ORIGIN, + // OffVault app (Bitwarden-SDK client) — its own custom-scheme origin. Allowlisted so it can reach the + // Vaultwarden reverse-proxy; ORIGIN_RULES below restricts it to /api/vault only. It authenticates to + // Vaultwarden with its own bearer token (not a platform account), so no super-admin rule applies. + OFFICER_VAULT_ORIGIN, ].filter((o): o is string => Boolean(o)); // The only path prefixes a non-owner account (and the music app) may reach. @@ -54,6 +58,9 @@ const ORIGIN_RULES: Record = {}; if (PUBLIC_ORIGIN) ORIGIN_RULES[PUBLIC_ORIGIN] = { superAdminOnly: true }; if (OFFICER_APP_ORIGIN) ORIGIN_RULES[OFFICER_APP_ORIGIN] = { superAdminOnly: true }; if (MUSIC_APP_ORIGIN) ORIGIN_RULES[MUSIC_APP_ORIGIN] = { paths: NON_OWNER_PATHS }; +// OffVault may reach ONLY the Vaultwarden proxy. No superAdminOnly: its callers hold Bitwarden tokens, +// not platform accounts, so the account backstop above never applies to them (verify() → null → passes). +if (OFFICER_VAULT_ORIGIN) ORIGIN_RULES[OFFICER_VAULT_ORIGIN] = { paths: ['/api/vault'] }; // True when an Origin is reserved for the platform owner (used at signin to reject a non-owner login). export function isSuperAdminOnlyOrigin(origin: string | undefined): boolean { diff --git a/src/servers/api/vault/proxy-util.ts b/src/servers/api/vault/proxy-util.ts new file mode 100644 index 00000000..5102f832 --- /dev/null +++ b/src/servers/api/vault/proxy-util.ts @@ -0,0 +1,29 @@ +// Generic proxy header hygiene for the platform-side /api/vault forwarder. The platform knows nothing +// about Vaultwarden — it only forwards faithfully to the officer-vault sidecar (loopback). Bodies are +// never touched; we only drop hop-by-hop headers and redact token-shaped query strings from logs. + +const HOP_BY_HOP = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'host', +]); + +/** Copy headers, dropping only hop-by-hop / proxy-* ones. Authorization + device headers pass through. */ +export function stripHopByHop(src: Headers): Headers { + const out = new Headers(); + src.forEach((value, key) => { + const k = key.toLowerCase(); + if (HOP_BY_HOP.has(k) || k.startsWith('proxy-')) return; + out.set(key, value); + }); + return out; +} + +const REDACT = /([?&](access_token|refresh_token|code|token)=)[^&]*/gi; +export const redactPath = (pathAndQuery: string): string => pathAndQuery.replace(REDACT, '$1'); diff --git a/src/servers/api/vault/router.ts b/src/servers/api/vault/router.ts new file mode 100644 index 00000000..323cf25d --- /dev/null +++ b/src/servers/api/vault/router.ts @@ -0,0 +1,49 @@ +import { createRouter } from '../../create-router'; +import { getVaultServerUrl } from './sidecar-server'; +import { stripHopByHop, redactPath } from './proxy-util'; + +// Thin, origin-gated forwarder for /api/vault/* → the officer-vault sidecar, which OWNS the transparent +// reverse-proxy to Vaultwarden (its URL, paths, and notifications WebSocket all live there). The platform's +// only jobs here are AUTH/ORIGIN (via the global originScopeMiddleware — OFFICER_VAULT_ORIGIN → /api/vault) +// and faithful FORWARDING. We never touch bodies: method/path/query/headers/status and both body streams +// pass through verbatim. +// +// Mounted TOP-LEVEL at /api/vault (see hono.ts) — NOT under the protected router: the Bitwarden client +// carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. The +// notifications WebSocket is upgraded at the serve level (server.tsx) and likewise forwarded to the sidecar. + +export const vaultRouter = createRouter(); + +const PREFIX = '/api/vault'; + +vaultRouter.all('/*', async (ctx) => { + const base = getVaultServerUrl(); + if (!base) return ctx.text('Vault sidecar not available', 503); + + const url = new URL(ctx.req.url); + // Strip the mount prefix so the sidecar sees Vaultwarden-root paths (/identity, /api, /notifications…). + const subpath = url.pathname.slice(PREFIX.length) || '/'; + const target = `${base}${subpath}${url.search}`; + const method = ctx.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(ctx.req.raw.headers), + body: hasBody ? ctx.req.raw.body : undefined, + redirect: 'manual', + }; + if (hasBody) init.duplex = 'half'; + + let upstream: Response; + try { + upstream = await fetch(target, init); + } catch { + console.error(`[vault] ${method} ${redactPath(subpath + url.search)} -> sidecar unreachable`); + return ctx.text('Vault sidecar unreachable', 502); + } + + return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) }); +}); diff --git a/src/servers/api/vault/sidecar-server.ts b/src/servers/api/vault/sidecar-server.ts new file mode 100644 index 00000000..4a35ff58 --- /dev/null +++ b/src/servers/api/vault/sidecar-server.ts @@ -0,0 +1,24 @@ +import * as sidecar from '@@/sidecar-registry'; + +// The officer-vault sidecar starts its Vaultwarden reverse-proxy on a random loopback port and reports it +// here on connect. We remember it so `/api/vault/*` (HTTP) and the notifications WebSocket always forward +// to the current sidecar. The platform holds NO knowledge of Vaultwarden itself — only where the sidecar is. + +let serverPort: number | null = null; + +sidecar.on('vault:server', (msg) => { + const port = (msg as { port?: number }).port; + if (typeof port !== 'number') return; + serverPort = port; + console.log(`[vault] sidecar proxy registered on port ${port}`); +}); + +/** Base URL of the sidecar's HTTP proxy, or null if the sidecar hasn't reported in yet. */ +export function getVaultServerUrl(): string | null { + return serverPort ? `http://127.0.0.1:${serverPort}` : null; +} + +/** Same, as a WebSocket base for the notifications hub. */ +export function getVaultServerWsUrl(): string | null { + return serverPort ? `ws://127.0.0.1:${serverPort}` : null; +} diff --git a/src/servers/api/vault/websocket.ts b/src/servers/api/vault/websocket.ts new file mode 100644 index 00000000..8c59e349 --- /dev/null +++ b/src/servers/api/vault/websocket.ts @@ -0,0 +1,120 @@ +import type { ServerWebSocket } from 'bun'; +import { getVaultServerWsUrl } from './sidecar-server'; +import { isOriginAllowed } from '../../_middlewares'; + +// Platform side of the Bitwarden notifications WebSocket: origin-gate the client upgrade, then pipe it to +// the officer-vault sidecar (which in turn pipes to Vaultwarden). Dumb pipe: text + binary frames both +// ways, no inspection. The platform holds NO Vaultwarden knowledge — only the sidecar's loopback address. +// SignalR's HTTP long-poll fallback does NOT arrive here — it rides the HTTP proxy (vaultRouter). + +export type VaultWSData = { + provider: 'vault'; + userId: number; + email: string; + username: string; + vaultWsPath: string; // subpath + query after /api/vault, e.g. /notifications/hub?access_token=… + vaultWsProtocol?: string; // requested Sec-WebSocket-Protocol, forwarded to the sidecar +}; + +type UpstreamState = { ws: WebSocket; queue: (string | Uint8Array)[]; ready: 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); + +export const vaultWebsocket = { + open(ws: ServerWebSocket) { + const wsBase = getVaultServerWsUrl(); + if (!wsBase) { + ws.close(1011, 'Vault sidecar not available'); + return; + } + + const url = `${wsBase}${ws.data.vaultWsPath}`; + const protocols = ws.data.vaultWsProtocol + ? ws.data.vaultWsProtocol + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : undefined; + const upstream = protocols?.length ? new WebSocket(url, protocols) : new WebSocket(url); + upstream.binaryType = 'arraybuffer'; + + const state: UpstreamState = { ws: upstream, queue: [], ready: false }; + upstreams.set(ws, state); + + 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.send(payload); + else state.queue.push(payload); // buffer until the sidecar socket opens + }, + close(ws: ServerWebSocket) { + const state = upstreams.get(ws); + if (state) { + try { + state.ws.close(); + } catch { + /* already closed */ + } + upstreams.delete(ws); + } + }, + drain() {}, +}; + +const PREFIX = '/api/vault'; + +// Serve-level upgrade for /api/vault/notifications/* WebSockets. Origin-gated: only an allow-listed origin +// (the OffVault app in prod; anything in dev) may connect — mirroring the HTTP origin gate. The Bitwarden +// access_token rides the query string and is forwarded untouched; we do NOT verify it as a platform JWT +// (it isn't one), matching the "don't inject platform session auth" caveat. +export function upgradeVaultWs(req: Request, server: any): Response | undefined { + const origin = req.headers.get('origin') ?? undefined; + const host = req.headers.get('host') ?? undefined; + if (!isOriginAllowed(origin, host)) return new Response('Forbidden', { status: 403 }); + + const url = new URL(req.url); + const data: VaultWSData = { + provider: 'vault', + userId: 0, + email: '', + username: '', + vaultWsPath: url.pathname.slice(PREFIX.length) + url.search, + vaultWsProtocol: req.headers.get('sec-websocket-protocol') ?? undefined, + }; + const ok = server.upgrade(req, { data }); + if (!ok) return new Response('Upgrade failed', { status: 500 }); + return undefined; +} diff --git a/src/servers/hono.ts b/src/servers/hono.ts index a98f2821..3be70d0a 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -20,9 +20,11 @@ import { dashboardsRouter } from './api/dashboards'; import { taskLogsRouter } from './api/task-logs/task-logs'; import { router as fileBrowserRouter } from './api/file-browser/router'; import { musicRouter } from './api/music/router'; +import { vaultRouter } from './api/vault/router'; import { systemMonitorRouter } from './api/system-monitor/system-monitor'; import { activityRouter } from './api/activity/router'; import './api/music/sidecar-server'; // side-effect: capture the officer-music audio server port +import './api/vault/sidecar-server'; // side-effect: capture the officer-vault reverse-proxy port import { devServerRouter, devServerProxyRouter } from './api/dev-server/router'; import { dockRouter } from './api/dock/dock'; import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations'; @@ -66,6 +68,11 @@ honoServer.route('/api/landing-page-data', landingPageDataRouter); honoServer.route('/api/waitlist', waitlistRouter); honoServer.route('/api/dev-server-proxy', devServerProxyRouter); honoServer.route('/api/app-serve', appServeRouter); +// Vaultwarden reverse-proxy — mounted TOP-LEVEL (not under protectedRouter): the Bitwarden client +// carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. Origin +// gating still applies via originScopeMiddleware above (OFFICER_VAULT_ORIGIN → /api/vault). The +// notifications WebSocket is upgraded at the serve level (server.tsx). +honoServer.route('/api/vault', vaultRouter); honoServer.get('/api/integrations/google/callback', googleCallbackHandler); honoServer.post('/api/hooks/claude-done', async (ctx) => { const body = await ctx.req.json().catch(() => null); diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index b8011117..bd2b4108 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -58,6 +58,8 @@ export type SidecarEvent = | { type: 'opencode:error'; id: string; error: string } // Music — the sidecar reports where its audio-streaming HTTP server is listening (random port) on connect | { type: 'music:server'; port: number } + // Vault — the sidecar reports where its Vaultwarden reverse-proxy HTTP/WS server is listening on connect + | { type: 'vault:server'; port: number } // Generic | { type: 'error'; id?: string; error: string }; diff --git a/src/servers/sidecar/vault/index.ts b/src/servers/sidecar/vault/index.ts new file mode 100644 index 00000000..7919116e --- /dev/null +++ b/src/servers/sidecar/vault/index.ts @@ -0,0 +1,237 @@ +import type { ServerWebSocket } from 'bun'; +import type { SidecarCommand, SidecarEvent } from '../protocol'; +import { createSidecarConnector } from '../connect'; +import { getVaultBase, getVaultWsBase, stripHopByHop, redactPath } from './upstream'; + +// The officer-vault sidecar. Same philosophy as the other officer-* sidecars: a singleton process that +// registers with the API server and OWNS a contract — here, a transparent reverse-proxy to a self-hosted +// Vaultwarden (Bitwarden server). The platform API is just a thin, origin-gated forwarder to us; ALL +// knowledge of Vaultwarden (its URL, its paths, its notifications WebSocket) lives here, so the vault can +// one day be broken off into its own deployable unit without touching the platform. +// +// 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. +// ───────────────────────────────────────────────────────────────────────────────────────────────── + +const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`; + +/** 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; queue: (string | Uint8Array)[]; ready: 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 = { + open(ws: ServerWebSocket) { + const wsBase = getVaultWsBase(); + if (!wsBase) { + ws.close(1011, 'Vault upstream not configured'); + return; + } + const protocols = ws.data.vaultWsProtocol + ? ws.data.vaultWsProtocol + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : undefined; + const upstream = protocols?.length + ? new WebSocket(`${wsBase}${ws.data.vaultWsPath}`, protocols) + : new WebSocket(`${wsBase}${ws.data.vaultWsPath}`); + upstream.binaryType = 'arraybuffer'; + + const state: UpstreamState = { ws: upstream, queue: [], ready: false }; + upstreams.set(ws, state); + + 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.send(payload); + else state.queue.push(payload); // buffer until the upstream socket opens + }, + close(ws: ServerWebSocket) { + const state = upstreams.get(ws); + if (state) { + 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. + if (url.pathname === '/_health') { + const base = getVaultBase(); + if (!base) return Response.json({ ok: false, error: 'VAULTWARDEN_URL not configured' }, { status: 503 }); + const started = Date.now(); + try { + const r = await fetch(`${base}/alive`, { 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 }); + } + } + + // 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 = 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, +}); + +console.log(`[vault] reverse-proxy listening on 127.0.0.1:${port} -> ${getVaultBase() ?? '(VAULTWARDEN_URL unset)'}`); + +// ── 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', + capabilities: ['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')); diff --git a/src/servers/sidecar/vault/upstream.ts b/src/servers/sidecar/vault/upstream.ts new file mode 100644 index 00000000..72549f70 --- /dev/null +++ b/src/servers/sidecar/vault/upstream.ts @@ -0,0 +1,58 @@ +// Vaultwarden upstream config + header hygiene for the officer-vault sidecar. +// +// HARD RULE (see BITWARDEN_SIDECAR_PROMPT.md): the vault is end-to-end encrypted. Nothing here — or in +// the sidecar that uses it — may decrypt, parse, rewrite, or log request/response BODIES. We only compute +// the upstream base URL and strip hop-by-hop headers. Bytes pass through untouched. + +const { VAULTWARDEN_URL } = process.env; + +let warnedUnset = false; + +/** The Vaultwarden base URL (no trailing slash), or null when unconfigured (the sidecar then 503s). */ +export function getVaultBase(): string | null { + const raw = VAULTWARDEN_URL?.trim(); + if (!raw) { + if (!warnedUnset) { + console.warn('[vault] VAULTWARDEN_URL is unset — the sidecar will respond 503 until it is set'); + warnedUnset = true; + } + return null; + } + return raw.replace(/\/+$/, ''); +} + +/** The same base as a WebSocket scheme (http→ws, https→wss) for the notifications hub. */ +export function getVaultWsBase(): string | null { + const base = getVaultBase(); + return base ? base.replace(/^http/i, 'ws') : null; +} + +// Hop-by-hop headers must not cross a proxy hop (RFC 7230 §6.1). `upgrade` is handled by the dedicated +// WebSocket path; `host` is dropped so the outgoing fetch sets the upstream authority itself. Everything +// else — crucially Authorization, Content-Type, and Bitwarden device headers — is forwarded verbatim. +const HOP_BY_HOP = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'host', +]); + +/** Copy headers, dropping only hop-by-hop / proxy-* ones. Used for both request and response directions. */ +export function stripHopByHop(src: Headers): Headers { + const out = new Headers(); + src.forEach((value, key) => { + const k = key.toLowerCase(); + if (HOP_BY_HOP.has(k) || k.startsWith('proxy-')) return; + out.set(key, value); + }); + return out; +} + +// Redact anything token-shaped from a path+query before it reaches a log line. +const REDACT = /([?&](access_token|refresh_token|code|token)=)[^&]*/gi; +export const redactPath = (pathAndQuery: string): string => pathAndQuery.replace(REDACT, '$1');