vault, extracted from the platform into its own repository
The fourth plugin out, after offscale, music and email. Two routers, a sidecar,
three tables and — new — a settings screen, since the vault never had a UI in
the platform.
What it is: a reverse-proxy to a Vaultwarden you already run, plus a credential
broker that keeps the vault session server-side so devices never hold one. It
stores no passwords and cannot read any; every byte through it is ciphertext.
Two things changed rather than moved:
• VAULTWARDEN_URL left .env for a one-row `vault_config` table with a panel
writing it. A plugin cannot ask the owner to edit a file and restart a
process, so the env var could not survive the move. Server-wide, decided
with the owner: one Vaultwarden per install. The sidecar reads it on a 10s
cache rather than per request, since it sits in front of every proxied byte.
• Core stopped importing clearVaultTokens/clearVaultUnlockKey from three auth
handlers. That is the security property behind the distress and panic
buttons, so it could not simply be dropped — the platform gained a small
account-lifecycle hook instead, and the severity mapping moved here where it
belongs. Core now names no table and no column of this plugin.
Two things were deleted rather than carried, both already dark since
2026-08-13 and both recorded in PLUGIN.md:
• The unauthenticated Bitwarden passthrough. A plugin's routes mount behind
Officer's account gate; top-level unauthenticated prefixes are not something
a manifest can declare. Its own comments already called it TEMPORARY,
pending a first-party browser extension that authenticates to Officer.
• The notifications websocket. No plugin owns a socket on this platform yet.
SignalR falls back to HTTP long-polling on its own, so clients still sync.
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
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<ArrayBuffer>)[];
|
||||
ready: boolean;
|
||||
closed: 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 = {
|
||||
// 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<VaultWSData>) {
|
||||
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<VaultWSData>, 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<VaultWSData>) {
|
||||
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<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 = 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}/alive`, { method: 'GET', signal: AbortSignal.timeout(5000) });
|
||||
return Response.json({ ok: r.ok, upstreamStatus: r.status, ms: Date.now() - started, url: base });
|
||||
} 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<string, unknown>).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'));
|
||||
@@ -0,0 +1,105 @@
|
||||
import { getVaultConfig } from '../db/queries';
|
||||
|
||||
// Vaultwarden upstream config + header hygiene for the officer-vault sidecar.
|
||||
//
|
||||
// HARD RULE: 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.
|
||||
//
|
||||
// ── The URL comes from the database now, not VAULTWARDEN_URL ──
|
||||
//
|
||||
// It was `process.env.VAULTWARDEN_URL`, read once at module load, until the plugin extraction on
|
||||
// 2026-08-18. See `db/schema.ts` → `vaultConfig` for why it moved. The consequence here is the only
|
||||
// interesting part: a value that used to be constant for the process lifetime is now something the owner
|
||||
// can change from a settings panel while the sidecar is running.
|
||||
//
|
||||
// ── Why a cache with a TTL, rather than reading per request ──
|
||||
//
|
||||
// This sits in front of every proxied byte, including attachment uploads. A Postgres round trip per
|
||||
// request would put the database on the hot path of a reverse-proxy whose entire design rule is that it
|
||||
// does as little as possible. So the answer is cached.
|
||||
//
|
||||
// The TTL is what makes "change it in the UI" work without a restart, and 10 seconds is chosen against
|
||||
// what the value actually is: a URL the owner edits by hand, roughly never, and then wants to see take
|
||||
// effect. Ten seconds is imperceptible to someone who just pressed Save and free the rest of the time.
|
||||
//
|
||||
// The TTL is the WHOLE mechanism, deliberately — there is no push. `sidecar-registry.ts` carries events
|
||||
// from a sidecar to the platform and typed helpers for the claude/opencode/vnc ones, but no generic
|
||||
// "send this command to the sidecar named X", so the config route in the other process has nothing to
|
||||
// call. Adding one for a value that changes twice a year would be a channel built for a cache hint.
|
||||
//
|
||||
// A failed read keeps serving the last known-good value rather than dropping to null. A database blip
|
||||
// must not turn into "vault not configured", which is indistinguishable on screen from the owner having
|
||||
// never set it up.
|
||||
|
||||
const TTL_MS = 10_000;
|
||||
|
||||
type Cached = { url: string | null; enabled: boolean; at: number };
|
||||
let cache: Cached | null = null;
|
||||
let inflight: Promise<Cached> | null = null;
|
||||
|
||||
async function read(): Promise<Cached> {
|
||||
try {
|
||||
const config = await getVaultConfig();
|
||||
cache = { url: config.url, enabled: config.enabled, at: Date.now() };
|
||||
return cache;
|
||||
} catch (err) {
|
||||
console.error(`[vault] could not read config: ${err instanceof Error ? err.message : String(err)}`);
|
||||
// Keep serving what we had. Only a cold cache (no successful read ever) reports "unconfigured".
|
||||
if (cache) return { ...cache, at: Date.now() };
|
||||
return { url: null, enabled: true, at: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
async function current(): Promise<Cached> {
|
||||
if (cache && Date.now() - cache.at < TTL_MS) return cache;
|
||||
// One read at a time. Under load every request would otherwise miss the same expired entry and fire its
|
||||
// own query — the stampede that makes a cache slower than no cache.
|
||||
inflight ??= read().finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
return inflight;
|
||||
}
|
||||
|
||||
/** The Vaultwarden base URL (no trailing slash), or null when unconfigured/disabled (the sidecar 503s). */
|
||||
export async function getVaultBase(): Promise<string | null> {
|
||||
const { url, enabled } = await current();
|
||||
if (!enabled) return null;
|
||||
return url || null;
|
||||
}
|
||||
|
||||
/** The same base as a WebSocket scheme (http→ws, https→wss) for the notifications hub. */
|
||||
export async function getVaultWsBase(): Promise<string | null> {
|
||||
const base = await 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