/** * Caddy upstream watcher * * Watches every `reverse_proxy ` directive in /etc/caddy/sites/* and * independently probes each upstream every 60s. After 5 minutes of * consecutive failures, emits a `caddy-upstream-dead` incident via the shared * healthChecker so the dashboard can surface it. * * This is intentionally separate from Caddy's own `reverse_proxy` health * checker: Caddy probes log every failure to syslog (the noisy spam the * dashboard currently sees for `100.120.159.34:5000`), but Caddy never * surfaces the result to the dashboard or to the API. This watcher gives * the operator (a) a deduped view, (b) a 5-minute confirmation window so a * one-off blip doesn't page, and (c) a mute toggle to silence known-dead * upstreams without editing the Caddyfile. * * State persisted to /caddy-upstreams.json. Mute list is part of * the same file so atomic-write semantics keep state + mutes consistent. * * The probe DOES NOT use Caddy's health_uri (that's Caddy's own probe and * the source of the spam). The probe also stamps `X-DashCaddy-HealthCheck: 1` * so the `dashcaddy_auth` forward_auth gate on *.sami bypasses for probes * (same trick as src/monitoring/health-checker.js _doRequest). * * @module caddy-upstream-watcher */ const fs = require('fs'); const path = require('path'); const https = require('https'); const http = require('http'); const EventEmitter = require('events'); const platformPaths = require('../../platform-paths'); /** Default probe interval: 60s. Independent of Caddy's 10s internal probe. */ const PROBE_INTERVAL_MS = parseInt(process.env.CADDY_UPSTREAM_PROBE_INTERVAL_MS || '60000', 10); /** Per-probe timeout. Short — these are liveness pings, not full requests. */ const PROBE_TIMEOUT_MS = parseInt(process.env.CADDY_UPSTREAM_PROBE_TIMEOUT_MS || '5000', 10); /** After this many ms of continuous failure, emit a "dead" incident. */ const DEAD_AFTER_MS = parseInt(process.env.CADDY_UPSTREAM_DEAD_AFTER_MS || (5 * 60 * 1000), 10); /** After this many ms of continuous success, auto-resolve any open incident. */ const RESOLVED_AFTER_MS = parseInt(process.env.CADDY_UPSTREAM_RESOLVED_AFTER_MS || (60 * 1000), 10); /** Status codes that prove the upstream answered. 4xx auth-walled counts as up. */ const HEALTHY_CODES = new Set([200, 201, 204, 301, 302, 303, 307, 308, 401, 403, 429]); const STATE_FILE = process.env.CADDY_UPSTREAMS_STATE_FILE || path.join(platformPaths.dataDir || path.dirname(platformPaths.configFile || '.'), 'caddy-upstreams.json'); const SITES_DIR = process.env.CADDY_SITES_DIR || '/etc/caddy/sites'; /** * Hostname the probe uses instead of a loopback address. * * CRITICAL: this watcher runs INSIDE the dashcaddy-api container. Caddy runs * on the HOST. A site config's `reverse_proxy localhost:8088` means "the * host's loopback" from Caddy's point of view — but from inside the container * `localhost`/`127.0.0.1` is the container's OWN loopback, where nothing * listens. Probing loopback verbatim makes every healthy host-side upstream * report ECONNREFUSED (live prod bug 2026-08-18: 9 of 14 tracked upstreams * showed 278 consecutive phantom failures and opened bogus `caddy-upstream-dead` * incidents). * * Fix: remap loopback probe targets to `host.docker.internal`, which start.sh * pins to the host's bridge IP via `--add-host=host.docker.internal:host-gateway` * (Docker ≥ 20.10). The upstream's display key stays `localhost:PORT` so * existing mute lists and UI labels are unaffected — only the probe target * changes. Set IN_CONTAINER=false (e.g. a bare-metal deployment where the API * runs beside Caddy) to disable the remap. */ const HOST_GATEWAY_NAME = process.env.CADDY_UPSTREAM_HOST_GATEWAY_NAME || 'host.docker.internal'; const IN_CONTAINER = process.env.IN_CONTAINER !== 'false'; const HOST_GATEWAY_PROBE = IN_CONTAINER ? HOST_GATEWAY_NAME : null; /** True when the address is IPv4 loopback (127.0.0.0/8) or the `localhost` name. */ function isLoopbackHost(host) { return host === 'localhost' || /^127(\.\d{1,3}){3}$/.test(host); } class CaddyUpstreamWatcher extends EventEmitter { constructor(opts = {}) { super(); this.log = opts.log || console; this.healthChecker = opts.healthChecker || null; /** Map keyed by host (host[:port]) */ this.upstreams = new Map(); /** Set hosts the user has muted */ this.muted = new Set(); /** Set incident IDs currently open — prevents duplicate incidents */ this.openIncidents = new Set(); this.timer = null; this.checking = false; this.scanTimer = null; this._loadState(); } /** Begin watching. Idempotent — safe to call twice. */ start() { if (this.checking) return; this.checking = true; // Initial scan + probe so the dashboard has data immediately after boot. this.scanSites().catch((e) => this.log.warn('caddy-upstream-watcher', e?.message || String(e))); this.timer = setInterval(() => this._tick().catch(() => {}), PROBE_INTERVAL_MS); // Re-scan sites every 5 min so newly added sites get picked up. this.scanTimer = setInterval(() => this.scanSites().catch(() => {}), 5 * 60 * 1000); this.log.info?.('caddy-upstream-watcher', 'started', { probeIntervalMs: PROBE_INTERVAL_MS, deadAfterMs: DEAD_AFTER_MS, stateFile: STATE_FILE, sitesDir: SITES_DIR }) ?? this.log.info?.('caddy-upstream-watcher', 'started'); } stop() { if (!this.checking) return; this.checking = false; if (this.timer) clearInterval(this.timer); if (this.scanTimer) clearInterval(this.scanTimer); this.timer = null; this.scanTimer = null; } /** Parse /etc/caddy/sites/* and seed/refresh the upstream map. */ async scanSites() { let entries; try { entries = fs.readdirSync(SITES_DIR); } catch (e) { // Sites dir might not exist in dev — that's OK, just skip. this.log.warn?.('caddy-upstream-watcher', `cannot read ${SITES_DIR}: ${e.message}`); return; } const seen = new Set(); for (const entry of entries) { // Caddy `import` sites have a wild mix of extensions: `.sami`, // `.caddy`, `.conf` — and ALSO bare hostnames like // `zap.sami-ahmed.net`, `samitest.space`, `blocks.cryptographic-triangles.org` // where the "extension" is `.net`/`.space`/`.org`. Filter out known // non-site junk (readmes, .bak) and accept everything else; the // reverse_proxy parse below is the real validation. if (/^README|\.bak$|\.swp$|^\.|^#/.test(entry)) continue; if (entry === 'Caddyfile' || entry === 'caddyfile') continue; const filePath = path.join(SITES_DIR, entry); let content; try { content = fs.readFileSync(filePath, 'utf8'); } catch (_) { continue; } // Cheap pre-check: skip files with no reverse_proxy and no brace block // (README files, .gitignore, etc.). The reverse_proxy regex below is // the authoritative parse, but this avoids regex-scanning every // unrelated file in the directory. if (!/reverse_proxy/i.test(content)) continue; // Capture the site block host from the first line: e.g. "arch.sami {" const siteMatch = content.match(/^\s*([a-z0-9._-]+)\s*\{/im); const siteName = siteMatch ? siteMatch[1] : entry.replace(/\.(sami|caddy|conf)$/i, ''); // Find every reverse_proxy directive. Match common shapes: // reverse_proxy 100.120.159.34:5000 { ... } // reverse_proxy http://100.120.159.34:5000 { ... } // reverse_proxy 100.120.159.34:5000 const re = /reverse_proxy\s+(?:https?:\/\/)?([0-9]{1,3}(?:\.[0-9]{1,3}){3}|[a-z0-9._-]+)(?::(\d+))?/gi; let m; while ((m = re.exec(content)) !== null) { const host = m[1]; let port = m[2]; if (!port) { if (m[0].includes('https')) port = '443'; else if (m[0].includes('http://')) port = '80'; else port = ''; } const key = port ? `${host}:${port}` : host; seen.add(key); if (!this.upstreams.has(key)) { this.upstreams.set(key, { host: key, ip: host, port: port || null, site: siteName, siteFile: entry, consecutiveFailures: 0, lastFailureAt: null, lastSuccessAt: null, lastError: null, lastCheckedAt: null, status: 'unknown' }); } else { // Refresh site name/file in case the file was renamed. const u = this.upstreams.get(key); u.site = siteName; u.siteFile = entry; } } } // Drop upstreams that disappeared from the Caddyfile (removed/renamed site). for (const key of Array.from(this.upstreams.keys())) { if (!seen.has(key)) this.upstreams.delete(key); } this._saveState(); } /** Single probe tick over every upstream. */ async _tick() { const probes = []; for (const u of this.upstreams.values()) { if (this.muted.has(u.host)) continue; probes.push(this._probeOne(u).catch((e) => { this.log.warn?.('caddy-upstream-watcher', `probe failed for ${u.host}: ${e.message}`); })); } await Promise.all(probes); this._saveState(); this.emit('tick', this.snapshot()); } /** Probe a single upstream and update state. */ async _probeOne(u) { // Loopback upstreams (see HOST_GATEWAY_PROBE header comment): the Caddyfile // `localhost`/`127.x` is host-relative, so probe the host gateway instead of // the container's own loopback. Display key and persisted `ip` are unchanged. const loopbackRemap = !!(HOST_GATEWAY_PROBE && isLoopbackHost(u.ip)); const probeHost = loopbackRemap ? HOST_GATEWAY_PROBE : u.ip; const result = await this._doProbe(probeHost, u.port); u.lastCheckedAt = new Date().toISOString(); if (result.healthy) { u.consecutiveFailures = 0; u.lastSuccessAt = u.lastCheckedAt; u.lastError = null; // Resolve open incident if upstream is healthy for RESOLVED_AFTER_MS. this._maybeResolve(u); // Only flip to 'up' if the upstream has been healthy long enough to not // be a flapping signal — short blips are normal and we want the dashboard // to be stable. After one full successful check we mark 'up' but the // incident resolution waits for RESOLVED_AFTER_MS. u.status = 'up'; // A successful host-gateway probe PROVES the bridge can reach the // host. If a later probe then fails, we have strong evidence the // upstream itself went dead — not that bridge connectivity broke. // Mark verifiedViaBridge so the unverifiable path can short-circuit // and treat it like a non-loopback upstream. if (loopbackRemap) u.verifiedViaBridge = true; } else if (loopbackRemap && !u.verifiedViaBridge) { // The host-gateway probe comes from the docker bridge IP. A service // bound to 0.0.0.0 on the host answers; a service bound to the host's // 127.0.0.1 ONLY refuses — indistinguishable, from this vantage point, // from a truly dead service. Caddy (on the host) reaches both fine, so // a failed probe here is NOT evidence the upstream is dead. Mark it // unverifiable: no failure counters, no incident, keep lastError for // visibility. (A successful probe IS conclusive — see above.) u.consecutiveFailures = 0; u.status = 'unverifiable'; u.lastError = `host-loopback upstream not verifiable from container (${result.error || `HTTP ${result.statusCode || 'unknown'}`})`; // Clear the success anchor: a 10-minute-old success is not evidence of // anything for an upstream we cannot observe from this vantage point, // and leaving it would make snapshot() compute a bogus failingForMs // and flag `dead`. u.lastSuccessAt = null; this._maybeResolve(u); } else if (loopbackRemap && u.verifiedViaBridge) { // The bridge previously reached this upstream successfully — so a // failed probe here is near-conclusive evidence the upstream itself // went dead (the bridge path itself doesn't change between probes). // Treat it like a non-loopback upstream failure: count it, open an // incident after DEAD_AFTER_MS. This restores dead-detection for the // subset of loopback upstreams that prove themselves reachable. u.consecutiveFailures += 1; u.lastFailureAt = u.lastCheckedAt; u.lastError = result.error || `HTTP ${result.statusCode || 'unknown'}`; u.status = 'down'; this._maybeOpenIncident(u); } else { u.consecutiveFailures += 1; u.lastFailureAt = u.lastCheckedAt; u.lastError = result.error || `HTTP ${result.statusCode || 'unknown'}`; // First failure flips status to 'down' immediately for the dashboard, but // we only OPEN an incident after the upstream has been continuously failing // for DEAD_AFTER_MS (5 min by default) so a single transient blip doesn't // page anyone. u.status = 'down'; this._maybeOpenIncident(u); } } _maybeOpenIncident(u) { if (!this.healthChecker) return; // "failingForMs" = continuous time the upstream has been unhealthy. // Use lastSuccessAt as the anchor — if it was up 7min ago and is still // down now, that's 7 minutes of continuous failure regardless of how many // individual probe failures have piled up in between. Falls back to // consecutiveFailures * interval when there's no success anchor (e.g. we've // never seen the upstream healthy since startup). const lastSuccessMs = u.lastSuccessAt ? new Date(u.lastSuccessAt).getTime() : null; const failingForMs = lastSuccessMs !== null ? Math.max(0, Date.now() - lastSuccessMs) : u.consecutiveFailures * PROBE_INTERVAL_MS; if (failingForMs < DEAD_AFTER_MS) return; if (this.openIncidents.has(u.host)) return; // Mimic the shape HealthChecker.createIncident expects. try { this.healthChecker.createIncident(u.host, 'caddy-upstream-dead', `Caddy upstream ${u.host} (site ${u.site}) unreachable for ${Math.round(failingForMs / 60000)}m: ${u.lastError || 'no response'}`, { serviceId: u.host, timestamp: u.lastFailureAt, status: 'down', error: u.lastError, details: { site: u.site, siteFile: u.siteFile } } ); this.openIncidents.add(u.host); this.emit('upstream-dead', u); this.log.warn?.('caddy-upstream-watcher', `upstream dead: ${u.host} (${u.site})`); } catch (e) { this.log.warn?.('caddy-upstream-watcher', `incident create failed: ${e.message}`); } } _maybeResolve(u) { if (!this.healthChecker) return; if (!this.openIncidents.has(u.host)) return; const downSince = u.lastFailureAt ? new Date(u.lastFailureAt).getTime() : 0; const recoveredForMs = downSince ? Date.now() - downSince : 0; if (recoveredForMs < RESOLVED_AFTER_MS) return; try { this.healthChecker.resolveIncident(u.host, 'caddy-upstream-dead', { serviceId: u.host, timestamp: u.lastSuccessAt || new Date().toISOString(), status: 'up' }); this.openIncidents.delete(u.host); this.emit('upstream-recovered', u); this.log.info?.('caddy-upstream-watcher', `upstream recovered: ${u.host}`); } catch (e) { this.log.warn?.('caddy-upstream-watcher', `incident resolve failed: ${e.message}`); } } _doProbe(host, port) { return new Promise((resolve) => { const isHttps = port === '443'; const lib = isHttps ? https : http; const opts = { hostname: host, port: port || (isHttps ? 443 : 80), method: 'HEAD', path: '/', timeout: PROBE_TIMEOUT_MS, headers: { 'X-DashCaddy-HealthCheck': '1', 'User-Agent': 'DashCaddy-CaddyUpstreamWatcher/1' }, rejectUnauthorized: false }; const req = lib.request(opts, (res) => { res.resume(); const healthy = HEALTHY_CODES.has(res.statusCode); resolve({ healthy, statusCode: res.statusCode }); }); req.on('timeout', () => { req.destroy(new Error('probe timeout')); }); req.on('error', (err) => { resolve({ healthy: false, error: err.message }); }); req.end(); }); } /** * Public snapshot for the API/UI. * * Each upstream record includes: * - host / site / siteFile: identity * - status: 'up' | 'down' | 'unverifiable' | 'unknown' (or 'muted' here) * - consecutiveFailures / failingForMs: dead-detection counters * - lastCheckedAt / lastSuccessAt / lastFailureAt / lastError: probe history * - muted: true if user silenced this upstream * - dead: true if failingForMs >= DEAD_AFTER_MS (5 min default) * - verifiedViaBridge (loopback upstreams only): true iff this upstream * has ever answered a host-gateway probe with success. A later failed * probe is then near-conclusive evidence of upstream death rather * than bridge/UFW refusal. UI consumers should label `unverifiable` * rows as "no prior observation" and `down` rows with * verifiedViaBridge=true as "previously-verified, now down". * * @returns {{ upstreams: Array, config: object }} */ snapshot() { const list = []; for (const u of this.upstreams.values()) { const muted = this.muted.has(u.host); // Same anchor as _maybeOpenIncident: time since the last successful // probe. If we've never seen a success, fall back to consecutive // failures × probe interval as a worst-case lower bound. const lastSuccessMs = u.lastSuccessAt ? new Date(u.lastSuccessAt).getTime() : null; let failingFor = 0; if (!muted) { if (lastSuccessMs !== null) { failingFor = Math.max(0, Date.now() - lastSuccessMs); } else if (u.status === 'down') { failingFor = u.consecutiveFailures * PROBE_INTERVAL_MS; } } list.push({ host: u.host, site: u.site, siteFile: u.siteFile, status: muted ? 'muted' : u.status, consecutiveFailures: u.consecutiveFailures, lastCheckedAt: u.lastCheckedAt, lastSuccessAt: u.lastSuccessAt, lastFailureAt: u.lastFailureAt, lastError: u.lastError, failingForMs: failingFor, muted, dead: !muted && failingFor >= DEAD_AFTER_MS, // True iff this loopback upstream has ever answered a host-gateway // probe with success — meaning we have at least one prior positive // observation of bridge connectivity, so a later failure is // evidence of upstream death rather than bridge/UFW refusal. verifiedViaBridge: !!u.verifiedViaBridge }); } // Sort: dead first, then down, then muted, then unverifiable (informational), // then up, then unknown. Within each, by host. list.sort((a, b) => { const order = { dead: 0, down: 1, muted: 2, unverifiable: 3, up: 4, unknown: 5 }; const oa = order[a.dead ? 'dead' : a.status] ?? 9; const ob = order[b.dead ? 'dead' : b.status] ?? 9; if (oa !== ob) return oa - ob; return a.host.localeCompare(b.host); }); return { upstreams: list, config: { probeIntervalMs: PROBE_INTERVAL_MS, deadAfterMs: DEAD_AFTER_MS, resolvedAfterMs: RESOLVED_AFTER_MS, sitesDir: SITES_DIR } }; } setMuted(host, muted) { if (muted) { this.muted.add(host); } else { this.muted.delete(host); // Reset failure state on unmute so we don't immediately re-incident a // upstream that just came off mute. const u = this.upstreams.get(host); if (u) { u.consecutiveFailures = 0; u.lastError = null; u.lastFailureAt = null; u.status = 'unknown'; } } this._saveState(); return { host, muted: !!muted }; } isMuted(host) { return this.muted.has(host); } _loadState() { try { if (!fs.existsSync(STATE_FILE)) return; const data = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); if (Array.isArray(data.muted)) this.muted = new Set(data.muted); // Don't reload upstreams from disk — sites dir is the source of truth. // But preserve last-check state for hosts that still exist. if (data.upstreams && typeof data.upstreams === 'object') { this._restoreUpstreamStates(data.upstreams); } } catch (e) { this.log.warn?.('caddy-upstream-watcher', `state load failed: ${e.message}`); } } _restoreUpstreamStates(persisted) { for (const [host, st] of Object.entries(persisted)) { if (this.upstreams.has(host)) continue; this.upstreams.set(host, { host, ip: st.ip || host.split(':')[0], port: st.port || null, site: st.site || '', siteFile: st.siteFile || '', consecutiveFailures: st.consecutiveFailures || 0, lastFailureAt: st.lastFailureAt || null, lastSuccessAt: st.lastSuccessAt || null, lastError: st.lastError || null, lastCheckedAt: st.lastCheckedAt || null, // Persist verifiedViaBridge so a loopback upstream that proved itself // reachable once doesn't have to re-prove it after every container // restart. A 1-tick blip is acceptable here because: // (a) the field is only used as a labelling gate for the // unverifiable-vs-down decision — a falsy restart value means // we re-mark unverifiable for one cycle, the safer direction; // (b) the bridge IP doesn't change between restarts of the same // container, so a previously-positive observation is still // good evidence. verifiedViaBridge: !!st.verifiedViaBridge, status: 'unknown' }); } } _saveState() { try { const dir = path.dirname(STATE_FILE); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); const upstreams = {}; for (const [k, v] of this.upstreams.entries()) { upstreams[k] = { ip: v.ip, port: v.port, site: v.site, siteFile: v.siteFile, consecutiveFailures: v.consecutiveFailures, lastFailureAt: v.lastFailureAt, lastSuccessAt: v.lastSuccessAt, lastError: v.lastError, lastCheckedAt: v.lastCheckedAt, verifiedViaBridge: !!v.verifiedViaBridge }; } const tmp = STATE_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify({ muted: Array.from(this.muted), upstreams }, null, 2)); fs.renameSync(tmp, STATE_FILE); } catch (e) { this.log.warn?.('caddy-upstream-watcher', `state save failed: ${e.message}`); } } } // Singleton — matches the pattern of health-checker.js so it integrates // without a separate instantiation site. module.exports = new CaddyUpstreamWatcher(); module.exports.CaddyUpstreamWatcher = CaddyUpstreamWatcher;