用户反馈:「你不需要帮我处理 tunnel 这部分,也许我不是通过 tunnel 开放内网呢? 你只需要给我一个填写开放的管理端链接的地方。」 之前把「Cloudflare Tunnel」当成既定方案写进了配置与文档,这是替用户做了假设。 现在改为:项目只提供一个填地址的地方,怎么暴露完全由使用者决定。 - 删除 edge/cloudflare/tunnel.yml 与全部 tunnel 相关文档 - 新增 settings.adminUrl:管理界面里填一个地址,公开面板页脚出现「管理」按钮 指向它;留空则完全不显示(默认,避免在公网面板暴露管理面位置) - 只接受 http/https,javascript: 等协议被服务端拒绝(会被渲染成 <a href>) - 设置落在 local.monitors.json(私有层),只有自己的面板会出现该按钮 - 面板启动时额外拉 /api/monitors 合并 settings:原先只读静态 monitors.json (公开层),导致私有层的设置面板看不到;纯静态部署无该接口时沿用公开配置 顺带修掉一个既存漏洞(与本轮改动无关,是引入私有层叠加机制时埋下的): /api/monitors 无鉴权却回吐合并后的完整配置,包含私有层的 UptimeRobot API Key —— 只要配了 key,任何访客 curl /api/monitors 就能拿到。现在两侧(Node 与 Worker) 都对对外响应剥离 uptimeRobot.apiKey,保留 enabled 等非敏感字段供面板读取; 探针内部同步仍用完整配置,剥离只作用于 HTTP 出口。 测试:verify-worker 36→40 项(新增 [E] 配置接口密钥泄露 4 条)、 verify-security 20→25 项(新增 [D2] 密钥泄露 5 条)。全项目 8 套 314 项通过。
257 lines
15 KiB
JavaScript
257 lines
15 KiB
JavaScript
/**
|
||
* 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} 个后台任务`);
|
||
|
||
// 配置接口无鉴权,绝不能回吐 API Key
|
||
group('[E] 配置接口不得泄露密钥');
|
||
const SECRET = 'urFAKE-0123456789abcdefSECRET';
|
||
const envSecret = makeEnv({ uptimeRobot: { enabled: true, apiKey: SECRET } }, LOCAL_TARGETS);
|
||
const mResp = await worker.default.fetch(new Request('https://x/api/monitors'), envSecret, { waitUntil() {} });
|
||
const mText = await mResp.text();
|
||
check('/api/monitors 不回吐 API Key', !mText.includes(SECRET), mText.includes(SECRET) ? '泄露了!' : '未泄露');
|
||
const mJson = JSON.parse(mText);
|
||
check('uptimeRobot.apiKey 被剥离', !('apiKey' in (mJson.settings?.uptimeRobot ?? {})), JSON.stringify(mJson.settings?.uptimeRobot));
|
||
check('仍返回非敏感配置(面板要读)', typeof mJson.settings?.uptimeRobot?.enabled === 'boolean' && Array.isArray(mJson.monitors), `enabled=${mJson.settings?.uptimeRobot?.enabled}`);
|
||
// 但 Worker 内部探测仍要能用 key 同步 UR —— 用同一份配置跑一轮确认不报错
|
||
const snapSecret = await worker.__test.runChecks(envSecret);
|
||
check('内部探测仍能读到 key 并同步 UR(剥离只作用于对外响应)', snapSecret.sources.uptimerobot.enabled === true, JSON.stringify(snapSecret.sources.uptimerobot).slice(0, 60));
|
||
|
||
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);
|
||
}); |