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 项通过。
This commit is contained in:
tamakyi
2026-10-14 15:23:22 +08:00
parent d2aa0a1163
commit f345626840
16 changed files with 480 additions and 379 deletions
+23 -11
View File
@@ -499,20 +499,28 @@ node prober/node/index.js
> 部署到公网时建议放在 HTTPS 反向代理之后:会话 cookie 未设 `Secure`(纯 HTTP 下设了会导致 cookie 完全不发送),由代理层负责加密传输。 > 部署到公网时建议放在 HTTPS 反向代理之后:会话 cookie 未设 `Secure`(纯 HTTP 下设了会导致 cookie 完全不发送),由代理层负责加密传输。
### 远程访问(Cloudflare Tunnel) ### 面板上的管理入口(`settings.adminUrl`)
探针在内网/家用主机上时,用 Cloudflare Tunnel 让内网**主动出站**建隧道(不需要公网 IP、不需要开放入站端口)。 如果你把管理端放在别的地址(内网 IP、Tailscale、VPS 域名、隧道……随便哪种),可以在管理界面里把这个地址填进**「面板上的管理入口链接」**,公开面板的页脚就会出现一个「管理」按钮指向它。
探针**默认只监听 `127.0.0.1`**,需要远程访问时显式打开: - **留空 = 面板不显示任何管理入口**(默认状态,避免在公网面板上暴露管理面的位置)
- 地址只接受 `http` / `https`,其它协议(`javascript:` 等)会被拒绝
- 写入位置是 `local.monitors.json`(私有层),所以填了之后**只有你自己的面板**会出现这个按钮
面板本身不关心你怎么暴露管理端 —— 它只负责「你填什么就跳到什么」。
### 让管理端能被外部访问
探针**默认只监听 `127.0.0.1`**,外部访问不到。需要时用 `settings.admin.allowRemote` 打开:
```jsonc ```jsonc
// local.monitors.json // local.monitors.json
{ "settings": { "admin": { "allowRemote": true } } } { "settings": { "admin": { "allowRemote": true } } }
``` ```
然后参考 [`edge/cloudflare/tunnel.yml`](edge/cloudflare/tunnel.yml) 建隧道 —— 该配置只放行管理路径(`/admin`、`/api/admin/*`、`/assets/*`),其余路径 404,**不要把公开面板一起挂到公网**。 打开后探针监听 `0.0.0.0`,启动日志会就此告警。**怎么把流量送到这个端口由你决定** —— 反向代理、隧道、VPN、直连公网 IP 都行,本项目不做任何假设。
> ⚠️ 打开 `allowRemote` 后,同一端口上的公开面板(`/` 与 `/api/status`)也会对外可达(它们本身只读,但目标名称、地址与可用率会一并暴露)。启动日志会就此发出警告。 > ⚠️ 注意:打开后同一端口上的公开面板(`/` 与 `/api/status`)也会跟着对外可达。它们本身是只读的,但目标名称、地址与可用率会一并暴露。如果不希望这样,请在你自己那层(反向代理/隧道)按路径收窄。
### 部署形态与可写能力 ### 部署形态与可写能力
@@ -631,15 +639,19 @@ node prober/node/index.js
- [x] **同步失败降级**:UR 拉取失败时标记 `sources.uptimerobot.ok = false` 并保留上次数据,**绝不把目标判为离线或从面板消失** - [x] **同步失败降级**:UR 拉取失败时标记 `sources.uptimerobot.ok = false` 并保留上次数据,**绝不把目标判为离线或从面板消失**
- [x] **UR 侧删除的监控自动清理**,避免僵尸条目 - [x] **UR 侧删除的监控自动清理**,避免僵尸条目
- [x] 快照新增 `probeScope` 字段;UR 目标带 `meta`(面板据此区分自建/外部)与 `uptimeRatios`(面板算官方可用率) - [x] 快照新增 `probeScope` 字段;UR 目标带 `meta`(面板据此区分自建/外部)与 `uptimeRatios`(面板算官方可用率)
- [x] **`settings.admin.allowRemote`**:探针**默认只监听 `127.0.0.1`**;显式打开才监听全网卡,启动日志就此告警。这是 Cloudflare Tunnel 的安全前提(实测:默认时非环回地址连不上,打开后可达) - [x] **`settings.admin.allowRemote`**:探针**默认只监听 `127.0.0.1`**;显式打开才监听全网卡,启动日志就此告警(实测:默认时非环回地址连不上,打开后可达)。**怎么把流量送到这个端口由使用者自己决定**,项目不做任何假设
- [x] **`edge/cloudflare/tunnel.yml`(新增)**:命名隧道配置,只放行 `^/(admin|api/admin/.*|assets/.*)$`,其余 404 —— 不把公开面板一起挂到公网 - [x] **`settings.adminUrl`(面板管理入口)**:管理界面里填一个地址,公开面板页脚就出现「管理」按钮指向它;留空则完全不显示(默认,避免在公网面板暴露管理面位置)。只接受 `http`/`https`,`javascript:` 等协议被服务端拒绝
- [x] **前后端分离落地**:Worker 侧 `/admin` 与 `/api/admin/*` 显式返回 **403 + 指向文档的提示**(而非默默落到静态资源变成 404),确保边缘侧**没有任何可写面**;管理端只在本地 Node 侧运行 - [x] **前后端分离落地**:Worker 侧 `/admin` 与 `/api/admin/*` 显式返回 **403 + 指向文档的提示**(而非默默落到静态资源变成 404),确保边缘侧**没有任何可写面**;管理端只在本地 Node 侧运行
- [x] 文档:`DEPLOYMENT.md` 补「接入 UptimeRobot 数据源」与「远程访问管理端」,各部署形态对照表加「UptimeRobot 数据源 / 管理端」两行;`SECURITY.md` 补部署形态可写能力对照、隧道威胁模型(含「限流按边缘 IP 统计」这一已知粒度损失);README 同步 - [x] **修掉一个既存的密钥泄露**:`/api/monitors` 无鉴权却回吐合并后的完整配置,**包含私有层的 UptimeRobot API Key**。现在两侧(Node 与 Worker)都对对外响应剥离 `uptimeRobot.apiKey`,保留 `enabled` 等非敏感字段供面板读取;探针内部同步仍用完整配置
- [x] **面板配置改为以服务端合并版为准**:原先只读静态 `monitors.json`(公开层),导致管理端写入私有层的设置(如 `adminUrl`)面板看不到。现在启动时额外拉 `/api/monitors` 合并 `settings`;纯静态部署没有该接口时沿用公开配置
- [x] 文档:`DEPLOYMENT.md` 补「接入 UptimeRobot 数据源」与「远程访问管理端」,各部署形态对照表加「UptimeRobot 数据源 / 管理端」两行;`SECURITY.md` 补部署形态可写能力对照、配置接口不得泄露密钥、代理层下的限流粒度损失
**测试**:新增 `scripts/verify-worker.cjs`(36 项)—— 把 Worker 以 data: URL 动态载入以绕开 `cloudflare:sockets`,注入假 KV / 静态资源 / UptimeRobot 服务器,**不需要 wrangler 也不需要联网**。全项目 8 套 310 项全部通过。 **测试**:`verify-worker.cjs` 扩到 40 项(新增 `[E]` 配置接口密钥泄露 4 条);`verify-security.cjs` 扩到 25 项(新增 `[D2]` 密钥泄露 5 条)。全项目 8 套 314 项全部通过。
过程中抓到两个真 bug:① UR 同步失败时降级路径引用了不含 `meta` 的旧对象,导致 `name`/`url` 为 `undefined`、UR 目标从面板上消失;② `stats.total` 按 `config.monitors.length` 计数,在 `probeScope=uptimerobot` 时与实际参与的目标数不符。 过程中抓到两个真 bug:① UR 同步失败时降级路径引用了不含 `meta` 的旧对象,导致 `name`/`url` 为 `undefined`、UR 目标从面板上消失;② `stats.total` 按 `config.monitors.length` 计数,在 `probeScope=uptimerobot` 时与实际参与的目标数不符。
**另有一处既存漏洞**(本轮顺带修复):`/api/monitors` 从引入私有层叠加机制起就无鉴权回吐完整配置,只要在 `local.monitors.json` 里配了 `uptimeRobot.apiKey`,任何访客 `curl /api/monitors` 就能拿到。这个漏洞与本轮改动无关,是之前埋下的。
> 另有一条测试夹具层面的教训:内存版 KV 必须实现 `get(key, 'json')` 的类型参数(真实 Workers KV 会自动反序列化),否则 `state.monitors` 恒为 undefined —— 表现为「状态永远读不到」,看起来像产品代码坏了。 > 另有一条测试夹具层面的教训:内存版 KV 必须实现 `get(key, 'json')` 的类型参数(真实 Workers KV 会自动反序列化),否则 `state.monitors` 恒为 undefined —— 表现为「状态永远读不到」,看起来像产品代码坏了。
### v0.12.0 已实现 ### v0.12.0 已实现
@@ -801,8 +813,8 @@ NODE_PATH=<playwright 的 node_modules> node scripts/verify.cjs # 热
NODE_PATH=<playwright 的 node_modules> node scripts/verify-views.cjs # 视图与数据源过滤 / 对比度 / 等高 / 事件区块(47 项) NODE_PATH=<playwright 的 node_modules> node scripts/verify-views.cjs # 视图与数据源过滤 / 对比度 / 等高 / 事件区块(47 项)
NODE_PATH=<playwright 的 node_modules> node scripts/verify-layout.cjs # 13 视口 × 2 视图零横向溢出 + 行高一致(26 项) NODE_PATH=<playwright 的 node_modules> node scripts/verify-layout.cjs # 13 视口 × 2 视图零横向溢出 + 行高一致(26 项)
NODE_PATH=<playwright 的 node_modules> node scripts/verify-admin.cjs # 管理端 2FA 全流程(102 项:RFC 向量 / 绑定 / 会话 / 限流 / 接口 / 页面) NODE_PATH=<playwright 的 node_modules> node scripts/verify-admin.cjs # 管理端 2FA 全流程(102 项:RFC 向量 / 绑定 / 会话 / 限流 / 接口 / 页面)
node scripts/verify-worker.cjs # Cloudflare Worker(36 项:UR 映射 / 探测范围 / 失败降级 / 管理端拒绝) node scripts/verify-worker.cjs # Cloudflare Worker(40 项:UR 映射 / 探测范围 / 失败降级 / 管理端拒绝 / 密钥泄露)
node scripts/verify-security.cjs # banner 探测 / 脱敏 / 私有分层 / 刷新限流(20 项) node scripts/verify-security.cjs # banner 探测 / 脱敏 / 私有分层 / 刷新限流 / 配置接口密钥泄露(25 项)
node scripts/verify-notify.cjs # 通知与证书(44 项:假 SMTP / 假 MeoW / 去抖 / 恢复 / 维护窗口 / 真实证书) node scripts/verify-notify.cjs # 通知与证书(44 项:假 SMTP / 假 MeoW / 去抖 / 恢复 / 维护窗口 / 真实证书)
UPTIMEROBOT_API_KEY=ur... node scripts/verify-uptimerobot.cjs # UR 映射/兼容接口/失败路径/冒烟(18 项) UPTIMEROBOT_API_KEY=ur... node scripts/verify-uptimerobot.cjs # UR 映射/兼容接口/失败路径/冒烟(18 项)
``` ```
+8 -13
View File
@@ -167,26 +167,21 @@ EdgeOne Pages 当前以静态托管为主,采用与方案二相同的「静态
--- ---
## 远程访问管理端(Cloudflare Tunnel) ## 远程访问管理端
探针在内网/家用主机上时,若要在外网打开 `/admin`,用 Cloudflare Tunnel 让内网**主动出网**建隧道(不需要公网 IP、不需要开放入站端口)。 探针**默认只监听 `127.0.0.1`**,外部访问不到。需要时用 `settings.admin.allowRemote` 打开:
前提与步骤:
1. **确认管理端已绑定 2FA**。Tunnel 只负责把端口暴露出去,真正拦住未授权访问的是管理端的 TOTP + 会话 + 失败限流。
2. 打开探针的远程管理开关(**默认关闭**,此时探针只监听 `127.0.0.1`):
```jsonc ```jsonc
// local.monitors.json // local.monitors.json
{ "settings": { "admin": { "allowRemote": true } } } { "settings": { "admin": { "allowRemote": true } } }
``` ```
3. 建命名隧道并只回源管理路径,参考 [`edge/cloudflare/tunnel.yml`](../edge/cloudflare/tunnel.yml): 打开后探针监听 `0.0.0.0`,启动日志会明确警告 —— 提醒你该端口同时伺服公开面板,前置层必须启用 HTTPS。
```bash **怎么把流量送到这个端口由你决定**:反向代理、隧道、VPN、直连公网 IP 都行,本项目不做任何假设,也不会替你生成某种特定的部署配置。
cloudflared tunnel --no-autoupdate run --token <你的 token>
```
该配置只放行 `^/(admin|api/admin/.*|assets/.*)$`,其余路径一律 404 —— 不要把公开面板与 `/api/status` 一起挂到公网。 ### 让面板知道管理端在哪
开启 `allowRemote` 后启动日志会明确警告探针正在监听 `0.0.0.0`,提醒你该端口同时伺服公开面板,前置代理必须启用 HTTPS。 管理界面里有一项「**面板上的管理入口链接**」:填上管理端的实际地址后,公开面板页脚会出现一个「管理」按钮指向它;留空则不显示(默认,避免在公网面板上暴露管理面的位置)。地址只接受 `http` / `https`。
这样部署形态与面板展示就解耦了:面板部署在 Cloudflare、管理端在别处、访客从面板点进去 —— 中间怎么连通由你的网络决定。
+13 -11
View File
@@ -165,33 +165,35 @@ v0.12.0 起,所有会改配置的操作都放在 `/admin`,**必须绑定并
这不是「没做」,而是这些能力依赖本地状态:2FA 档案与会话存在 `data/` 目录与进程内存中,而 Workers 无文件系统、isolate 之间不共享内存(KV 又是最终一致,无法承载「恢复码一次性使用」这类语义)。 这不是「没做」,而是这些能力依赖本地状态:2FA 档案与会话存在 `data/` 目录与进程内存中,而 Workers 无文件系统、isolate 之间不共享内存(KV 又是最终一致,无法承载「恢复码一次性使用」这类语义)。
### 远程访问(Cloudflare Tunnel) ### 远程访问
探针在内网时若需从外网访问 `/admin`,用 Cloudflare Tunnel 让内网主动出网建隧道。 探针在内网时若需从外网访问 `/admin`,用 `settings.admin.allowRemote` 打开远程访问(**默认关闭**,此时只监听 `127.0.0.1`):
**探针默认只监听 `127.0.0.1`**,必须显式打开才能被隧道访问:
```jsonc ```jsonc
// local.monitors.json // local.monitors.json
{ "settings": { "admin": { "allowRemote": true } } } { "settings": { "admin": { "allowRemote": true } } }
``` ```
开启后启动日志会警告「正在监听 0.0.0.0」。**请注意该端口同时伺服公开面板** —— 也就是说一旦打开这个开关,`/` 与 `/api/status` 也会跟着对外可达,这两项本身是只读的,但你的目标名称、地址与可用率会一并暴露。隧道配置(`edge/cloudflare/tunnel.yml`)默认只放行 `^/(admin|api/admin/.*|assets/.*)$`,其余路径 404,就是为了不把公开面板一起挂出去。 **怎么把流量送到这个端口由你决定**(反向代理 / 隧道 / VPN / 直连公网 IP),本项目不做任何假设。唯一要提醒的是:打开后该端口同时伺服公开面板,也就是说 `/` 与 `/api/status` 也会跟着对外可达 —— 这两项本身只读,但目标名称、地址与可用率会一并暴露。若不希望如此,请在你自己的那层按路径收窄。
### 配置接口不得泄露密钥
`/api/monitors` 与 Worker 上的同名接口都**无鉴权**,因此对外响应一律剥离 `uptimeRobot.apiKey`(保留 `enabled` 等非敏感字段供面板读取)。探针内部同步 UptimeRobot 仍使用完整配置,剥离只作用于 HTTP 出口。`/api/status` 与静态 `monitors.json` 同样不含私有层内容。
### 威胁模型与对应措施 ### 威胁模型与对应措施
| 威胁 | 措施 | | 威胁 | 措施 |
| --- | --- | | --- | --- |
| 未绑定就被抢先接管管理端 | 绑定需 `SHIROKO_ADMIN_SETUP_KEY`;未绑定时 `/api/admin/*` 除绑定流程外**一律 403**;未设置该环境变量时绑定入口直接关闭(503) | | 未绑定就被抢先接管管理端 | 绑定需 `SHIROKO_ADMIN_SETUP_KEY`;未绑定时 `/api/admin/*` 除绑定流程外**一律 403**;未设置该环境变量时绑定入口直接关闭(503) |
| 未授权者经隧道访问 | 隧道暴露的是同一端口,防护完全依赖 2FA:TOTP 校验 + 会话哈希 + 失败限流三重 | | 未授权者经外部网络访问管理端 | 打开 `allowRemote` 后,防护完全依赖管理端的 TOTP 校验 + 会话哈希 + 失败限流三重;默认不监听外部地址 |
| 隧道配置过宽导致面板外泄 | 示例配置只放行管理路径,并注释说明不要把 `/` 与 `/api/status` 一起暴露 | | 配置接口泄露 UptimeRobot Key | 对外响应剥离 `uptimeRobot.apiKey`;内部同步仍用完整配置(有断言覆盖两侧) |
| 忘记关`allowRemote` | 启动日志显式警告监听地址;默认值为关闭 | | 面板成为探测他人管理面的跳板 | 管理入口链接只接受 `http`/`https`,`javascript:` 等协议被服务端拒绝;不配置则不渲染入口 |
| 暴力枚举 6 位码(100 万种) | 按 IP 计数,连续 5 次失败后指数退避锁定(1m → 2m → 4m …上限 30m);锁定期间即使验证码正确也拒绝 | | 恶意枚举 6 位码(100 万种) | 按 IP 计数,连续 5 次失败后指数退避锁定(1m → 2m → 4m …上限 30m);锁定期间即使验证码正确也拒绝 |
| 会话 id 被窃取 | id 为 32 字节随机数,**服务端只存 SHA-256 哈希**;cookie 为 `HttpOnly`(JS 读不到)+ `SameSite=Strict`;30 分钟空闲失效、最长 2 小时;进程重启清空全部会话 | | 会话 id 被窃取 | id 为 32 字节随机数,**服务端只存 SHA-256 哈希**;cookie 为 `HttpOnly`(JS 读不到)+ `SameSite=Strict`;30 分钟空闲失效、最长 2 小时;进程重启清空全部会话 |
| 截图复用验证码 | 记录最近使用的 TOTP counter,**同一时间步的码用过即作废** | | 截图复用验证码 | 记录最近使用的 TOTP counter,**同一时间步的码用过即作废** |
| 恢复码泄露 | 只存 SHA-256 哈希(带随机盐),一次性使用,用后立刻从档案中移除 | | 恢复码泄露 | 只存 SHA-256 哈希(带随机盐),一次性使用,用后立刻从档案中移除 |
| 伪造来源 IP 绕过限流 | **只信任 socket 对端地址,不解析 `X-Forwarded-For`**(可被任意伪造)—— 经隧道访问时对端地址是 Cloudflare 边缘节点,因此限流是按边缘 IP 而非真实客户端统计的,这是隧道形态下已知的粒度损失 | | 伪造来源 IP 绕过限流 | **只信任 socket 对端地址,不解析 `X-Forwarded-For`**(可被任意伪造)。若前面挂了反向代理/隧道,对端地址是代理节点,因此限流是按代理 IP 而非真实客户端统计的 —— 这是有代理层时的已知粒度损失 |
| 篡改请求 | `Path=/` + `HttpOnly` + `SameSite=Strict`;请求体上限 64KB,超出返回 413;参数逐项白名单校验(如 `probeScope` 只接受三个枚举值) | | 篡改请求 | `Path=/` + `HttpOnly` + `SameSite=Strict`;请求体上限 64KB,超出返回 413;参数逐项白名单校验(如 `probeScope` 只接受三个枚举值、`adminUrl` 只接受 http/https) |
| 通过管理端把密钥写进仓库 | **所有写操作只落 `local.monitors.json`**(私有、已 gitignore),绝不改 `public/monitors.json` | | 通过管理端把密钥写进仓库 | **所有写操作只落 `local.monitors.json`**(私有、已 gitignore),绝不改 `public/monitors.json` |
### 已知限制(请按需叠加外部措施) ### 已知限制(请按需叠加外部措施)
-42
View File
@@ -1,42 +0,0 @@
# 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/<TUNNEL_ID>.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
+9 -1
View File
@@ -40,7 +40,7 @@ export default {
async fetch(request, env, ctx) { async fetch(request, env, ctx) {
const url = new URL(request.url); const url = new URL(request.url);
if (url.pathname === '/api/status') return json(await getStatus(env, ctx)); if (url.pathname === '/api/status') return json(await getStatus(env, ctx));
if (url.pathname === '/api/monitors') return json(await getMonitors(env, request.url)); if (url.pathname === '/api/monitors') return json(stripSecrets(await getMonitors(env, request.url)));
if (url.pathname === '/api/refresh') { if (url.pathname === '/api/refresh') {
// 公网上的强制探测必须显式开启,避免被任意访客当作「探测放大器」使用 // 公网上的强制探测必须显式开启,避免被任意访客当作「探测放大器」使用
const gate = refreshGate(env, request); const gate = refreshGate(env, request);
@@ -110,6 +110,14 @@ async function getMonitors(env, baseUrl) {
return { settings: {}, monitors: [] }; return { settings: {}, monitors: [] };
} }
// 对外提供配置时必须剥掉密钥:/api/monitors 无鉴权,而配置里可能含 UptimeRobot API Key
// (该 Key 以 secret 形式传入 env,不写进 monitors.json,但手动写进 KV 的配置可能带)
function stripSecrets(config) {
const ur = config?.settings?.uptimeRobot;
if (!ur) return config;
return { ...config, settings: { ...config.settings, uptimeRobot: { ...ur, apiKey: undefined } } };
}
async function getStatus(env, ctx) { async function getStatus(env, ctx) {
const state = (await env.MONITOR_KV.get('state', 'json')) ?? { monitors: {}, generatedAt: null }; const state = (await env.MONITOR_KV.get('state', 'json')) ?? { monitors: {}, generatedAt: null };
if (!state.generatedAt) ctx.waitUntil(runChecks(env)); if (!state.generatedAt) ctx.waitUntil(runChecks(env));
+15 -1
View File
@@ -101,6 +101,20 @@ function loadLocalOverlay() {
} }
} }
// 对外提供配置时必须剥掉密钥:/api/monitors 无鉴权,而合并后的 config 含私有层的
// UptimeRobot API Key(以及未来的其他凭据)。面板只需要非敏感的 settings 字段。
function publicConfig(config) {
const ur = config.settings?.uptimeRobot;
if (!ur) return config;
return {
...config,
settings: {
...config.settings,
uptimeRobot: { ...ur, apiKey: undefined },
},
};
}
function loadPersistedStatus() { function loadPersistedStatus() {
try { try {
return JSON.parse(fs.readFileSync(STATUS_PATH, 'utf8')); return JSON.parse(fs.readFileSync(STATUS_PATH, 'utf8'));
@@ -972,7 +986,7 @@ function createServer(runtime) {
return void fs.createReadStream(file).pipe(res); return void fs.createReadStream(file).pipe(res);
} }
if (url.pathname === '/api/status') return sendJson(res, snapshot(runtime)); if (url.pathname === '/api/status') return sendJson(res, snapshot(runtime));
if (url.pathname === '/api/monitors') return sendJson(res, config); if (url.pathname === '/api/monitors') return sendJson(res, publicConfig(config));
if (url.pathname === '/api/refresh') { if (url.pathname === '/api/refresh') {
const decide = refreshDecision(config, req.socket.remoteAddress); const decide = refreshDecision(config, req.socket.remoteAddress);
if (!decide.ok) { if (!decide.ok) {
+25
View File
@@ -138,6 +138,7 @@ export function createAdminRouter({ auth, localConfigPath, reloadConfig, runtime
hideTargets: s.hideTargets === true, hideTargets: s.hideTargets === true,
notifyEnabled: notifyConfig(cfg).enabled, notifyEnabled: notifyConfig(cfg).enabled,
tlsEnabled: tlsConfig(cfg).enabled, tlsEnabled: tlsConfig(cfg).enabled,
adminUrl: s.adminUrl ?? '',
}, },
maintenance: Array.isArray(s.maintenance) ? s.maintenance : [], maintenance: Array.isArray(s.maintenance) ? s.maintenance : [],
monitors: cfg.monitors.map((m) => ({ monitors: cfg.monitors.map((m) => ({
@@ -300,6 +301,30 @@ export function createAdminRouter({ auth, localConfigPath, reloadConfig, runtime
return sendJson(res, { ok: true, dataFilter: filter }); return sendJson(res, { ok: true, dataFilter: filter });
} }
// 面板上的管理入口链接:只存地址,不做任何暴露判断(怎么暴露由使用者自己决定)
if (p === '/api/admin/admin-url' && req.method === 'POST') {
const parsed = await readJsonBody(req, res);
if (!parsed.ok) return;
const body = parsed.value;
const raw = String(body.adminUrl ?? '').trim();
if (raw) {
// 只收 http/https:面板会把它渲染成 <a href>,javascript: 之类必须挡住
let u;
try { u = new URL(raw); } catch { return sendJson(res, { error: 'invalid-url', hint: '不是合法的绝对 URL' }, { status: 400 }); }
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
return sendJson(res, { error: 'invalid-url', hint: '只支持 http/https' }, { status: 400 });
}
}
savePrivateConfig(localConfigPath, (local) => {
local.settings = { ...(local.settings ?? {}), adminUrl: raw };
return local;
});
reloadConfig();
scheduleWrite(runtime);
console.log(`[admin] 面板管理入口链接${raw ? `已设置为 ${raw}` : '已清空'}(来自 ${ip})`);
return sendJson(res, { ok: true, adminUrl: raw });
}
if (p === '/api/admin/probe-now' && req.method === 'POST') { if (p === '/api/admin/probe-now' && req.method === 'POST') {
await Promise.race([forceRound(runtime), new Promise((r) => setTimeout(r, forcedMaxWaitMs))]); await Promise.race([forceRound(runtime), new Promise((r) => setTimeout(r, forcedMaxWaitMs))]);
if (res.writableEnded || res.destroyed) return; if (res.writableEnded || res.destroyed) return;
+9
View File
@@ -125,6 +125,15 @@
</select> </select>
<p class="admin-hint">只影响面板展示哪些目标;与探测范围相互独立。</p> <p class="admin-hint">只影响面板展示哪些目标;与探测范围相互独立。</p>
</div> </div>
<div class="admin-field">
<label for="admin-url">面板上的管理入口链接</label>
<input type="url" id="admin-url" placeholder="https://admin.example.com/admin" autocomplete="off">
<p class="admin-hint">
填了之后,公开面板页脚会出现一个「管理」按钮指向这里;<b>留空则不显示</b>。
地址由你自行决定怎么暴露(内网 IP、Tailscale、VPS 域名、隧道……都可以),
面板不做任何假设。
</p>
</div>
<div class="admin-row"> <div class="admin-row">
<button type="button" class="admin-btn" id="probe-now">立即重新探测</button> <button type="button" class="admin-btn" id="probe-now">立即重新探测</button>
<button type="button" class="admin-btn" id="notify-test">发送通知自检</button> <button type="button" class="admin-btn" id="notify-test">发送通知自检</button>
+14
View File
@@ -380,6 +380,8 @@ function renderConsole(d) {
$('probe-scope').value = d.settings?.probeScope ?? 'all'; $('probe-scope').value = d.settings?.probeScope ?? 'all';
$('data-filter').value = d.settings?.dataFilter ?? 'both'; $('data-filter').value = d.settings?.dataFilter ?? 'both';
$('admin-url').value = d.settings?.adminUrl ?? '';
$('admin-url').dataset.saved = $('admin-url').value;
// 维护窗口目标下拉 // 维护窗口目标下拉
const sel = $('mw-id'); const sel = $('mw-id');
@@ -545,6 +547,18 @@ $('data-filter').addEventListener('change', async (e) => {
else msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err'); else msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err');
}); });
$('admin-url').addEventListener('change', async (e) => {
const v = e.target.value.trim();
const r = await api('/admin-url', { method: 'POST', body: { adminUrl: v } });
if (r.ok) {
msg($('action-msg'), v ? `面板页脚将显示指向 ${v} 的「管理」入口` : '已清空,面板不再显示管理入口', 'ok');
refresh();
} else {
msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err');
e.target.value = $('admin-url').dataset.saved ?? '';
}
});
$('probe-now').addEventListener('click', async () => { $('probe-now').addEventListener('click', async () => {
const btn = $('probe-now'); const btn = $('probe-now');
btn.disabled = true; btn.disabled = true;
+28
View File
@@ -664,6 +664,25 @@ function localStats() {
}; };
} }
// 管理入口:地址写在 settings.adminUrl(可为内网地址、Tailscale 地址、VPS 域名等,
// 由使用者自己决定怎么暴露)。默认不配置即不渲染,避免在公网面板上泄露管理面位置。
function renderAdminLink() {
const el = $('admin-link');
if (!el) return;
const raw = String(state.config?.settings?.adminUrl ?? '').trim();
// 只接受 http/https,避免 javascript: 之类被当作可点击链接注入
let url = null;
try {
const u = new URL(raw);
if (u.protocol === 'http:' || u.protocol === 'https:') url = u;
} catch { /* 不是合法绝对 URL 则视为未配置 */ }
if (!url) { el.hidden = true; return; }
el.href = url.href;
el.textContent = '管理';
el.title = `管理后台:${url.href}\n需绑定 2FA 并通过两步验证才能进入`;
el.hidden = false;
}
function renderSummary() { function renderSummary() {
const st = state.status; const st = state.status;
const stats = localStats(); const stats = localStats();
@@ -727,6 +746,7 @@ function renderAll() {
renderViewSwitch(); renderViewSwitch();
renderView(); renderView();
renderEvents(); renderEvents();
renderAdminLink();
} }
function renderError(err) { function renderError(err) {
@@ -938,6 +958,14 @@ async function init() {
renderError(err); renderError(err);
return; return;
} }
// 配置以服务端合并后的版本为准(/api/monitors 含 private overlay):
// 静态 monitors.json 是不入库的公开层,管理端写入的设置只落在私有层,
// 不合并的话面板读不到(例如 adminUrl)。
// 纯静态部署没有这个接口,此时沿用已读到的公开配置即可。
try {
const merged = await fetchJson('api/monitors', 5000);
if (merged?.settings) state.config = { ...state.config, settings: { ...state.config.settings, ...merged.settings } };
} catch { /* 无边缘接口(静态部署):保留公开配置 */ }
document.title = state.config.settings?.title || 'Shiroko Monitor'; document.title = state.config.settings?.title || 'Shiroko Monitor';
$('site-title').textContent = state.config.settings?.title || 'Shiroko Monitor'; $('site-title').textContent = state.config.settings?.title || 'Shiroko Monitor';
$('site-desc').textContent = state.config.settings?.description || ''; $('site-desc').textContent = state.config.settings?.description || '';
+11
View File
@@ -333,6 +333,17 @@ body {
color: var(--text-2); color: var(--text-2);
} }
.footer .version { margin-left: auto; } .footer .version { margin-left: auto; }
/* 管理入口:仅在配置了 settings.adminUrl 时出现;默认隐藏,避免在公网面板上暴露管理面位置 */
.admin-link {
padding: 1px 9px;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text-2);
text-decoration: none;
white-space: nowrap;
transition: color 0.15s, border-color 0.15s;
}
.admin-link:hover { color: var(--accent); border-color: var(--accent); }
.demo-chip { padding: 1px 8px; border-radius: 999px; background: var(--pending-bg); color: var(--pending-text); font-weight: 600; } .demo-chip { padding: 1px 8px; border-radius: 999px; background: var(--pending-bg); color: var(--pending-text); font-weight: 600; }
.warn-chip { padding: 1px 8px; border-radius: 999px; background: var(--down-bg); color: var(--down-text); font-weight: 600; } .warn-chip { padding: 1px 8px; border-radius: 999px; background: var(--down-bg); color: var(--down-text); font-weight: 600; }
+1
View File
@@ -75,6 +75,7 @@
<span id="notify-badge" class="footer-item" hidden>通知 –</span> <span id="notify-badge" class="footer-item" hidden>通知 –</span>
<span id="updated-at" class="footer-item">–</span> <span id="updated-at" class="footer-item">–</span>
<span id="countdown" class="footer-item">–</span> <span id="countdown" class="footer-item">–</span>
<a id="admin-link" class="footer-item admin-link" hidden>管理</a>
<span class="footer-item version">shiroko-monitor v0.12.1</span> <span class="footer-item version">shiroko-monitor v0.12.1</span>
</footer> </footer>
+1
View File
@@ -9,6 +9,7 @@
"dataFilter": "uptimerobot", "dataFilter": "uptimerobot",
"refreshInterval": 300, "refreshInterval": 300,
"dataSource": "auto", "dataSource": "auto",
"adminUrl": "",
"uptimeRobot": { "uptimeRobot": {
"enabled": false, "enabled": false,
"apiKey": "", "apiKey": "",
+282 -297
View File
@@ -1,8 +1,8 @@
{ {
"prober": "shiroko-monitor-node/0.12.0", "prober": "shiroko-monitor-node/0.12.1",
"demo": false, "demo": false,
"generatedAt": "2026-10-14T06:50:04.642Z", "generatedAt": "2026-10-14T07:22:36.648Z",
"dataUpdatedAt": "2026-10-14T06:50:02.233Z", "dataUpdatedAt": "2026-10-14T07:22:28.178Z",
"updateIntervalSec": 300, "updateIntervalSec": 300,
"dataFilter": "uptimerobot", "dataFilter": "uptimerobot",
"probeScope": "all", "probeScope": "all",
@@ -11,12 +11,12 @@
"up": 5, "up": 5,
"down": 1, "down": 1,
"pending": 0, "pending": 0,
"avgLatency": 671, "avgLatency": 805,
"avgUptime90": 83.3, "avgUptime90": 83.3,
"memory": { "memory": {
"rssMB": 82.8, "rssMB": 75.5,
"heapUsedMB": 12.5, "heapUsedMB": 13.7,
"heapTotalMB": 16 "heapTotalMB": 14.7
} }
}, },
"sources": { "sources": {
@@ -24,10 +24,10 @@
"enabled": true, "enabled": true,
"ok": true, "ok": true,
"error": null, "error": null,
"lastSyncAt": "2026-10-14T06:46:25.561Z", "lastSyncAt": "2026-10-14T07:20:45.501Z",
"count": 6, "count": 6,
"syncing": false, "syncing": false,
"lastSyncMs": 1791960384004 "lastSyncMs": 1791962444262
} }
}, },
"notify": { "notify": {
@@ -55,16 +55,11 @@
"monitors": { "monitors": {
"ur-792059439": { "ur-792059439": {
"status": "up", "status": "up",
"latency": 262, "latency": 433,
"code": null, "code": null,
"detail": "UptimeRobot:运行正常", "detail": "UptimeRobot:运行正常",
"lastCheck": "2026-10-14T06:40:00.000Z", "lastCheck": "2026-10-14T07:15:00.000Z",
"history": [ "history": [
{
"t": "2026-10-13T07:55:00.000Z",
"ok": true,
"ms": 227
},
{ {
"t": "2026-10-13T08:35:00.000Z", "t": "2026-10-13T08:35:00.000Z",
"ok": true, "ok": true,
@@ -226,23 +221,23 @@
"ms": 311 "ms": 311
}, },
{ {
"t": "2026-10-14T06:04:00.000Z", "t": "2026-10-14T06:34:00.000Z",
"ok": true, "ok": true,
"ms": 357 "ms": 177
}, },
{ {
"t": "2026-10-14T06:40:00.000Z", "t": "2026-10-14T07:15:00.000Z",
"ok": true, "ok": true,
"ms": 262 "ms": 433
} }
], ],
"daily": { "daily": {
"2026-10-14": { "2026-10-14": {
"total": 26, "total": 4,
"up": 26, "up": 4,
"latSum": 7486, "latSum": 1744,
"latCount": 26, "latCount": 4,
"latMax": 454 "latMax": 439
} }
}, },
"meta": { "meta": {
@@ -267,211 +262,211 @@
"90": 0 "90": 0
}, },
"lat": { "lat": {
"avg": 288, "avg": 436,
"max": 454, "max": 439,
"days": [ "days": [
{ {
"key": "2026-10-14", "key": "2026-10-14",
"avg": 288 "avg": 436
} }
] ]
} }
}, },
"ur-795497022": { "ur-795497022": {
"status": "up", "status": "up",
"latency": 714, "latency": 672,
"code": null, "code": null,
"detail": "UptimeRobot:运行正常", "detail": "UptimeRobot:运行正常",
"lastCheck": "2026-10-14T06:46:00.000Z", "lastCheck": "2026-10-14T07:18:00.000Z",
"history": [ "history": [
{ {
"t": "2026-10-13T07:35:00.000Z", "t": "2026-10-13T08:00:00.000Z",
"ok": true, "ok": true,
"ms": 696 "ms": 758
}, },
{ {
"t": "2026-10-13T08:15:00.000Z", "t": "2026-10-13T08:40:00.000Z",
"ok": true, "ok": true,
"ms": 718 "ms": 826
}, },
{ {
"t": "2026-10-13T08:55:00.000Z", "t": "2026-10-13T09:20:00.000Z",
"ok": true,
"ms": 939
},
{
"t": "2026-10-13T09:35:00.000Z",
"ok": true,
"ms": 663
},
{
"t": "2026-10-13T10:15:00.000Z",
"ok": true,
"ms": 837
},
{
"t": "2026-10-13T10:55:00.000Z",
"ok": true,
"ms": 723
},
{
"t": "2026-10-13T11:35:00.000Z",
"ok": true,
"ms": 688
},
{
"t": "2026-10-13T12:15:00.000Z",
"ok": true,
"ms": 824
},
{
"t": "2026-10-13T12:55:00.000Z",
"ok": true,
"ms": 799
},
{
"t": "2026-10-13T13:35:00.000Z",
"ok": true,
"ms": 943
},
{
"t": "2026-10-13T14:20:00.000Z",
"ok": true,
"ms": 808
},
{
"t": "2026-10-13T15:00:00.000Z",
"ok": true,
"ms": 842
},
{
"t": "2026-10-13T15:40:00.000Z",
"ok": true,
"ms": 918
},
{
"t": "2026-10-13T16:20:00.000Z",
"ok": true,
"ms": 646
},
{
"t": "2026-10-13T17:00:00.000Z",
"ok": true,
"ms": 722
},
{
"t": "2026-10-13T17:40:00.000Z",
"ok": true, "ok": true,
"ms": 749 "ms": 749
}, },
{ {
"t": "2026-10-13T18:20:00.000Z", "t": "2026-10-13T10:00:00.000Z",
"ok": true, "ok": true,
"ms": 750 "ms": 791
}, },
{ {
"t": "2026-10-13T19:00:00.000Z", "t": "2026-10-13T10:40:00.000Z",
"ok": true, "ok": true,
"ms": 920 "ms": 678
}, },
{ {
"t": "2026-10-13T19:40:00.000Z", "t": "2026-10-13T11:20:00.000Z",
"ok": true,
"ms": 715
},
{
"t": "2026-10-13T20:20:00.000Z",
"ok": true,
"ms": 835
},
{
"t": "2026-10-13T21:00:00.000Z",
"ok": true,
"ms": 1038
},
{
"t": "2026-10-13T21:40:00.000Z",
"ok": true,
"ms": 1235
},
{
"t": "2026-10-13T22:20:00.000Z",
"ok": true,
"ms": 731
},
{
"t": "2026-10-13T23:00:00.000Z",
"ok": true,
"ms": 685
},
{
"t": "2026-10-13T23:40:00.000Z",
"ok": true,
"ms": 597
},
{
"t": "2026-10-14T00:20:00.000Z",
"ok": true,
"ms": 676
},
{
"t": "2026-10-14T01:00:00.000Z",
"ok": true,
"ms": 755
},
{
"t": "2026-10-14T01:40:00.000Z",
"ok": true,
"ms": 658
},
{
"t": "2026-10-14T02:20:00.000Z",
"ok": true, "ok": true,
"ms": 740 "ms": 740
}, },
{ {
"t": "2026-10-14T03:00:00.000Z", "t": "2026-10-13T12:00:00.000Z",
"ok": true, "ok": true,
"ms": 718 "ms": 828
}, },
{ {
"t": "2026-10-14T03:40:00.000Z", "t": "2026-10-13T12:40:00.000Z",
"ok": true, "ok": true,
"ms": 593 "ms": 722
}, },
{ {
"t": "2026-10-14T04:20:00.000Z", "t": "2026-10-13T13:20:00.000Z",
"ok": true, "ok": true,
"ms": 627 "ms": 849
}, },
{ {
"t": "2026-10-14T05:00:00.000Z", "t": "2026-10-13T14:00:00.000Z",
"ok": true, "ok": true,
"ms": 674 "ms": 1071
}, },
{ {
"t": "2026-10-14T05:41:00.000Z", "t": "2026-10-13T14:40:00.000Z",
"ok": true,
"ms": 863
},
{
"t": "2026-10-13T15:20:00.000Z",
"ok": true,
"ms": 893
},
{
"t": "2026-10-13T16:00:00.000Z",
"ok": true,
"ms": 742
},
{
"t": "2026-10-13T16:40:00.000Z",
"ok": true,
"ms": 769
},
{
"t": "2026-10-13T17:20:00.000Z",
"ok": true,
"ms": 769
},
{
"t": "2026-10-13T18:00:00.000Z",
"ok": true,
"ms": 738
},
{
"t": "2026-10-13T18:40:00.000Z",
"ok": true,
"ms": 888
},
{
"t": "2026-10-13T19:20:00.000Z",
"ok": true,
"ms": 810
},
{
"t": "2026-10-13T20:00:00.000Z",
"ok": true,
"ms": 754
},
{
"t": "2026-10-13T20:40:00.000Z",
"ok": true,
"ms": 1721
},
{
"t": "2026-10-13T21:20:00.000Z",
"ok": true,
"ms": 749
},
{
"t": "2026-10-13T22:00:00.000Z",
"ok": true,
"ms": 1288
},
{
"t": "2026-10-13T22:40:00.000Z",
"ok": true,
"ms": 694
},
{
"t": "2026-10-13T23:20:00.000Z",
"ok": true, "ok": true,
"ms": 728 "ms": 728
}, },
{ {
"t": "2026-10-14T06:13:00.000Z", "t": "2026-10-14T00:00:00.000Z",
"ok": true, "ok": true,
"ms": 732 "ms": 611
}, },
{ {
"t": "2026-10-14T06:46:00.000Z", "t": "2026-10-14T00:40:00.000Z",
"ok": true, "ok": true,
"ms": 714 "ms": 752
},
{
"t": "2026-10-14T01:20:00.000Z",
"ok": true,
"ms": 734
},
{
"t": "2026-10-14T02:00:00.000Z",
"ok": true,
"ms": 635
},
{
"t": "2026-10-14T02:40:00.000Z",
"ok": true,
"ms": 781
},
{
"t": "2026-10-14T03:20:00.000Z",
"ok": true,
"ms": 710
},
{
"t": "2026-10-14T04:00:00.000Z",
"ok": true,
"ms": 645
},
{
"t": "2026-10-14T04:40:00.000Z",
"ok": true,
"ms": 668
},
{
"t": "2026-10-14T05:20:00.000Z",
"ok": true,
"ms": 683
},
{
"t": "2026-10-14T06:13:00.000Z",
"ok": true,
"ms": 711
},
{
"t": "2026-10-14T06:45:00.000Z",
"ok": true,
"ms": 692
},
{
"t": "2026-10-14T07:18:00.000Z",
"ok": true,
"ms": 672
} }
], ],
"daily": { "daily": {
"2026-10-14": { "2026-10-14": {
"total": 26, "total": 4,
"up": 26, "up": 4,
"latSum": 18278, "latSum": 2889,
"latCount": 26, "latCount": 4,
"latMax": 778 "latMax": 751
} }
}, },
"meta": { "meta": {
@@ -496,206 +491,201 @@
"90": 5432 "90": 5432
}, },
"lat": { "lat": {
"avg": 703, "avg": 722,
"max": 778, "max": 751,
"days": [ "days": [
{ {
"key": "2026-10-14", "key": "2026-10-14",
"avg": 703 "avg": 722
} }
] ]
} }
}, },
"ur-795556828": { "ur-795556828": {
"status": "up", "status": "up",
"latency": 252, "latency": 435,
"code": null, "code": null,
"detail": "UptimeRobot:运行正常", "detail": "UptimeRobot:运行正常",
"lastCheck": "2026-10-14T06:40:00.000Z", "lastCheck": "2026-10-14T07:15:00.000Z",
"history": [ "history": [
{ {
"t": "2026-10-13T07:50:00.000Z", "t": "2026-10-13T08:35:00.000Z",
"ok": true, "ok": true,
"ms": 512 "ms": 224
}, },
{ {
"t": "2026-10-13T08:30:00.000Z", "t": "2026-10-13T09:15:00.000Z",
"ok": true, "ok": true,
"ms": 216 "ms": 247
}, },
{ {
"t": "2026-10-13T09:10:00.000Z", "t": "2026-10-13T10:00:00.000Z",
"ok": true,
"ms": 256
},
{
"t": "2026-10-13T09:55:00.000Z",
"ok": true, "ok": true,
"ms": 455 "ms": 455
}, },
{ {
"t": "2026-10-13T10:35:00.000Z", "t": "2026-10-13T10:40:00.000Z",
"ok": true, "ok": true,
"ms": 372 "ms": 384
}, },
{ {
"t": "2026-10-13T11:15:00.000Z", "t": "2026-10-13T11:20:00.000Z",
"ok": true, "ok": true,
"ms": 240 "ms": 334
}, },
{ {
"t": "2026-10-13T11:55:00.000Z", "t": "2026-10-13T12:00:00.000Z",
"ok": true, "ok": true,
"ms": 261 "ms": 234
}, },
{ {
"t": "2026-10-13T12:35:00.000Z", "t": "2026-10-13T12:40:00.000Z",
"ok": true, "ok": true,
"ms": 335 "ms": 335
}, },
{ {
"t": "2026-10-13T13:15:00.000Z", "t": "2026-10-13T13:20:00.000Z",
"ok": true, "ok": true,
"ms": 413 "ms": 379
}, },
{ {
"t": "2026-10-13T13:55:00.000Z", "t": "2026-10-13T14:00:00.000Z",
"ok": true, "ok": true,
"ms": 283 "ms": 304
}, },
{ {
"t": "2026-10-13T14:35:00.000Z", "t": "2026-10-13T14:40:00.000Z",
"ok": true, "ok": true,
"ms": 309 "ms": 341
}, },
{ {
"t": "2026-10-13T15:20:00.000Z", "t": "2026-10-13T15:25:00.000Z",
"ok": true, "ok": true,
"ms": 451 "ms": 417
}, },
{ {
"t": "2026-10-13T16:00:00.000Z", "t": "2026-10-13T16:05:00.000Z",
"ok": true, "ok": true,
"ms": 248 "ms": 241
}, },
{ {
"t": "2026-10-13T16:40:00.000Z", "t": "2026-10-13T16:45:00.000Z",
"ok": true,
"ms": 366
},
{
"t": "2026-10-13T17:25:00.000Z",
"ok": true,
"ms": 244
},
{
"t": "2026-10-13T18:05:00.000Z",
"ok": true, "ok": true,
"ms": 378 "ms": 378
}, },
{ {
"t": "2026-10-13T17:20:00.000Z", "t": "2026-10-13T18:45:00.000Z",
"ok": true, "ok": true,
"ms": 293 "ms": 310
}, },
{ {
"t": "2026-10-13T18:00:00.000Z", "t": "2026-10-13T19:25:00.000Z",
"ok": true, "ok": true,
"ms": 495 "ms": 335
}, },
{ {
"t": "2026-10-13T18:40:00.000Z", "t": "2026-10-13T20:05:00.000Z",
"ok": true, "ok": true,
"ms": 391 "ms": 351
}, },
{ {
"t": "2026-10-13T19:20:00.000Z", "t": "2026-10-13T20:45:00.000Z",
"ok": true, "ok": true,
"ms": 338 "ms": 523
}, },
{ {
"t": "2026-10-13T20:00:00.000Z", "t": "2026-10-13T21:25:00.000Z",
"ok": true, "ok": true,
"ms": 363 "ms": 400
}, },
{ {
"t": "2026-10-13T20:40:00.000Z", "t": "2026-10-13T22:05:00.000Z",
"ok": true, "ok": true,
"ms": 499 "ms": 222
}, },
{ {
"t": "2026-10-13T21:20:00.000Z", "t": "2026-10-13T22:45:00.000Z",
"ok": true, "ok": true,
"ms": 372 "ms": 169
}, },
{ {
"t": "2026-10-13T22:00:00.000Z", "t": "2026-10-13T23:25:00.000Z",
"ok": true,
"ms": 194
},
{
"t": "2026-10-13T22:40:00.000Z",
"ok": true, "ok": true,
"ms": 225 "ms": 225
}, },
{ {
"t": "2026-10-13T23:20:00.000Z", "t": "2026-10-14T00:05:00.000Z",
"ok": true, "ok": true,
"ms": 237 "ms": 209
}, },
{ {
"t": "2026-10-14T00:00:00.000Z", "t": "2026-10-14T00:45:00.000Z",
"ok": true, "ok": true,
"ms": 215 "ms": 272
}, },
{ {
"t": "2026-10-14T00:40:00.000Z", "t": "2026-10-14T01:25:00.000Z",
"ok": true, "ok": true,
"ms": 298 "ms": 431
}, },
{ {
"t": "2026-10-14T01:20:00.000Z", "t": "2026-10-14T02:05:00.000Z",
"ok": true, "ok": true,
"ms": 450 "ms": 241
}, },
{ {
"t": "2026-10-14T02:00:00.000Z", "t": "2026-10-14T02:45:00.000Z",
"ok": true, "ok": true,
"ms": 319 "ms": 444
}, },
{ {
"t": "2026-10-14T02:40:00.000Z", "t": "2026-10-14T03:25:00.000Z",
"ok": true, "ok": true,
"ms": 398 "ms": 359
}, },
{ {
"t": "2026-10-14T03:20:00.000Z", "t": "2026-10-14T04:05:00.000Z",
"ok": true, "ok": true,
"ms": 347 "ms": 400
}, },
{ {
"t": "2026-10-14T04:00:00.000Z", "t": "2026-10-14T04:45:00.000Z",
"ok": true, "ok": true,
"ms": 407 "ms": 214
}, },
{ {
"t": "2026-10-14T04:40:00.000Z", "t": "2026-10-14T05:25:00.000Z",
"ok": true, "ok": true,
"ms": 344 "ms": 404
}, },
{ {
"t": "2026-10-14T05:20:00.000Z", "t": "2026-10-14T06:35:00.000Z",
"ok": true, "ok": true,
"ms": 337 "ms": 278
}, },
{ {
"t": "2026-10-14T06:04:00.000Z", "t": "2026-10-14T07:15:00.000Z",
"ok": true, "ok": true,
"ms": 365 "ms": 435
},
{
"t": "2026-10-14T06:40:00.000Z",
"ok": true,
"ms": 252
} }
], ],
"daily": { "daily": {
"2026-10-14": { "2026-10-14": {
"total": 26, "total": 4,
"up": 26, "up": 4,
"latSum": 8635, "latSum": 1749,
"latCount": 26, "latCount": 4,
"latMax": 638 "latMax": 438
} }
}, },
"meta": { "meta": {
@@ -720,28 +710,23 @@
"90": 0 "90": 0
}, },
"lat": { "lat": {
"avg": 332, "avg": 437,
"max": 638, "max": 438,
"days": [ "days": [
{ {
"key": "2026-10-14", "key": "2026-10-14",
"avg": 332 "avg": 437
} }
] ]
} }
}, },
"ur-792059414": { "ur-792059414": {
"status": "up", "status": "up",
"latency": 1494, "latency": 2007,
"code": null, "code": null,
"detail": "UptimeRobot:运行正常", "detail": "UptimeRobot:运行正常",
"lastCheck": "2026-10-14T06:42:00.000Z", "lastCheck": "2026-10-14T07:16:00.000Z",
"history": [ "history": [
{
"t": "2026-10-13T07:40:00.000Z",
"ok": true,
"ms": 1715
},
{ {
"t": "2026-10-13T08:25:00.000Z", "t": "2026-10-13T08:25:00.000Z",
"ok": true, "ok": true,
@@ -888,28 +873,28 @@
"ms": 1955 "ms": 1955
}, },
{ {
"t": "2026-10-14T05:20:00.000Z", "t": "2026-10-14T05:25:00.000Z",
"ok": true, "ok": true,
"ms": 3605 "ms": 3612
}, },
{ {
"t": "2026-10-14T06:03:00.000Z", "t": "2026-10-14T06:36:00.000Z",
"ok": true, "ok": true,
"ms": 2050 "ms": 1548
}, },
{ {
"t": "2026-10-14T06:42:00.000Z", "t": "2026-10-14T07:16:00.000Z",
"ok": true, "ok": true,
"ms": 1494 "ms": 2007
} }
], ],
"daily": { "daily": {
"2026-10-14": { "2026-10-14": {
"total": 26, "total": 4,
"up": 26, "up": 4,
"latSum": 61398, "latSum": 7923,
"latCount": 26, "latCount": 4,
"latMax": 4043 "latMax": 2007
} }
}, },
"meta": { "meta": {
@@ -934,22 +919,22 @@
"90": 7593 "90": 7593
}, },
"lat": { "lat": {
"avg": 2361, "avg": 1981,
"max": 4043, "max": 2007,
"days": [ "days": [
{ {
"key": "2026-10-14", "key": "2026-10-14",
"avg": 2361 "avg": 1981
} }
] ]
} }
}, },
"ur-798788113": { "ur-798788113": {
"status": "up", "status": "up",
"latency": 635, "latency": 480,
"code": null, "code": null,
"detail": "UptimeRobot:运行正常", "detail": "UptimeRobot:运行正常",
"lastCheck": "2026-10-14T06:46:00.000Z", "lastCheck": "2026-10-14T07:16:00.000Z",
"history": [ "history": [
{ {
"t": "2026-10-13T08:00:00.000Z", "t": "2026-10-13T08:00:00.000Z",
@@ -1117,23 +1102,23 @@
"ms": 347 "ms": 347
}, },
{ {
"t": "2026-10-14T06:10:00.000Z", "t": "2026-10-14T06:36:00.000Z",
"ok": true, "ok": true,
"ms": 361 "ms": 484
}, },
{ {
"t": "2026-10-14T06:46:00.000Z", "t": "2026-10-14T07:16:00.000Z",
"ok": true, "ok": true,
"ms": 635 "ms": 480
} }
], ],
"daily": { "daily": {
"2026-10-14": { "2026-10-14": {
"total": 26, "total": 4,
"up": 26, "up": 4,
"latSum": 11895, "latSum": 1875,
"latCount": 26, "latCount": 4,
"latMax": 675 "latMax": 480
} }
}, },
"meta": { "meta": {
@@ -1158,12 +1143,12 @@
"90": 0 "90": 0
}, },
"lat": { "lat": {
"avg": 458, "avg": 469,
"max": 675, "max": 480,
"days": [ "days": [
{ {
"key": "2026-10-14", "key": "2026-10-14",
"avg": 458 "avg": 469
} }
] ]
} }
@@ -1183,7 +1168,7 @@
], ],
"daily": { "daily": {
"2026-10-14": { "2026-10-14": {
"total": 26, "total": 4,
"up": 0 "up": 0
} }
}, },
+24
View File
@@ -148,6 +148,30 @@ async function main() {
check('只读模式下面板数据接口仍可用', r5.status === 200, `status=${r5.status}`); check('只读模式下面板数据接口仍可用', r5.status === 200, `status=${r5.status}`);
srvB.kill(); srvB.kill();
// ---------- D2:接口不得泄露私有层密钥 ----------
console.log('\n[D2] 配置接口的密钥泄露');
// 私有层放一个可识别的假 key:/api/monitors 无鉴权,绝不能把它回吐给面板或访客
const SECRET = 'urFAKE-0123456789abcdefSECRET';
const pubCfg = path.join(TMP, 'secret-pub.json');
const privCfg = path.join(TMP, 'secret-priv.json');
fs.writeFileSync(pubCfg, JSON.stringify({ settings: { uptimeRobot: { enabled: false, apiKey: '' } }, monitors: [] }, null, 2));
fs.writeFileSync(privCfg, JSON.stringify({ settings: { uptimeRobot: { enabled: true, apiKey: SECRET } } }, null, 2));
const srvC = startProber([`--config=${pubCfg}`, `--local-config=${privCfg}`, `--out=${path.join(TMP, 'c.json')}`], { PORT: '8896' });
await waitForHttp('http://127.0.0.1:8896/api/status');
const mResp = await fetch('http://127.0.0.1:8896/api/monitors');
const mText = await mResp.text();
check('/api/monitors 不回吐私有层的 API Key', !mText.includes(SECRET) && !mText.includes('FAKE'), mText.includes(SECRET) ? '泄露了!' : '未泄露');
const mJson = JSON.parse(mText);
check('/api/monitors 的 uptimeRobot.apiKey 被剥离', !('apiKey' in (mJson.settings?.uptimeRobot ?? {})), JSON.stringify(mJson.settings?.uptimeRobot));
check('/api/monitors 仍返回非敏感配置(面板要读)', typeof mJson.settings?.uptimeRobot?.enabled === 'boolean' && Array.isArray(mJson.monitors), `enabled=${mJson.settings?.uptimeRobot?.enabled}`);
const sText = await (await fetch('http://127.0.0.1:8896/api/status')).text();
check('/api/status 快照不含 API Key', !sText.includes(SECRET), '');
const pubText = await (await fetch('http://127.0.0.1:8896/monitors.json')).text();
check('静态 monitors.json 不含私有层内容', !pubText.includes(SECRET), '');
srvC.kill();
// ---------- E:来源判定 ---------- // ---------- E:来源判定 ----------
console.log('\n[E] 来源判定'); console.log('\n[E] 来源判定');
const { isPrivateAddress } = await import(`file://${PROBER.replace(/\\/g, '/')}`); const { isPrivateAddress } = await import(`file://${PROBER.replace(/\\/g, '/')}`);
+14
View File
@@ -224,6 +224,20 @@ async function main() {
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('/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} 个后台任务`); 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; globalThis.fetch = origFetch;
urSrv.close(); urSrv.close();
httpSrv.close(); httpSrv.close();