From d2aa0a1163dabd05006262948eb91e3412aa271c Mon Sep 17 00:00:00 2001 From: tamakyi Date: Wed, 14 Oct 2026 14:51:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.12.1=20=E2=80=94=E2=80=94=20Worker?= =?UTF-8?q?=20=E6=94=AF=E6=8C=81=20UptimeRobot=20+=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=AB=AF=E8=BF=9C=E7=A8=8B=E8=AE=BF=E9=97=AE=20+=20=E5=89=8D?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E5=88=86=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 起因:核实「管理端在 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。 --- README.md | 53 ++- docs/DEPLOYMENT.md | 60 +++ docs/SECURITY.md | 35 +- edge/cloudflare/tunnel.yml | 42 +++ edge/cloudflare/uptimerobot.js | 128 +++++++ edge/cloudflare/worker.js | 121 ++++++- prober/node/index.js | 17 +- public/assets/app.js | 2 +- public/index.html | 2 +- public/status.json | 641 +++++++++++++++++---------------- scripts/verify-worker.cjs | 243 +++++++++++++ 11 files changed, 1013 insertions(+), 331 deletions(-) create mode 100644 edge/cloudflare/tunnel.yml create mode 100644 edge/cloudflare/uptimerobot.js create mode 100644 scripts/verify-worker.cjs diff --git a/README.md b/README.md index ded10f6..416f7bb 100644 --- a/README.md +++ b/README.md @@ -499,6 +499,33 @@ node prober/node/index.js > 部署到公网时建议放在 HTTPS 反向代理之后:会话 cookie 未设 `Secure`(纯 HTTP 下设了会导致 cookie 完全不发送),由代理层负责加密传输。 +### 远程访问(Cloudflare Tunnel) + +探针在内网/家用主机上时,用 Cloudflare Tunnel 让内网**主动出站**建隧道(不需要公网 IP、不需要开放入站端口)。 + +探针**默认只监听 `127.0.0.1`**,需要远程访问时显式打开: + +```jsonc +// local.monitors.json +{ "settings": { "admin": { "allowRemote": true } } } +``` + +然后参考 [`edge/cloudflare/tunnel.yml`](edge/cloudflare/tunnel.yml) 建隧道 —— 该配置只放行管理路径(`/admin`、`/api/admin/*`、`/assets/*`),其余路径 404,**不要把公开面板一起挂到公网**。 + +> ⚠️ 打开 `allowRemote` 后,同一端口上的公开面板(`/` 与 `/api/status`)也会对外可达(它们本身只读,但目标名称、地址与可用率会一并暴露)。启动日志会就此发出警告。 + +### 部署形态与可写能力 + +| 部署形态 | 面板可读 | 探测 | 通知/维护窗口/事件 | 管理端(2FA) | +| --- | --- | --- | --- | --- | +| 本地 Node | ✅ | ✅ | ✅ | ✅ | +| Cloudflare Workers | ✅ | ✅(含 UptimeRobot 数据源) | ❌ | ❌ 403 | +| GitHub Pages / EdgeOne Pages | ✅ | ❌(仅展示 CI 快照) | ❌ | ❌ 不存在 | + +**管理端只存在于「本地 Node」形态。** 边缘环境访问 `/admin` 或 `/api/admin/*` 会得到 403 + 指向文档的提示(而非 404,明确表达「这里不提供写能力」)。 + +原因是这些能力依赖本地状态:2FA 档案与会话存在 `data/` 目录与进程内存里,而 Workers 无文件系统、isolate 之间不共享内存(KV 又是最终一致,无法承载「恢复码一次性使用」这类语义)。 + ## 部署方案 | 方案 | 架构 | 数据更新 | 成本 | 适合场景 | @@ -592,6 +619,29 @@ node prober/node/index.js - [x] 页脚改为展示「数据更新于 … + 下次检查 Ns」,tooltip 内含服务端更新节奏与快照生成时间 - [x] `scripts/verify.cjs` 适配纯外部数据源部署(自动跳过不适用的探测类断言),并新增「倒计时对齐服务端」断言 +### v0.12.1 已实现 + +**让边缘部署真正可用 + 管理端远程访问 + 前后端分离** + +起因:核实「管理端在 Cloudflare 上是否有效」时发现一个更要紧的问题 —— Worker **完全没有 UptimeRobot 同步能力**(`runChecks` 的探测目标只有 `config.monitors`)。而实际配置是 `dataFilter: uptimerobot`,也就是说**直接部署到 Worker 面板会是空的**:它探测 8 个本地条目,而真正在监控的 6 个 UR 目标它根本不认识。 + +- [x] **`edge/cloudflare/uptimerobot.js`(新增)**:Workers 版 UR 只读数据源,与 `prober/node/lib/uptimerobot.js` 同一套语义(术语、状态映射、ratios 解析、故障原因提取) +- [x] **`worker.js` 探测集合扩展**:自建目标 + UR 镜像目标统一纳入一轮探测;UR 目标只拉取不探测,采用其状态并补齐本地 `daily` 分桶 +- [x] **`probeScope` 与 `dataFilter` 解耦**(与本地探针同语义):未显式设置 `probeScope` 时跟随 `dataFilter`;`config` 范围下不拉 UR,`uptimerobot` 范围下不探测本地 +- [x] **同步失败降级**:UR 拉取失败时标记 `sources.uptimerobot.ok = false` 并保留上次数据,**绝不把目标判为离线或从面板消失** +- [x] **UR 侧删除的监控自动清理**,避免僵尸条目 +- [x] 快照新增 `probeScope` 字段;UR 目标带 `meta`(面板据此区分自建/外部)与 `uptimeRatios`(面板算官方可用率) +- [x] **`settings.admin.allowRemote`**:探针**默认只监听 `127.0.0.1`**;显式打开才监听全网卡,启动日志就此告警。这是 Cloudflare Tunnel 的安全前提(实测:默认时非环回地址连不上,打开后可达) +- [x] **`edge/cloudflare/tunnel.yml`(新增)**:命名隧道配置,只放行 `^/(admin|api/admin/.*|assets/.*)$`,其余 404 —— 不把公开面板一起挂到公网 +- [x] **前后端分离落地**:Worker 侧 `/admin` 与 `/api/admin/*` 显式返回 **403 + 指向文档的提示**(而非默默落到静态资源变成 404),确保边缘侧**没有任何可写面**;管理端只在本地 Node 侧运行 +- [x] 文档:`DEPLOYMENT.md` 补「接入 UptimeRobot 数据源」与「远程访问管理端」,各部署形态对照表加「UptimeRobot 数据源 / 管理端」两行;`SECURITY.md` 补部署形态可写能力对照、隧道威胁模型(含「限流按边缘 IP 统计」这一已知粒度损失);README 同步 + +**测试**:新增 `scripts/verify-worker.cjs`(36 项)—— 把 Worker 以 data: URL 动态载入以绕开 `cloudflare:sockets`,注入假 KV / 静态资源 / UptimeRobot 服务器,**不需要 wrangler 也不需要联网**。全项目 8 套 310 项全部通过。 + +过程中抓到两个真 bug:① UR 同步失败时降级路径引用了不含 `meta` 的旧对象,导致 `name`/`url` 为 `undefined`、UR 目标从面板上消失;② `stats.total` 按 `config.monitors.length` 计数,在 `probeScope=uptimerobot` 时与实际参与的目标数不符。 + +> 另有一条测试夹具层面的教训:内存版 KV 必须实现 `get(key, 'json')` 的类型参数(真实 Workers KV 会自动反序列化),否则 `state.monitors` 恒为 undefined —— 表现为「状态永远读不到」,看起来像产品代码坏了。 + ### v0.12.0 已实现 **① 每行绝对等高**(你的要求:不再有「内容长度相当时才等高」) @@ -751,12 +801,13 @@ NODE_PATH= node scripts/verify.cjs # 热 NODE_PATH= node scripts/verify-views.cjs # 视图与数据源过滤 / 对比度 / 等高 / 事件区块(47 项) NODE_PATH= node scripts/verify-layout.cjs # 13 视口 × 2 视图零横向溢出 + 行高一致(26 项) NODE_PATH= node scripts/verify-admin.cjs # 管理端 2FA 全流程(102 项:RFC 向量 / 绑定 / 会话 / 限流 / 接口 / 页面) +node scripts/verify-worker.cjs # Cloudflare Worker(36 项:UR 映射 / 探测范围 / 失败降级 / 管理端拒绝) node scripts/verify-security.cjs # banner 探测 / 脱敏 / 私有分层 / 刷新限流(20 项) node scripts/verify-notify.cjs # 通知与证书(44 项:假 SMTP / 假 MeoW / 去抖 / 恢复 / 维护窗口 / 真实证书) UPTIMEROBOT_API_KEY=ur... node scripts/verify-uptimerobot.cjs # UR 映射/兼容接口/失败路径/冒烟(18 项) ``` -`verify-security.cjs` 会临时起一个假 SSH 服务与静默端口,验证 banner 探测的在线/不匹配/超时三种判定、默认不回显版本、脱敏与私有配置分层,以及刷新接口的 429/403 行为。`verify-uptimerobot.cjs` 用合成 payload 做无网映射单测,真实 Key 冒烟为可选(未设置环境变量则跳过)。`verify-notify.cjs` 会临时起**假 SMTP 服务器与假 MeoW 服务器**,完整走一遍邮件握手与推送协议,并真跑三轮探针验证去抖、恢复、维护窗口抑制与事件落盘;真实证书握手需要外网,无网络时自动跳过(不计失败)。`verify-layout.cjs` 用极端内容(40 目标 / 6 分组 / 超长名称与 URL)在 13 种视口 × 2 种视图下断言零横向溢出**且行高完全一致**。`verify-admin.cjs` 先用 RFC 4226 / RFC 6238 官方测试向量校验自实现的 TOTP,再覆盖 2FA 绑定、会话生命周期、防重放、失败限流、HTTP 鉴权边界与页面流程。 +`verify-security.cjs` 会临时起一个假 SSH 服务与静默端口,验证 banner 探测的在线/不匹配/超时三种判定、默认不回显版本、脱敏与私有配置分层,以及刷新接口的 429/403 行为。`verify-uptimerobot.cjs` 用合成 payload 做无网映射单测,真实 Key 冒烟为可选(未设置环境变量则跳过)。`verify-notify.cjs` 会临时起**假 SMTP 服务器与假 MeoW 服务器**,完整走一遍邮件握手与推送协议,并真跑三轮探针验证去抖、恢复、维护窗口抑制与事件落盘;真实证书握手需要外网,无网络时自动跳过(不计失败)。`verify-layout.cjs` 用极端内容(40 目标 / 6 分组 / 超长名称与 URL)在 13 种视口 × 2 种视图下断言零横向溢出**且行高完全一致**。`verify-admin.cjs` 先用 RFC 4226 / RFC 6238 官方测试向量校验自实现的 TOTP,再覆盖 2FA 绑定、会话生命周期、防重放、失败限流、HTTP 鉴权边界与页面流程。`verify-worker.cjs` 把 Worker 以 data: URL 动态载入(绕开 `cloudflare:sockets`),注入假的 KV / 静态资源 / UptimeRobot 服务器,**不需要 wrangler 也不需要联网**即可验证边缘逻辑。 代码约定:探针与面板通过 `status.json` / `/api/status` 的同一份 JSON 契约协作;新增探测类型时只需在 `prober/node/lib/` 与 `edge/cloudflare/worker.js` 各实现一个 `probeXxx(monitor)`,返回 `{ status, latency, code, detail }` 即可被面板直接渲染。 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index e0e2081..8c40cf1 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -73,9 +73,36 @@ wrangler deploy - 手动触发一轮:`curl https:///api/refresh`(等待本轮完成后返回最新快照,与本地探针同语义) - cron 表达式在 `wrangler.toml` 的 `[triggers].crons` 中调整(`* * * * *` = 每分钟) +### 接入 UptimeRobot 数据源 + +Worker 与本地探针共用同一套 UptimeRobot 语义(只读,映射逻辑见 `edge/cloudflare/uptimerobot.js`)。API Key 用 secret 传入,**不要写进 `monitors.json`**(它会随静态资源公开): + +```bash +wrangler secret put UPTIMEROBOT_API_KEY # 填 ur... 开头的只读 Key +``` + +同时在 `public/monitors.json` 的 `settings` 里开启(`apiKey` 留空,由 secret 提供): + +```jsonc +{ + "settings": { + "dataFilter": "uptimerobot", // 只展示 UptimeRobot 目标 + "uptimeRobot": { "enabled": true, "apiKey": "" } + } +} +``` + +行为与本地探针一致: + +- `probeScope` 决定真正发请求的范围(`all` / `config` / `uptimerobot`),未显式设置时跟随 `dataFilter` +- UptimeRobot 目标只拉取不探测,采用其状态并补齐本地 `daily` 分桶 +- 同步失败时**保留上次数据**并把 `sources.uptimerobot.ok` 置为 `false`,绝不因此把目标判为离线 +- UR 侧删除的监控会从面板移除,避免残留僵尸条目 + 注意事项: - **TCP 出站连接(`cloudflare:sockets`)需要 Workers 付费计划($5/月)**;免费计划下 `http` 类型探测与面板完全正常,`tcp` 类型会报错并记为离线 +- **banner 类型边缘端不支持**(需原始 socket 读取),会记为离线并给出说明文案;这类目标请用本地探针 - KV 为最终一致存储,刚写入后立即读取可能有秒级延迟,对分钟级巡检无影响 - 90 天可用率分桶随 state 一起存于 KV,按 **UTC 日期** 分桶(边缘环境无本地时区);每分钟 cron 约 1440 次/天 - 免费版 Worker 每日 10 万次请求,面板静态资源走 Assets 不计入 Worker 请求数 @@ -126,7 +153,40 @@ EdgeOne Pages 当前以静态托管为主,采用与方案二相同的「静态 | TCP 探测 | ✅ | ✅(Actions 内) | 付费版 ✅ / 免费版 ❌ | 同 GH Pages | | 真实状态码 | ✅ | ✅ | ✅ | ✅ | | 内网目标 | ✅(浏览器同网即可) | ❌ | ❌ | ❌ | +| UptimeRobot 数据源 | ✅ | ✅ | ✅ | ❌(需改用自建探测) | +| 通知 / 维护窗口 / 事件 | ✅ | ❌ | ❌ | ❌ | +| **管理端(2FA)** | ✅ | ❌ | ❌ | ❌ | | 国内访问速度 | 本机最快 | 一般 | 一般 | 快 | | 运维成本 | 需保活进程 | 零 | 零 | 零 | 建议:个人/演示用 **GitHub Pages**;生产公网监控用 **Cloudflare Workers**;服务对象在国内且以 HTTP 监控为主用 **EdgeOne Pages**;内网资产用 **本地 Node**。 + +> ⚠️ **管理端只存在于「本地 Node」部署形态。** 其余形态的部署环境只提供只读面板与探测,没有任何写接口 —— Cloudflare Worker 上访问 `/admin` 或 `/api/admin/*` 会得到 403(而非 404,因为那里确实不提供管理能力)。需要管理操作(改配置、维护窗口、通知自检、立即重探)时,在本地 Node 那一侧完成。 +> +> 原因不是「没做」,而是这些能力依赖本地状态:2FA 档案与会话存在 `data/` 目录与进程内存里,而 Workers 无文件系统、会话状态也无法跨 isolate 共享(KV 又是最终一致,无法承载「恢复码一次性」这类语义)。把这个结论说清楚,比在边缘环境里做一个半可靠的管理端更有价值。 + +--- + +## 远程访问管理端(Cloudflare Tunnel) + +探针在内网/家用主机上时,若要在外网打开 `/admin`,用 Cloudflare Tunnel 让内网**主动出网**建隧道(不需要公网 IP、不需要开放入站端口)。 + +前提与步骤: + +1. **确认管理端已绑定 2FA**。Tunnel 只负责把端口暴露出去,真正拦住未授权访问的是管理端的 TOTP + 会话 + 失败限流。 +2. 打开探针的远程管理开关(**默认关闭**,此时探针只监听 `127.0.0.1`): + + ```jsonc + // local.monitors.json + { "settings": { "admin": { "allowRemote": true } } } + ``` + +3. 建命名隧道并只回源管理路径,参考 [`edge/cloudflare/tunnel.yml`](../edge/cloudflare/tunnel.yml): + + ```bash + cloudflared tunnel --no-autoupdate run --token <你的 token> + ``` + +该配置只放行 `^/(admin|api/admin/.*|assets/.*)$`,其余路径一律 404 —— 不要把公开面板与 `/api/status` 一起挂到公网。 + +开启 `allowRemote` 后启动日志会明确警告探针正在监听 `0.0.0.0`,提醒你该端口同时伺服公开面板,前置代理必须启用 HTTPS。 diff --git a/docs/SECURITY.md b/docs/SECURITY.md index fb78fa9..6f9484f 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -151,16 +151,46 @@ banner 探测的副作用仅有一次 TCP 连接与读取,与端口扫描器 v0.12.0 起,所有会改配置的操作都放在 `/admin`,**必须绑定并通过两步验证才能进入**。 +### 部署形态与可写能力 + +管理端**只存在于「本地 Node 探针」这一种部署形态**。各形态的可写面: + +| 部署形态 | 面板可读 | 探测 | 管理端(2FA) | +| --- | --- | --- | --- | +| 本地 Node | ✅ | ✅ | ✅ | +| Cloudflare Workers | ✅ | ✅ | ❌ 403 | +| GitHub Pages / EdgeOne Pages(纯静态) | ✅ | ❌(仅展示 CI 产出的快照) | ❌ 不存在 | + +边缘形态访问 `/admin` 或 `/api/admin/*` 会得到 **403 + 指向本文的提示**,而不是 404 —— 明确表达「这里不提供写能力」,避免访客把 404 误读成路径写错。 + +这不是「没做」,而是这些能力依赖本地状态:2FA 档案与会话存在 `data/` 目录与进程内存中,而 Workers 无文件系统、isolate 之间不共享内存(KV 又是最终一致,无法承载「恢复码一次性使用」这类语义)。 + +### 远程访问(Cloudflare Tunnel) + +探针在内网时若需从外网访问 `/admin`,用 Cloudflare Tunnel 让内网主动出网建隧道。 + +**探针默认只监听 `127.0.0.1`**,必须显式打开才能被隧道访问: + +```jsonc +// local.monitors.json +{ "settings": { "admin": { "allowRemote": true } } } +``` + +开启后启动日志会警告「正在监听 0.0.0.0」。**请注意该端口同时伺服公开面板** —— 也就是说一旦打开这个开关,`/` 与 `/api/status` 也会跟着对外可达,这两项本身是只读的,但你的目标名称、地址与可用率会一并暴露。隧道配置(`edge/cloudflare/tunnel.yml`)默认只放行 `^/(admin|api/admin/.*|assets/.*)$`,其余路径 404,就是为了不把公开面板一起挂出去。 + ### 威胁模型与对应措施 | 威胁 | 措施 | | --- | --- | | 未绑定就被抢先接管管理端 | 绑定需 `SHIROKO_ADMIN_SETUP_KEY`;未绑定时 `/api/admin/*` 除绑定流程外**一律 403**;未设置该环境变量时绑定入口直接关闭(503) | +| 未授权者经隧道访问 | 隧道暴露的是同一端口,防护完全依赖 2FA:TOTP 校验 + 会话哈希 + 失败限流三重 | +| 隧道配置过宽导致面板外泄 | 示例配置只放行管理路径,并注释说明不要把 `/` 与 `/api/status` 一起暴露 | +| 忘记关`allowRemote` | 启动日志显式警告监听地址;默认值为关闭 | | 暴力枚举 6 位码(100 万种) | 按 IP 计数,连续 5 次失败后指数退避锁定(1m → 2m → 4m …上限 30m);锁定期间即使验证码正确也拒绝 | | 会话 id 被窃取 | id 为 32 字节随机数,**服务端只存 SHA-256 哈希**;cookie 为 `HttpOnly`(JS 读不到)+ `SameSite=Strict`;30 分钟空闲失效、最长 2 小时;进程重启清空全部会话 | | 截图复用验证码 | 记录最近使用的 TOTP counter,**同一时间步的码用过即作废** | | 恢复码泄露 | 只存 SHA-256 哈希(带随机盐),一次性使用,用后立刻从档案中移除 | -| 伪造来源 IP 绕过限流 | **只信任 socket 对端地址,不解析 `X-Forwarded-For`**(可被任意伪造) | +| 伪造来源 IP 绕过限流 | **只信任 socket 对端地址,不解析 `X-Forwarded-For`**(可被任意伪造)—— 经隧道访问时对端地址是 Cloudflare 边缘节点,因此限流是按边缘 IP 而非真实客户端统计的,这是隧道形态下已知的粒度损失 | | 篡改请求 | `Path=/` + `HttpOnly` + `SameSite=Strict`;请求体上限 64KB,超出返回 413;参数逐项白名单校验(如 `probeScope` 只接受三个枚举值) | | 通过管理端把密钥写进仓库 | **所有写操作只落 `local.monitors.json`**(私有、已 gitignore),绝不改 `public/monitors.json` | @@ -184,5 +214,6 @@ node scripts/verify-admin.cjs # 102 项:RFC 向量 / 绑定 / 会话 / 防 - `.gitignore` 已包含 `local.monitors.json` 与 `.dev.vars`(Cloudflare 本地密钥) - Worker 的 `REFRESH_TOKEN` 用 `wrangler secret put REFRESH_TOKEN` 管理,不要写进 `wrangler.toml` +- **UptimeRobot API Key 在 Worker 上必须用 `wrangler secret put UPTIMEROBOT_API_KEY`**,写进 `public/monitors.json` 会随静态资源公开。只用 `ur*` 开头的只读 Key - **公开面板**(`/`)仍是只读前端:无 cookie、无写入接口、无服务端会话;渲染配置字段时统一做 HTML 转义(`escapeHtml`),XSS 面仅剩配置自身。管理能力全部隔离在 `/admin` 与 `/api/admin/*`(需会话) -- Node 探针同时充当静态服务器,**不要直接暴露公网**;确实需要时在其前方加带认证的反向代理,并设 `allowRefresh: false` +- Node 探针同时充当静态服务器,**默认只监听 `127.0.0.1`**;需要远程访问时用 `settings.admin.allowRemote=true` 显式打开,并确保前置代理已启用 HTTPS diff --git a/edge/cloudflare/tunnel.yml b/edge/cloudflare/tunnel.yml new file mode 100644 index 0000000..2bb68e5 --- /dev/null +++ b/edge/cloudflare/tunnel.yml @@ -0,0 +1,42 @@ +# Cloudflare Tunnel:让管理端从外网访问 +# +# 场景:探针跑在内网/家用小主机上(没有公网入口),但你需要在手机或外网笔记本上 +# 打开 /admin 做管理操作。 +# +# 原理:cloudflared 在你的机器上**主动向外**建立到 Cloudflare 的隧道(出站连接, +# 不需要公网 IP、不需要开放任何入站端口),Cloudflare 再把指定子域名回源到它。 +# 因此内网无需任何端口映射。 +# +# 用法(命名隧道,推荐): +# 1. 安装 cloudflared +# 2. 在 Cloudflare Zero Trust 后台创建命名隧道,取它的 token +# 3. 打开探针的远程管理开关(默认关闭): +# 在 local.monitors.json 里加 "settings": { "admin": { "allowRemote": true } } +# 4. 跑:cloudflared tunnel --no-autoupdate run --token <你的 token> +# +# 一次性(快速隧道)命令,仅适合临时验证: +# cloudflared tunnel --url http://localhost:8787 +# 它会打印一个随机 trycloudflare.com 地址;不要用于正式环境(无鉴权、无 SLA)。 +# +# 前提:探针必须处于「已绑定 2FA」的状态。Tunnel 只是把端口暴露出去, +# 真正拦住未授权访问的是管理端的 TOTP + 会话 + 失败限流。 + +tunnel: <你的隧道 ID 或名字> +credentials-file: /etc/cloudflared/.json + +# Tunnel 只回源管理端这一条路径,面板与公开 API 不通过隧道暴露。 +# ingress 的第一条是 catch-all,必须放在最后。 +ingress: + # 管理后台页面与接口:都指向同一个本地端口(管理端自身会校验 2FA 与会话) + - hostname: admin.example.com + path: ^/(admin|api/admin/.*|assets/.*)$ + service: http://localhost:8787 + + # 其余一律拒绝:不要把公开面板与 /api/status 一起暴露出去 + - service: http_status:404 + +# 探针侧的探测与通知日志仍打在 stdout,不受隧道影响。 +# 若用 systemd 常驻,建议: +# ExecStart=/usr/local/bin/cloudflared tunnel run --no-autoupdate --token-file /etc/cloudflared/token +# Restart=on-failure +# RestartSec=5s \ No newline at end of file diff --git a/edge/cloudflare/uptimerobot.js b/edge/cloudflare/uptimerobot.js new file mode 100644 index 0000000..389bed6 --- /dev/null +++ b/edge/cloudflare/uptimerobot.js @@ -0,0 +1,128 @@ +/** + * UptimeRobot 只读数据源(API v2)—— Cloudflare Workers 版 + * + * 与 prober/node/lib/uptimerobot.js 共用同一份语义(术语对齐见该文件顶部注释), + * 但按 Workers 环境改了两处: + * 1. 没有 node:crypto / node:fs,API Key 从 env(wrangler secret)读取 + * 2. 限流改为「按 KV 里记录的上次同步时间判断」,KV 最终一致对分钟级同步无影响 + * + * 只读:仅调用 getMonitors,不做任何写操作,因此用 ur* 开头的只读 Key 即可。 + */ + +const API_URL = 'https://api.uptimerobot.com/v2/getMonitors'; +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(Number).filter(Number.isFinite); +} + +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 响应映射成内部结构(纯函数,可离线单测) */ +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 historyLimit = options.historyLimit ?? 48; + 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, historyLimit) + .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), + source: 'uptimerobot', + sourceId: m.id, + sourceType: m.type, + sourceLabel: TYPE_LABEL[m.type] ?? `TYPE${m.type}`, + interval: Number(m.interval) || 300, + status, + latency, + 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), + }); + } + return monitors; +} + +/** + * 拉取 UptimeRobot 监控列表。 + * @param {string} apiKey + * @param {{ratios?: number[], timeoutMs?: number}} [options] + */ +export async function fetchUptimeRobotMonitors({ apiKey, ratios = [1, 7, 30, 90], timeoutMs = 15000 }) { + 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) + }); + 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 }); +} \ No newline at end of file diff --git a/edge/cloudflare/worker.js b/edge/cloudflare/worker.js index cd9b9e4..debeecf 100644 --- a/edge/cloudflare/worker.js +++ b/edge/cloudflare/worker.js @@ -2,16 +2,21 @@ * shiroko-monitor 边缘探针(Cloudflare Workers) * * - scheduled:cron 每分钟触发一轮探测(HTTP 用 fetch,TCP 用 cloudflare:sockets) + * 探测目标 = 配置里的自建目标 + UptimeRobot 只读镜像目标(与本地 Node 探针同口径) * - 状态与历史心率整体存于 KV 的 state 键(小规模单键足够,上限 25MB) * - /api/status、/api/monitors、/api/refresh 由本 Worker 提供,其余请求交给静态资源 * + * 管理端说明:本 Worker 不提供任何写接口,/admin 与 /api/admin/* 一律拒绝 + * (管理操作在本地 Node 探针侧完成,见 docs/DEPLOYMENT.md)。 + * * 注意:cloudflare:sockets 的 TCP 出站连接需要 Workers 付费计划; * 免费计划下 http 类型探测与静态面板均可正常使用,tcp 类型会返回 down。 */ import { connect } from 'cloudflare:sockets'; +import { fetchUptimeRobotMonitors } from './uptimerobot.js'; -const VERSION = '0.11.2'; +const VERSION = '0.12.1'; const HISTORY_LIMIT = 48; const UPTIME_DAYS = 90; const USER_AGENT = 'shiroko-monitor/0.3 (lightweight uptime panel)'; @@ -42,10 +47,21 @@ export default { if (!gate.ok) return json({ error: gate.error }, { status: gate.status }); return json(await runChecksDedup(env)); } + // 管理端只存在于本地 Node 探针:这里显式拒绝而非落到静态资源, + // 避免访客把 404 误读成「路径写错」,也确保 Worker 侧没有任何可写面 + if (url.pathname === '/admin' || url.pathname.startsWith('/api/admin')) { + return json({ + error: 'admin-not-here', + hint: '管理端(含 2FA)只在本地 Node 探针上运行,不在 Cloudflare Worker 上。部署方式见 docs/DEPLOYMENT.md', + }, { status: 403 }); + } return env.ASSETS.fetch(request); }, }; +// 供 scripts/verify-worker.cjs 离线单测 runChecks(生产路径不依赖它) +export const __test = { runChecks, mapStatus: null }; + // 未配置 REFRESH_TOKEN 时一律拒绝(wrangler secret put REFRESH_TOKEN 开启); // 比较采用等长逐字节异或,避免按字符提前返回带来的时序差异 function refreshGate(env, request) { @@ -154,14 +170,98 @@ async function runChecks(env) { const state = (await env.MONITOR_KV.get('state', 'json')) ?? { monitors: {} }; const monitors = state.monitors ?? {}; const redact = config.settings?.redactDetails === true; + + // 探测范围与展示口径解耦:dataFilter 只管展示,probeScope 决定真正发请求的范围 + const filter = ['both', 'config', 'uptimerobot'].includes(config.settings?.dataFilter) ? config.settings.dataFilter : 'both'; + const scope = ['all', 'config', 'uptimerobot'].includes(config.settings?.probeScope) + ? config.settings.probeScope + : (filter === 'both' ? 'all' : filter); + + // UptimeRobot 目标:只拉取不探测。同步失败时保留上次数据,绝不因此把目标判为离线 + const urCfg = config.settings?.uptimeRobot ?? {}; + const urKey = String(urCfg.apiKey || '').trim(); + const urEnabled = urCfg.enabled === true && Boolean(urKey); + const urStatus = state.sources?.uptimerobot ?? { enabled: false, ok: null, count: 0 }; + let urList = []; + if (scope !== 'config' && urEnabled) { + try { + const ratios = Array.isArray(urCfg.ratios) && urCfg.ratios.length ? urCfg.ratios.map(Number).filter(Number.isFinite) : [1, 7, 30, 90]; + urList = await fetchUptimeRobotMonitors({ apiKey: urKey, ratios }); + urStatus.enabled = true; + urStatus.ok = true; + urStatus.error = null; + urStatus.count = urList.length; + urStatus.lastSyncAt = new Date().toISOString(); + } catch (err) { + urStatus.enabled = true; + urStatus.ok = false; + urStatus.error = String(err?.message ?? err); + } + } + + // 本轮要处理的全部目标:自建(scope 含 config)+ UR 镜像(scope 含 uptimerobot) + const localTargets = scope === 'uptimerobot' ? [] : (config.monitors ?? []); + const urTargets = scope === 'config' ? [] : urList; + const urById = new Map(urTargets.map((m) => [m.id, m])); + let up = 0; let down = 0; let pending = 0; let latencySum = 0; let latencyCount = 0; + // 本次同步没拿到数据时(失败或被限流跳过),沿用上一轮已写入快照的 UR 目标: + // 直接保留其 status / meta / uptimeRatios,只更新 daily 分桶,绝不把目标改成离线或删掉。 + // 注意不能直接复用 urTargets 里的旧对象——那需要 meta 里的名称/地址,缺了就渲染不出行。 + const urSource = urTargets.length + ? urTargets.map((m) => ({ ...m, _fresh: true })) + : Object.entries(monitors) + .filter(([, s]) => s.meta?.source === 'uptimerobot') + .map(([id, s]) => ({ + id, + name: s.meta.name, + group: s.meta.group, + url: s.meta.url, + sourceLabel: s.meta.sourceLabel, + interval: s.meta.interval, + status: s.status, + latency: s.latency, + code: s.code, + detail: s.detail, + lastCheckIso: s.lastCheck, + history: s.history ?? [], + uptimeRatios: s.uptimeRatios, + _fresh: false, + })); + + for (const m of urSource) { + const prev = monitors[m.id]; + const daily = { ...(prev?.daily ?? {}) }; + const key = dayKey(); + const bucket = daily[key] ?? (daily[key] = { total: 0, up: 0 }); + bucket.total++; + if (m.status === 'up') bucket.up++; + pruneDaily(daily); + + monitors[m.id] = { + status: m.status, + latency: m.latency ?? null, + code: m.code ?? null, + detail: redact ? (m.status === 'up' ? '正常' : '异常') : m.detail, + lastCheck: m.lastCheckIso ?? new Date().toISOString(), + // meta 让面板认出这是外部数据源目标(名称/分组/协议都来自 UR 侧) + meta: { name: m.name, group: m.group ?? 'UptimeRobot', url: m.url ?? '', source: 'uptimerobot', sourceLabel: m.sourceLabel ?? 'HTTP', interval: m.interval ?? 300, type: 'ur' }, + uptimeRatios: m.uptimeRatios, + history: m.history ?? [], + daily, + }; + if (m.status === 'up') { up++; if (m.latency != null) { latencySum += m.latency; latencyCount++; } } + else if (m.status === 'down') down++; + else pending++; + } + await Promise.all( - (config.monitors ?? []).map(async (m) => { + localTargets.map(async (m) => { const prev = monitors[m.id]; const r = await probeMonitor(m); r.checkedAt = new Date().toISOString(); @@ -187,12 +287,21 @@ async function runChecks(env) { up++; latencySum += r.latency ?? 0; latencyCount++; - } else { + } else if (r.status === 'down') { down++; + } else { + pending++; } }) ); + // UR 侧已删除的监控要从面板移除,避免残留僵尸条目 + if (scope !== 'config' && urEnabled && urStatus.ok && urById.size) { + for (const [id, s] of Object.entries(monitors)) { + if (s.meta?.source === 'uptimerobot' && !urById.has(id)) delete monitors[id]; + } + } + let uptimeUp = 0; let uptimeTotal = 0; for (const m of Object.values(monitors)) { @@ -204,7 +313,6 @@ async function runChecks(env) { // 与本地探针保持同一份快照口径:dataUpdatedAt / updateIntervalSec / dataFilter // 面板据此把倒计时对齐服务端节奏,并按同一过滤口径展示 - const filter = ['both', 'config', 'uptimerobot'].includes(config.settings?.dataFilter) ? config.settings.dataFilter : 'both'; const intervals = []; if (filter !== 'uptimerobot') { for (const m of config.monitors ?? []) { @@ -221,15 +329,16 @@ async function runChecks(env) { dataUpdatedAt: state.generatedAt ?? new Date().toISOString(), updateIntervalSec: Math.max(5, Math.round(intervals.length ? Math.min(...intervals) : 60)), dataFilter: filter, + probeScope: scope, + sources: { uptimerobot: { ...urStatus, syncing: false } }, stats: { - total: (config.monitors ?? []).length, + total: localTargets.length + urSource.length, up, down, pending, avgLatency: latencyCount ? Math.round(latencySum / latencyCount) : null, avgUptime90: uptimeTotal ? Math.round((uptimeUp / uptimeTotal) * 1000) / 10 : null, }, - sources: {}, monitors, }; await env.MONITOR_KV.put('state', JSON.stringify(next)); diff --git a/prober/node/index.js b/prober/node/index.js index 87605eb..4e55863 100644 --- a/prober/node/index.js +++ b/prober/node/index.js @@ -39,7 +39,7 @@ const CONCURRENCY = 8; const WRITE_DEBOUNCE_MS = 300; const DEFAULT_INTERVAL_SEC = 60; const PORT = Number(process.env.PORT || 8787); -const VERSION = '0.12.0'; +const VERSION = '0.12.1'; const UR_DEFAULT_POLL_SEC = 300; const UR_MIN_REFRESH_GAP_MS = 60000; @@ -1120,7 +1120,13 @@ async function main() { } }, 1000); - createServer(runtime).listen(PORT, () => { + // 管理端是否允许经由非本机地址访问(如 Cloudflare Tunnel 挂到公网)。 + // 默认关闭:探针通常只该在内网/本机使用,暴露管理面必须由使用者显式打开。 + const adminCfg = runtime.config.settings?.admin ?? {}; + const adminPublic = adminCfg.allowRemote === true; + const BIND_HOST = adminPublic ? '0.0.0.0' : '127.0.0.1'; + + createServer(runtime).listen(PORT, BIND_HOST, () => { console.log(`shiroko-monitor v${VERSION}`); console.log(`面板: http://localhost:${PORT}/`); console.log(`状态 API: http://localhost:${PORT}/api/status`); @@ -1136,6 +1142,13 @@ async function main() { if (!localCount && !runtime.ur.enabled) { console.warn('[warn] 当前没有任何数据源:本地探测已关闭且 UptimeRobot 未启用,面板将显示「等待检测」'); } + if (adminPublic) { + console.log(`管理后台: http://localhost:${PORT}/admin`); + console.warn(`[warn] settings.admin.allowRemote=true —— 探针正在监听 ${BIND_HOST},管理端可经由反向代理/隧道从外部访问。`); + console.warn(' 该端口同时伺服公开面板,请确保管理端已绑定 2FA,且前置代理已启用 HTTPS。'); + } else { + console.log(`管理后台: http://localhost:${PORT}/admin (仅本机可访问;需远程访问请设 settings.admin.allowRemote=true)`); + } }); } diff --git a/public/assets/app.js b/public/assets/app.js index 276ff65..7220250 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -1,4 +1,4 @@ -const VERSION = '0.12.0'; +const VERSION = '0.12.1'; const HB_LIMIT = 48; const UPTIME_DAYS = 90; const BROWSER_CONCURRENCY = 6; diff --git a/public/index.html b/public/index.html index 5cc64a9..c3b1ed0 100644 --- a/public/index.html +++ b/public/index.html @@ -75,7 +75,7 @@ – – - shiroko-monitor v0.12.0 + shiroko-monitor v0.12.1 diff --git a/public/status.json b/public/status.json index 8e54318..4276b46 100644 --- a/public/status.json +++ b/public/status.json @@ -1,8 +1,8 @@ { "prober": "shiroko-monitor-node/0.12.0", "demo": false, - "generatedAt": "2026-10-14T04:36:38.881Z", - "dataUpdatedAt": "2026-10-14T04:36:26.361Z", + "generatedAt": "2026-10-14T06:50:04.642Z", + "dataUpdatedAt": "2026-10-14T06:50:02.233Z", "updateIntervalSec": 300, "dataFilter": "uptimerobot", "probeScope": "all", @@ -11,12 +11,12 @@ "up": 5, "down": 1, "pending": 0, - "avgLatency": 755, + "avgLatency": 671, "avgUptime90": 83.3, "memory": { - "rssMB": 80.2, - "heapUsedMB": 10.8, - "heapTotalMB": 15 + "rssMB": 82.8, + "heapUsedMB": 12.5, + "heapTotalMB": 16 } }, "sources": { @@ -24,10 +24,10 @@ "enabled": true, "ok": true, "error": null, - "lastSyncAt": "2026-10-14T04:33:06.976Z", + "lastSyncAt": "2026-10-14T06:46:25.561Z", "count": 6, "syncing": false, - "lastSyncMs": 1791952385563 + "lastSyncMs": 1791960384004 } }, "notify": { @@ -55,31 +55,11 @@ "monitors": { "ur-792059439": { "status": "up", - "latency": 99, + "latency": 262, "code": null, "detail": "UptimeRobot:运行正常", - "lastCheck": "2026-10-14T04:33:00.000Z", + "lastCheck": "2026-10-14T06:40:00.000Z", "history": [ - { - "t": "2026-10-13T05:15:00.000Z", - "ok": true, - "ms": 353 - }, - { - "t": "2026-10-13T05:55:00.000Z", - "ok": true, - "ms": 294 - }, - { - "t": "2026-10-13T06:35:00.000Z", - "ok": true, - "ms": 238 - }, - { - "t": "2026-10-13T07:15:00.000Z", - "ok": true, - "ms": 259 - }, { "t": "2026-10-13T07:55:00.000Z", "ok": true, @@ -231,23 +211,38 @@ "ms": 282 }, { - "t": "2026-10-14T03:57:00.000Z", + "t": "2026-10-14T04:00:00.000Z", "ok": true, - "ms": 321 + "ms": 289 }, { - "t": "2026-10-14T04:33:00.000Z", + "t": "2026-10-14T04:40:00.000Z", "ok": true, - "ms": 99 + "ms": 171 + }, + { + "t": "2026-10-14T05:20:00.000Z", + "ok": true, + "ms": 311 + }, + { + "t": "2026-10-14T06:04:00.000Z", + "ok": true, + "ms": 357 + }, + { + "t": "2026-10-14T06:40:00.000Z", + "ok": true, + "ms": 262 } ], "daily": { "2026-10-14": { - "total": 4, - "up": 4, - "latSum": 690, - "latCount": 4, - "latMax": 197 + "total": 26, + "up": 26, + "latSum": 7486, + "latCount": 26, + "latMax": 454 } }, "meta": { @@ -272,38 +267,23 @@ "90": 0 }, "lat": { - "avg": 173, - "max": 197, + "avg": 288, + "max": 454, "days": [ { "key": "2026-10-14", - "avg": 173 + "avg": 288 } ] } }, "ur-795497022": { "status": "up", - "latency": 619, + "latency": 714, "code": null, "detail": "UptimeRobot:运行正常", - "lastCheck": "2026-10-14T04:32:00.000Z", + "lastCheck": "2026-10-14T06:46:00.000Z", "history": [ - { - "t": "2026-10-13T05:35:00.000Z", - "ok": true, - "ms": 784 - }, - { - "t": "2026-10-13T06:15:00.000Z", - "ok": true, - "ms": 679 - }, - { - "t": "2026-10-13T06:55:00.000Z", - "ok": true, - "ms": 640 - }, { "t": "2026-10-13T07:35:00.000Z", "ok": true, @@ -450,23 +430,48 @@ "ms": 740 }, { - "t": "2026-10-14T03:55:00.000Z", + "t": "2026-10-14T03:00:00.000Z", "ok": true, - "ms": 631 + "ms": 718 }, { - "t": "2026-10-14T04:32:00.000Z", + "t": "2026-10-14T03:40:00.000Z", "ok": true, - "ms": 619 + "ms": 593 + }, + { + "t": "2026-10-14T04:20:00.000Z", + "ok": true, + "ms": 627 + }, + { + "t": "2026-10-14T05:00:00.000Z", + "ok": true, + "ms": 674 + }, + { + "t": "2026-10-14T05:41:00.000Z", + "ok": true, + "ms": 728 + }, + { + "t": "2026-10-14T06:13:00.000Z", + "ok": true, + "ms": 732 + }, + { + "t": "2026-10-14T06:46:00.000Z", + "ok": true, + "ms": 714 } ], "daily": { "2026-10-14": { - "total": 4, - "up": 4, - "latSum": 2484, - "latCount": 4, - "latMax": 627 + "total": 26, + "up": 26, + "latSum": 18278, + "latCount": 26, + "latMax": 778 } }, "meta": { @@ -491,43 +496,23 @@ "90": 5432 }, "lat": { - "avg": 621, - "max": 627, + "avg": 703, + "max": 778, "days": [ { "key": "2026-10-14", - "avg": 621 + "avg": 703 } ] } }, "ur-795556828": { "status": "up", - "latency": 335, + "latency": 252, "code": null, "detail": "UptimeRobot:运行正常", - "lastCheck": "2026-10-14T04:28:00.000Z", + "lastCheck": "2026-10-14T06:40:00.000Z", "history": [ - { - "t": "2026-10-13T05:10:00.000Z", - "ok": true, - "ms": 181 - }, - { - "t": "2026-10-13T05:50:00.000Z", - "ok": true, - "ms": 206 - }, - { - "t": "2026-10-13T06:30:00.000Z", - "ok": true, - "ms": 241 - }, - { - "t": "2026-10-13T07:10:00.000Z", - "ok": true, - "ms": 241 - }, { "t": "2026-10-13T07:50:00.000Z", "ok": true, @@ -544,158 +529,173 @@ "ms": 256 }, { - "t": "2026-10-13T09:50:00.000Z", + "t": "2026-10-13T09:55:00.000Z", "ok": true, - "ms": 500 + "ms": 455 }, { - "t": "2026-10-13T10:30:00.000Z", + "t": "2026-10-13T10:35:00.000Z", "ok": true, - "ms": 337 + "ms": 372 }, { - "t": "2026-10-13T11:10:00.000Z", + "t": "2026-10-13T11:15:00.000Z", "ok": true, - "ms": 197 + "ms": 240 }, { - "t": "2026-10-13T11:50:00.000Z", + "t": "2026-10-13T11:55:00.000Z", "ok": true, - "ms": 344 + "ms": 261 }, { - "t": "2026-10-13T12:30:00.000Z", + "t": "2026-10-13T12:35:00.000Z", "ok": true, - "ms": 341 + "ms": 335 }, { - "t": "2026-10-13T13:10:00.000Z", + "t": "2026-10-13T13:15:00.000Z", "ok": true, - "ms": 347 + "ms": 413 }, { - "t": "2026-10-13T13:50:00.000Z", + "t": "2026-10-13T13:55:00.000Z", "ok": true, - "ms": 220 + "ms": 283 }, { - "t": "2026-10-13T14:30:00.000Z", + "t": "2026-10-13T14:35:00.000Z", "ok": true, - "ms": 338 + "ms": 309 }, { - "t": "2026-10-13T15:10:00.000Z", + "t": "2026-10-13T15:20:00.000Z", "ok": true, - "ms": 510 + "ms": 451 }, { - "t": "2026-10-13T15:50:00.000Z", + "t": "2026-10-13T16:00:00.000Z", "ok": true, - "ms": 230 + "ms": 248 }, { - "t": "2026-10-13T16:30:00.000Z", + "t": "2026-10-13T16:40:00.000Z", "ok": true, - "ms": 401 + "ms": 378 }, { - "t": "2026-10-13T17:10:00.000Z", + "t": "2026-10-13T17:20:00.000Z", "ok": true, - "ms": 302 + "ms": 293 }, { - "t": "2026-10-13T17:50:00.000Z", + "t": "2026-10-13T18:00:00.000Z", "ok": true, "ms": 495 }, { - "t": "2026-10-13T18:30:00.000Z", + "t": "2026-10-13T18:40:00.000Z", "ok": true, - "ms": 389 + "ms": 391 }, { - "t": "2026-10-13T19:10:00.000Z", - "ok": true, - "ms": 328 - }, - { - "t": "2026-10-13T19:50:00.000Z", + "t": "2026-10-13T19:20:00.000Z", "ok": true, "ms": 338 }, { - "t": "2026-10-13T20:35:00.000Z", + "t": "2026-10-13T20:00:00.000Z", "ok": true, - "ms": 423 + "ms": 363 }, { - "t": "2026-10-13T21:15:00.000Z", + "t": "2026-10-13T20:40:00.000Z", "ok": true, - "ms": 394 + "ms": 499 }, { - "t": "2026-10-13T21:55:00.000Z", + "t": "2026-10-13T21:20:00.000Z", "ok": true, - "ms": 214 + "ms": 372 }, { - "t": "2026-10-13T22:35:00.000Z", + "t": "2026-10-13T22:00:00.000Z", "ok": true, - "ms": 240 + "ms": 194 }, { - "t": "2026-10-13T23:15:00.000Z", + "t": "2026-10-13T22:40:00.000Z", + "ok": true, + "ms": 225 + }, + { + "t": "2026-10-13T23:20:00.000Z", + "ok": true, + "ms": 237 + }, + { + "t": "2026-10-14T00:00:00.000Z", "ok": true, "ms": 215 }, { - "t": "2026-10-13T23:55:00.000Z", + "t": "2026-10-14T00:40:00.000Z", "ok": true, - "ms": 240 + "ms": 298 }, { - "t": "2026-10-14T00:35:00.000Z", + "t": "2026-10-14T01:20:00.000Z", "ok": true, - "ms": 329 + "ms": 450 }, { - "t": "2026-10-14T01:15:00.000Z", + "t": "2026-10-14T02:00:00.000Z", "ok": true, - "ms": 387 + "ms": 319 }, { - "t": "2026-10-14T01:55:00.000Z", + "t": "2026-10-14T02:40:00.000Z", + "ok": true, + "ms": 398 + }, + { + "t": "2026-10-14T03:20:00.000Z", + "ok": true, + "ms": 347 + }, + { + "t": "2026-10-14T04:00:00.000Z", + "ok": true, + "ms": 407 + }, + { + "t": "2026-10-14T04:40:00.000Z", + "ok": true, + "ms": 344 + }, + { + "t": "2026-10-14T05:20:00.000Z", "ok": true, "ms": 337 }, { - "t": "2026-10-14T02:35:00.000Z", + "t": "2026-10-14T06:04:00.000Z", "ok": true, - "ms": 381 + "ms": 365 }, { - "t": "2026-10-14T03:15:00.000Z", + "t": "2026-10-14T06:40:00.000Z", "ok": true, - "ms": 355 - }, - { - "t": "2026-10-14T03:52:00.000Z", - "ok": true, - "ms": 538 - }, - { - "t": "2026-10-14T04:28:00.000Z", - "ok": true, - "ms": 335 + "ms": 252 } ], "daily": { "2026-10-14": { - "total": 4, - "up": 4, - "latSum": 1352, - "latCount": 4, - "latMax": 339 + "total": 26, + "up": 26, + "latSum": 8635, + "latCount": 26, + "latMax": 638 } }, "meta": { @@ -720,38 +720,23 @@ "90": 0 }, "lat": { - "avg": 338, - "max": 339, + "avg": 332, + "max": 638, "days": [ { "key": "2026-10-14", - "avg": 338 + "avg": 332 } ] } }, "ur-792059414": { "status": "up", - "latency": 2408, + "latency": 1494, "code": null, "detail": "UptimeRobot:运行正常", - "lastCheck": "2026-10-14T04:27:00.000Z", + "lastCheck": "2026-10-14T06:42:00.000Z", "history": [ - { - "t": "2026-10-13T05:25:00.000Z", - "ok": true, - "ms": 2562 - }, - { - "t": "2026-10-13T06:10:00.000Z", - "ok": true, - "ms": 2625 - }, - { - "t": "2026-10-13T06:55:00.000Z", - "ok": true, - "ms": 2226 - }, { "t": "2026-10-13T07:40:00.000Z", "ok": true, @@ -813,98 +798,118 @@ "ms": 2097 }, { - "t": "2026-10-13T16:35:00.000Z", + "t": "2026-10-13T16:30:00.000Z", "ok": true, - "ms": 2206 + "ms": 2060 }, { - "t": "2026-10-13T17:20:00.000Z", + "t": "2026-10-13T17:15:00.000Z", "ok": true, - "ms": 2293 + "ms": 2264 }, { - "t": "2026-10-13T18:05:00.000Z", + "t": "2026-10-13T17:55:00.000Z", "ok": true, - "ms": 1347 + "ms": 1246 }, { - "t": "2026-10-13T18:50:00.000Z", + "t": "2026-10-13T18:35:00.000Z", "ok": true, - "ms": 1656 + "ms": 1451 }, { - "t": "2026-10-13T19:35:00.000Z", + "t": "2026-10-13T19:15:00.000Z", "ok": true, - "ms": 2883 + "ms": 3225 }, { - "t": "2026-10-13T20:20:00.000Z", + "t": "2026-10-13T19:55:00.000Z", "ok": true, - "ms": 2649 + "ms": 2138 }, { - "t": "2026-10-13T21:05:00.000Z", + "t": "2026-10-13T20:35:00.000Z", "ok": true, - "ms": 2923 + "ms": 2633 }, { - "t": "2026-10-13T21:50:00.000Z", + "t": "2026-10-13T21:15:00.000Z", "ok": true, - "ms": 1964 + "ms": 3605 }, { - "t": "2026-10-13T22:35:00.000Z", + "t": "2026-10-13T21:55:00.000Z", "ok": true, - "ms": 2335 + "ms": 1785 }, { - "t": "2026-10-13T23:20:00.000Z", + "t": "2026-10-13T22:40:00.000Z", "ok": true, - "ms": 2418 + "ms": 1904 }, { - "t": "2026-10-14T00:05:00.000Z", + "t": "2026-10-13T23:25:00.000Z", "ok": true, - "ms": 2098 + "ms": 2527 }, { - "t": "2026-10-14T00:50:00.000Z", + "t": "2026-10-14T00:10:00.000Z", "ok": true, - "ms": 1921 + "ms": 2372 }, { - "t": "2026-10-14T01:35:00.000Z", + "t": "2026-10-14T00:55:00.000Z", "ok": true, - "ms": 3110 + "ms": 1934 }, { - "t": "2026-10-14T02:20:00.000Z", + "t": "2026-10-14T01:40:00.000Z", "ok": true, - "ms": 1719 + "ms": 3003 }, { - "t": "2026-10-14T03:05:00.000Z", + "t": "2026-10-14T02:25:00.000Z", "ok": true, - "ms": 1623 + "ms": 1655 }, { - "t": "2026-10-14T03:48:00.000Z", + "t": "2026-10-14T03:10:00.000Z", "ok": true, - "ms": 3512 + "ms": 1873 }, { - "t": "2026-10-14T04:27:00.000Z", + "t": "2026-10-14T03:55:00.000Z", "ok": true, - "ms": 2408 + "ms": 3017 + }, + { + "t": "2026-10-14T04:40:00.000Z", + "ok": true, + "ms": 1955 + }, + { + "t": "2026-10-14T05:20:00.000Z", + "ok": true, + "ms": 3605 + }, + { + "t": "2026-10-14T06:03:00.000Z", + "ok": true, + "ms": 2050 + }, + { + "t": "2026-10-14T06:42:00.000Z", + "ok": true, + "ms": 1494 } ], "daily": { "2026-10-14": { - "total": 4, - "up": 4, - "latSum": 10418, - "latCount": 4, - "latMax": 2670 + "total": 26, + "up": 26, + "latSum": 61398, + "latCount": 26, + "latMax": 4043 } }, "meta": { @@ -929,206 +934,206 @@ "90": 7593 }, "lat": { - "avg": 2605, - "max": 2670, + "avg": 2361, + "max": 4043, "days": [ { "key": "2026-10-14", - "avg": 2605 + "avg": 2361 } ] } }, "ur-798788113": { "status": "up", - "latency": 312, + "latency": 635, "code": null, "detail": "UptimeRobot:运行正常", - "lastCheck": "2026-10-14T04:29:00.000Z", + "lastCheck": "2026-10-14T06:46:00.000Z", "history": [ { - "t": "2026-10-13T05:30:00.000Z", + "t": "2026-10-13T08:00:00.000Z", "ok": true, - "ms": 449 + "ms": 279 }, { - "t": "2026-10-13T06:10:00.000Z", + "t": "2026-10-13T08:40:00.000Z", "ok": true, - "ms": 386 + "ms": 407 }, { - "t": "2026-10-13T06:50:00.000Z", + "t": "2026-10-13T09:20:00.000Z", "ok": true, - "ms": 311 + "ms": 317 }, { - "t": "2026-10-13T07:30:00.000Z", + "t": "2026-10-13T10:00:00.000Z", "ok": true, - "ms": 384 + "ms": 627 }, { - "t": "2026-10-13T08:10:00.000Z", + "t": "2026-10-13T10:40:00.000Z", "ok": true, - "ms": 390 + "ms": 505 }, { - "t": "2026-10-13T08:50:00.000Z", + "t": "2026-10-13T11:20:00.000Z", "ok": true, - "ms": 461 + "ms": 424 }, { - "t": "2026-10-13T09:30:00.000Z", + "t": "2026-10-13T12:00:00.000Z", "ok": true, - "ms": 545 + "ms": 1135 }, { - "t": "2026-10-13T10:10:00.000Z", + "t": "2026-10-13T12:40:00.000Z", "ok": true, - "ms": 456 + "ms": 462 }, { - "t": "2026-10-13T10:50:00.000Z", + "t": "2026-10-13T13:20:00.000Z", "ok": true, - "ms": 394 + "ms": 436 }, { - "t": "2026-10-13T11:30:00.000Z", + "t": "2026-10-13T14:00:00.000Z", "ok": true, - "ms": 398 + "ms": 521 }, { - "t": "2026-10-13T12:10:00.000Z", + "t": "2026-10-13T14:40:00.000Z", "ok": true, - "ms": 588 + "ms": 480 }, { - "t": "2026-10-13T12:50:00.000Z", + "t": "2026-10-13T15:20:00.000Z", "ok": true, - "ms": 430 + "ms": 462 }, { - "t": "2026-10-13T13:30:00.000Z", + "t": "2026-10-13T16:00:00.000Z", "ok": true, - "ms": 502 + "ms": 331 }, { - "t": "2026-10-13T14:15:00.000Z", + "t": "2026-10-13T16:40:00.000Z", "ok": true, - "ms": 355 + "ms": 373 }, { - "t": "2026-10-13T14:55:00.000Z", + "t": "2026-10-13T17:20:00.000Z", "ok": true, - "ms": 358 + "ms": 429 }, { - "t": "2026-10-13T15:35:00.000Z", + "t": "2026-10-13T18:00:00.000Z", "ok": true, - "ms": 385 + "ms": 558 }, { - "t": "2026-10-13T16:15:00.000Z", + "t": "2026-10-13T18:40:00.000Z", "ok": true, - "ms": 342 + "ms": 370 }, { - "t": "2026-10-13T16:55:00.000Z", + "t": "2026-10-13T19:20:00.000Z", "ok": true, - "ms": 428 + "ms": 1519 }, { - "t": "2026-10-13T17:35:00.000Z", + "t": "2026-10-13T20:00:00.000Z", "ok": true, - "ms": 508 + "ms": 483 }, { - "t": "2026-10-13T18:15:00.000Z", + "t": "2026-10-13T20:40:00.000Z", "ok": true, - "ms": 602 + "ms": 388 }, { - "t": "2026-10-13T18:55:00.000Z", + "t": "2026-10-13T21:20:00.000Z", "ok": true, - "ms": 363 + "ms": 367 }, { - "t": "2026-10-13T19:40:00.000Z", + "t": "2026-10-13T22:00:00.000Z", "ok": true, - "ms": 1380 + "ms": 273 }, { - "t": "2026-10-13T20:20:00.000Z", + "t": "2026-10-13T22:40:00.000Z", "ok": true, - "ms": 512 + "ms": 261 }, { - "t": "2026-10-13T21:00:00.000Z", - "ok": true, - "ms": 394 - }, - { - "t": "2026-10-13T21:40:00.000Z", - "ok": true, - "ms": 238 - }, - { - "t": "2026-10-13T22:20:00.000Z", - "ok": true, - "ms": 323 - }, - { - "t": "2026-10-13T23:00:00.000Z", - "ok": true, - "ms": 353 - }, - { - "t": "2026-10-13T23:40:00.000Z", - "ok": true, - "ms": 179 - }, - { - "t": "2026-10-14T00:20:00.000Z", + "t": "2026-10-13T23:20:00.000Z", "ok": true, "ms": 263 }, { - "t": "2026-10-14T01:00:00.000Z", + "t": "2026-10-14T00:00:00.000Z", "ok": true, - "ms": 381 + "ms": 229 }, { - "t": "2026-10-14T01:40:00.000Z", + "t": "2026-10-14T00:40:00.000Z", "ok": true, - "ms": 378 + "ms": 363 }, { - "t": "2026-10-14T02:20:00.000Z", + "t": "2026-10-14T01:20:00.000Z", "ok": true, - "ms": 278 + "ms": 329 }, { - "t": "2026-10-14T03:17:00.000Z", + "t": "2026-10-14T02:00:00.000Z", "ok": true, - "ms": 345 + "ms": 302 }, { - "t": "2026-10-14T03:53:00.000Z", + "t": "2026-10-14T02:40:00.000Z", "ok": true, - "ms": 259 + "ms": 425 }, { - "t": "2026-10-14T04:29:00.000Z", + "t": "2026-10-14T03:20:00.000Z", "ok": true, - "ms": 312 + "ms": 328 + }, + { + "t": "2026-10-14T04:00:00.000Z", + "ok": true, + "ms": 319 + }, + { + "t": "2026-10-14T04:40:00.000Z", + "ok": true, + "ms": 272 + }, + { + "t": "2026-10-14T05:20:00.000Z", + "ok": true, + "ms": 347 + }, + { + "t": "2026-10-14T06:10:00.000Z", + "ok": true, + "ms": 361 + }, + { + "t": "2026-10-14T06:46:00.000Z", + "ok": true, + "ms": 635 } ], "daily": { "2026-10-14": { - "total": 4, - "up": 4, - "latSum": 1245, - "latCount": 4, - "latMax": 323 + "total": 26, + "up": 26, + "latSum": 11895, + "latCount": 26, + "latMax": 675 } }, "meta": { @@ -1153,12 +1158,12 @@ "90": 0 }, "lat": { - "avg": 311, - "max": 323, + "avg": 458, + "max": 675, "days": [ { "key": "2026-10-14", - "avg": 311 + "avg": 458 } ] } @@ -1178,7 +1183,7 @@ ], "daily": { "2026-10-14": { - "total": 4, + "total": 26, "up": 0 } }, diff --git a/scripts/verify-worker.cjs b/scripts/verify-worker.cjs new file mode 100644 index 0000000..cf805d7 --- /dev/null +++ b/scripts/verify-worker.cjs @@ -0,0 +1,243 @@ +/** + * 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); +}); \ No newline at end of file