Files
tamakyi f345626840 fix: 管理入口改为「填链接」而非绑定特定隧道方案,并修掉配置接口的密钥泄露
用户反馈:「你不需要帮我处理 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 项通过。
2026-10-14 15:23:22 +08:00

257 lines
15 KiB
JavaScript
Raw Permalink 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} 个后台任务`);
// 配置接口无鉴权,绝不能回吐 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);
});