Files
shiroko-monitor/prober/node/index.js
T
tamakyi 752fad412b feat: v0.12.0 —— 表格每行绝对等高 + 管理 WebUI(2FA 绑定才能进入)
① 每行绝对等高
- 行高与内容长度彻底解耦:新增 --row-h: 72px 作为唯一基准
- 名称列改为「文本区限两行 + 徽标区固定单行」两段式;文本区用 .clamp2
- 关键点:必须用 height 而非 max-height —— 后者会让短内容缩成一行、
  长内容撑到两行,反而产生 66px/71px 两种行高(实测踩过)
- 分组标题行用 :not(.group-row) 排除在数据行规则之外
- 事件表与监控表格共用同一个 --row-h,两张表视觉上也严格对齐
- 完整值仍可通过悬停 title 查看(已加断言强制所有截断单元格带 title)
- 卡片视图同样等高(13 视口 × 250px 全一致)

② 管理 WebUI + 2FA
- 新增 /admin 与 /api/admin/*;未绑定 2FA 时除绑定流程外一律 403
- 零依赖实现 TOTP(RFC 6238),用 RFC 4226/6238 官方测试向量验证(16 个向量)
- 内置 QR 码生成器(byte 模式 / ECC L / 版本 1-10,GF(256) RS 纠错 + 掩码评分),
  用独立解码器实测:版本 1 与版本 6 均能读出逐字节一致的内容
- 绑定流程:口令 → 生成密钥(扫码/手输/otpauth 链接)→ 验证 6 位码 → 10 枚恢复码;
  完成后凭本次验证码直接换取会话
- 安全:会话 id 32 字节随机数且服务端只存哈希;HttpOnly+SameSite=Strict;
  30 分钟空闲/2 小时最长;同时间步的码防重放;恢复码一次性;
  登录失败按 IP 指数退避锁定;不解析 X-Forwarded-For
- 管理功能:运行状态、探测范围、展示口径、维护窗口增删、立即重探、通知自检、
  目标只读列表、最近事件
- 所有写操作只落 local.monitors.json(私有不入库),绝不改 public/monitors.json

过程中修掉三个真 bug:
1. 会话 cookie 的 Path=/admin 覆盖不到 /api/admin/*,登录 200 但后续全部 401
2. 恢复码登录被 verifyTotp 的 invalid-format 提前挡掉(恢复码非 6 位数字)
3. 超大请求体用 req.destroy() 硬断连接,客户端只看到 fetch failed 拿不到状态码

测试:新增 scripts/verify-admin.cjs(102 项),全项目共 7 套 274 项全部通过
2026-10-14 12:37:07 +08:00

1144 lines
49 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.
#!/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';
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.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()];
}
// 记录一次探测到「当日分桶」;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=<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);
// 演示用延迟:随日期缓慢起伏,让 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));
}
/* ---------- /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);
}
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 时先同步一次,保证首次渲染就有异地探测数据
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);
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 只读兼容)`);
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 未启用,面板将显示「等待检测」');
}
});
}
// 直接执行(node index.js)时启动;被测试脚本 import 时不自动启动
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();