/** * Caddy upstreams routes * * Exposes: * GET /api/v1/caddy/upstreams — full snapshot * GET /api/v1/caddy/upstreams/incidents — open dead-upstream incidents (via healthChecker) * POST /api/v1/caddy/upstreams/mute — body { host, muted: true|false } * POST /api/v1/caddy/upstreams/:host/mute — body { muted: true|false } OR query ?muted=true * POST /api/v1/caddy/upstreams/:host/unmute — clears the mute * * Auth: same as the rest of /api/v1 — handled by the global middleware * (the router is mounted under the auth-gated apiRouter in app.js). * * @module routes/caddy-upstreams */ const express = require('express'); const { success, errorResponse } = require('../src/utils/responses'); const { ValidationError } = require('../src/utilities/errors'); /** * DC-073: shared mute helper — used by all three mute endpoints so the * host-validation logic can't drift. * * Pre-fix, only the bare `/caddy/upstreams/mute` body-style endpoint * rejected unknown hosts (with a "not a known upstream" 400). The * path-style `/:host/mute` and `/:host/unmute` endpoints skipped that * check entirely, so an authenticated operator could POST * `/caddy/upstreams/phantom.test:12345/mute` and the watcher would * silently add `phantom.test:12345` to its muted Set and `_saveState()` * would persist it to disk. The phantom entry then survives container * restarts, pollutes the snapshot view (the muted Set is iterated in * places like the dashboard's "muted upstreams" badge), and would * silently disable any future probe that happened to resolve to the * same string. * * Post-fix, every mute path runs through this helper so: * (1) host format is well-formed (rejects injection / `:` / `?` / etc.) * (2) host is in `caddyUpstreamWatcher.upstreams` (the live registry * populated by `scanSites()` reading every `reverse_proxy` from * /etc/caddy/sites/*. A phantom host cannot reach setMuted.) * (3) the muted Set never holds entries the scanner doesn't know. * * @param {Object} watcher caddyUpstreamWatcher instance * @param {string} host raw host string from the request * @param {boolean} wantMuted true to mute, false to unmute * @returns {{host: string, muted: boolean}} the result of setMuted * @throws {ValidationError} on invalid format or unknown host */ function validateAndMuteHost(watcher, host, wantMuted) { if (typeof host !== 'string' || host.length === 0 || host.length > 253) { throw new ValidationError('host must be a non-empty string up to 253 chars'); } if (!/^[a-z0-9._:-]+$/i.test(host)) { throw new ValidationError('host must be a valid host[:port] string'); } if (!watcher || !watcher.upstreams || !watcher.upstreams.has(host)) { throw new ValidationError(`host ${host} is not a known upstream (run scan first)`); } return watcher.setMuted(host, wantMuted); } module.exports = function({ asyncHandler, caddyUpstreamWatcher, healthChecker }) { const router = express.Router(); router.get('/caddy/upstreams', asyncHandler(async (req, res) => { if (!caddyUpstreamWatcher) { // DC-062: errorResponse(res, statusCode, message) — statusCode-first per // src/utils/responses.js:66. The prior (res, message, statusCode) call // order passed a STRING as the status code, which made // res.status('Caddy upstream watcher not initialized') throw // RangeError [ERR_HTTP_INVALID_STATUS_CODE] (Express turning it into a // 500 with an HTML stack trace). All four `!caddyUpstreamWatcher` // guards had the same latent bug — fixed to canonical order. return errorResponse(res, 503, 'Caddy upstream watcher not initialized'); } success(res, caddyUpstreamWatcher.snapshot()); }, 'caddy-upstreams-list')); router.get('/caddy/upstreams/incidents', asyncHandler(async (req, res) => { if (!healthChecker) { return success(res, { incidents: [] }); } // Filter the in-memory incidents array to caddy-upstream-dead entries. const all = Array.isArray(healthChecker.incidents) ? healthChecker.incidents : []; const open = all .filter((i) => i && i.type === 'caddy-upstream-dead' && i.status === 'open') .map((i) => ({ id: i.id, serviceId: i.serviceId, type: i.type, message: i.message, severity: i.severity, createdAt: i.createdAt, lastOccurrence: i.lastOccurrence, occurrences: i.occurrences, details: i.details })); success(res, { incidents: open }); }, 'caddy-upstreams-incidents')); // Bare /mute with JSON body {host, muted}. Default mutes when muted is // absent or unparseable; require muted === false explicitly to unmute. // DC-073: now routes through validateAndMuteHost so the unknown-host // check applies (was already correct here pre-fix, but path-style // was missing it — see validateAndMuteHost docblock). router.post('/caddy/upstreams/mute', asyncHandler(async (req, res) => { if (!caddyUpstreamWatcher) { return errorResponse(res, 503, 'Caddy upstream watcher not initialized'); } const { host, muted } = req.body || {}; // Explicit boolean coercion — string 'false' should NOT mute. const wantMuted = muted === undefined ? true : muted === true; const result = validateAndMuteHost(caddyUpstreamWatcher, host, wantMuted); success(res, result); }, 'caddy-upstreams-mute-bare')); // Path-style /:host/mute — body { muted: true|false } OR query ?muted=true|false. // DC-073: now also rejects unknown hosts (was the bug — see docblock). router.post('/caddy/upstreams/:host/mute', asyncHandler(async (req, res) => { if (!caddyUpstreamWatcher) { return errorResponse(res, 503, 'Caddy upstream watcher not initialized'); } let wantMuted; if (typeof req.body?.muted === 'boolean') wantMuted = req.body.muted; else if (typeof req.query.muted === 'string') wantMuted = req.query.muted === 'true'; else wantMuted = true; // bare POST = mute const result = validateAndMuteHost(caddyUpstreamWatcher, req.params.host, wantMuted); success(res, result); }, 'caddy-upstreams-mute')); // DC-073: path-style /:host/unmute now also rejects unknown hosts. router.post('/caddy/upstreams/:host/unmute', asyncHandler(async (req, res) => { if (!caddyUpstreamWatcher) { return errorResponse(res, 503, 'Caddy upstream watcher not initialized'); } const result = validateAndMuteHost(caddyUpstreamWatcher, req.params.host, false); success(res, result); }, 'caddy-upstreams-unmute')); return router; }; // Export the helper for unit tests so the validation surface can be // exercised without spinning up a full Express app. module.exports.__test = { validateAndMuteHost };