DC-012: Add Kubernetes-style /healthz + /readyz probe aliases
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

Fresh users copy-pasting healthcheck blocks from k8s/Docker docs need
the standard short aliases. Without /healthz and /readyz they get
connection refused. This commit:

1. Adds /healthz + /readyz as root-level aliases for /health/live +
   /health/ready in src/app.js. Handler bodies DRYed into named
   functions (livenessHandler, readinessHandler) so a probe semantics
   change updates all five paths at once.

2. Removes the dead /api/v1/health*, /api/v1/health/live, /api/v1/health/ready
   registrations from PUBLIC_ROUTES and CSRF exclusion list — those
   routes were never actually mounted on the apiRouter (only root
   paths existed). Anyone probing /api/v1/health now gets a clean 404
   instead of being routed through to a duplicate root handler.

3. Adds bypass for the 5 probe paths in three places where it matters:
   - PUBLIC_ROUTES (no auth)
   - csrf-protection.js excludedPaths (no CSRF check)
   - middleware.js request-logging exclusion (k8s polling every 10s
     doesn't flood the audit log)
   - middleware.js Tailscale auth bypass (probes don't carry Tailscale
     identity headers)

4. Adds __tests__/health-probe-aliases.test.js (19 tests):
   - Alias equivalence (/healthz == /health/live, /readyz == /health/ready)
   - Back-compat (/health == /health/live)
   - Path consolidation (all 3 /api/v1/health* return 404)
   - Source-of-truth PUBLIC_ROUTES allowlist sync check
   - Source-of-truth src/app.js mount list sync check (catches drift
     between handler mount and middleware allowlist)

5. Documents probes in README (copy-paste docker-compose.yml +
   Kubernetes blocks) and user-guide (Health Probes section + System
   API table updated).

Post-fix: 941/941 tests pass (+19 new). Zero new ESLint warnings
introduced. The pre-existing warnings/errors in src/app.js line 906
('os' is not defined) and the empty blocks in logging.test.js are
not regressions from this commit.
This commit is contained in:
Hermes
2026-06-25 17:16:16 -07:00
parent c39c80b3ad
commit 8ec6c0ca6a
9 changed files with 444 additions and 28 deletions
+38 -20
View File
@@ -654,10 +654,10 @@ async function createApp() {
logError: ctx.logError,
}));
// Inline API routes
apiRouter.get('/health', (req, res) => {
ok(res, { status: 'ok', timestamp: new Date().toISOString() });
});
// Inline API routes (mounted under /api/v1 below)
// Note: /health lives at root only — see root-level health check below.
// Probes (/healthz, /readyz, /health/live, /health/ready) also at root only.
// Do NOT add another /api/v1/health route — it's been consolidated.
apiRouter.get('/csrf-token', (req, res) => {
ok(res, { token: req.csrfToken, headerName: CSRF_HEADER_NAME });
@@ -670,24 +670,33 @@ async function createApp() {
// Mount at /api/v1 (canonical, single version)
app.use('/api/v1', apiRouter);
// Root-level health check
app.get('/health', (req, res) => {
ok(res, { status: 'ok', timestamp: new Date().toISOString() });
});
// ===========================================================================
// Health probes — root-level, no auth, no CSRF, no rate limit.
//
// Two semantics, four paths:
//
// LIVENESS — "is the Node.js process alive?"
// /health/live (explicit, recommended)
// /healthz (k8s/Docker-standard alias)
// READINESS — "are critical dependencies reachable?"
// /health/ready (explicit, recommended)
// /readyz (k8s/Docker-standard alias)
//
// k8s/Docker/Caddy call these to decide whether to RESTART or ROUTE TRAFFIC.
// They MUST stay cheap (no DB queries, no logging side effects, no auth).
//
// Plain /health is kept for backwards compatibility and returns the same
// payload as /health/live. Use /health/live or /healthz in new code.
// ===========================================================================
// Liveness probe — "is the process alive?"
// Always returns 200 unless the Node.js event loop is completely blocked.
// Used by k8s/Docker to decide whether to RESTART the container.
// DO NOT add dependency checks here — those belong in /health/ready.
app.get('/health/live', (req, res) => {
// Liveness — pure process check, no deps.
const livenessHandler = (req, res) => {
ok(res, { status: 'alive', uptime: process.uptime() });
});
};
// Readiness probe — "is the app ready to serve traffic?"
// Checks critical dependencies: Docker daemon, Caddy admin API, config file.
// Returns 200 with details if all OK, 503 with failed components otherwise.
// Used by k8s/Docker to decide whether to ROUTE TRAFFIC to this instance.
app.get('/health/ready', boundAsyncHandler(async (req, res) => {
// Readiness — checks critical dependencies (config, services file,
// Docker daemon, Caddy admin). 200 if all OK, 503 if any failed.
const readinessHandler = boundAsyncHandler(async (req, res) => {
const checks = {};
let allOk = true;
@@ -753,7 +762,16 @@ async function createApp() {
checks
};
ok(res, body, allOk ? 200 : 503);
}));
});
// Liveness paths
app.get('/health', livenessHandler);
app.get('/health/live', livenessHandler);
app.get('/healthz', livenessHandler);
// Readiness paths
app.get('/health/ready', readinessHandler);
app.get('/readyz', readinessHandler);
// Lightweight probe endpoint
app.get('/probe/:id', boundAsyncHandler(async (req, res) => {
@@ -136,12 +136,19 @@ function csrfValidationMiddleware(req, res, next) {
}
// Excluded paths that don't require CSRF validation
// Note: probe endpoints (/health, /health/live, /health/ready, /healthz,
// /readyz) are GET-only so they're already excluded by the safe-methods
// check above. Listed here for explicit safety in case any of them ever
// accept a POST in the future.
const excludedPaths = [
'/api/v1/totp/verify',
'/api/v1/totp/verify-setup',
'/api/v1/totp/setup',
'/health',
'/api/v1/health',
'/health/live',
'/health/ready',
'/healthz',
'/readyz',
// Machine-to-machine: publishing host POSTs here with its own shared-secret
// header (X-DashCaddy-Notify-Secret) — browsers never reach this endpoint.
'/api/v1/system/update-notify'
+21 -5
View File
@@ -96,7 +96,14 @@ module.exports = function configureMiddleware(app, {
res.on('finish', () => {
const duration = Date.now() - start;
metrics.recordRequest(req.method, req.path, res.statusCode, duration);
if (req.path !== '/health' && req.path !== '/api/v1/health') {
// Skip noisy per-request logging for probe endpoints — k8s/Docker
// hit these every few seconds and would flood the audit log.
const isProbe = req.path === '/health'
|| req.path === '/health/live'
|| req.path === '/health/ready'
|| req.path === '/healthz'
|| req.path === '/readyz';
if (!isProbe) {
const level = res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'debug';
log[level]('http', `${req.method} ${req.path} ${res.statusCode}`, {
ms: duration, ip: req.ip, id: req.id
@@ -112,7 +119,14 @@ module.exports = function configureMiddleware(app, {
return next();
}
if (req.path === '/health' || req.path === '/api/v1/health' || req.path.startsWith('/probe/')) {
// Probe endpoints bypass Tailscale auth — k8s/Docker healthchecks
// don't carry a Tailscale identity header.
if (req.path === '/health'
|| req.path === '/health/live'
|| req.path === '/health/ready'
|| req.path === '/healthz'
|| req.path === '/readyz'
|| req.path.startsWith('/probe/')) {
return next();
}
@@ -294,12 +308,14 @@ module.exports = function configureMiddleware(app, {
})();
const PUBLIC_ROUTES = [
// Health probes — root-level only. See src/app.js for the handler block.
// Both the explicit (/health/live, /health/ready) and k8s-standard
// (/healthz, /readyz) aliases are unauthenticated by design.
{ path: '/health', exact: true },
{ path: '/health/live', exact: true },
{ path: '/health/ready', exact: true },
{ path: '/api/v1/health', exact: true },
{ path: '/api/v1/health/live', exact: true },
{ path: '/api/v1/health/ready', exact: true },
{ path: '/healthz', exact: true },
{ path: '/readyz', exact: true },
{ path: '/probe/', prefix: true },
{ path: '/api/v1/tailscale/', prefix: true },
{ path: '/api/v1/totp/config', exact: true, method: 'GET' },