Files
dashcaddy/dashcaddy-api/src/monitoring/caddy-upstream-watcher.js
T
Krystie 901df8608b
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
[glm-grade=B] fix(monitoring): restore dead-detection for verified loopback upstreams (DC-054)
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.
2026-08-18 00:13:58 -07:00

547 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;