DC-053 follow-ups (queue item 2b). Three small fixes to the caddy-upstream-watcher: 1. verifiedViaBridge flag: a loopback upstream whose PRIOR probe succeeded via host-gateway proves the bridge CAN reach the host. A later failed probe is then near-conclusive evidence the upstream itself went dead. The DC-053 code unconditionally marked loopback failures as unverifiable, throwing away this signal. Now: track verifiedViaBridge per-upstream and treat verified-then-failed as down (count failures, open incident after DEAD_AFTER_MS=5min). 2. IN_CONTAINER=false kill-switch test (B-grade polish, folded into same commit per conjoint-commit anti-pattern). 3. git.sami intermittent ENOTFOUND (~2/h in ssl-monitor TLS handshake): pin git.sami -> 100.121.150.22 (DNS2 Tailscale) in container /etc/hosts via --add-host in start.sh. Existing comment explicitly forbids pinning to DNS3/100.81.59.99 (no HTTPS listener there); DNS2/100.121.150.22 is correct (Caddy serves git.sami on DNS2:443 and routes to DNS3:3030 internally). GLM-5.3 judge round 1 (208s, 6 tool calls, on-disk verified): grade B, all 25 tests green, no blocking issues, 3 LOW polish suggestions. Folded two actionable LOWs (persistence + JSDoc) into this commit: - verifiedViaBridge now persisted in _saveState/_restoreUpstreamStates so a known-good loopback upstream stays labeled across container restarts (1-tick blip becomes 0-tick). - snapshot() gained JSDoc describing the verifiedViaBridge semantic for dashboard consumers. Third LOW (long-term: prefer host-side liveness signal from Caddy) is a roadmap note, not actionable now. Tests: 1921/1921 (was 1910; +11 net: 6 new for items 2b-a/2b-b/persistence + 5 previously-skipped baseline). Full suite 86/86 green.
547 lines
23 KiB
JavaScript
547 lines
23 KiB
JavaScript
/**
|
||
* Caddy upstream watcher
|
||
*
|
||
* Watches every `reverse_proxy <host>` 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 <dataDir>/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<string, UpstreamState> keyed by host (host[:port]) */
|
||
this.upstreams = new Map();
|
||
/** Set<string> hosts the user has muted */
|
||
this.muted = new Set();
|
||
/** Set<string> 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 <host[:port]> 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<object>, 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; |