From 680b8fb2ba6e46f0f36d74ae1895cb15a398a992 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Tue, 18 Aug 2026 13:56:35 +0000 Subject: [PATCH] health: probe /api/config, and report when the target is not a Vaultwarden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- sidecar/index.ts | 43 ++++++++++++++++++++++++++++++++++++++++--- web/VaultPanel.tsx | 19 ++++++++++++++++++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/sidecar/index.ts b/sidecar/index.ts index 0184085..eaf8557 100644 --- a/sidecar/index.ts +++ b/sidecar/index.ts @@ -137,14 +137,51 @@ const server = Bun.serve({ async fetch(req, server) { const url = new URL(req.url); - // Reachability probe — ours, never touches vault data. + /* + 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}/alive`, { method: 'GET', signal: AbortSignal.timeout(5000) }); - return Response.json({ ok: r.ok, upstreamStatus: r.status, ms: Date.now() - started, url: base }); + 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; + const server = (body.server ?? {}) as Record; + 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 }, diff --git a/web/VaultPanel.tsx b/web/VaultPanel.tsx index 8b9f42f..dc40a53 100644 --- a/web/VaultPanel.tsx +++ b/web/VaultPanel.tsx @@ -25,6 +25,11 @@ type HealthResult = { upstreamStatus?: number; ms?: number; url?: string; + /** `server.name` from Vaultwarden's own `/api/config`, or null if it did not identify itself. */ + server?: string | null; + version?: string | null; + /** Answered 200 AND said it is a Vaultwarden. False means something else is on that address. */ + identified?: boolean; }; const Field = ({ label, children }: { label: string; children: React.ReactNode }) => ( @@ -230,10 +235,22 @@ export const VaultPanel = () => { {health ? (
- {health.ok ? 'Answering' : 'No answer'} + {/* Three outcomes, not two. "Answered but is not a Vaultwarden" is its own state and the + main reason this check exists — pointing at the wrong port on a busy host gives a + perfectly good 200 from something that can never serve a vault. */} + + {health.ok ? (health.identified ? 'Vaultwarden' : 'Answered, but not a Vaultwarden') : 'No answer'} + + {health.version && v{health.version}} {health.ms !== undefined && {health.ms}ms} {health.upstreamStatus !== undefined && HTTP {health.upstreamStatus}}
+ {health.ok && !health.identified && ( + + Something is listening there, but it did not identify itself as a Vaultwarden. Check the + port. + + )} {health.error && {health.error}}
) : (