fix(config): monitoring.public gate was dead 4 ways — live gate + schema + dedupe (DC-096) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

The documented hardening option for exposed deploys (monitoring: {public: false}
in config.json / MONITORING_PUBLIC env) never worked:
1. applyConfigFields dropped the monitoring key entirely
2. monitoring missing from config-schema KNOWN_KEYS (Unknown-key warnings)
3. MONITORING_PUBLIC frozen at mount + re-required singleton instead of injected dep
4. PUBLIC_ROUTES had unconditional duplicate entries defeating the gated spread

- site.js: copy monitoring through; drop dead write-only siteConfig.caName
- config-schema: +monitoring key, validateMonitoring (public must be boolean);
  remove never-written typo-footgun keys setupCompleted/setupMode (git -S: zero writers ever)
- middleware: live isMonitoringPublic() (env > config > default public), per-request
  gate via monitoring:true flag, remove duplicate unconditional route entries
- default unchanged (endpoints stay public — System Overview widget)

Tests: +11 (__tests__/monitoring-public-gate-dc096.test.js); suite 118/2735 green.
Judge: GLM-5.3 cold-read A (deleg_98aba845), URN urn:ump:zgvtskqljurasdagc632atnybb2p6gakk4cxcmjd3i4ivy7rvwta
This commit is contained in:
Hermes
2026-08-22 21:35:51 -07:00
parent 83ef84d218
commit bb59595d6d
4 changed files with 247 additions and 25 deletions
+33 -21
View File
@@ -359,18 +359,25 @@ module.exports = function configureMiddleware(app, {
// (env var) or `monitoring: { public: false }` (config.json) to require
// auth for these — useful for internet-exposed deployments where
// CPU/memory/disk data is sensitive.
const MONITORING_PUBLIC = (() => {
//
// DC-096: this used to be a const frozen at mount time AND it re-required
// the config/site singleton instead of using the `siteConfig` dependency
// injected by app.js — so POST /api/v1/config changes never took effect
// until a full process restart, and a fresh process with
// monitoring.public=false in config.json never saw it either (the field
// was dropped by applyConfigFields — see site.js). Resolved per-request
// from: explicit env override → live config value → default (public).
const isMonitoringPublic = () => {
if (process.env.MONITORING_PUBLIC === 'false') return false;
if (process.env.MONITORING_PUBLIC === 'true') return true;
// Default: check config.json if loaded
try {
const cfg = require('../config/site').siteConfig;
if (cfg && cfg.monitoring && typeof cfg.monitoring.public === 'boolean') {
return cfg.monitoring.public;
}
} catch { /* config not loaded yet, use default */ }
// Read the injected config object live — siteConfig is the same mutable
// singleton that loadSiteConfig()/POST /config refresh in place.
if (siteConfig && typeof siteConfig.monitoring === 'object' && siteConfig.monitoring !== null
&& typeof siteConfig.monitoring.public === 'boolean') {
return siteConfig.monitoring.public;
}
return true; // default: public (current behavior, dashboard needs it)
})();
};
const PUBLIC_ROUTES = [
// Health probes — root-level only. See src/app.js for the handler block.
@@ -452,18 +459,18 @@ module.exports = function configureMiddleware(app, {
{ path: '/api/v1/themes', exact: true, method: 'GET' },
{ path: '/api/v1/license/status', exact: true, method: 'GET' },
{ path: '/api/v1/license/feature/', prefix: true, method: 'GET' },
{ path: '/api/v1/config', exact: true, method: 'GET' },
{ path: '/api/v1/services/status', exact: true, method: 'GET' },
{ path: '/api/v1/health-checks/status', exact: true, method: 'GET' },
{ path: '/api/v1/config', exact: true, method: 'GET' },
{ path: '/api/v1/services/status', exact: true, method: 'GET' },
// (/api/v1/health-checks/status and /api/v1/monitoring/stats are listed
// further below WITH the monitoring.public live gate — DC-096. They were
// previously duplicated here unconditionally, which silently defeated
// the MONITORING_PUBLIC gate entirely.)
// DC-075: System health endpoint for external uptime monitoring (UptimeRobot, BetterStack)
{ path: '/api/v1/system/health', exact: true, method: 'GET' },
{ path: '/api/v1/system/health', exact: true, method: 'GET' },
// DC-097: Prometheus metrics endpoint (scraped by Prometheus, no auth)
{ path: '/api/v1/metrics/prometheus', exact: true, method: 'GET' },
// DC-077: i18n endpoints (language list + translations, public)
{ path: '/api/v1/i18n/', prefix: true, method: 'GET' },
// System Overview widget on the dashboard — needs the flattened CPU/mem
// data without going through auth. See skill references/totp-and-system-overview-pitfalls.md §3.
{ path: '/api/v1/monitoring/stats', exact: true, method: 'GET' },
// Read-only update/version info shown on the dashboard view (verification
// modal, topbar version, update badges). Mutating actions — update-apply,
// rollback (POST) — are NOT listed here and stay TOTP-protected.
@@ -473,11 +480,13 @@ module.exports = function configureMiddleware(app, {
{ path: '/api/v1/system/update-check', exact: true, method: 'GET' },
{ path: '/api/v1/updates/available', exact: true, method: 'GET' },
{ path: '/api/v1/system/update-notify', exact: true, method: 'POST' },
// Monitoring endpoints — only public if MONITORING_PUBLIC is true
...(MONITORING_PUBLIC ? [
{ path: '/api/v1/monitoring/stats', exact: true, method: 'GET' },
{ path: '/api/v1/health-checks/status', exact: true, method: 'GET' },
] : []),
// Monitoring endpoints — public only while isMonitoringPublic() is true.
// DC-096: these are listed unconditionally and gated inside
// isPublicRoute() so the gate is resolved LIVE per request — flipping
// `monitoring: { public: false }` via POST /api/v1/config takes effect
// on the next request, no process restart.
{ path: '/api/v1/monitoring/stats', exact: true, method: 'GET', monitoring: true },
{ path: '/api/v1/health-checks/status', exact: true, method: 'GET', monitoring: true },
{ path: '/api/v1/version', exact: true, method: 'GET' },
// Security ingest endpoints — auth via per-host Bearer token (handled in routes/security.js),
// NOT via TOTP/JWT. This lets remote DashCaddy agents and log parsers push events
@@ -489,6 +498,9 @@ module.exports = function configureMiddleware(app, {
function isPublicRoute(req) {
return PUBLIC_ROUTES.some(r => {
if (r.method && req.method !== r.method) return false;
// DC-096: monitoring routes are only public while the live gate says so
// (env override → config → default public). Checked per request.
if (r.monitoring && !isMonitoringPublic()) return false;
if (r.exact) {
// Exact string match, BUT allow `:param` placeholders in the
// PUBLIC_ROUTES entry to match any single path segment. This was a