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
+5 -2
View File
@@ -13,7 +13,6 @@ const { loadAndMigrate, CURRENT_VERSION } = require('./migrations');
const siteConfig = {
tld: '.home',
caName: '',
dnsServerIp: '',
dnsServerPort: CADDY.DEFAULT_DNS_PORT,
dashboardHost: '',
@@ -27,7 +26,6 @@ const siteConfig = {
function applyConfigFields(raw) {
siteConfig.tld = raw.tld || '.home';
if (!siteConfig.tld.startsWith('.')) siteConfig.tld = '.' + siteConfig.tld;
siteConfig.caName = raw.caName || '';
siteConfig.dnsServerIp = (raw.dns && raw.dns.ip) || '';
siteConfig.dnsServerPort = (raw.dns && raw.dns.port) || CADDY.DEFAULT_DNS_PORT;
siteConfig.dashboardHost = raw.dashboardHost || `status${siteConfig.tld}`;
@@ -37,6 +35,11 @@ function applyConfigFields(raw) {
siteConfig.domain = raw.domain || '';
siteConfig.routingMode = raw.routingMode || 'subdomain';
siteConfig.pylon = raw.pylon || null;
// DC-096: `monitoring` was previously NOT copied out of raw config, so the
// documented hardening option `monitoring: { public: false }` (middleware.js
// MONITORING_PUBLIC) silently never applied — siteConfig.monitoring stayed
// undefined forever. Copy it through so the middleware actually sees it.
siteConfig.monitoring = raw.monitoring || null;
}
function validateAndLogConfig(raw, log) {
const { valid, errors: configErrors, warnings: configWarnings } = validateConfig(raw);
+25 -2
View File
@@ -10,7 +10,7 @@ const VALID_DNS_PROVIDERS = ['technitium', 'cloudflare', 'rfc2136', 'manual'];
const KNOWN_KEYS = [
'tld', 'caName', 'dns', 'dnsServers', 'dashboardHost', 'timezone', 'theme',
'updatedAt', 'timestamp', 'logo', 'logoPosition', 'favicon', 'weather',
'setupComplete', 'setupCompleted', 'setupMode', 'onboardingCompleted',
'setupComplete', 'onboardingCompleted',
'configurationType', 'defaults', 'customLogo', 'customFavicon',
'dashboardTitle', 'tailscale', 'license', 'skipped',
'routingMode', 'domain', 'email', 'defaultIP', 'pylon',
@@ -18,7 +18,13 @@ const KNOWN_KEYS = [
// license-manager.js persists the last activation to config.licenseBackup
// (restore-on-restart path); src/config/migrations.js stamps _version.
// Both are first-party writes — see DC-091.
'licenseBackup', '_version'
'licenseBackup', '_version',
// DC-096: monitoring.public gates whether /api/v1/monitoring/stats and
// /api/v1/health-checks/status are public (middleware.js isMonitoringPublic).
// Removed 'setupCompleted' and 'setupMode' — never written by any code
// (past or present); they only existed here, where they masked the actual
// typo of the real key `setupComplete` (writers: setup-wizard.js).
'monitoring'
];
/**
@@ -162,6 +168,22 @@ function validateKnownKeys(ctx, config) {
}
}
/**
* @param {{errors:string[], warnings:string[]}} ctx
* @param {object} config
*/
function validateMonitoring(ctx, config) {
if (config.monitoring === undefined) return;
if (typeof config.monitoring !== 'object' || config.monitoring === null) {
ctx.errors.push('monitoring must be an object');
return;
}
if (config.monitoring.public !== undefined
&& typeof config.monitoring.public !== 'boolean') {
ctx.errors.push('monitoring.public must be a boolean');
}
}
/**
* Validate a config object and return errors/warnings.
* @param {object} config - The config object to validate
@@ -183,6 +205,7 @@ function validateConfig(config) {
validateTheme(ctx, config);
validateRoutingMode(ctx, config);
validateDomain(ctx, config);
validateMonitoring(ctx, config);
validateKnownKeys(ctx, config);
return { valid: errors.length === 0, errors, warnings };
+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