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 <noreply@anthropic.com>
This commit is contained in:
@@ -42,5 +42,11 @@ module.exports = {
|
|||||||
args: 'run src/servers/sidecar/music/index.ts',
|
args: 'run src/servers/sidecar/music/index.ts',
|
||||||
watch: false,
|
watch: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'officer-vault',
|
||||||
|
script: 'bun',
|
||||||
|
args: 'run src/servers/sidecar/vault/index.ts',
|
||||||
|
watch: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor';
|
|||||||
import { cliampWebsocket } from './servers/api/cliamp/websocket';
|
import { cliampWebsocket } from './servers/api/cliamp/websocket';
|
||||||
import { cliampAudioWebsocket } from './servers/api/cliamp/audio-ws';
|
import { cliampAudioWebsocket } from './servers/api/cliamp/audio-ws';
|
||||||
import { desktopWebsocket } from './servers/api/desktop/websocket';
|
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 { findEntryByProxyId, touchEntry } from './servers/api/dev-server/router';
|
||||||
import officerWeb from './apps/officer-web/index.gen.html';
|
import officerWeb from './apps/officer-web/index.gen.html';
|
||||||
import { startBrowserRelay } from './servers/api/browser/relay';
|
import { startBrowserRelay } from './servers/api/browser/relay';
|
||||||
@@ -42,6 +43,7 @@ type WSData = {
|
|||||||
| 'cliamp'
|
| 'cliamp'
|
||||||
| 'cliamp-audio'
|
| 'cliamp-audio'
|
||||||
| 'desktop'
|
| 'desktop'
|
||||||
|
| 'vault'
|
||||||
| 'sidecar';
|
| 'sidecar';
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
@@ -145,6 +147,7 @@ const handlers: Record<string, any> = {
|
|||||||
cliamp: cliampWebsocket,
|
cliamp: cliampWebsocket,
|
||||||
'cliamp-audio': cliampAudioWebsocket,
|
'cliamp-audio': cliampAudioWebsocket,
|
||||||
desktop: desktopWebsocket,
|
desktop: desktopWebsocket,
|
||||||
|
vault: vaultWebsocket,
|
||||||
sidecar: sidecarWebsocket,
|
sidecar: sidecarWebsocket,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -315,6 +318,12 @@ const server = serve({
|
|||||||
if (req.headers.get('upgrade') === 'websocket') return upgradeDevServerWs(req, server);
|
if (req.headers.get('upgrade') === 'websocket') return upgradeDevServerWs(req, server);
|
||||||
return honoServer.fetch(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) => {
|
'/api/sidecar/register': (req: Request, server: any) => {
|
||||||
const ok = server.upgrade(req, {
|
const ok = server.upgrade(req, {
|
||||||
data: { provider: 'sidecar', userId: 0, email: '', username: '' },
|
data: { provider: 'sidecar', userId: 0, email: '', username: '' },
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { IS_DEV_BUILD } from '../build-env';
|
|||||||
import { verify } from '../jwt';
|
import { verify } from '../jwt';
|
||||||
import { isSuperAdmin } from '../super-admin';
|
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),
|
// The allowed production web origin comes from PUBLIC_URL in .env (e.g. https://officer.pastilhas.dev),
|
||||||
// not a hardcoded domain.
|
// 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
|
// 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.
|
// and stream; SCOPED_ORIGINS below restricts it to /api/auth + /api/music only.
|
||||||
MUSIC_APP_ORIGIN,
|
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));
|
].filter((o): o is string => Boolean(o));
|
||||||
|
|
||||||
// The only path prefixes a non-owner account (and the music app) may reach.
|
// The only path prefixes a non-owner account (and the music app) may reach.
|
||||||
@@ -54,6 +58,9 @@ const ORIGIN_RULES: Record<string, OriginRule> = {};
|
|||||||
if (PUBLIC_ORIGIN) ORIGIN_RULES[PUBLIC_ORIGIN] = { superAdminOnly: true };
|
if (PUBLIC_ORIGIN) ORIGIN_RULES[PUBLIC_ORIGIN] = { superAdminOnly: true };
|
||||||
if (OFFICER_APP_ORIGIN) ORIGIN_RULES[OFFICER_APP_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 };
|
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).
|
// 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 {
|
export function isSuperAdminOnlyOrigin(origin: string | undefined): boolean {
|
||||||
|
|||||||
@@ -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<redacted>');
|
||||||
@@ -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) });
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<ArrayBuffer>)[]; ready: boolean };
|
||||||
|
const upstreams = new Map<ServerWebSocket<VaultWSData>, 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<ArrayBuffer> =>
|
||||||
|
typeof raw === 'string' ? raw : (raw as Uint8Array<ArrayBuffer>);
|
||||||
|
|
||||||
|
export const vaultWebsocket = {
|
||||||
|
open(ws: ServerWebSocket<VaultWSData>) {
|
||||||
|
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<VaultWSData>, 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<VaultWSData>) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -20,9 +20,11 @@ import { dashboardsRouter } from './api/dashboards';
|
|||||||
import { taskLogsRouter } from './api/task-logs/task-logs';
|
import { taskLogsRouter } from './api/task-logs/task-logs';
|
||||||
import { router as fileBrowserRouter } from './api/file-browser/router';
|
import { router as fileBrowserRouter } from './api/file-browser/router';
|
||||||
import { musicRouter } from './api/music/router';
|
import { musicRouter } from './api/music/router';
|
||||||
|
import { vaultRouter } from './api/vault/router';
|
||||||
import { systemMonitorRouter } from './api/system-monitor/system-monitor';
|
import { systemMonitorRouter } from './api/system-monitor/system-monitor';
|
||||||
import { activityRouter } from './api/activity/router';
|
import { activityRouter } from './api/activity/router';
|
||||||
import './api/music/sidecar-server'; // side-effect: capture the officer-music audio server port
|
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 { devServerRouter, devServerProxyRouter } from './api/dev-server/router';
|
||||||
import { dockRouter } from './api/dock/dock';
|
import { dockRouter } from './api/dock/dock';
|
||||||
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
|
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/waitlist', waitlistRouter);
|
||||||
honoServer.route('/api/dev-server-proxy', devServerProxyRouter);
|
honoServer.route('/api/dev-server-proxy', devServerProxyRouter);
|
||||||
honoServer.route('/api/app-serve', appServeRouter);
|
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.get('/api/integrations/google/callback', googleCallbackHandler);
|
||||||
honoServer.post('/api/hooks/claude-done', async (ctx) => {
|
honoServer.post('/api/hooks/claude-done', async (ctx) => {
|
||||||
const body = await ctx.req.json().catch(() => null);
|
const body = await ctx.req.json().catch(() => null);
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export type SidecarEvent =
|
|||||||
| { type: 'opencode:error'; id: string; error: string }
|
| { type: 'opencode:error'; id: string; error: string }
|
||||||
// Music — the sidecar reports where its audio-streaming HTTP server is listening (random port) on connect
|
// Music — the sidecar reports where its audio-streaming HTTP server is listening (random port) on connect
|
||||||
| { type: 'music:server'; port: number }
|
| { 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
|
// Generic
|
||||||
| { type: 'error'; id?: string; error: string };
|
| { type: 'error'; id?: string; error: string };
|
||||||
|
|
||||||
|
|||||||
@@ -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<ArrayBuffer>)[]; ready: boolean };
|
||||||
|
const upstreams = new Map<ServerWebSocket<VaultWSData>, 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<ArrayBuffer> =>
|
||||||
|
typeof raw === 'string' ? raw : (raw as Uint8Array<ArrayBuffer>);
|
||||||
|
|
||||||
|
const wsHandler = {
|
||||||
|
open(ws: ServerWebSocket<VaultWSData>) {
|
||||||
|
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<VaultWSData>, 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<VaultWSData>) {
|
||||||
|
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<VaultWSData>({
|
||||||
|
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<string, unknown>).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'));
|
||||||
@@ -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<redacted>');
|
||||||
Reference in New Issue
Block a user