chore: v0.6.0 baseline — 主题美化前存档

已实现:HTTP/TCP/Banner 探测、90 天可用率、表格/卡片视图、
数据源过滤、UptimeRobot 双向兼容、服务端驱动的刷新节奏、
刷新接口滥用防护与信息泄露控制。
This commit is contained in:
tamakyi
2026-10-13 18:31:58 +08:00
commit 3db3ce570a
31 changed files with 5136 additions and 0 deletions
+746
View File
@@ -0,0 +1,746 @@
#!/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.6.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';
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++;
// 平均延迟只统计自建探针:UR 的延迟来自其探测节点,与本机视角不可比
if (!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();
+64
View File
@@ -0,0 +1,64 @@
import net from 'node:net';
// 协议族识别只用于生成「SSH 响应正常」这类摘要;版本号等细节默认不回显,避免写入公开的 status.json
const FAMILIES = [
{ re: /^SSH-\d/i, name: 'SSH' },
{ re: /^220[\s\S]*ESMTP/i, name: 'SMTP' },
{ re: /^220[\s\S]*FTP/i, name: 'FTP' },
{ re: /^\+OK/i, name: 'POP3' },
{ re: /^\* OK/i, name: 'IMAP' },
{ re: /^HTTP\/\d/i, name: 'HTTP' },
{ re: /^220\b/i, name: 'SMTP/TCP' },
];
/**
* 被动 banner 探测:连接后仅读取服务端主动发送的标识串(RFC 4253 的 SSH banner 即此类),
* 不做认证、不发送任何数据、不执行任何命令——因此不会触发目标侧的登录失败计数或封禁。
* 需要客户端先发字节的协议(Redis/MySQL)不适用,属于路线图内容。
*/
export function probeBanner(monitor) {
const started = Date.now();
const timeoutMs = (monitor.timeout ?? 10) * 1000;
const maxBytes = Math.max(32, Number(monitor.maxBannerBytes) || 512);
return new Promise((resolve) => {
const socket = net.createConnection({ host: monitor.host, port: monitor.port });
let raw = '';
let settled = false;
const done = (status, detail) => {
if (settled) return;
settled = true;
socket.destroy();
resolve({ status, latency: Date.now() - started, code: null, detail });
};
const evaluate = () => {
const line = (raw.split(/\r?\n/)[0] ?? '').trim();
const family = FAMILIES.find((f) => f.re.test(line))?.name ?? null;
if (monitor.expectBanner) {
let ok = false;
try {
ok = new RegExp(monitor.expectBanner, monitor.bannerFlags || 'i').test(line);
} catch {
return done('down', 'banner 规则无法编译(expectBanner 不是合法正则)');
}
// 不匹配时只报告策略结果,不回显实际收到的标识串
if (!ok) return done('down', '标识串与期望规则不符');
}
const label = family ? `${family} 响应正常` : '收到服务标识串';
return done('up', monitor.revealBanner ? `${label}:${line.slice(0, 80)}` : label);
};
socket.setTimeout(timeoutMs, () => done('down', '等待标识串超时'));
socket.once('error', (err) =>
done('down', err.code === 'ECONNREFUSED' ? '连接被拒绝(端口关闭)' : (err.code || err.message))
);
// 对端发完就断开(无换行结尾)也算有效响应
socket.once('end', () => (raw ? evaluate() : done('down', '连接被对端关闭且未发送标识串')));
// 只在收到换行或达到字节上限时判定,避免把分包到达的标识串截断
socket.on('data', (chunk) => {
raw += chunk.toString('latin1');
if (/[\r\n]/.test(raw) || raw.length >= maxBytes) evaluate();
});
});
}
+31
View File
@@ -0,0 +1,31 @@
const USER_AGENT = 'shiroko-monitor/0.2 (lightweight uptime panel)';
export async function probeHttp(monitor) {
const started = Date.now();
const timeoutMs = (monitor.timeout ?? 10) * 1000;
try {
// redirect: manual —— 保留 3xx 原始状态码交给 expectStatus 判定,避免跟随重定向掩盖异常
const res = await fetch(monitor.url, {
method: monitor.method || 'GET',
redirect: 'manual',
signal: AbortSignal.timeout(timeoutMs),
headers: { 'user-agent': USER_AGENT, accept: '*/*' },
});
const latency = Date.now() - started;
const expect = monitor.expectStatus ?? [200];
const ok = expect.includes(res.status);
return {
status: ok ? 'up' : 'down',
latency,
code: res.status,
detail: ok ? `HTTP ${res.status}` : `HTTP ${res.status}(期望 ${expect.join('/')})`,
};
} catch (err) {
const latency = Date.now() - started;
let detail;
if (err.name === 'TimeoutError') detail = `请求超时(>${timeoutMs / 1000}s)`;
else if (err.cause?.code) detail = err.cause.code;
else detail = err.message;
return { status: 'down', latency, code: 0, detail };
}
}
+18
View File
@@ -0,0 +1,18 @@
import net from 'node:net';
export function probeTcp(monitor) {
const started = Date.now();
const timeoutMs = (monitor.timeout ?? 10) * 1000;
return new Promise((resolve) => {
const socket = net.createConnection({ host: monitor.host, port: monitor.port });
const finish = (status, detail) => {
socket.destroy();
resolve({ status, latency: Date.now() - started, code: null, detail });
};
socket.setTimeout(timeoutMs, () => finish('down', 'TCP 连接超时'));
socket.once('error', (err) =>
finish('down', err.code === 'ECONNREFUSED' ? '连接被拒绝(端口关闭)' : (err.code || err.message))
);
socket.once('connect', () => finish('up', '端口可连接'));
});
}
+140
View File
@@ -0,0 +1,140 @@
/**
* UptimeRobot 只读数据源(API v2)
*
* 作用:把 UptimeRobot 已有的监控结果接入本面板,作为「异地探测视角」与自建探针并列展示。
* 只读:仅调用 getMonitors,不做任何写操作(暂停/编辑/删除),因此用 ur* 开头的只读 Key 即可。
*
* 术语对齐(UptimeRobot v2):
* monitor.type 1=HTTP(s) 2=Keyword 3=Ping 4=Port
* monitor.status 0=暂停 1=未检测 2=正常 8/9=异常
* logs[].type 1=故障 2=恢复 98=开始监控 99=暂停
* logs[].reason { code: "403", detail: "Forbidden" }
* custom_uptime_ratio / custom_down_durations 按请求顺序以 '-' 分隔
*/
const API_URL = 'https://api.uptimerobot.com/v2/getMonitors';
const HISTORY_LIMIT = 48;
const TYPE_LABEL = { 1: 'HTTP', 2: 'KEYWORD', 3: 'PING', 4: 'PORT' };
// 暂停/未检测都归入 pending:否则会被误报成「离线」
const STATUS_MAP = { 0: 'pending', 1: 'pending', 2: 'up', 8: 'down', 9: 'down' };
function splitNums(value) {
return String(value ?? '')
.split('-')
.map((v) => Number(v))
.filter((v) => Number.isFinite(v));
}
function ratiosToObject(value, ratios) {
const nums = splitNums(value);
const out = {};
ratios.forEach((r, i) => {
if (Number.isFinite(nums[i])) out[r] = nums[i];
});
return out;
}
function targetOf(m) {
if (m.type === 4 && m.port) return `${m.url ? `${m.url}:` : ''}${m.port}`;
return m.url || '';
}
function latestDownReason(logs) {
const down = (logs ?? []).find((l) => l.type === 1);
const reason = down?.reason;
if (!reason) return null;
const code = String(reason.code ?? '').trim();
const detail = String(reason.detail ?? '').trim();
return { code: code || null, text: [code, detail].filter(Boolean).join(' ') || null };
}
/**
* 纯函数:把 UptimeRobot 的 getMonitors 响应映射成本探针的内部结构(可离线单测)
* @param {object} payload getMonitors 原始响应
* @param {{group?: string, ratios?: number[], now?: number}} [options]
*/
export function mapUptimeRobotMonitors(payload, options = {}) {
const group = options.group || 'UptimeRobot';
const ratios = options.ratios ?? [1, 7, 30, 90];
const now = options.now ?? Date.now();
const monitors = [];
for (const m of payload?.monitors ?? []) {
const status = STATUS_MAP[m.status] ?? 'pending';
const samples = Array.isArray(m.response_times) ? m.response_times : [];
const logs = Array.isArray(m.logs) ? m.logs : [];
// UR 侧的真实检测时刻:正常取最新响应样本,故障取最近一次故障日志
const lastCheckSec = status === 'down' ? logs.find((l) => l.type === 1)?.datetime : samples[0]?.datetime;
const lastCheckIso = new Date((Number(lastCheckSec) || Math.floor(now / 1000)) * 1000).toISOString();
const history = samples
.slice(0, HISTORY_LIMIT)
.map((s) => ({ t: new Date(s.datetime * 1000).toISOString(), ok: Number(s.value) > 0, ms: Math.max(0, Number(s.value) || 0) }))
.reverse();
if (status === 'down') history.push({ t: lastCheckIso, ok: false, ms: 0 });
const last = samples[0]?.value;
const avg = m.average_response_time;
const latency = Number.isFinite(Number(last)) && Number(last) > 0
? Math.round(Number(last))
: Number.isFinite(Number(avg)) ? Math.round(Number(avg)) : null;
const reason = latestDownReason(logs);
let detail;
if (status === 'up') detail = 'UptimeRobot:运行正常';
else if (status === 'down') detail = `UptimeRobot:${reason?.text ?? '连接失败'}`;
else detail = m.status === 0 ? 'UptimeRobot:监控已暂停' : 'UptimeRobot:尚未检测';
monitors.push({
id: `ur-${m.id}`,
name: m.friendly_name || `UptimeRobot #${m.id}`,
group,
type: 'ur',
url: targetOf(m),
// 与 UR 侧一致:编号、协议、检测间隔
source: 'uptimerobot',
sourceId: m.id,
sourceType: m.type,
sourceLabel: TYPE_LABEL[m.type] ?? `TYPE${m.type}`,
interval: Number(m.interval) || 300,
status,
latency,
// UR 不直接给「当前 HTTP 状态码」,故障时取最近一次故障日志里的 code
code: status === 'down' && reason?.code ? Number(reason.code) || null : null,
detail,
lastCheckIso,
history,
uptimeRatios: ratiosToObject(m.custom_uptime_ratio, ratios),
downSeconds: ratiosToObject(m.custom_down_durations, ratios),
createDatetime: Number(m.create_datetime) || null,
});
}
return monitors;
}
export async function fetchUptimeRobotMonitors({ apiKey, timeoutMs = 15000, ratios = [1, 7, 30, 90] }) {
if (!apiKey) throw new Error('缺少 UptimeRobot API Key');
const body = new URLSearchParams({
api_key: apiKey,
format: 'json',
response_times: '1',
response_times_average: '30',
custom_uptime_ratios: ratios.join('-'),
logs: '1',
log_types: '1-2',
// 注意:custom_down_durations 不能作为请求参数传入(v2 会返回 invalid_parameter),
// 但响应中默认会带该字段,故 downSeconds 仍可解析
});
const res = await fetch(API_URL, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
body,
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) throw new Error(`UptimeRobot HTTP ${res.status}`);
const payload = await res.json();
if (payload.stat !== 'ok') throw new Error(`UptimeRobot 返回错误:${payload.error?.message || payload.error?.type || 'unknown'}`);
return mapUptimeRobotMonitors(payload, { ratios });
}