health: probe /api/config, and report when the target is not a Vaultwarden
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>
This commit is contained in:
+40
-3
@@ -137,14 +137,51 @@ const server = Bun.serve<VaultWSData>({
|
|||||||
async fetch(req, server) {
|
async fetch(req, server) {
|
||||||
const url = new URL(req.url);
|
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') {
|
if (url.pathname === '/_health') {
|
||||||
const base = await getVaultBase();
|
const base = await getVaultBase();
|
||||||
if (!base) return Response.json({ ok: false, error: 'no Vaultwarden URL configured' }, { status: 503 });
|
if (!base) return Response.json({ ok: false, error: 'no Vaultwarden URL configured' }, { status: 503 });
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${base}/alive`, { method: 'GET', signal: AbortSignal.timeout(5000) });
|
const r = await fetch(`${base}/api/config`, { method: 'GET', signal: AbortSignal.timeout(5000) });
|
||||||
return Response.json({ ok: r.ok, upstreamStatus: r.status, ms: Date.now() - started, url: base });
|
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 {
|
} catch {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ ok: false, error: 'upstream unreachable', ms: Date.now() - started, url: base },
|
{ ok: false, error: 'upstream unreachable', ms: Date.now() - started, url: base },
|
||||||
|
|||||||
+18
-1
@@ -25,6 +25,11 @@ type HealthResult = {
|
|||||||
upstreamStatus?: number;
|
upstreamStatus?: number;
|
||||||
ms?: number;
|
ms?: number;
|
||||||
url?: string;
|
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 }) => (
|
const Field = ({ label, children }: { label: string; children: React.ReactNode }) => (
|
||||||
@@ -230,10 +235,22 @@ export const VaultPanel = () => {
|
|||||||
{health ? (
|
{health ? (
|
||||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Pill tone={health.ok ? 'success' : 'danger'}>{health.ok ? 'Answering' : 'No answer'}</Pill>
|
{/* 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. */}
|
||||||
|
<Pill tone={health.ok ? (health.identified ? 'success' : 'warning') : 'danger'}>
|
||||||
|
{health.ok ? (health.identified ? 'Vaultwarden' : 'Answered, but not a Vaultwarden') : 'No answer'}
|
||||||
|
</Pill>
|
||||||
|
{health.version && <span>v{health.version}</span>}
|
||||||
{health.ms !== undefined && <span>{health.ms}ms</span>}
|
{health.ms !== undefined && <span>{health.ms}ms</span>}
|
||||||
{health.upstreamStatus !== undefined && <span>HTTP {health.upstreamStatus}</span>}
|
{health.upstreamStatus !== undefined && <span>HTTP {health.upstreamStatus}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{health.ok && !health.identified && (
|
||||||
|
<span className={toneText.warning}>
|
||||||
|
Something is listening there, but it did not identify itself as a Vaultwarden. Check the
|
||||||
|
port.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{health.error && <span className={toneText.danger}>{health.error}</span>}
|
{health.error && <span className={toneText.danger}>{health.error}</span>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user