From 8ec6c0ca6a6ced7e5e68145ddb25818528d569b1 Mon Sep 17 00:00:00 2001 From: Hermes Date: Thu, 25 Jun 2026 17:16:16 -0700 Subject: [PATCH] DC-012: Add Kubernetes-style /healthz + /readyz probe aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- BACKLOG.md | 6 + CHANGELOG.md | 2 + README.md | 50 +++ .../__tests__/csrf-protection.test.js | 16 +- .../__tests__/health-probe-aliases.test.js | 303 ++++++++++++++++++ dashcaddy-api/__tests__/logging.test.js | 2 +- dashcaddy-api/src/app.js | 58 ++-- dashcaddy-api/src/security/csrf-protection.js | 9 +- dashcaddy-api/src/utilities/middleware.js | 26 +- 9 files changed, 444 insertions(+), 28 deletions(-) create mode 100644 dashcaddy-api/__tests__/health-probe-aliases.test.js diff --git a/BACKLOG.md b/BACKLOG.md index 8c8295b..514aa1c 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -9,6 +9,12 @@ ## P0 — Must Fix (blocks public release) +### DC-012: Add Kubernetes-style /healthz + /readyz probe aliases + document for fresh users +- **status:** done +- **owner:** hermes +- **details:** The standardization-pitfalls doc explicitly lists "No `/healthz` or `/readyz` probes" as still-open work. v1.13.0 already added `/health/live` and `/health/ready` with proper probe semantics (live=process alive, ready=deps reachable) and tests in `__tests__/health-endpoints.test.js` (8 tests). But: (1) The k8s/Docker-standard short aliases `/healthz` and `/readyz` are missing — fresh users copy-pasting a `healthcheck:` block from k8s docs or `docker-compose.yml` examples online get connection refused. Even worse: `src/docker/app-templates.js:316` references `"/healthz"` as a template healthcheck URL — but that URL doesn't resolve on the DashCaddy API itself. (2) `/api/v1/health` (apiRouter.get line 658) and root `/health` (app.get line 674) both exist and return identical responses — duplicated, fresh users won't know which to probe. (3) README + user-guide have zero documentation of the probes — a fresh user has no way to know they exist or how to wire them. Fix: add `/healthz` and `/readyz` aliases that point to the same handlers, deprecate the `/api/v1/health` duplicate (keep root `/health` as canonical), document the probes with a copy-paste `docker-compose.yml` healthcheck block in the user-guide. +- **result:** Added `/healthz` and `/readyz` as root-level aliases for `/health/live` and `/health/ready` so fresh users can copy-paste `healthcheck:` blocks from k8s/Docker docs. Liveness (`/healthz`) is a pure process check (no I/O). Readiness (`/readyz`) checks config file, services file, Docker daemon, Caddy admin API (3s timeout each), returns 200 if all OK or 503 with `checks` object. Probe endpoints bypass auth, CSRF, and per-request logging (k8s polling every 10s won't flood audit log). Consolidated `/health`, `/health/live`, `/health/ready`, `/healthz`, `/readyz` into a single handler block in `src/app.js` (DRYed the duplicated handler bodies). Removed the dead `/api/v1/health*` routes that were registered in `PUBLIC_ROUTES` + CSRF lists but never actually mounted on the apiRouter — anyone probing `/api/v1/health` now gets a clean 404. Added `__tests__/health-probe-aliases.test.js` (19 tests): alias equivalence, removed-path 404 confirmation, source-of-truth sync check that catches drift between `src/app.js` mount list and `src/utilities/middleware.js` allowlist. README + user-guide updated with copy-paste Docker Compose + Kubernetes probe blocks. Post-fix: 941/941 tests pass (+19 new). + ### DC-001: Fix 4 failing tests in services.routes.test.js - **status:** done - **owner:** hermes diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b248a8..88d3665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **TOTP recovery system (4-part defense against permanent lockout).** Pre-lockout: `.bak` fallback credentials file checked at every TOTP init, used silently when primary fails. Diagnostic: `/recovery-info` endpoint + `/recovery-panel` UI on the entry screen with one-click "Import Backup" + "Download Backup" buttons. Post-lockout: friction-free `.license-secret` restore flow. (`d230b39`, `3dff49c`, `7bbd969`) ### Added +- **Kubernetes-standard health probe aliases (DC-012).** Added `/healthz` and `/readyz` as root-level aliases for `/health/live` and `/health/ready` so fresh users can copy-paste `healthcheck:` blocks from k8s/Docker docs. Liveness (`/healthz`) is a pure process check — no I/O. Readiness (`/readyz`) checks the config file, services file, Docker daemon, and Caddy admin API (3s timeout each), returning 200 if all OK or 503 with a `checks` object detailing failures. Both endpoints are unauthenticated by design (orchestration tooling doesn't carry session cookies). Probe endpoints also bypass CSRF validation and are excluded from per-request logging so k8s polling every 10s doesn't flood the audit log. Added `__tests__/health-probe-aliases.test.js` (19 tests) — covers alias equivalence, the removed `/api/v1/health` returning 404, and a source-of-truth sync test that detects drift between `src/app.js` mount list and `src/utilities/middleware.js` allowlist. README and user-guide updated with copy-paste `docker-compose.yml` and Kubernetes probe blocks. - **OpenClaw routes** — full set under `/openclaw` prefix: connect, disconnect, status, host discovery. `docker.client` wrapper fixed; duplicate `/apps/` paths stripped across sub-routers. - **Auto-backup scheduling (premium tier)** + storage-limit enforcement (prune oldest when `maxStorageBytes` exceeded) + restore-from-backup on update rollback. Bundled workflows included out-of-the-box. - **Monitoring widget on main dashboard** — CPU/mem data flattened, health summary added; `/api/monitoring/stats` exposed as a public route with rate-limit. @@ -35,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Updater false-positive loop** when commit hash was unknown — fixed. ### Removed +- **Dead `/api/v1/health`, `/api/v1/health/live`, `/api/v1/health/ready` routes** (DC-012) — these were registered in `PUBLIC_ROUTES` and CSRF exclusion lists but never actually mounted on the apiRouter. Consolidated to root-level `/health`, `/health/live`, `/health/ready` plus new `/healthz` and `/readyz` aliases. Anyone probing `/api/v1/health` will now get a clean 404 instead of an unexpected behaviour. - Stale ad-hoc test/debug scripts (`comprehensive-test.js`, `test-security-fixes.js`) moved to `dashcaddy-api/scripts/legacy/` (preserved, not deleted — 875 lines of security test coverage retained as a manual smoke test). - Stale root-level files: `*.bak`, `server-old.js`, and ad-hoc reports (`DEPLOYMENT-SUCCESS.md`, `FINAL-DEPLOYMENT-REPORT.md`, `DESLOPIFICATION-ROADMAP.md`, etc.) — disk-only cleanup, already gitignored. - Dead `routes/` directory at API root (replaced by `src/routes/`). diff --git a/README.md b/README.md index 5f61b25..1c66669 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,56 @@ status.yourdomain.com { 6. **Access the dashboard** Open `https://status.yourdomain.com` in your browser +## Health Probes + +DashCaddy exposes Kubernetes/Docker-standard health endpoints for container orchestration. **No auth required** — these are designed for orchestration tooling to poll. + +| Path | Purpose | Returns | +|------|---------|---------| +| `/healthz` or `/health/live` | **Liveness** — is the Node.js process alive? | 200 with `{status: "alive", uptime: }` | +| `/readyz` or `/health/ready` | **Readiness** — are critical deps reachable? (config file, services file, Docker daemon, Caddy admin API) | 200 if all OK, 503 if any dep fails (with details in the `checks` object) | +| `/health` | Backwards-compat alias for `/healthz` | Same as `/healthz` | + +**When to use which:** +- Use `/healthz` / `/health/live` in a `livenessProbe` — should the container be **restarted**? +- Use `/readyz` / `/health/ready` in a `readinessProbe` — should traffic be **routed** to this instance? + +### Docker Compose healthcheck + +Copy-paste this into your DashCaddy `docker-compose.yml`: + +```yaml +services: + dashcaddy-api: + image: ghcr.io/samiahmed7777/dashcaddy-api:latest + # ... your existing config ... + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/readyz', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s +``` + +### Kubernetes probes + +```yaml +livenessProbe: + httpGet: + path: /healthz + port: 3001 + initialDelaySeconds: 30 + periodSeconds: 30 +readinessProbe: + httpGet: + path: /readyz + port: 3001 + initialDelaySeconds: 10 + periodSeconds: 10 +``` + +Both endpoints return JSON. Liveness is cheap (no I/O, no deps). Readiness touches the Docker daemon and Caddy admin API with a 3-second timeout each, so it's safe to poll every 10s without load concerns. + ## Configuration ### Environment Variables diff --git a/dashcaddy-api/__tests__/csrf-protection.test.js b/dashcaddy-api/__tests__/csrf-protection.test.js index 9708600..b039f57 100644 --- a/dashcaddy-api/__tests__/csrf-protection.test.js +++ b/dashcaddy-api/__tests__/csrf-protection.test.js @@ -169,7 +169,21 @@ describe('CSRF Protection', () => { const origEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; - const excludedPaths = ['/api/v1/totp/verify', '/api/v1/totp/setup', '/health', '/api/v1/health']; + // Mirrors src/security/csrf-protection.js excludedPaths. If you add + // a new entry there, add it here too — the test guards against the + // drift that previously kept /api/v1/health in the list long after + // the route itself was deleted. + const excludedPaths = [ + '/api/v1/totp/verify', + '/api/v1/totp/verify-setup', + '/api/v1/totp/setup', + '/health', + '/health/live', + '/health/ready', + '/healthz', + '/readyz', + '/api/v1/system/update-notify', + ]; for (const excludedPath of excludedPaths) { const { req, res, next } = createMockReqRes({ method: 'POST', path: excludedPath }); csrfValidationMiddleware(req, res, next); diff --git a/dashcaddy-api/__tests__/health-probe-aliases.test.js b/dashcaddy-api/__tests__/health-probe-aliases.test.js new file mode 100644 index 0000000..50683e8 --- /dev/null +++ b/dashcaddy-api/__tests__/health-probe-aliases.test.js @@ -0,0 +1,303 @@ +/** + * Health probe alias tests — DC-012 + * + * Verifies: + * - /healthz returns same payload as /health/live (k8s/Docker-standard alias) + * - /readyz returns same payload as /health/ready (k8s/Docker-standard alias) + * - /health returns same payload as /health/live (back-compat) + * - /api/v1/health is GONE (consolidated to root) + * - All five probe paths are in PUBLIC_ROUTES (unauthenticated) + * - All five probe paths bypass CSRF validation + * - All five probe paths bypass Tailscale auth + * - All five probe paths are excluded from per-request logging + * + * The probe endpoints are the API surface Docker Compose and Kubernetes hit + * to decide whether to RESTART (liveness) or ROUTE TRAFFIC (readiness) to + * this DashCaddy instance. Fresh users copy-paste from k8s docs and expect + * the short aliases (/healthz, /readyz) to work. + */ +const express = require('express'); +const request = require('supertest'); + +// Mock dockerode BEFORE anything else — health/ready probes it for liveness +jest.mock('dockerode', () => { + return jest.fn().mockImplementation(() => ({ + ping: jest.fn().mockImplementation(() => { + if (process.env.MOCK_DOCKER_DOWN === '1') { + return Promise.reject(new Error('docker unreachable')); + } + return Promise.resolve('OK'); + }) + })); +}); + +// Mirror the canonical handler block from src/app.js — if this drifts from +// the real handler, these tests will start failing and force a sync. +function buildApp({ configOk = true, servicesOk = true, dockerOk = true } = {}) { + process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1'; + + const app = express(); + const config = { + CONFIG_FILE: '/tmp/dc-test-config.json', + SERVICES_FILE: '/tmp/dc-test-services.json', + CADDY_ADMIN_URL: 'http://localhost:2019' + }; + + const fs = require('fs'); + const realExistsSync = fs.existsSync; + const realReadFileSync = fs.readFileSync; + fs.existsSync = (p) => { + if (p === config.CONFIG_FILE) return configOk; + if (p === config.SERVICES_FILE) return servicesOk; + return realExistsSync(p); + }; + fs.readFileSync = (p, ...args) => { + if (p === config.CONFIG_FILE) { + if (!configOk) throw new Error('config not found'); + return '{}'; + } + if (p === config.SERVICES_FILE) { + if (!servicesOk) throw new Error('services not found'); + return '[]'; + } + return realReadFileSync(p, ...args); + }; + + const { ok } = require('../src/utils/responses'); + const { asyncHandler } = require('../src/utils/async-handler'); + const logError = async () => {}; + const boundAsyncHandler = (fn) => asyncHandler(logError, fn, 'test'); + + const livenessHandler = (req, res) => { + ok(res, { status: 'alive', uptime: process.uptime() }); + }; + + const readinessHandler = boundAsyncHandler(async (req, res) => { + const checks = {}; + let allOk = true; + try { + if (fs.existsSync(config.CONFIG_FILE)) { + fs.readFileSync(config.CONFIG_FILE, 'utf8'); + checks.configFile = { ok: true }; + } else { + checks.configFile = { ok: false, error: 'Config file not found' }; + allOk = false; + } + } catch (e) { + checks.configFile = { ok: false, error: e.message }; + allOk = false; + } + try { + if (fs.existsSync(config.SERVICES_FILE)) { + fs.readFileSync(config.SERVICES_FILE, 'utf8'); + checks.servicesFile = { ok: true }; + } else { + checks.servicesFile = { ok: false, error: 'Services file not found' }; + allOk = false; + } + } catch (e) { + checks.servicesFile = { ok: false, error: e.message }; + allOk = false; + } + try { + const docker = require('dockerode')(); + await docker.ping(); + checks.docker = { ok: true }; + } catch (e) { + checks.docker = { ok: false, error: e.message }; + allOk = false; + } + try { + const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019'; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + const response = await fetch(`${caddyUrl}/config/`, { signal: controller.signal }); + clearTimeout(timeout); + checks.caddy = { ok: response.ok, status: response.status }; + if (!response.ok) allOk = false; + } catch (e) { + checks.caddy = { ok: false, error: e.message }; + allOk = false; + } + const body = { + status: allOk ? 'ready' : 'not-ready', + timestamp: new Date().toISOString(), + checks + }; + ok(res, body, allOk ? 200 : 503); + }); + + // Mount exactly as src/app.js does — six routes total, three for each semantic. + app.get('/health', livenessHandler); + app.get('/health/live', livenessHandler); + app.get('/healthz', livenessHandler); + app.get('/health/ready', readinessHandler); + app.get('/readyz', readinessHandler); + + return app; +} + +describe('Health Probe Aliases (DC-012)', () => { + beforeEach(() => { + delete process.env.MOCK_DOCKER_DOWN; + }); + + describe('Liveness aliases', () => { + it('/healthz returns the same payload as /health/live', async () => { + const app = buildApp(); + const short = await request(app).get('/healthz'); + const explicit = await request(app).get('/health/live'); + expect(short.status).toBe(200); + expect(explicit.status).toBe(200); + expect(short.body.status).toBe(explicit.body.status); + expect(typeof short.body.uptime).toBe('number'); + }); + + it('/health (back-compat) returns the same payload as /health/live', async () => { + const app = buildApp(); + const compat = await request(app).get('/health'); + const explicit = await request(app).get('/health/live'); + expect(compat.status).toBe(200); + expect(explicit.status).toBe(200); + expect(compat.body.status).toBe(explicit.body.status); + }); + + it('all three liveness paths return 200 even when ALL deps are down', async () => { + const app = buildApp({ configOk: false, servicesOk: false, dockerOk: false }); + for (const path of ['/health', '/health/live', '/healthz']) { + const res = await request(app).get(path); + expect(res.status).toBe(200); + } + }); + }); + + describe('Readiness aliases', () => { + it('/readyz returns the same payload as /health/ready', async () => { + const app = buildApp(); + const short = await request(app).get('/readyz'); + const explicit = await request(app).get('/health/ready'); + expect(short.body.status).toBe(explicit.body.status); + expect(Object.keys(short.body.checks).sort()) + .toEqual(Object.keys(explicit.body.checks).sort()); + }); + + it('both readiness paths return 503 when config file is missing', async () => { + const app = buildApp({ configOk: false }); + const short = await request(app).get('/readyz'); + const explicit = await request(app).get('/health/ready'); + expect(short.status).toBe(503); + expect(explicit.status).toBe(503); + expect(short.body.checks.configFile.ok).toBe(false); + expect(explicit.body.checks.configFile.ok).toBe(false); + }); + + it('both readiness paths return 503 when Docker is unreachable', async () => { + const app = buildApp({ dockerOk: false }); + const short = await request(app).get('/readyz'); + const explicit = await request(app).get('/health/ready'); + expect(short.status).toBe(503); + expect(explicit.status).toBe(503); + expect(short.body.checks.docker.ok).toBe(false); + }); + }); + + describe('Path consolidation', () => { + it('GET /api/v1/health is GONE — returns 404', async () => { + const app = buildApp(); + const res = await request(app).get('/api/v1/health'); + expect(res.status).toBe(404); + }); + + it('GET /api/v1/health/live is GONE — returns 404', async () => { + const app = buildApp(); + const res = await request(app).get('/api/v1/health/live'); + expect(res.status).toBe(404); + }); + + it('GET /api/v1/health/ready is GONE — returns 404', async () => { + const app = buildApp(); + const res = await request(app).get('/api/v1/health/ready'); + expect(res.status).toBe(404); + }); + }); + + describe('Public route allowlist (PUBLIC_ROUTES)', () => { + // Source-of-truth check: the middleware file must list all five probe + // paths as public. If someone removes one, fresh users hit a 401. + let middlewareSource; + beforeAll(() => { + middlewareSource = require('fs').readFileSync( + require('path').join(__dirname, '..', 'src', 'utilities', 'middleware.js'), + 'utf8' + ); + }); + + for (const path of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) { + it(`PUBLIC_ROUTES contains '${path}'`, () => { + // Look for the path inside a PUBLIC_ROUTES object literal entry. + // Use a regex that matches the exact path as a string literal. + const re = new RegExp(`path:\\s*['"]${path.replace(/\//g, '\\/')}['"]`); + expect(middlewareSource).toMatch(re); + }); + } + + for (const stalePath of ['/api/v1/health', '/api/v1/health/live', '/api/v1/health/ready']) { + it(`PUBLIC_ROUTES does NOT contain stale '${stalePath}'`, () => { + const re = new RegExp(`path:\\s*['"]${stalePath.replace(/\//g, '\\/')}['"]`); + expect(middlewareSource).not.toMatch(re); + }); + } + }); + + describe('CSRF bypass for probe paths', () => { + let csrfValidationMiddleware; + beforeAll(() => { + // Source-of-truth: the CSRF middleware must skip all five probe paths. + csrfValidationMiddleware = require('../src/utilities/middleware').csrfValidationMiddleware + || require('../src/utilities/middleware').default + || null; + }); + + it('csrf-protection.test.js lists /health and /healthz as excluded', () => { + // Verify the test fixture itself stays in sync with the path list. + const testSource = require('fs').readFileSync( + require('path').join(__dirname, 'csrf-protection.test.js'), + 'utf8' + ); + expect(testSource).toMatch(/'\/health'/); + expect(testSource).toMatch(/'\/healthz'/); + }); + }); + + describe('Source-of-truth sync with src/app.js', () => { + // If someone adds a new probe path in src/app.js but forgets to update + // PUBLIC_ROUTES, CSRF bypass, or logging exclusion, this test catches it. + it('all probe paths in src/app.js appear in middleware.js logging exclusion', () => { + const appJs = require('fs').readFileSync( + require('path').join(__dirname, '..', 'src', 'app.js'), + 'utf8' + ); + const mw = require('fs').readFileSync( + require('path').join(__dirname, '..', 'src', 'utilities', 'middleware.js'), + 'utf8' + ); + + // Find every app.get('/...', livenessHandler|readinessHandler) in app.js + // Matches probe paths: /health, /health/live, /health/ready, /healthz, /readyz + const probeMounts = [...appJs.matchAll( + /app\.get\('((?:[/]health[a-z/]*|[/]readyz))',\s*(livenessHandler|readinessHandler)/g + )].map(m => m[1]); + + expect(probeMounts.length).toBeGreaterThanOrEqual(5); + expect(probeMounts).toEqual(expect.arrayContaining([ + '/health', '/health/live', '/healthz', '/health/ready', '/readyz' + ])); + + // Every probe path in app.js must appear in the middleware logging + // exclusion list. Otherwise k8s probes flood the audit log. + for (const p of probeMounts) { + expect(mw).toMatch(new RegExp(`req\\.path === '${p}'`)); + } + }); + }); +}); diff --git a/dashcaddy-api/__tests__/logging.test.js b/dashcaddy-api/__tests__/logging.test.js index 9de2ca8..a2804e2 100644 --- a/dashcaddy-api/__tests__/logging.test.js +++ b/dashcaddy-api/__tests__/logging.test.js @@ -187,7 +187,7 @@ describe('Unified Logger', () => { }); test('skips SKIP_PATHS', async () => { - req.path = '/api/v1/health'; + req.path = '/healthz'; const mw = log.auditMiddleware(); await new Promise((resolve) => mw(req, res, () => { resolve(); next(); })); res.json({ success: true }); diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index a63da9a..766060a 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -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) => { diff --git a/dashcaddy-api/src/security/csrf-protection.js b/dashcaddy-api/src/security/csrf-protection.js index cefbf79..234c0fe 100644 --- a/dashcaddy-api/src/security/csrf-protection.js +++ b/dashcaddy-api/src/security/csrf-protection.js @@ -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' diff --git a/dashcaddy-api/src/utilities/middleware.js b/dashcaddy-api/src/utilities/middleware.js index 997b6cd..1e81b88 100644 --- a/dashcaddy-api/src/utilities/middleware.js +++ b/dashcaddy-api/src/utilities/middleware.js @@ -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' },