#!/usr/bin/env node /** * shiroko-monitor 本地探针 + 面板服务器(零依赖,Node >= 18) * * 模式: * 默认 常驻巡检:按各 monitor 的 interval 周期探测,结果写入 public/status.json,同时伺服面板 * --once 执行一轮探测后退出(供 GitHub Actions 等 CI 使用,历史心率从已有 status.json 恢复) * --demo 生成一份演示状态数据后退出(用于静态部署的首版占位) * --out= 指定快照输出路径,默认 public/status.json(可避免演示数据覆盖真实快照) * --config= / --local-config= 指定公开配置与私有覆盖的路径(多实例、测试用) * * 私有配置:若存在 local.monitors.json(已在 .gitignore 中),其 monitors 按 id 覆盖公开配置, * 用于把真实地址/端口从会公开的 public/monitors.json 中拆出去。 */ import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { probeHttp } from './lib/http-probe.js'; import { probeTcp } from './lib/tcp-probe.js'; import { probeBanner } from './lib/banner-probe.js'; import { fetchUptimeRobotMonitors } from './lib/uptimerobot.js'; import { notifyAll, sendTestNotifications, createMeoWLimiter } from './lib/notify.js'; import { probeCertificate, tlsTargetOf } from './lib/tls-probe.js'; import { createAdminAuth } from './lib/admin.js'; import { createAdminRouter } from './lib/admin-routes.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '../..'); const PUBLIC_DIR = path.join(ROOT, 'public'); const CONFIG_PATH = path.join(PUBLIC_DIR, 'monitors.json'); // 私有配置(不入库):用于把真实地址/端口从公开的 monitors.json 中拆出去 const LOCAL_CONFIG_PATH = path.join(ROOT, 'local.monitors.json'); const STATUS_PATH = path.join(PUBLIC_DIR, 'status.json'); const HISTORY_LIMIT = 48; const UPTIME_DAYS = 90; const CONCURRENCY = 8; const WRITE_DEBOUNCE_MS = 300; const DEFAULT_INTERVAL_SEC = 60; const PORT = Number(process.env.PORT || 8787); const VERSION = '0.12.2'; const UR_DEFAULT_POLL_SEC = 300; const UR_MIN_REFRESH_GAP_MS = 60000; const argv = process.argv.slice(2); const ONCE = argv.includes('--once'); const DEMO = argv.includes('--demo'); // --test-notify:只给各通知渠道发一条自检消息后退出(不探测、不写快照) const TEST_NOTIFY = argv.includes('--test-notify'); // --out= 或 --out :指定快照输出路径(默认 public/status.json) function argValue(name) { const inline = argv.find((a) => a.startsWith(`${name}=`)); if (inline) return inline.slice(name.length + 1); const idx = argv.indexOf(name); return idx >= 0 && argv[idx + 1] && !argv[idx + 1].startsWith('--') ? argv[idx + 1] : null; } const OUT_PATH = path.resolve(argValue('--out') || STATUS_PATH); // --config / --local-config:多实例或测试时指定配置文件(默认 public/monitors.json 与仓库根的私有覆盖) const CONFIG_OVERRIDE = argValue('--config'); const LOCAL_CONFIG_OVERRIDE = argValue('--local-config'); // --data-dir=:通知状态与事件时间线的存放目录(默认 ./data,也可用环境变量 SHIROKO_DATA_DIR) if (argValue('--data-dir')) process.env.SHIROKO_DATA_DIR = argValue('--data-dir'); // 私有数据目录:通知状态(告警去抖)与事件时间线,含本机运行痕迹,默认不入库 const DATA_DIR = process.env.SHIROKO_DATA_DIR ? path.resolve(process.env.SHIROKO_DATA_DIR) : path.join(ROOT, 'data'); const ACTIVE_CONFIG_PATH = CONFIG_OVERRIDE ? path.resolve(CONFIG_OVERRIDE) : CONFIG_PATH; const ACTIVE_LOCAL_CONFIG_PATH = LOCAL_CONFIG_OVERRIDE ? path.resolve(LOCAL_CONFIG_OVERRIDE) : LOCAL_CONFIG_PATH; const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.ico': 'image/x-icon', '.woff2': 'font/woff2', }; function loadConfig() { const pub = JSON.parse(fs.readFileSync(ACTIVE_CONFIG_PATH, 'utf8')); const local = loadLocalOverlay(); if (!local) return pub; // 私有配置按 id 覆盖公开条目(前者可只写 url/host/port 等敏感字段),settings 同样按需覆盖 const byId = new Map((local.monitors ?? []).map((m) => [m.id, m])); return { ...pub, settings: { ...pub.settings, ...(local.settings ?? {}) }, monitors: (pub.monitors ?? []).map((m) => ({ ...m, ...(byId.get(m.id) ?? {}) })), }; } function loadLocalOverlay() { try { return JSON.parse(fs.readFileSync(ACTIVE_LOCAL_CONFIG_PATH, 'utf8')); } catch { return null; } } // 对外提供配置时必须剥掉密钥:/api/monitors 无鉴权,而合并后的 config 含私有层的 // UptimeRobot API Key(以及未来的其他凭据)。面板只需要非敏感的 settings 字段。 function publicConfig(config) { const ur = config.settings?.uptimeRobot; if (!ur) return config; return { ...config, settings: { ...config.settings, uptimeRobot: { ...ur, apiKey: undefined }, }, }; } function loadPersistedStatus() { try { return JSON.parse(fs.readFileSync(STATUS_PATH, 'utf8')); } catch { return null; } } function dayKey(d = new Date()) { const p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; } // 只保留最近 UPTIME_DAYS 天;YYYY-MM-DD 的字典序即时间序 function pruneDaily(daily) { const keys = Object.keys(daily).sort(); while (keys.length > UPTIME_DAYS) delete daily[keys.shift()]; } // 记录一次探测到「当日分桶」;latency 仅在在线时传入(离线结果没有有意义的延迟) // 同时累计延迟统计(sum/count/max),用于面板的 90 天延迟趋势 function recordDaily(daily, ok, latency = null) { const key = dayKey(); const bucket = daily[key] ?? (daily[key] = { total: 0, up: 0 }); bucket.total++; if (ok) { bucket.up++; if (Number.isFinite(latency) && latency >= 0) { bucket.latSum = (bucket.latSum ?? 0) + latency; bucket.latCount = (bucket.latCount ?? 0) + 1; bucket.latMax = Math.max(bucket.latMax ?? 0, latency); } } pruneDaily(daily); } // 从分桶聚合延迟趋势(90 天):返回 { avg, max, days: [{ key, avg }] } function latencyTrend(daily) { const keys = Object.keys(daily ?? {}).sort(); const days = []; let sum = 0; let count = 0; let max = 0; for (const key of keys) { const b = daily[key]; if (!b?.latCount) continue; const avg = Math.round(b.latSum / b.latCount); days.push({ key, avg }); sum += b.latSum; count += b.latCount; max = Math.max(max, b.latMax ?? 0); } if (!count) return null; return { avg: Math.round(sum / count), max, days }; } function intervalMs(monitor) { const sec = Number(monitor.interval); // 非法或缺失的 interval 一律回落到默认值,避免算出 NaN 让该目标永久不再被调度 return Math.max(5, Number.isFinite(sec) && sec > 0 ? sec : DEFAULT_INTERVAL_SEC) * 1000; } /* ---------- UptimeRobot 只读数据源(异地探测视角) ---------- */ // API Key 建议放 local.monitors.json(不入库)或环境变量 UPTIMEROBOT_API_KEY;两者都无则视为未启用 function uptimeRobotConfig(config) { const ur = config.settings?.uptimeRobot ?? {}; const apiKey = String(ur.apiKey || process.env.UPTIMEROBOT_API_KEY || '').trim(); const ratios = Array.isArray(ur.ratios) && ur.ratios.length ? ur.ratios.map(Number).filter(Number.isFinite) : [1, 7, 30, 90]; return { enabled: ur.enabled === true && Boolean(apiKey), apiKey, pollIntervalSec: Math.max(60, Number(ur.pollIntervalSec) || UR_DEFAULT_POLL_SEC), group: ur.group || 'UptimeRobot', includeTypes: Array.isArray(ur.includeTypes) && ur.includeTypes.length ? ur.includeTypes.map(Number) : null, ratios: ratios.length ? ratios : [1, 7, 30, 90], }; } // UR 侧不探测,只做「拉取 + 映射」;失败时保留上一次数据,绝不因为读不到 UR 就把服务判为离线 async function syncUptimeRobot(runtime, { minGapMs = 0 } = {}) { const ur = uptimeRobotConfig(runtime.config); runtime.ur.enabled = ur.enabled; if (!ur.enabled || runtime.ur.syncing) return; // minGapMs 用于「面板手动刷新时顺带同步」的场景,避免被反复点击打爆 UR 接口 if (minGapMs && Date.now() - runtime.ur.lastSyncMs < minGapMs) return; runtime.ur.lastSyncMs = Date.now(); runtime.ur.syncing = true; try { let list = await fetchUptimeRobotMonitors({ apiKey: ur.apiKey, ratios: ur.ratios }); if (ur.includeTypes) list = list.filter((m) => ur.includeTypes.includes(m.sourceType)); const seen = new Set(); for (const item of list) { seen.add(item.id); let s = runtime.state.get(item.id); if (!s) { s = { monitor: item, nextDue: 0, inFlight: false, lastResult: null, history: [], daily: {} }; runtime.state.set(item.id, s); } s.monitor = item; s.lastResult = { status: item.status, latency: item.latency, code: item.code, detail: item.detail, checkedAt: item.lastCheckIso, }; s.history = item.history; s.uptimeRatios = item.uptimeRatios; s.downSeconds = item.downSeconds; if (item.status !== 'pending') recordDaily(s.daily, item.status === 'up', item.latency); } // 清理 UR 侧已删除的监控 for (const [id, s] of runtime.state) { if (s.monitor?.source === 'uptimerobot' && !seen.has(id)) runtime.state.delete(id); } runtime.ur.ok = true; runtime.ur.error = null; runtime.ur.count = list.length; runtime.ur.lastSyncAt = new Date().toISOString(); markDataUpdated(runtime); console.log(`[uptimerobot] 同步完成:${list.length} 个监控(在线 ${list.filter((m) => m.status === 'up').length})`); } catch (err) { runtime.ur.ok = false; runtime.ur.error = err.message; console.error(`[uptimerobot] 同步失败(保留上次数据):${err.message}`); } finally { runtime.ur.syncing = false; } } // 恢复 lastResult / history / daily:优先复用内存态(配置热重载),否则读持久化的 status.json(重启) function hydrate(config, prevState = null) { const persisted = prevState ? null : loadPersistedStatus(); const state = new Map(); for (const m of config.monitors) { const own = prevState?.get(m.id); const saved = own ? { status: own.lastResult?.status, latency: own.lastResult?.latency, code: own.lastResult?.code, detail: own.lastResult?.detail, lastCheck: own.lastResult?.checkedAt, history: own.history, daily: own.daily, } : persisted?.monitors?.[m.id]; state.set(m.id, { monitor: m, nextDue: 0, inFlight: false, lastResult: saved?.status && saved.status !== 'pending' ? { status: saved.status, latency: saved.latency, code: saved.code, detail: saved.detail, checkedAt: saved.lastCheck } : null, history: Array.isArray(saved?.history) ? saved.history.slice(-HISTORY_LIMIT) : [], daily: saved?.daily && typeof saved.daily === 'object' ? { ...saved.daily } : {}, // UR 侧的聚合可用率(用于在逐日分桶填满前也能显示官方数字) uptimeRatios: own?.uptimeRatios ?? saved?.uptimeRatios ?? null, downSeconds: own?.downSeconds ?? saved?.downSeconds ?? null, }); } // UptimeRobot 镜像目标不在 config 里,热重载时需整体保留,否则会丢掉已累积的逐日分桶 if (prevState) { for (const [id, s] of prevState) { if (s.monitor?.source === 'uptimerobot') state.set(id, s); } } return state; } async function probeOne(monitor) { let result; if (monitor.type === 'tcp') result = await probeTcp(monitor); else if (monitor.type === 'banner') result = await probeBanner(monitor); else if (monitor.type === 'http') result = await probeHttp(monitor); else return { status: 'down', latency: 0, code: null, detail: `不支持的探测类型:${monitor.type}`, checkedAt: new Date().toISOString() }; result.checkedAt = new Date().toISOString(); return result; } async function mapLimit(items, limit, fn) { let idx = 0; const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { while (idx < items.length) { const i = idx++; await fn(items[i], i); } }); await Promise.all(workers); } async function probeDue(runtime, force = false) { const { config, state } = runtime; const now = Date.now(); const due = []; for (const m of localProbeTargets(config)) { const s = state.get(m.id); if (!s || s.inFlight) continue; // UptimeRobot 镜像目标由其自身节点探测,本探针只拉取结果 if (m.type === 'ur' || m.source === 'uptimerobot') continue; // 目标可单独退出「刷新即检测」(如有限流的 API),只按自身 interval 探测 if (force && m.probeOnRefresh === false) continue; if (!force && s.nextDue > now) continue; // 先同步占位再探测:否则慢目标(如 10s 超时)尚未返回时会被下一秒的 tick 重复调度,形成探测风暴 s.inFlight = true; s.nextDue = Date.now() + intervalMs(m); due.push(m); } await mapLimit(due, CONCURRENCY, async (m) => { const s = state.get(m.id); const task = (async () => { try { const result = await probeOne(m); s.lastResult = result; s.history.push({ t: result.checkedAt, ok: result.status === 'up', ms: result.latency ?? 0 }); if (s.history.length > HISTORY_LIMIT) s.history.shift(); recordDaily(s.daily, result.status === 'up', result.latency); const tag = result.status === 'up' ? '[up] ' : '[down]'; console.log(`${tag} ${m.id.padEnd(20)} ${String(result.latency ?? '-').padStart(5)}ms ${result.detail}`); } catch (err) { console.error(`[probe] ${m.id} 探测异常:${err.message}`); } finally { s.inFlight = false; s.inFlightPromise = null; } })(); s.inFlightPromise = task; await task; }); if (due.length) markDataUpdated(runtime); } // 等待在途探测收尾(强制刷新前调用,避免"正在探测中"的目标被跳过而显示旧数据) async function settleInflight(state) { const pending = [...state.values()].map((s) => s.inFlightPromise).filter(Boolean); if (pending.length) await Promise.allSettled(pending); } // 探针自报内存占用(MB):便于观察这个「监控器」自身的资源开销 function memoryStats() { const m = process.memoryUsage(); const mb = (v) => Math.round((v / 1048576) * 10) / 10; return { rssMB: mb(m.rss), heapUsedMB: mb(m.heapUsed), heapTotalMB: mb(m.heapTotal) }; } /* ---------- 通知(邮件 / MeoW)、事件时间线、维护窗口与证书监控 ---------- */ const DEFAULT_TLS_CHECK_SEC = 21600; // 证书默认 6 小时查一次 const TLS_ALERT_DAYS = [14, 7, 3]; const EVENT_LIMIT = 500; const EVENT_IN_SNAPSHOT = 20; function notifyConfig(config) { const n = config.settings?.notify ?? {}; const channels = n.channels ?? {}; return { enabled: n.enabled === true && Object.values(channels).some((c) => c?.enabled === true), failureThreshold: Math.max(1, Number(n.failureThreshold) || 2), recoveryNotify: n.recoveryNotify !== false, quietHours: n.quietHours ?? null, channels, }; } function tlsConfig(config) { const t = config.settings?.tls ?? {}; const alertDays = Array.isArray(t.alertDays) ? t.alertDays.map(Number).filter(Number.isFinite) : null; return { enabled: t.enabled !== false, // 默认对 https 目标开启 checkIntervalSec: Math.max(600, Number(t.checkIntervalSec) || DEFAULT_TLS_CHECK_SEC), alertDays: alertDays?.length ? alertDays.sort((a, b) => b - a) : TLS_ALERT_DAYS, }; } // 维护窗口:{ from, to, id?: string|string[], note? };不写 id 表示对所有目标生效 function activeMaintenanceFor(config, id, at = new Date()) { const list = config.settings?.maintenance; if (!Array.isArray(list) || !list.length) return null; const t = at.getTime(); for (const w of list) { const from = Date.parse(w.from); const to = Date.parse(w.to); if (!Number.isFinite(from) || !Number.isFinite(to) || t < from || t > to) continue; if (!w.id) return w; const ids = Array.isArray(w.id) ? w.id : [w.id]; if (ids.includes(id)) return w; } return null; } function readJson(file, fallback) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; } } function writeJson(file, data) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`); } const NOTIFY_STATE_PATH = () => path.join(DATA_DIR, 'notify-state.json'); const EVENTS_PATH = () => path.join(DATA_DIR, 'events.json'); // 2FA 档案:与通知状态同放私有数据目录(data/ 已在 .gitignore 中) const ADMIN_2FA_PATH = () => path.join(DATA_DIR, 'admin-2fa.json'); function monitorNotifyState(runtime, id) { const list = runtime.notifyState.monitors; if (!list[id]) list[id] = { lastStatus: null, failStreak: 0, lastCheckedAt: null, notifiedTls: [], tlsValidTo: null }; return list[id]; } function appendEvents(runtime, entries) { if (!entries.length) return; runtime.events = [...runtime.events, ...entries].slice(-EVENT_LIMIT); writeJson(EVENTS_PATH(), runtime.events); } // 通知 + 事件:每轮探测结束后评估状态翻转(带去抖)、恢复、维护窗口抑制 async function evaluateRound(runtime) { const notify = notifyConfig(runtime.config); const events = []; const pendingNotifications = []; let changed = false; // 事件与告警覆盖两类目标:本地探测目标 + UptimeRobot 镜像目标 // (UR 镜像目标同样会发生状态翻转,只拉数据不告警等于「配了 uptimerobot 口径就收不到任何提醒」) const targets = localProbeTargets(runtime.config).map((m) => ({ id: m.id, name: m.name, target: m.url ?? `${m.host}:${m.port}` })); for (const [id, s] of runtime.state) { if (s.monitor?.source !== 'uptimerobot') continue; targets.push({ id, name: s.monitor.name, target: s.monitor.url ?? '', source: 'uptimerobot' }); } for (const m of targets) { const r = runtime.state.get(m.id)?.lastResult; if (!r || r.status === 'pending') continue; const st = monitorNotifyState(runtime, m.id); // 去抖计数只应该跟着「新的探测结果」走:本函数每秒被调用,若每次评估都累加, // 一个持续离线的目标会在 1 秒内就凑够「连续失败 N 次」,阈值形同虚设 const stamp = r.checkedAt ?? null; if (st.lastCheckedAt !== stamp) { st.lastCheckedAt = stamp; st.failStreak = r.status === 'down' ? (st.failStreak ?? 0) + 1 : 0; changed = true; } const maintenance = activeMaintenanceFor(runtime.config, m.id); const at = new Date().toISOString(); if (r.status === 'down') { // 去抖:连续失败达到阈值才算确认故障(阈值 1 表示立即告警) if (st.lastStatus === 'down' || st.failStreak < notify.failureThreshold) continue; st.lastStatus = 'down'; changed = true; st.lastNotifiedAt = at; events.push({ t: at, id: m.id, name: m.name, type: 'down', detail: r.detail ?? null, maintenance: Boolean(maintenance) }); if (!maintenance) { pendingNotifications.push({ subject: `【故障】${m.name} 连接失败`, text: `目标:${m.name}\n地址:${m.target}\n时间:${at}\n详情:${r.detail ?? '无'}\n连续失败:${st.failStreak} 次\n\n—— shiroko-monitor`, }); } } else if (r.status === 'up') { if (st.lastStatus === null) { st.lastStatus = 'up'; // 首次观测不产生「恢复」噪音 changed = true; continue; } if (st.lastStatus === 'down') { st.lastStatus = 'up'; st.lastNotifiedAt = at; changed = true; events.push({ t: at, id: m.id, name: m.name, type: 'up', detail: '已恢复', maintenance: Boolean(maintenance) }); if (notify.recoveryNotify && !maintenance) { pendingNotifications.push({ subject: `【恢复】${m.name} 已恢复正常`, text: `目标:${m.name}\n地址:${m.target}\n恢复时间:${at}\n延迟:${r.latency ?? '—'} ms\n\n—— shiroko-monitor`, }); } } } } if (events.length) { appendEvents(runtime, events); console.log(`[events] 新增 ${events.length} 条:${events.map((e) => `${e.name} ${e.type}`).join('、')}`); } // 汇总成一条通知,避免同一轮多点故障刷屏 if (pendingNotifications.length) { const message = pendingNotifications.length === 1 ? pendingNotifications[0] : { subject: `【${pendingNotifications.length} 个目标状态变化】shiroko-monitor`, text: pendingNotifications.map((p) => p.text).join('\n\n──────────\n\n'), }; const res = await notifyAll(notify, message, { logger: console }); runtime.notifyState.lastResult = { at: new Date().toISOString(), ...res }; changed = true; } // 只在状态真变化时落盘:tick 会每秒调用本函数,无变化时不写文件 if (changed) saveNotifyState(runtime); } // 证书检查:按 checkIntervalSec 逐个握手,跨阈值时告警(同一张证书每个阈值只告一次) async function checkCertificates(runtime) { const tlsCfg = tlsConfig(runtime.config); if (!tlsCfg.enabled) return; const now = Date.now(); const events = []; const messages = []; for (const m of localProbeTargets(runtime.config)) { const target = tlsTargetOf(m); if (!target) continue; const prev = runtime.tls.get(m.id); if (prev && now - (prev.checkedAtMs ?? 0) < tlsCfg.checkIntervalSec * 1000) continue; const result = await probeCertificate({ ...target, timeoutMs: Math.max(5, Number(m.timeout) || 8) * 1000 }); runtime.tls.set(m.id, { ...result, checkedAt: new Date().toISOString(), checkedAtMs: now }); const st = monitorNotifyState(runtime, m.id); if (result.error) { console.warn(`[tls] ${m.id} 证书检查失败:${result.error}`); continue; } // 换了证书(validTo 变化)→ 重置已通知阈值,新证书重新提醒 if (st.tlsValidTo !== result.validTo) { st.tlsValidTo = result.validTo; st.notifiedTls = []; } const maintenance = activeMaintenanceFor(runtime.config, m.id); // 已过期的证书只报最严重的一档(避免一次刷三条) const thresholds = result.daysLeft <= 0 ? tlsCfg.alertDays.slice(-1) : tlsCfg.alertDays; for (const threshold of thresholds) { if (result.daysLeft > threshold || (st.notifiedTls ?? []).includes(threshold)) continue; st.notifiedTls = [...(st.notifiedTls ?? []), threshold]; const at = new Date().toISOString(); events.push({ t: at, id: m.id, name: m.name, type: 'tls', detail: `证书剩余 ${result.daysLeft} 天(阈值 ${threshold} 天)`, maintenance: Boolean(maintenance) }); if (!maintenance) { messages.push(`- ${m.name}:证书剩余 ${result.daysLeft} 天(到期 ${result.validTo.slice(0, 10)},签发者 ${result.issuer ?? '未知'})`); } } } if (events.length) { appendEvents(runtime, events); if (messages.length) { const res = await notifyAll(notifyConfig(runtime.config), { subject: `【证书提醒】${messages.length} 个证书即将到期`, text: `${messages.join('\n')}\n\n签发者与到期时间取自实际握手,请及时续期。\n\n—— shiroko-monitor`, }, { logger: console }); runtime.notifyState.lastResult = { at: new Date().toISOString(), ...res }; } saveNotifyState(runtime); } } function saveNotifyState(runtime) { writeJson(NOTIFY_STATE_PATH(), runtime.notifyState); } // 通知与证书检查串行执行:避免并发轮次重复发同一条告警 function queueEvaluation(runtime, task) { runtime.notifyChain = (runtime.notifyChain ?? Promise.resolve()) .then(() => task(runtime)) .catch((err) => console.error(`[notify] 评估异常:${err.stack || err.message}`)); return runtime.notifyChain; } // 只影响「展示与快照输出」,不影响探测:配置文件里的目标始终按各自 interval 探测 function dataFilter(config) { const f = config.settings?.dataFilter; return ['both', 'config', 'uptimerobot'].includes(f) ? f : 'both'; } // 探测范围(是否真的去探本地配置里的目标): // 显式设置 settings.probeScope 优先(all | config | uptimerobot) // 未设置时**跟随 dataFilter** —— 面板只要一个来源的数据,就没必要再把另一个来源的目标探测一遍 // (既省资源,也避免不必要的对外请求;想保留探测可显式设 "all") function probeScopeSetting(config) { const p = config.settings?.probeScope; if (['all', 'config', 'uptimerobot'].includes(p)) return p; const f = dataFilter(config); return f === 'both' ? 'all' : f; } function externalSyncEnabled(config) { return probeScopeSetting(config) !== 'config'; } // 本轮需要本地探测的目标(UR 镜像目标不在此列,它们由 UR 节点探测、这里只拉结果) function localProbeTargets(config) { return probeScopeSetting(config) === 'uptimerobot' ? [] : config.monitors; } /* ---------- 服务端更新节奏 ---------- */ // 快照里回报「数据最后变更时刻」与「服务端产出新数据的节奏」,供面板把倒计时对齐到服务端, // 而不是从用户打开页面那一刻重新计时(否则每次进来都要重等一整个周期,最坏会看到近两倍周期的旧数据) function markDataUpdated(runtime) { runtime.dataUpdatedAt = Date.now(); } // 当前展示集合下,服务端多久产出一次新数据:取各目标 interval 与 UR 拉取间隔的最小值 function updateIntervalSec(config, filter) { const intervals = []; if (filter !== 'uptimerobot') { for (const m of config.monitors) { const n = Number(m.interval); if (Number.isFinite(n) && n > 0) intervals.push(n); } } if (filter !== 'config') { const ur = uptimeRobotConfig(config); if (ur.enabled) intervals.push(ur.pollIntervalSec); } return Math.max(5, Math.round(intervals.length ? Math.min(...intervals) : DEFAULT_INTERVAL_SEC)); } function snapshot(runtime, demo = false) { const { config, state } = runtime; // redactDetails:公开部署时可只输出「正常/异常」,避免 detail 中的版本、地址等细节被公开读取 const redact = config.settings?.redactDetails === true; const filter = dataFilter(config); const hideUrls = config.settings?.hideTargets === true; const includeConfig = filter !== 'uptimerobot'; const includeExternal = filter !== 'config'; // 混合来源时平均延迟只统计自建探测(UR 的延迟来自其探测节点,与本机视角不可比); // 若展示集合只有外部数据源,则 UR 延迟就是唯一口径,照常统计 const latencyEligible = (isUr) => !isUr || filter === 'uptimerobot'; const monitors = {}; let up = 0; let down = 0; let pending = 0; let latencySum = 0; let latencyCount = 0; let uptimeUp = 0; let uptimeTotal = 0; // 配置里的探测目标 + UptimeRobot 镜像目标(后者只存在于 state 中,由同步产生) const entries = []; if (includeConfig) for (const m of config.monitors) entries.push([m.id, m, state.get(m.id)]); if (includeExternal) { for (const [id, s] of state) { if (s.monitor?.source === 'uptimerobot') entries.push([id, s.monitor, s]); } } for (const [id, m, s] of entries) { const r = s?.lastResult; const isUr = m.source === 'uptimerobot'; if (!r) { pending++; monitors[id] = { status: 'pending', latency: null, code: null, detail: '等待检测', lastCheck: null, history: [], daily: s?.daily ?? {} }; continue; } if (r.status === 'up') { up++; if (latencyEligible(isUr) && r.latency != null) { latencySum += r.latency; latencyCount++; } } else if (r.status === 'down') { down++; } else { pending++; } for (const bucket of Object.values(s.daily ?? {})) { uptimeTotal += bucket.total ?? 0; uptimeUp += bucket.up ?? 0; } const entry = { status: r.status, latency: r.latency, code: r.code, detail: redact ? (r.status === 'up' ? '正常' : r.status === 'down' ? '异常' : '等待检测') : r.detail, lastCheck: r.checkedAt, history: s.history, daily: s.daily, }; if (isUr) { // UR 镜像目标不在 config 中,需要把展示元信息随快照下发,面板据此合并渲染; // hideTargets 开启时不下发地址:面板反正不渲染,公开的 status.json 里也就不该有 entry.meta = { name: m.name, group: m.group, type: 'ur', url: hideUrls ? '' : m.url, source: 'uptimerobot', sourceLabel: m.sourceLabel, interval: m.interval, }; entry.uptimeRatios = s.uptimeRatios ?? null; entry.downSeconds = s.downSeconds ?? null; } else { // 证书与维护窗口信息(来自独立的握手检查与配置),供面板打标 const tlsInfo = runtime.tls?.get(id); if (tlsInfo) entry.tls = redact ? { daysLeft: tlsInfo.daysLeft ?? null, error: tlsInfo.error ? '不可用' : null } : { daysLeft: tlsInfo.daysLeft ?? null, validTo: tlsInfo.validTo ?? null, issuer: tlsInfo.issuer ?? null, checkedAt: tlsInfo.checkedAt ?? null, error: tlsInfo.error ?? null }; const maintenance = activeMaintenanceFor(config, id); if (maintenance) entry.maintenance = { note: maintenance.note ?? null, to: maintenance.to ?? null }; } // 90 天延迟趋势(自当日分桶聚合,自建与 UR 镜像目标都适用) const trend = latencyTrend(s.daily); if (trend) entry.lat = trend; monitors[id] = entry; } return { prober: `shiroko-monitor-node/${VERSION}`, demo, generatedAt: new Date().toISOString(), // 数据最后变更时刻 + 服务端更新节奏:面板据此把刷新对齐到服务端,而不是每访客重新计时 dataUpdatedAt: new Date(runtime.dataUpdatedAt ?? Date.now()).toISOString(), updateIntervalSec: updateIntervalSec(config, filter), // 让面板与服务端共用同一份过滤口径,避免私有配置覆盖后两边不一致 dataFilter: filter, // 探测范围(本地目标 / 外部数据源),面板据此显示「数据源」与是否停止本地探测 probeScope: probeScopeSetting(config), stats: { total: entries.length, up, down, pending, avgLatency: latencyCount ? Math.round(latencySum / latencyCount) : null, avgUptime90: uptimeTotal ? Math.round((uptimeUp / uptimeTotal) * 1000) / 10 : null, memory: memoryStats(), }, sources: { uptimerobot: { ...(runtime.ur ?? {}) } }, // 通知能力与最近事件(事件含目标名与原因,redactDetails 时只保留类型与时间) notify: (() => { const n = notifyConfig(config); return { enabled: n.enabled, channels: Object.entries(n.channels ?? {}).filter(([, c]) => c?.enabled).map(([k]) => k), quietHours: n.quietHours, failureThreshold: n.failureThreshold, lastResult: runtime.notifyState?.lastResult ?? null, }; })(), // 只下发当前展示口径内目标的事件:切到 uptimerobot 口径后不该再看到「已经不管的目标」的旧事件 events: (runtime.events ?? []) .filter((e) => monitors[e.id]) .slice(-EVENT_IN_SNAPSHOT) .reverse() .map((e) => (redact ? { t: e.t, id: e.id, type: e.type, name: e.name, detail: e.type === 'down' ? '异常' : e.type === 'up' ? '恢复' : e.detail, maintenance: e.maintenance } : e)), monitors, }; } function writeStatus(data) { const payload = `${JSON.stringify(data, null, 2)}\n`; const tmp = `${OUT_PATH}.tmp`; try { fs.writeFileSync(tmp, payload); // 原子替换:避免面板读到写了一半的 JSON fs.renameSync(tmp, OUT_PATH); } catch { // Windows 下 rename 可能因目标文件被短暂占用而失败(EPERM,常见于杀软扫描), // 退化为直接写入;监控探针绝不能因为一次写文件抖动而整个进程退出 try { fs.writeFileSync(OUT_PATH, payload); fs.rmSync(tmp, { force: true }); } catch (err) { console.error(`[write] status.json 写入失败:${err.message}`); } } } function scheduleWrite(runtime) { if (scheduleWrite.timer) return; scheduleWrite.timer = setTimeout(() => { scheduleWrite.timer = null; writeStatus(snapshot(runtime)); }, WRITE_DEBOUNCE_MS); } // 刷新即重新检测:多客户端/多标签页同时刷新时共享同一轮探测,避免对目标重复施压; // 间隔过近的重复请求直接复用当前快照,只有真正需要时才新起一轮 const FORCED_MIN_GAP_MS = 2000; // 兜底上限:正常情况下轮次在目标超时内完成;若有 socket 卡死,也不让请求无限挂起 const FORCED_MAX_WAIT_MS = 22000; function forceRound(runtime) { if (runtime.forced) return runtime.forced; if (runtime.forcedAt && Date.now() - runtime.forcedAt < FORCED_MIN_GAP_MS) return Promise.resolve(); runtime.forced = (async () => { try { // 先等在途探测收尾再统一探测,保证返回时每个目标都是本轮的新结果 await settleInflight(runtime.state); await probeDue(runtime, true); await settleInflight(runtime.state); runtime.forcedAt = Date.now(); scheduleWrite(runtime); queueEvaluation(runtime, evaluateRound); } catch (err) { console.error(`[refresh] 强制探测异常:${err.message}`); } finally { runtime.forced = null; } })(); return runtime.forced; } function writeDemo(config) { if (OUT_PATH === STATUS_PATH && fs.existsSync(STATUS_PATH) && !loadPersistedStatus()?.demo) { console.warn('[demo] 目标已是真实快照,将被演示数据覆盖(如不希望覆盖,请用 --out= 指定其他输出路径)'); } const state = hydrate(config); for (const m of config.monitors) { const s = state.get(m.id); // 过期证书目标在演示数据中固定为离线,其余按 id 哈希产生稳定的伪随机结果 const seed = [...m.id].reduce((a, c) => a + c.charCodeAt(0), 0); const alwaysDown = m.id.includes('badssl'); const history = Array.from({ length: HISTORY_LIMIT }, (_, i) => { const ok = alwaysDown ? false : (seed + i * 7) % 23 !== 0; return { t: new Date(Date.now() - (HISTORY_LIMIT - i) * (m.interval ?? 60) * 1000).toISOString(), ok, ms: ok ? 30 + ((seed * (i + 3)) % 240) : 0 }; }); const lastOk = history[history.length - 1].ok; s.lastResult = { status: lastOk ? 'up' : 'down', latency: lastOk ? history[history.length - 1].ms : 0, code: m.type === 'tcp' ? null : lastOk ? 200 : 0, detail: m.type === 'tcp' ? (lastOk ? '端口可连接' : '连接被拒绝(端口关闭)') : m.type === 'banner' ? (lastOk ? `${m.protocol ?? 'BANNER'} 响应正常` : '标识串与期望规则不符') : lastOk ? 'HTTP 200' : 'CERT_HAS_EXPIRED', checkedAt: history[history.length - 1].t, }; s.history = history; // 90 天可用率演示数据:多数日期满分,少量日期按档位抖落,便于观察分级配色 const perDay = Math.max(1, Math.round(86400 / (Number(m.interval) > 0 ? Number(m.interval) : DEFAULT_INTERVAL_SEC))); const daily = {}; for (let d = UPTIME_DAYS - 1; d >= 0; d--) { const key = dayKey(new Date(Date.now() - d * 86400000)); const missRatio = alwaysDown ? 1 : (seed + d * 13) % 23 === 0 ? 0.05 : (seed + d * 7) % 11 === 0 ? 0.012 : 0; const miss = Math.round(perDay * missRatio); // 演示用延迟:随日期缓慢起伏,让 90 天趋势有形状可看 const up = perDay - miss; const avg = alwaysDown ? 0 : 40 + ((seed + d * 5) % 160); daily[key] = { total: perDay, up, latSum: avg * up, latCount: up, latMax: avg + 60 }; } s.daily = daily; } writeStatus(snapshot({ config, state }, true)); console.log(`[demo] 演示状态已写入 ${path.relative(ROOT, OUT_PATH) || OUT_PATH}`); } function sendJson(res, data, { status = 200, headers = {} } = {}) { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers }); res.end(JSON.stringify(data)); } /* ---------- 跨域只读(前端可部署在别处,数据源指向本机) ---------- */ // 允许跨域读取的前端来源。默认放行本机:本地开发时前端页面与探针常常跑在不同端口上 // (例如 Vite 的 5173),而 origin 是「协议 + 主机 + 端口」的完整组合, // 因此默认项按「忽略端口」匹配,主机相同即放行。 // 部署在 GitHub Pages / Cloudflare 等其它域名时,在 settings.corsOrigins 里显式列出 // 完整 origin(含协议与端口,如 https://you.github.io)。绝不使用 '*': // 那等于让任意网站都能读走监控数据。 function corsOrigins(config) { const extra = Array.isArray(config.settings?.corsOrigins) ? config.settings.corsOrigins.map((s) => String(s).trim()).filter(Boolean) : []; return { exact: new Set(extra), loopbackHosts: new Set(['127.0.0.1', 'localhost', '[::1]']) }; } // 请求的 origin 是否在白名单内。主机是本机回环地址时忽略端口(本地开发常见形态), // 其余情况要求完整 origin 精确匹配。 function originAllowed(reqOrigin, config) { let u; try { u = new URL(reqOrigin); } catch { return false; } const { exact, loopbackHosts } = corsOrigins(config); if (exact.has(reqOrigin)) return true; return u.protocol === 'http:' && loopbackHosts.has(u.hostname); } // 返回跨域响应头;origin 不在白名单时返回空对象(请求本身照常处理,只是浏览器读不到响应) function corsHeaders(req, config) { const origin = req.headers.origin; if (!origin || !originAllowed(origin, config)) return {}; return { 'access-control-allow-origin': origin, // 回显具体 origin,由白名单保证安全 'access-control-allow-methods': 'GET, POST, OPTIONS', 'access-control-allow-headers': 'content-type', 'access-control-max-age': '86400', vary: 'Origin', // 缓存必须区分 Origin,否则会串味 }; } // 只读接口的预检响应。写接口(/api/refresh、/api/admin/*)不放行 OPTIONS, // 因此外部页面无法用简单请求之外的方式触发有副作用的调用。 function handlePreflight(req, res, config) { const headers = corsHeaders(req, config); if (!headers['access-control-allow-origin']) { res.writeHead(403); return res.end('origin not allowed'); } res.writeHead(204, headers); res.end(); } /* ---------- /api/refresh 滥用防护 ---------- */ // 仅信任 socket 的对端地址,不解析 X-Forwarded-For(可被伪造);若前方挂了反向代理,请改用 allowRefresh:false export function isPrivateAddress(addr) { const ip = String(addr || '').replace(/^::ffff:/i, ''); if (ip === '::1' || ip.startsWith('127.')) return true; if (ip.startsWith('10.') || ip.startsWith('192.168.')) return true; if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true; if (/^f[cd][0-9a-f]{2}:/i.test(ip) || /^fe80:/i.test(ip)) return true; return false; } const refreshHits = new Map(); const DEFAULT_COOLDOWN_SEC = 5; function refreshDecision(config, ip) { const s = config.settings ?? {}; if (s.allowRefresh === false) return { ok: false, status: 403, error: 'refresh-disabled' }; if (s.refreshLocalOnly !== false && !isPrivateAddress(ip)) return { ok: false, status: 403, error: 'refresh-local-only' }; const cooldown = Math.max(1, Number(s.refreshCooldownSec) || DEFAULT_COOLDOWN_SEC) * 1000; const wait = (refreshHits.get(ip) ?? 0) + cooldown - Date.now(); if (wait > 0) return { ok: false, status: 429, error: 'refresh-throttled', retryAfter: Math.ceil(wait / 1000) }; refreshHits.set(ip, Date.now()); // 简单清理,避免长期运行下 Map 无界增长 const expire = Date.now() - 10 * 60 * 1000; for (const [k, ts] of refreshHits) if (ts < expire) refreshHits.delete(k); return { ok: true }; } /* ---------- UptimeRobot 兼容输出(只读) ---------- */ // 把内部快照映射成 getMonitors 的响应形状,便于既有 UptimeRobot 生态的脚本/看板直接复用本服务数据 function toUptimeRobotResponse(runtime) { const snap = snapshot(runtime); const cfgById = new Map(runtime.config.monitors.map((m) => [m.id, m])); // 字符串 id → 稳定数字 id(UptimeRobot 侧 id 为数字) const numericId = (id) => { let h = 0; for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) % 1e9; return h; }; const ratio = (st, days) => { const buckets = Object.entries(st.daily ?? {}).sort().slice(-days); const total = buckets.reduce((a, [, b]) => a + (b.total ?? 0), 0); const upCount = buckets.reduce((a, [, b]) => a + (b.up ?? 0), 0); if (total) return (upCount / total) * 100; // 无逐日数据时回退到数据源给出的聚合比率(UptimeRobot 镜像目标自带) const provided = st.uptimeRatios?.[days]; return Number.isFinite(provided) ? provided : 0; }; const monitors = Object.entries(snap.monitors).map(([id, st]) => { const cfg = cfgById.get(id); const isUr = (st.meta?.source ?? cfg?.source) === 'uptimerobot'; const name = st.meta?.name ?? cfg?.name ?? id; const url = cfg ? (cfg.type === 'http' ? cfg.url : `${cfg.host}:${cfg.port}`) : st.meta?.url ?? ''; const portLike = isUr ? st.meta?.sourceLabel === 'PORT' : cfg?.type !== 'http'; return { id: numericId(id), friendly_name: name, url, type: portLike ? 4 : 1, status: st.status === 'up' ? 2 : st.status === 'down' ? 9 : 1, interval: cfg?.interval ?? st.meta?.interval ?? DEFAULT_INTERVAL_SEC, custom_uptime_ratio: [1, 7, 30, 90].map((d) => ratio(st, d).toFixed(3)).join('-'), average_response_time: st.latency ?? 0, response_times: (st.history ?? []).slice(-HISTORY_LIMIT).map((h) => ({ datetime: Math.floor(new Date(h.t).getTime() / 1000), value: h.ms })), }; }); return { stat: 'ok', pagination: { offset: 0, limit: 50, total: monitors.length }, monitors }; } // runtime 是持有器对象:热重载只更新其属性,服务器与巡检循环始终读到最新引用 function createServer(runtime) { const adminAuth = createAdminAuth({ storePath: ADMIN_2FA_PATH() }); // 私有层被管理端改写后要立刻生效:沿用与 fs.watch 热重载相同的复用逻辑,保留内存态 const reloadConfig = () => { const next = loadConfig(); runtime.state = hydrate(next, runtime.state); runtime.config = next; }; const handleAdminApi = createAdminRouter({ auth: adminAuth, localConfigPath: ACTIVE_LOCAL_CONFIG_PATH, reloadConfig, runtime, fns: { snapshot, dataFilter, probeScopeSetting, localProbeTargets, notifyConfig, tlsConfig, notifyLastResult: () => snapshot(runtime).notify ?? null, forceRound, scheduleWrite, forcedMaxWaitMs: FORCED_MAX_WAIT_MS, version: VERSION, }, }); return http.createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const { config, state } = runtime; try { if (url.pathname.startsWith('/api/admin/')) return await handleAdminApi(req, res, url); if (url.pathname === '/admin' || url.pathname === '/admin/') { const file = path.join(PUBLIC_DIR, 'admin.html'); res.writeHead(200, { 'content-type': MIME['.html'], 'cache-control': 'no-store' }); return void fs.createReadStream(file).pipe(res); } // 预检:只对只读接口与 /api/refresh 放行(/api/admin/* 一律不放行,管理端必须同源访问)。 // /api/refresh 虽然有副作用,但已有 refreshDecision 的限流与 refreshLocalOnly 兜底, // 且它不带自定义头时属于「简单请求」——浏览器无论如何都会把它发出去, // 加跨域头只影响响应能否被 JS 读到(前端据此退回只读),不影响安全性。 if (req.method === 'OPTIONS' && (url.pathname === '/api/status' || url.pathname === '/api/monitors' || url.pathname === '/api/events' || url.pathname === '/api/refresh' || url.pathname === '/api/v2/getMonitors' || url.pathname === '/v2/getMonitors')) { return handlePreflight(req, res, config); } if (url.pathname === '/api/status') return sendJson(res, snapshot(runtime), { headers: corsHeaders(req, config) }); if (url.pathname === '/api/monitors') return sendJson(res, publicConfig(config), { headers: corsHeaders(req, config) }); if (url.pathname === '/api/refresh') { const decide = refreshDecision(config, req.socket.remoteAddress); if (!decide.ok) { // 只回错误码,不回显目标地址与内部细节 return sendJson(res, { error: decide.error }, { status: decide.status, headers: { ...(decide.retryAfter ? { 'retry-after': String(decide.retryAfter) } : {}), ...corsHeaders(req, config) }, }); } // scope=external:只同步 UptimeRobot,不跑本地探测轮次(面板在「只看 UR」模式下用) const scope = url.searchParams.get('scope'); if (scope !== 'external') { // 等本轮探测完成再返回快照,避免面板刷新后读到的仍是旧数据;上限兜底防止个别目标卡死请求 await Promise.race([forceRound(runtime), new Promise((resolve) => setTimeout(resolve, FORCED_MAX_WAIT_MS))]); } // 顺带同步 UptimeRobot(至少间隔 60s,尊重其接口限流),让「只看 UR」模式下的刷新同样有意义; // 再等 6s 上限,避免 UR 接口慢把整体刷新拖过面板超时 await Promise.race([ syncUptimeRobot(runtime, { minGapMs: UR_MIN_REFRESH_GAP_MS }), new Promise((resolve) => setTimeout(resolve, 6000)), ]); // 浏览器中断请求(切页/关标签)是常态:等待期间连接可能已断开,此时不能再写响应 if (res.writableEnded || res.destroyed) return; return sendJson(res, snapshot(runtime), { headers: corsHeaders(req, config) }); } // UptimeRobot 兼容的只读接口(GET/POST 均可):已有脚本/看板可直接消费本服务数据。 // 不做鉴权,因为其暴露内容与公开面板一致;如需鉴权请在其前方加代理。 if (url.pathname === '/api/v2/getMonitors' || url.pathname === '/v2/getMonitors') { return sendJson(res, toUptimeRobotResponse(runtime), { headers: corsHeaders(req, config) }); } // 事件时间线:面板「事件」视图取这里(含通知去抖前的全部状态翻转记录) if (url.pathname === '/api/events') { const list = [...(runtime.events ?? [])].reverse(); return sendJson(res, { total: list.length, events: list.slice(0, 200) }, { headers: corsHeaders(req, config) }); } let rel = decodeURIComponent(url.pathname); if (rel === '/') rel = '/index.html'; const file = path.join(PUBLIC_DIR, path.normalize(rel)); if (!file.startsWith(PUBLIC_DIR)) { res.writeHead(403); return res.end('Forbidden'); } if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) { res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); return res.end('Not Found'); } const ext = path.extname(file).toLowerCase(); const live = rel === '/status.json' || rel === '/monitors.json'; res.writeHead(200, { 'content-type': MIME[ext] || 'application/octet-stream', 'cache-control': live ? 'no-store' : 'public, max-age=300', }); fs.createReadStream(file).pipe(res); } catch (err) { try { res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }); res.end('Internal Error'); } catch { // 连接已被对端关闭,忽略写入失败(避免未捕获异常终止探针进程) } } }); } async function main() { const runtime = { config: loadConfig(), state: null, dataUpdatedAt: Date.now(), // 通知去抖状态与事件时间线从私有数据目录恢复,重启不会重复告警 notifyState: readJson(NOTIFY_STATE_PATH(), { monitors: {} }), events: readJson(EVENTS_PATH(), []), tls: new Map(), tlsLastCheckMs: 0, ur: { enabled: false, ok: null, error: null, lastSyncAt: null, count: 0, syncing: false, lastSyncMs: 0 }, }; if (TEST_NOTIFY) { // 通道自检:只发一条测试消息,不探测、不写快照 const cfg = notifyConfig(runtime.config); if (!cfg.enabled) { console.error('未启用任何通知渠道(settings.notify.enabled 或 channels.*.enabled 未开启)'); process.exitCode = 1; return; } console.log('通知通道自检:'); const out = await sendTestNotifications(cfg, { logger: console }); if (out.some((r) => !r.ok)) process.exitCode = 1; return; } if (DEMO) { writeDemo(runtime.config); return; } runtime.state = hydrate(runtime.config); // 启用 UptimeRobot 时先同步一次,保证首次渲染就有异地探测数据 if (externalSyncEnabled(runtime.config)) await syncUptimeRobot(runtime); if (ONCE) { await probeDue(runtime, true); await queueEvaluation(runtime, evaluateRound); await queueEvaluation(runtime, checkCertificates); writeStatus(snapshot(runtime)); const s = snapshot(runtime).stats; console.log(`[once] 完成:共 ${s.total},在线 ${s.up},离线 ${s.down}`); return; } let reloadTimer = null; fs.watch(ACTIVE_CONFIG_PATH, () => { clearTimeout(reloadTimer); reloadTimer = setTimeout(() => { try { const next = loadConfig(); // 复用当前内存态恢复 history/daily;仅替换持有器属性,不再产生游离的旧引用 runtime.state = hydrate(next, runtime.state); runtime.config = next; console.log('[config] monitors.json 已热重载'); scheduleWrite(runtime); } catch (err) { console.error(`[config] 重载失败:${err.message}`); } }, 200); }); setInterval(() => { probeDue(runtime) .then(() => { scheduleWrite(runtime); queueEvaluation(runtime, evaluateRound); }) .catch((err) => console.error(`[tick] ${err.stack || err}`)); // UptimeRobot 按自身 pollIntervalSec 拉取,失败不影响本地巡检 if (externalSyncEnabled(runtime.config) && Date.now() - runtime.ur.lastSyncMs >= uptimeRobotConfig(runtime.config).pollIntervalSec * 1000) { syncUptimeRobot(runtime) .then(() => scheduleWrite(runtime)) .catch(() => { /* syncUptimeRobot 内部已记录错误 */ }); } // 证书到期检查:按 checkIntervalSec 周期执行(默认 6 小时),握手失败只记录不影响巡检 if (Date.now() - runtime.tlsLastCheckMs >= tlsConfig(runtime.config).checkIntervalSec * 1000) { runtime.tlsLastCheckMs = Date.now(); queueEvaluation(runtime, checkCertificates); } }, 1000); // 管理端是否允许经由非本机地址访问(如 Cloudflare Tunnel 挂到公网)。 // 默认关闭:探针通常只该在内网/本机使用,暴露管理面必须由使用者显式打开。 const adminCfg = runtime.config.settings?.admin ?? {}; const adminPublic = adminCfg.allowRemote === true; const BIND_HOST = adminPublic ? '0.0.0.0' : '127.0.0.1'; createServer(runtime).listen(PORT, BIND_HOST, () => { console.log(`shiroko-monitor v${VERSION}`); console.log(`面板: http://localhost:${PORT}/`); console.log(`状态 API: http://localhost:${PORT}/api/status`); console.log(`兼容接口: http://localhost:${PORT}/api/v2/getMonitors (UptimeRobot getMonitors 只读兼容)`); const scope = probeScopeSetting(runtime.config); const localCount = localProbeTargets(runtime.config).length; const scopeText = { all: `全部(自建 ${localCount} 个${runtime.ur.enabled ? ` + UptimeRobot ${runtime.ur.count} 个` : ''})`, config: `仅自建(${localCount} 个,不同步 UptimeRobot)`, uptimerobot: `仅 UptimeRobot(${runtime.ur.count} 个,**不探测本地配置里的 ${runtime.config.monitors.length} 个目标**)`, }[scope]; console.log(`展示口径: ${dataFilter(runtime.config)} 探测范围: ${scope} —— ${scopeText}(settings.probeScope 可显式覆盖)`); if (!localCount && !runtime.ur.enabled) { console.warn('[warn] 当前没有任何数据源:本地探测已关闭且 UptimeRobot 未启用,面板将显示「等待检测」'); } if (adminPublic) { console.log(`管理后台: http://localhost:${PORT}/admin`); console.warn(`[warn] settings.admin.allowRemote=true —— 探针正在监听 ${BIND_HOST},管理端可经由反向代理/隧道从外部访问。`); console.warn(' 该端口同时伺服公开面板,请确保管理端已绑定 2FA,且前置代理已启用 HTTPS。'); } else { console.log(`管理后台: http://localhost:${PORT}/admin (仅本机可访问;需远程访问请设 settings.admin.allowRemote=true)`); } }); } // 直接执行(node index.js)时启动;被测试脚本 import 时不自动启动 if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();