Files
shiroko-monitor/scripts/verify-worker.cjs
T
tamakyi d2aa0a1163 feat: v0.12.1 —— Worker 支持 UptimeRobot + 管理端远程访问 + 前后端分离
起因:核实「管理端在 Cloudflare 上是否有效」时发现一个更要紧的问题 ——
Worker 完全没有 UptimeRobot 同步能力(runChecks 的探测目标只有 config.monitors)。
而实际配置是 dataFilter=uptimerobot,也就是说直接部署到 Worker 面板会是空的:
它探测 8 个本地条目,而真正在监控的 6 个 UR 目标它根本不认识。

① Worker 支持 UptimeRobot(Step 1)
- edge/cloudflare/uptimerobot.js(新增):Workers 版 UR 只读数据源,
  与 prober/node/lib/uptimerobot.js 同一套语义(状态映射/ratios 解析/故障原因)
- runChecks 的探测集合扩展为「自建目标 + UR 镜像目标」;UR 目标只拉取不探测,
  采用其状态并补齐本地 daily 分桶
- probeScope 与 dataFilter 解耦(与本地探针同语义):config 范围不拉 UR,
  uptimerobot 范围不探测本地
- 同步失败时标记 sources.uptimerobot.ok=false 并保留上次数据,
  绝不把目标判为离线或从面板消失;UR 侧删除的监控自动清理
- 快照新增 probeScope;UR 目标带 meta(面板区分自建/外部)与 uptimeRatios

② 管理端远程访问(Step 2)
- settings.admin.allowRemote:探针默认只监听 127.0.0.1,显式打开才监听全网卡,
  启动日志就此告警。这是 Cloudflare Tunnel 的安全前提(已实测两种行为)
- edge/cloudflare/tunnel.yml(新增):命名隧道配置,只放行
  ^/(admin|api/admin/.*|assets/.*)$,其余 404 —— 不把公开面板一起挂到公网

③ 前后端分离(Step 3)
- Worker 侧 /admin 与 /api/admin/* 显式返回 403 + 指向文档的提示,
  而非默默落到静态资源变成 404,确保边缘侧没有任何可写面

测试:新增 scripts/verify-worker.cjs(36 项)—— 把 Worker 以 data: URL 动态载入以
绕开 cloudflare:sockets,注入假 KV/静态资源/UptimeRobot 服务器,
不需要 wrangler 也不需要联网。全项目 8 套 310 项全部通过。

过程中抓到两个真 bug:
1. UR 同步失败的降级路径引用了不含 meta 的旧对象,导致 name/url 为 undefined、
   UR 目标从面板上消失
2. stats.total 按 config.monitors.length 计数,在 probeScope=uptimerobot 时
   与实际参与的目标数不符

另有一条测试夹具教训:内存版 KV 必须实现 get(key, 'json') 的类型参数
(真实 Workers KV 会自动反序列化),否则 state.monitors 恒为 undefined。
2026-10-14 14:51:28 +08:00

243 lines
13 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.
/**
* Cloudflare Worker 边缘探针验证(离线,不需要 wrangler)
*
* 做法:把 worker.js 与 uptimerobot.js 以 data: URL 动态载入,
* 注入假的 ASSETS / MONITOR_KV / UptimeRobot 服务器,实测:
* [A] UptimeRobot 映射(暂停/离线/可用率/端口目标)
* [B] 探测范围(probeScope)与展示口径解耦
* [C] UR 同步失败时保留上次数据,绝不误判为离线
* [D] 管理端路由(/admin 与 /api/admin/* 一律拒绝,Worker 无可写面)
*
* 用法:node scripts/verify-worker.cjs
*/
const fs = require('node:fs');
const http = require('node:http');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const EDGE = path.join(ROOT, 'edge', 'cloudflare');
const UR_PORT = 8896;
const HTTP_PORT = 8895;
const results = [];
const check = (name, pass, detail) => {
results.push({ name, pass });
console.log(` ${pass ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`);
};
const group = (t) => console.log(`\n${t}`);
const UR_PAYLOAD = {
stat: 'ok',
monitors: [
{
id: 700001, friendly_name: 'UR-主站', url: 'https://a.example.com', type: 1, status: 2, interval: 300,
average_response_time: '120',
response_times: [{ datetime: Math.floor(Date.now() / 1000) - 60, value: '118' }, { datetime: Math.floor(Date.now() / 1000) - 360, value: '130' }],
custom_uptime_ratio: '100-100-99.980-99.990', custom_down_durations: '0-0-120-260',
logs: [{ type: 2, datetime: Math.floor(Date.now() / 1000) - 86400 }],
},
{
id: 700002, friendly_name: 'UR-端口', url: 'b.example.com', port: 8443, type: 4, status: 9, interval: 300,
average_response_time: '0', response_times: [{ datetime: Math.floor(Date.now() / 1000) - 120, value: '0' }],
custom_uptime_ratio: '98-99-99.5-99.7', custom_down_durations: '300-600-1200-2400',
logs: [{ type: 1, datetime: Math.floor(Date.now() / 1000) - 120, reason: { code: '503', detail: 'Service Unavailable' } }],
},
{
id: 700003, friendly_name: 'UR-暂停', url: 'https://c.example.com', type: 1, status: 0, interval: 300,
response_times: [], custom_uptime_ratio: '100-100-100-100', custom_down_durations: '0-0-0-0', logs: [],
},
],
};
// 假 UptimeRobot:正常返回 payload;切到 error 模式返回 stat=fail
let urMode = 'ok';
function startUrServer() {
return http.createServer((req, res) => {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
res.writeHead(200, { 'content-type': 'application/json' });
if (urMode === 'fail') return res.end(JSON.stringify({ stat: 'fail', error: { type: 'invalid_parameter', message: 'bad key' } }));
res.end(JSON.stringify(UR_PAYLOAD));
});
});
}
// 载入 Worker:替换 cloudflare:sockets 与 UR 模块的 API 地址
async function loadWorker() {
let ur = fs.readFileSync(path.join(EDGE, 'uptimerobot.js'), 'utf8');
ur = ur.replace("const API_URL = 'https://api.uptimerobot.com/v2/getMonitors';", `const API_URL = 'http://localhost:${UR_PORT}/v2/getMonitors';`);
const urUrl = `data:text/javascript;base64,${Buffer.from(ur).toString('base64')}`;
let w = fs.readFileSync(path.join(EDGE, 'worker.js'), 'utf8');
// Workers 专有模块在 Node 下无法加载,替换为抛错桩(测试只覆盖 http 与 UR 路径)
w = w.replace("import { connect } from 'cloudflare:sockets';", "const connect = () => { throw new Error('TCP unavailable in offline test'); };");
w = w.replace("import { fetchUptimeRobotMonitors } from './uptimerobot.js';", `import { fetchUptimeRobotMonitors } from '${urUrl}';`);
return import(`data:text/javascript;base64,${Buffer.from(w).toString('base64')}`);
}
// 内存版 KV + 静态资源夹具
function makeEnv(settings, monitors) {
const kv = new Map();
const config = JSON.stringify({ settings, monitors });
return {
MONITOR_KV: {
// 与真实 KV 一致:type='json' 时自动反序列化,否则返回原始字符串
async get(key, type) {
const v = kv.get(key);
if (v == null) return null;
return type === 'json' ? JSON.parse(v) : v;
},
async put(k, v) { kv.set(k, v); },
},
ASSETS: {
// 与真实 Workers 一致:这里收到的是 URL 对象而非 Request
async fetch(input) {
const raw = String(input && typeof input.href === 'string' ? input.href : input);
const pathname = new URL(raw, 'http://placeholder').pathname;
if (pathname === '/monitors.json') {
return new Response(config, { headers: { 'content-type': 'application/json' } });
}
return new Response('not found', { status: 404 });
},
},
_kv: kv,
};
}
const LOCAL_TARGETS = [
{ id: 'local-1', name: '本地A', type: 'http', url: `http://127.0.0.1:${HTTP_PORT}/ok`, interval: 60 },
{ id: 'local-2', name: '本地B', type: 'http', url: `http://127.0.0.1:${HTTP_PORT}/ok`, interval: 60 },
];
// 记录哪些 URL 被真正探测过(用于验证探测范围)
let probed = [];
const origFetch = globalThis.fetch;
async function main() {
const urSrv = startUrServer();
await new Promise((r) => urSrv.listen(UR_PORT, r));
const httpSrv = http.createServer((req, res) => { res.writeHead(200); res.end('ok'); });
await new Promise((r) => httpSrv.listen(HTTP_PORT, r));
const worker = await loadWorker();
const { fetchUptimeRobotMonitors } = await import(
`data:text/javascript;base64,${Buffer.from(
fs.readFileSync(path.join(EDGE, 'uptimerobot.js'), 'utf8')
.replace("const API_URL = 'https://api.uptimerobot.com/v2/getMonitors';", `const API_URL = 'http://localhost:${UR_PORT}/v2/getMonitors';`)
).toString('base64')}`
);
/* ---------- [A] UptimeRobot 映射 ---------- */
group('[A] UptimeRobot 映射');
const list = await fetchUptimeRobotMonitors({ apiKey: 'ur-test-key' });
check('映射出 3 个目标', list.length === 3, list.map((m) => m.name).join('、'));
check('在线目标延迟取自最新响应样本', list[0].latency === 118, `${list[0].latency}ms`);
check('离线目标取故障日志 code 作状态码', list[1].code === 503, `code=${list[1].code}`);
check('离线目标详情带原因', /503 Service Unavailable/.test(list[1].detail), list[1].detail);
check('暂停目标归为 pending 而非离线', list[2].status === 'pending', list[2].status);
check('暂停目标提示已暂停', /暂停/.test(list[2].detail), list[2].detail);
check('90 天可用率按 ratios 顺序解析', list[0].uptimeRatios[90] === 99.99, `${list[0].uptimeRatios[1]}/${list[0].uptimeRatios[7]}/${list[0].uptimeRatios[30]}/${list[0].uptimeRatios[90]}`);
check('累计离线时长解析正确', list[1].downSeconds[90] === 2400, `${list[1].downSeconds[90]}s`);
check('端口目标地址带端口号', list[1].url === 'b.example.com:8443', list[1].url);
check('历史心率按时间升序(供心率图直接绘制)', list[0].history[0].t < list[0].history[1].t, '');
check('类型标注为来源协议', list[1].sourceLabel === 'PORT', list[1].sourceLabel);
check('目标 id 加 ur- 前缀(避免与自建 id 冲突)', list.every((m) => m.id.startsWith('ur-')), list.map((m) => m.id).join(','));
urMode = 'fail';
let threw = null;
try { await fetchUptimeRobotMonitors({ apiKey: 'bad' }); } catch (e) { threw = e; }
check('UR 返回 stat=fail 时抛出可读错误', Boolean(threw) && /UptimeRobot/.test(threw.message), threw?.message);
urMode = 'ok';
/* ---------- [B] 探测范围与展示口径解耦 ---------- */
group('[B] 探测范围(probeScope)与展示口径(dataFilter)解耦');
const urSettings = (extra = {}) => ({ dataFilter: 'uptimerobot', uptimeRobot: { enabled: true, apiKey: 'ur-k' }, ...extra });
// 记录探测行为
globalThis.fetch = async (input, init) => {
const u = String(input);
if (u.startsWith(`http://localhost:${UR_PORT}`)) return origFetch(input, init);
probed.push(u);
return origFetch(input, init);
};
probed = [];
let env = makeEnv(urSettings(), LOCAL_TARGETS);
let snap = await worker.__test.runChecks(env);
const urIds = (s) => Object.keys(s.monitors).filter((k) => k.startsWith('ur-'));
check('未设 probeScope 时跟随 dataFilter=uptimerobot(不探测本地目标)', probed.length === 0, `探测了 ${probed.length} 个本地目标`);
check('UR 目标进入快照', urIds(snap).length === 3, `${urIds(snap).length} 个`);
check('快照带 sources.uptimerobot 同步状态', snap.sources.uptimerobot.ok === true && snap.sources.uptimerobot.count === 3, `count=${snap.sources.uptimerobot.count}`);
check('快照带 probeScope(面板据此解释口径)', snap.probeScope === 'uptimerobot', snap.probeScope);
check('统计总数 = 实际参与的目标数(scope=uptimerobot 时只有 3 个 UR)', snap.stats.total === 3, `total=${snap.stats.total}`);
check('UR 侧口径:在线 1 / 离线 1 / 待检测 1', snap.stats.up === 1 && snap.stats.down === 1 && snap.stats.pending === 1, `up=${snap.stats.up} down=${snap.stats.down} pending=${snap.stats.pending}`);
const urState = snap.monitors['ur-700001'];
check('UR 目标带 meta(面板靠它区分自建/外部)', urState.meta?.source === 'uptimerobot' && urState.meta.name === 'UR-主站', JSON.stringify(urState.meta).slice(0, 70));
check('UR 目标带 uptimeRatios(面板算官方可用率用)', urState.uptimeRatios?.[90] === 99.99, `${urState.uptimeRatios?.[90]}`);
check('UR 目标有 daily 分桶(本探针自行累积)', urState.daily && Object.keys(urState.daily).length > 0, Object.keys(urState.daily)[0]);
probed = [];
snap = await worker.__test.runChecks(makeEnv(urSettings({ probeScope: 'all' }), LOCAL_TARGETS));
check('显式 probeScope=all 时探测本地目标', probed.length === 2, `探测了 ${probed.length} 个`);
check('probeScope=all 时 UR 目标同样在快照中', urIds(snap).length === 3, '');
probed = [];
snap = await worker.__test.runChecks(makeEnv(urSettings({ probeScope: 'config' }), LOCAL_TARGETS));
check('probeScope=config 时不拉 UR 也不展示 UR 目标', urIds(snap).length === 0 && probed.length === 2, `UR=${urIds(snap).length} 本地探测=${probed.length}`);
probed = [];
snap = await worker.__test.runChecks(makeEnv(urSettings({ uptimeRobot: { enabled: false, apiKey: '' } }), LOCAL_TARGETS));
check('UR 未启用时不拉取', urIds(snap).length === 0 && probed.length === 0, `UR=${urIds(snap).length} 本地探测=${probed.length}`);
/* ---------- [C] UR 同步失败的降级行为 ---------- */
group('[C] UR 同步失败时保留上次数据(绝不误判为离线)');
const envC = makeEnv(urSettings(), LOCAL_TARGETS);
await worker.__test.runChecks(envC); // 先成功一轮,把 UR 数据写进 KV
urMode = 'fail';
snap = await worker.__test.runChecks(envC);
check('同步失败时标记 ok=false 并给出原因', snap.sources.uptimerobot.ok === false && /UptimeRobot/.test(snap.sources.uptimerobot.error), snap.sources.uptimerobot.error);
check('同步失败时 UR 目标仍在快照里(用上次数据)', urIds(snap).length === 3, `${urIds(snap).length} 个`);
check('同步失败时 UR 目标状态未被改成 offline', snap.monitors['ur-700002'].status === 'down', `仍是上次的 ${snap.monitors['ur-700002'].status}(真实状态)`);
urMode = 'ok';
/* ---------- [D] 管理端路由 ---------- */
group('[D] 管理端:Worker 侧无可写面');
// 真实运行时会用 ctx.waitUntil 托管后台任务,测试里同步执行以便断言生效
const pending = [];
const call = (pathname) => worker.default.fetch(new Request(`https://x${pathname}`), makeEnv(urSettings(), LOCAL_TARGETS), { waitUntil(p) { pending.push(p); } });
await Promise.all(pending.map((p) => p.catch(() => {})));
const adminPage = await call('/admin');
const adminBody = await adminPage.json();
check('/admin 返回 403(而非 404,提示去哪找管理端)', adminPage.status === 403, `HTTP ${adminPage.status}`);
check('/admin 的提示指向本地 Node 探针', /docs\/DEPLOYMENT\.md|本地 Node/.test(adminBody.hint), adminBody.hint?.slice(0, 50));
const adminApi = await call('/api/admin/overview');
check('/api/admin/* 一律 403', adminApi.status === 403 && (await adminApi.json()).error === 'admin-not-here', `HTTP ${adminApi.status}`);
const adminStatus = await call('/api/admin/status');
check('未绑定的 2FA 状态接口在 Worker 上也不开放', adminStatus.status === 403, `HTTP ${adminStatus.status}`);
const panel = await call('/');
check('面板本身不受影响', panel.status === 404 || panel.status === 200, `静态资源响应 HTTP ${panel.status}(夹具无 /index.html,属预期)`);
const st = await call('/api/status');
const stBody = await st.json();
check('/api/status 正常提供(首次无数据时返回空 state 并后台补跑)', st.status === 200 && typeof stBody === 'object' && stBody.monitors !== undefined, `HTTP ${st.status},monitors=${stBody.monitors ? Object.keys(stBody.monitors).length : '无'}`);
check('首次访问触发后台探测(waitUntil 被调用)', pending.length > 0, `${pending.length} 个后台任务`);
globalThis.fetch = origFetch;
urSrv.close();
httpSrv.close();
const failed = results.filter((x) => !x.pass);
console.log(`\n结果:${results.length - failed.length}/${results.length} 通过`);
if (failed.length) {
console.log('失败项:');
for (const f of failed) console.log(` ✗ ${f.name}`);
process.exit(1);
}
}
main().catch((err) => {
console.error('FAILED:', err.stack || err.message);
process.exit(1);
});