通知(P0-1): - 邮件:自写 SMTP 客户端,零依赖。465 隐式 TLS / 587·25 STARTTLS、 AUTH LOGIN+PLAIN、RFC2047 中文主题、base64 正文、多收件人; 服务器不支持 STARTTLS 时默认拒绝明文(需显式 allowInsecure) - MeoW:按官方文档实现,昵称寻址、msgType/htmlHeight 走查询参数、 以响应体 status 判定成功(HTTP 恒 200)、429/404/403 可读化、 本地限流器遵守 3s/15min/60h - 告警:连续 N 次失败去抖 + 恢复通知 + 同轮合并 + 去抖状态持久化 (data/notify-state.json,gitignore)+ 静默时段(跨零点) - --test-notify 自检命令、--data-dir / SHIROKO_DATA_DIR 证书(P0-2): - 独立 TLS 握手取 notAfter(不动既有 fetch 探测路径),默认 6 小时一次 - 面板徽标 ≤14 天黄 / ≤7 天红,阈值 14/7/3 各告一次、已过期只报最严重一档 - 单个目标可用 tls: false 关闭 事件与维护(P1-1 的后端部分): - data/events.json 追加式事件流(最近 500 条)+ GET /api/events + 快照内附最近 20 条;redactDetails 时脱敏 - 维护窗口:窗口内不计入可用率、不发告警、显示「维护中」徽标 测试抓到并修复的三个真 bug: 1. SMTP 空闲超时无人处理 → 服务器不响应会把探针循环挂死 2. MeoW 限流器把空历史当 0 → 第一条通知永远被自己拦掉 3. 去抖状态在无事轮次不落盘 → 连续失败计数丢失、永远达不到阈值 验证:新增 verify-notify.cjs 39 项(假 SMTP / 假 MeoW / 三轮集成 / 维护窗口 / 真实证书握手),六套合计 154 项全通过。
1015 lines
43 KiB
JavaScript
1015 lines
43 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* shiroko-monitor 本地探针 + 面板服务器(零依赖,Node >= 18)
|
||
*
|
||
* 模式:
|
||
* 默认 常驻巡检:按各 monitor 的 interval 周期探测,结果写入 public/status.json,同时伺服面板
|
||
* --once 执行一轮探测后退出(供 GitHub Actions 等 CI 使用,历史心率从已有 status.json 恢复)
|
||
* --demo 生成一份演示状态数据后退出(用于静态部署的首版占位)
|
||
* --out=<path> 指定快照输出路径,默认 public/status.json(可避免演示数据覆盖真实快照)
|
||
* --config=<path> / --local-config=<path> 指定公开配置与私有覆盖的路径(多实例、测试用)
|
||
*
|
||
* 私有配置:若存在 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';
|
||
|
||
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.10.0';
|
||
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=<path> 或 --out <path>:指定快照输出路径(默认 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=<path>:通知状态与事件时间线的存放目录(默认 ./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;
|
||
}
|
||
}
|
||
|
||
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()];
|
||
}
|
||
|
||
function recordDaily(daily, ok) {
|
||
const key = dayKey();
|
||
const bucket = daily[key] ?? (daily[key] = { total: 0, up: 0 });
|
||
bucket.total++;
|
||
if (ok) bucket.up++;
|
||
pruneDaily(daily);
|
||
}
|
||
|
||
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');
|
||
}
|
||
// 清理 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 config.monitors) {
|
||
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');
|
||
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');
|
||
|
||
function monitorNotifyState(runtime, id) {
|
||
const list = runtime.notifyState.monitors;
|
||
if (!list[id]) list[id] = { lastStatus: null, failStreak: 0, 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 = [];
|
||
|
||
for (const m of runtime.config.monitors) {
|
||
const r = runtime.state.get(m.id)?.lastResult;
|
||
if (!r || r.status === 'pending') continue;
|
||
const st = monitorNotifyState(runtime, m.id);
|
||
st.failStreak = r.status === 'down' ? (st.failStreak ?? 0) + 1 : 0;
|
||
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';
|
||
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.url ?? `${m.host}:${m.port}`}\n时间:${at}\n详情:${r.detail ?? '无'}\n连续失败:${st.failStreak} 次\n\n—— shiroko-monitor`,
|
||
});
|
||
}
|
||
} else if (r.status === 'up') {
|
||
if (st.lastStatus === null) {
|
||
st.lastStatus = 'up'; // 首次观测不产生「恢复」噪音
|
||
continue;
|
||
}
|
||
if (st.lastStatus === 'down') {
|
||
st.lastStatus = 'up';
|
||
st.lastNotifiedAt = at;
|
||
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.url ?? `${m.host}:${m.port}`}\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 };
|
||
}
|
||
// 无论是否发通知都要落盘:否则「连续失败计数」会丢,重启或下一轮就达不到阈值
|
||
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 runtime.config.monitors) {
|
||
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';
|
||
}
|
||
|
||
/* ---------- 服务端更新节奏 ---------- */
|
||
|
||
// 快照里回报「数据最后变更时刻」与「服务端产出新数据的节奏」,供面板把倒计时对齐到服务端,
|
||
// 而不是从用户打开页面那一刻重新计时(否则每次进来都要重等一整个周期,最坏会看到近两倍周期的旧数据)
|
||
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 };
|
||
}
|
||
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,
|
||
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,
|
||
};
|
||
})(),
|
||
events: (runtime.events ?? []).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=<path> 指定其他输出路径)');
|
||
}
|
||
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);
|
||
daily[key] = { total: perDay, up: perDay - miss };
|
||
}
|
||
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));
|
||
}
|
||
|
||
/* ---------- /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) {
|
||
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 === '/api/status') return sendJson(res, snapshot(runtime));
|
||
if (url.pathname === '/api/monitors') return sendJson(res, 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) } : {},
|
||
});
|
||
}
|
||
// 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));
|
||
}
|
||
|
||
// UptimeRobot 兼容的只读接口(GET/POST 均可):已有脚本/看板可直接消费本服务数据。
|
||
// 不做鉴权,因为其暴露内容与公开面板一致;如需鉴权请在其前方加代理。
|
||
if (url.pathname === '/api/v2/getMonitors' || url.pathname === '/v2/getMonitors') {
|
||
return sendJson(res, toUptimeRobotResponse(runtime));
|
||
}
|
||
|
||
// 事件时间线:面板「事件」视图取这里(含通知去抖前的全部状态翻转记录)
|
||
if (url.pathname === '/api/events') {
|
||
const list = [...(runtime.events ?? [])].reverse();
|
||
return sendJson(res, { total: list.length, events: list.slice(0, 200) });
|
||
}
|
||
|
||
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 时先同步一次,保证首次渲染就有异地探测数据
|
||
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 (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);
|
||
|
||
createServer(runtime).listen(PORT, () => {
|
||
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 只读兼容)`);
|
||
console.log(`监控目标: ${runtime.config.monitors.length} 个自建探测${runtime.ur.enabled ? ` + ${runtime.ur.count} 个 UptimeRobot 监控` : ''}(配置热重载已启用)`);
|
||
});
|
||
}
|
||
|
||
// 直接执行(node index.js)时启动;被测试脚本 import 时不自动启动
|
||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
|