Files
shiroko-monitor/prober/node/index.js
T
tamakyi a7408e2bb5 feat(theme): 全局装饰遮罩 + 尺寸统一;全局审阅修复对比度/暗色/平板布局
用户反馈的两点:
1. 遮罩看不出效果 → 人物与单车统一全局半透明 settings.decoOpacity(默认 .78)
   (此前 0.92/0.80 不一致且几乎无感),各自保留边缘渐隐
2. 切视图时人物比例变化 → 删掉卡片视图专用的 300px 高度,两种视图尺寸一致
3. 保留原始素材 → 原图归档 assets-src/,处理流程固化为 scripts/prepare-artwork.py

全局审阅发现并修复:
- 无障碍对比度:两套主题的状态色当文字用时远低于 AA(最低 2.56:1)→ 双色体系
  (填充色 ≥3:1 保持鲜亮 / 文字色 ≥4.5:1 同色系加深),暗色模式同步
- 暗色模式主题失效:顶栏/卡片/表头/分组行是硬编码白色 → 改为随模式切换的变量
- 平板竖屏 768px 表格需内部横滚 242px → 自动切卡片阈值 700 → 1024
- Cloudflare Worker 快照补齐 dataUpdatedAt/updateIntervalSec/dataFilter
- 清理失效 CSS(.logo .halo)

测试脚本修掉两个自身 bug(均为误报):color(srgb/…) 与半透明底的解析、
渐变背景被误判为透明。verify-views 新增对比度断言 8 条,四套合计 89 项全通过。
2026-10-13 21:55:44 +08:00

749 lines
31 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';
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.9.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');
// --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');
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) };
}
/* ---------- 数据源过滤 ---------- */
// 只影响「展示与快照输出」,不影响探测:配置文件里的目标始终按各自 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;
}
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 ?? {}) } },
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);
} 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));
}
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(),
ur: { enabled: false, ok: null, error: null, lastSyncAt: null, count: 0, syncing: false, lastSyncMs: 0 },
};
if (DEMO) {
writeDemo(runtime.config);
return;
}
runtime.state = hydrate(runtime.config);
// 启用 UptimeRobot 时先同步一次,保证首次渲染就有异地探测数据
await syncUptimeRobot(runtime);
if (ONCE) {
await probeDue(runtime, true);
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))
.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 内部已记录错误 */ });
}
}, 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();