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.
257 lines
10 KiB
TypeScript
257 lines
10 KiB
TypeScript
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'));
|