Was /alive, which answers on a Vaultwarden and proves only that SOMETHING is
listening on that address. /api/config self-identifies:
{"version":"2026.6.0","server":{"name":"Vaultwarden",...}}
That distinction is the point of the check. The failure worth catching is not
"the box is down" — that surfaces on its own the moment anything else fails —
it is pointing the vault at the wrong port on a host running several things,
where a 200 from an unrelated service reads as success and the real problem
appears later as a login that cannot work.
So the panel now has three states rather than two: reachable and identified,
reachable but NOT a Vaultwarden (amber, with what to check), or no answer. The
server version is shown when it identifies itself.
Only /api/config is parsed, and only for `server.name` and `version`. It is
unauthenticated and carries no vault data — the hard rule about never reading
bodies governs the proxy path, and this is not on it.
Also recorded in-code: Vaultwarden does not implement /api/status, which does
exist in Bitwarden's official cloud API, so a probe aimed there 404s against a
perfectly healthy server. That near-miss looks exactly like a wrong address.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
294 lines
12 KiB
TypeScript
294 lines
12 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.
|
|
|
|
`/api/config`, not `/alive`. Both answer, and `/alive` is the smaller request, but it only proves
|
|
that SOMETHING is listening on that address. `/api/config` proves the thing listening is actually a
|
|
Vaultwarden, because it self-identifies:
|
|
|
|
{"version":"2026.6.0","server":{"name":"Vaultwarden","url":...}, ...}
|
|
|
|
That distinction is the whole value of the check. The failure this guards against is not "the box is
|
|
down" — the owner finds that out the moment anything else fails — it is pointing the vault at the
|
|
wrong port on a host that runs several things, where a 200 from an unrelated service reads as
|
|
success and the real problem surfaces later as a login that cannot work.
|
|
|
|
Vaultwarden does NOT implement `/api/status`, which exists in Bitwarden's official cloud API; a
|
|
probe aimed there 404s against a perfectly healthy server. Recorded because it is exactly the kind
|
|
of near-miss that looks like a wrong address.
|
|
*/
|
|
if (url.pathname === '/_health') {
|
|
const base = await getVaultBase();
|
|
if (!base) return Response.json({ ok: false, error: 'no Vaultwarden URL configured' }, { status: 503 });
|
|
const started = Date.now();
|
|
try {
|
|
const r = await fetch(`${base}/api/config`, { method: 'GET', signal: AbortSignal.timeout(5000) });
|
|
const ms = Date.now() - started;
|
|
if (!r.ok) {
|
|
return Response.json({ ok: false, upstreamStatus: r.status, ms, url: base }, { status: 502 });
|
|
}
|
|
// Read only what identifies the server. This endpoint is unauthenticated and carries no vault
|
|
// data, which is why it may be parsed at all — the hard rule about never reading bodies is about
|
|
// everything on the proxy path, and this is not on it.
|
|
const body = (await r.json().catch(() => ({}))) as Record<string, unknown>;
|
|
const server = (body.server ?? {}) as Record<string, unknown>;
|
|
const name = typeof server.name === 'string' ? server.name : null;
|
|
return Response.json({
|
|
ok: true,
|
|
upstreamStatus: r.status,
|
|
ms,
|
|
url: base,
|
|
server: name,
|
|
version: typeof body.version === 'string' ? body.version : null,
|
|
// A 200 from something that is not a Vaultwarden is the case this probe exists to catch, so it
|
|
// is reported rather than smoothed over into a plain `ok`.
|
|
identified: name !== null,
|
|
});
|
|
} catch {
|
|
return Response.json(
|
|
{ ok: false, error: 'upstream unreachable', ms: Date.now() - started, url: base },
|
|
{ status: 502 },
|
|
);
|
|
}
|
|
}
|
|
|
|
// Notifications hub: upgrade the WebSocket here (piped to Vaultwarden by wsHandler); the SignalR
|
|
// long-poll fallback is not an upgrade, so it falls through to the HTTP proxy below.
|
|
if (url.pathname.startsWith('/notifications/') && req.headers.get('upgrade') === 'websocket') {
|
|
const ok = server.upgrade(req, {
|
|
data: {
|
|
vaultWsPath: url.pathname + url.search,
|
|
vaultWsProtocol: req.headers.get('sec-websocket-protocol') ?? undefined,
|
|
} satisfies VaultWSData,
|
|
});
|
|
return ok ? undefined : new Response('Upgrade failed', { status: 500 });
|
|
}
|
|
|
|
// Faithful HTTP pass-through to Vaultwarden.
|
|
const base = await getVaultBase();
|
|
if (!base) return new Response('Vault upstream not configured', { status: 503 });
|
|
|
|
const target = `${base}${url.pathname}${url.search}`;
|
|
const method = req.method;
|
|
const hasBody = method !== 'GET' && method !== 'HEAD';
|
|
const started = Date.now();
|
|
|
|
// Bun/undici require half-duplex to stream a request body straight through (attachments can be large).
|
|
const init: RequestInit & { duplex?: 'half' } = {
|
|
method,
|
|
headers: stripHopByHop(req.headers),
|
|
body: hasBody ? req.body : undefined,
|
|
redirect: 'manual', // a transparent proxy passes 3xx through rather than following them
|
|
};
|
|
if (hasBody) init.duplex = 'half';
|
|
|
|
let upstream: Response;
|
|
try {
|
|
upstream = await fetch(target, init);
|
|
} catch {
|
|
console.error(`[vault] ${method} ${redactPath(url.pathname + url.search)} -> upstream unreachable`);
|
|
return new Response('Vault upstream unreachable', { status: 502 });
|
|
}
|
|
|
|
// Log at most method / path / status / duration — never bodies, tokens, or Authorization.
|
|
console.log(`[vault] ${method} ${redactPath(url.pathname)} -> ${upstream.status} ${Date.now() - started}ms`);
|
|
|
|
return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) });
|
|
},
|
|
websocket: wsHandler,
|
|
});
|
|
|
|
// Fire-and-forget: the config read must not hold up the listener, and an unconfigured install is an
|
|
// ordinary state that the panel exists to fix rather than something to announce as a failure.
|
|
void getVaultBase().then((base) => {
|
|
console.log(`[vault] reverse-proxy listening on 127.0.0.1:${port} -> ${base ?? '(no URL configured yet)'}`);
|
|
});
|
|
|
|
// ── Command handlers ──
|
|
|
|
type ReplyFn = (msg: SidecarEvent) => void;
|
|
|
|
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|
switch (cmd.type) {
|
|
case 'ping':
|
|
reply({ type: 'pong', id: cmd.id });
|
|
break;
|
|
default:
|
|
reply({
|
|
type: 'error',
|
|
id: (cmd as SidecarCommand).id,
|
|
error: `Unknown command type: ${(cmd as Record<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'));
|