已实现:HTTP/TCP/Banner 探测、90 天可用率、表格/卡片视图、 数据源过滤、UptimeRobot 双向兼容、服务端驱动的刷新节奏、 刷新接口滥用防护与信息泄露控制。
194 lines
9.7 KiB
JavaScript
194 lines
9.7 KiB
JavaScript
/**
|
||
* shiroko-monitor UptimeRobot 集成验证(开发用)
|
||
*
|
||
* 覆盖:
|
||
* A. 映射单测(无网络):用合成 payload 校验状态/延迟/状态码/心率/聚合比率/元信息,且不泄露 Key
|
||
* B. 真实 Key 冒烟(需 UPTIMEROBOT_API_KEY,未设置则跳过)
|
||
* C. UptimeRobot 兼容只读接口 /api/v2/getMonitors
|
||
* D. 失败路径:Key 无效时只记录错误、不把任何目标误判为离线
|
||
*
|
||
* 用法:UPTIMEROBOT_API_KEY=ur... node scripts/verify-uptimerobot.cjs
|
||
*/
|
||
const fs = require('node:fs');
|
||
const os = require('node:os');
|
||
const path = require('node:path');
|
||
const { spawn } = require('node:child_process');
|
||
|
||
const ROOT = path.join(__dirname, '..');
|
||
const PROBER = path.join(ROOT, 'prober', 'node', 'index.js');
|
||
const LIB = path.join(ROOT, 'prober', 'node', 'lib', 'uptimerobot.js');
|
||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-ur-'));
|
||
const LIVE_KEY = process.env.UPTIMEROBOT_API_KEY || '';
|
||
const results = [];
|
||
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
function check(name, pass, detail) {
|
||
results.push({ name, pass });
|
||
console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`);
|
||
}
|
||
|
||
async function waitForHttp(url, timeoutMs = 8000) {
|
||
const deadline = Date.now() + timeoutMs;
|
||
while (Date.now() < deadline) {
|
||
try {
|
||
await fetch(url, { cache: 'no-store' });
|
||
return true;
|
||
} catch {
|
||
await sleep(200);
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function runOnce(args) {
|
||
return new Promise((resolve) => {
|
||
const child = spawn(process.execPath, [PROBER, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||
let out = '';
|
||
child.stdout.on('data', (d) => (out += d));
|
||
child.stderr.on('data', (d) => (out += d));
|
||
child.on('exit', (code) => resolve({ code, out }));
|
||
});
|
||
}
|
||
|
||
// 合成的 getMonitors 响应:结构照搬真实字段,但内容全部虚构
|
||
const FIXTURE = {
|
||
stat: 'ok',
|
||
pagination: { offset: 0, limit: 50, total: 3 },
|
||
monitors: [
|
||
{
|
||
id: 111111111,
|
||
friendly_name: '演示站点 A',
|
||
url: 'https://a.example.test',
|
||
type: 1,
|
||
interval: 300,
|
||
status: 2,
|
||
create_datetime: 1700000000,
|
||
custom_uptime_ratio: '100.000-99.500-99.900-99.950',
|
||
custom_down_durations: '0-100-200-300',
|
||
average_response_time: 250.5,
|
||
response_times: [
|
||
{ datetime: 1791880000, value: 300 },
|
||
{ datetime: 1791879700, value: 200 },
|
||
{ datetime: 1791879400, value: 0 },
|
||
],
|
||
logs: [
|
||
{ type: 2, datetime: 1791879700, duration: 300, reason: { code: '200', detail: 'OK' } },
|
||
{ type: 1, datetime: 1791879400, duration: 300, reason: { code: '500', detail: 'Internal Server Error' } },
|
||
],
|
||
},
|
||
{
|
||
id: 222222222,
|
||
friendly_name: '演示端口 B',
|
||
url: 'b.example.test',
|
||
type: 4,
|
||
port: 22,
|
||
interval: 300,
|
||
status: 9,
|
||
custom_uptime_ratio: '0.000-0.000-0.000-0.000',
|
||
custom_down_durations: '86400-86400-86400-86400',
|
||
average_response_time: 0,
|
||
response_times: [],
|
||
logs: [{ type: 1, datetime: 1791000000, duration: 0, reason: { code: '403', detail: 'Forbidden' } }],
|
||
},
|
||
{
|
||
id: 333333333,
|
||
friendly_name: '演示暂停项 C',
|
||
url: 'https://c.example.test',
|
||
type: 1,
|
||
interval: 300,
|
||
status: 0,
|
||
custom_uptime_ratio: '0.000-0.000-0.000-0.000',
|
||
response_times: [],
|
||
logs: [],
|
||
},
|
||
],
|
||
};
|
||
|
||
async function main() {
|
||
const { mapUptimeRobotMonitors } = await import(`file://${LIB.replace(/\\/g, '/')}`);
|
||
|
||
// ---------- A. 映射单测 ----------
|
||
console.log('\n[A] 映射单测(合成 payload,无网络)');
|
||
const mapped = mapUptimeRobotMonitors(FIXTURE, { group: 'UR 测试', now: 1791880300000 });
|
||
const [a, b, c] = mapped;
|
||
check('监控数量与 id 前缀', mapped.length === 3 && a.id === 'ur-111111111', `${mapped.length} 个,首个 id=${a.id}`);
|
||
check('status 映射:2→up / 9→down / 0→pending', a.status === 'up' && b.status === 'down' && c.status === 'pending', `${a.status}/${b.status}/${c.status}`);
|
||
check('延迟取最新一次样本', a.latency === 300 && b.latency === 0, `A=${a.latency}ms B=${b.latency}ms`);
|
||
check('故障时取故障日志里的 HTTP 状态码', b.code === 403 && a.code === null, `B.code=${b.code}`);
|
||
check('detail 含 UR 侧原因', b.detail === 'UptimeRobot:403 Forbidden', b.detail);
|
||
check('心率来自 response_times 且按时间升序', a.history.length === 3 && a.history[0].ms === 0 && a.history[2].ms === 300, JSON.stringify(a.history.map((h) => h.ms)));
|
||
check('离线目标的心率末尾补一条离线记录', b.history.length === 1 && b.history[0].ok === false, `${b.history.length} 条`);
|
||
check('聚合比率解析(1/7/30/90)', a.uptimeRatios['90'] === 99.95 && a.downSeconds['30'] === 200, JSON.stringify(a.uptimeRatios));
|
||
check('端口型目标拼出 host:port 形式', b.url === 'b.example.test:22', b.url);
|
||
check('来源标识与协议标签', a.source === 'uptimerobot' && a.sourceLabel === 'HTTP' && b.sourceLabel === 'PORT', `${a.sourceLabel}/${b.sourceLabel}`);
|
||
check('映射结果不含 API Key 字段', !JSON.stringify(mapped).toLowerCase().includes('apikey'), '无泄露');
|
||
|
||
// ---------- C. 兼容接口 ----------
|
||
console.log('\n[C] UptimeRobot 兼容只读接口');
|
||
const cfg = path.join(TMP, 'monitors.json');
|
||
const out = path.join(TMP, 'status.json');
|
||
fs.writeFileSync(cfg, JSON.stringify({
|
||
settings: { title: 't' },
|
||
monitors: [
|
||
{ id: 'ok-site', name: '正常站点', group: '网站', type: 'http', url: 'data:text/plain,ok', expectStatus: [200], interval: 60, timeout: 5 },
|
||
],
|
||
}, null, 2));
|
||
const child = spawn(process.execPath, [PROBER, `--config=${cfg}`, `--local-config=${cfg}`, `--out=${out}`], { env: { ...process.env, PORT: '8891' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||
await waitForHttp('http://127.0.0.1:8891/api/status');
|
||
const compat = await fetch('http://127.0.0.1:8891/api/v2/getMonitors').then((r) => r.json());
|
||
const first = compat.monitors?.[0] ?? {};
|
||
check('返回 stat=ok 与 pagination', compat.stat === 'ok' && compat.pagination?.total === 1, JSON.stringify(compat.pagination));
|
||
check('字段与 UptimeRobot 对齐', ['id', 'friendly_name', 'url', 'type', 'status', 'interval', 'custom_uptime_ratio', 'average_response_time', 'response_times'].every((k) => k in first), Object.keys(first).join(','));
|
||
check('id 为数字、type/status 编码一致', typeof first.id === 'number' && first.type === 1 && [1, 2, 9].includes(first.status), `id=${first.id} type=${first.type} status=${first.status}`);
|
||
check('custom_uptime_ratio 为 1-7-30-90 四位三位小数', /^\d+\.\d{3}-\d+\.\d{3}-\d+\.\d{3}-\d+\.\d{3}$/.test(first.custom_uptime_ratio), first.custom_uptime_ratio);
|
||
child.kill();
|
||
|
||
// ---------- D. 失败路径:Key 无效不得误判目标状态 ----------
|
||
console.log('\n[D] 失败路径(无效 Key)');
|
||
const badCfg = path.join(TMP, 'bad.json');
|
||
const badOut = path.join(TMP, 'bad-status.json');
|
||
fs.writeFileSync(badCfg, JSON.stringify({
|
||
settings: { title: 't', uptimeRobot: { enabled: true, apiKey: 'ur000000-this-key-is-not-valid' } },
|
||
monitors: [
|
||
{ id: 'only-site', name: '唯一目标', group: '网站', type: 'http', url: 'data:text/plain,ok', expectStatus: [200], interval: 60, timeout: 5 },
|
||
],
|
||
}, null, 2));
|
||
const r = await runOnce([`--once`, `--config=${badCfg}`, `--local-config=${badCfg}`, `--out=${badOut}`]);
|
||
const badSnap = JSON.parse(fs.readFileSync(badOut, 'utf8'));
|
||
check('同步失败被记录为 ok:false', badSnap.sources.uptimerobot.ok === false, String(badSnap.sources.uptimerobot.error).slice(0, 60));
|
||
check('同步失败不影响本地目标(无目标被误判为离线)', badSnap.stats.total === 1 && badSnap.stats.up === 1, `total=${badSnap.stats.total} up=${badSnap.stats.up}`);
|
||
check('未创建任何 UptimeRobot 镜像目标', Object.values(badSnap.monitors).every((m) => m.meta?.source !== 'uptimerobot'), '无镜像目标');
|
||
|
||
// ---------- B. 真实 Key 冒烟 ----------
|
||
console.log('\n[B] 真实 Key 冒烟');
|
||
if (!LIVE_KEY) {
|
||
console.log(' - 跳过(未设置 UPTIMEROBOT_API_KEY)');
|
||
} else {
|
||
const liveCfg = path.join(TMP, 'live.json');
|
||
const liveOut = path.join(TMP, 'live-status.json');
|
||
fs.writeFileSync(liveCfg, JSON.stringify({
|
||
settings: { title: 't', uptimeRobot: { enabled: true, apiKey: LIVE_KEY, pollIntervalSec: 300, group: 'UptimeRobot' } },
|
||
monitors: [],
|
||
}, null, 2));
|
||
await runOnce([`--once`, `--config=${liveCfg}`, `--local-config=${liveCfg}`, `--out=${liveOut}`]);
|
||
const live = JSON.parse(fs.readFileSync(liveOut, 'utf8'));
|
||
const mirrors = Object.entries(live.monitors).filter(([, m]) => m.meta?.source === 'uptimerobot');
|
||
check('真实账号同步成功', live.sources.uptimerobot.ok === true, `count=${live.sources.uptimerobot.count}`);
|
||
check('拉到监控并全部映射', mirrors.length > 0 && mirrors.every(([, m]) => ['up', 'down', 'pending'].includes(m.status)), `${mirrors.length} 个`);
|
||
check('每个镜像目标都带展示元信息', mirrors.every(([, m]) => m.meta.name && Array.isArray(m.history) && m.uptimeRatios), 'name/history/uptimeRatios 齐备');
|
||
check('快照中不含 API Key', !JSON.stringify(live).includes(LIVE_KEY.slice(-12)), '无泄露');
|
||
for (const [id, m] of mirrors) console.log(` · ${id.padEnd(14)} ${m.status.padEnd(7)} ${String(m.latency ?? '-').padStart(5)}ms UR90天=${m.uptimeRatios?.['90']} ${m.meta.name}`);
|
||
}
|
||
|
||
fs.rmSync(TMP, { recursive: true, force: true });
|
||
const failed = results.filter((x) => !x.pass);
|
||
console.log(`\n结果:${results.length - failed.length}/${results.length} 通过`);
|
||
if (failed.length) process.exit(1);
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error('FAILED:', err.stack || err.message);
|
||
fs.rmSync(TMP, { recursive: true, force: true });
|
||
process.exit(1);
|
||
});
|