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:
@@ -499,20 +499,28 @@ node prober/node/index.js
|
||||
|
||||
> 部署到公网时建议放在 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
|
||||
// local.monitors.json
|
||||
{ "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 侧删除的监控自动清理**,避免僵尸条目
|
||||
- [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] **`settings.admin.allowRemote`**:探针**默认只监听 `127.0.0.1`**;显式打开才监听全网卡,启动日志就此告警(实测:默认时非环回地址连不上,打开后可达)。**怎么把流量送到这个端口由使用者自己决定**,项目不做任何假设
|
||||
- [x] **`settings.adminUrl`(面板管理入口)**:管理界面里填一个地址,公开面板页脚就出现「管理」按钮指向它;留空则完全不显示(默认,避免在公网面板暴露管理面位置)。只接受 `http`/`https`,`javascript:` 等协议被服务端拒绝
|
||||
- [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` 时与实际参与的目标数不符。
|
||||
|
||||
**另有一处既存漏洞**(本轮顺带修复):`/api/monitors` 从引入私有层叠加机制起就无鉴权回吐完整配置,只要在 `local.monitors.json` 里配了 `uptimeRobot.apiKey`,任何访客 `curl /api/monitors` 就能拿到。这个漏洞与本轮改动无关,是之前埋下的。
|
||||
|
||||
> 另有一条测试夹具层面的教训:内存版 KV 必须实现 `get(key, 'json')` 的类型参数(真实 Workers KV 会自动反序列化),否则 `state.monitors` 恒为 undefined —— 表现为「状态永远读不到」,看起来像产品代码坏了。
|
||||
|
||||
### 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-layout.cjs # 13 视口 × 2 视图零横向溢出 + 行高一致(26 项)
|
||||
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-security.cjs # banner 探测 / 脱敏 / 私有分层 / 刷新限流(20 项)
|
||||
node scripts/verify-worker.cjs # Cloudflare Worker(40 项:UR 映射 / 探测范围 / 失败降级 / 管理端拒绝 / 密钥泄露)
|
||||
node scripts/verify-security.cjs # banner 探测 / 脱敏 / 私有分层 / 刷新限流 / 配置接口密钥泄露(25 项)
|
||||
node scripts/verify-notify.cjs # 通知与证书(44 项:假 SMTP / 假 MeoW / 去抖 / 恢复 / 维护窗口 / 真实证书)
|
||||
UPTIMEROBOT_API_KEY=ur... node scripts/verify-uptimerobot.cjs # UR 映射/兼容接口/失败路径/冒烟(18 项)
|
||||
```
|
||||
|
||||
+11
-16
@@ -167,26 +167,21 @@ EdgeOne Pages 当前以静态托管为主,采用与方案二相同的「静态
|
||||
|
||||
---
|
||||
|
||||
## 远程访问管理端(Cloudflare Tunnel)
|
||||
## 远程访问管理端
|
||||
|
||||
探针在内网/家用主机上时,若要在外网打开 `/admin`,用 Cloudflare Tunnel 让内网**主动出网**建隧道(不需要公网 IP、不需要开放入站端口)。
|
||||
探针**默认只监听 `127.0.0.1`**,外部访问不到。需要时用 `settings.admin.allowRemote` 打开:
|
||||
|
||||
前提与步骤:
|
||||
```jsonc
|
||||
// local.monitors.json
|
||||
{ "settings": { "admin": { "allowRemote": true } } }
|
||||
```
|
||||
|
||||
1. **确认管理端已绑定 2FA**。Tunnel 只负责把端口暴露出去,真正拦住未授权访问的是管理端的 TOTP + 会话 + 失败限流。
|
||||
2. 打开探针的远程管理开关(**默认关闭**,此时探针只监听 `127.0.0.1`):
|
||||
打开后探针监听 `0.0.0.0`,启动日志会明确警告 —— 提醒你该端口同时伺服公开面板,前置层必须启用 HTTPS。
|
||||
|
||||
```jsonc
|
||||
// local.monitors.json
|
||||
{ "settings": { "admin": { "allowRemote": true } } }
|
||||
```
|
||||
**怎么把流量送到这个端口由你决定**:反向代理、隧道、VPN、直连公网 IP 都行,本项目不做任何假设,也不会替你生成某种特定的部署配置。
|
||||
|
||||
3. 建命名隧道并只回源管理路径,参考 [`edge/cloudflare/tunnel.yml`](../edge/cloudflare/tunnel.yml):
|
||||
### 让面板知道管理端在哪
|
||||
|
||||
```bash
|
||||
cloudflared tunnel --no-autoupdate run --token <你的 token>
|
||||
```
|
||||
管理界面里有一项「**面板上的管理入口链接**」:填上管理端的实际地址后,公开面板页脚会出现一个「管理」按钮指向它;留空则不显示(默认,避免在公网面板上暴露管理面的位置)。地址只接受 `http` / `https`。
|
||||
|
||||
该配置只放行 `^/(admin|api/admin/.*|assets/.*)$`,其余路径一律 404 —— 不要把公开面板与 `/api/status` 一起挂到公网。
|
||||
|
||||
开启 `allowRemote` 后启动日志会明确警告探针正在监听 `0.0.0.0`,提醒你该端口同时伺服公开面板,前置代理必须启用 HTTPS。
|
||||
这样部署形态与面板展示就解耦了:面板部署在 Cloudflare、管理端在别处、访客从面板点进去 —— 中间怎么连通由你的网络决定。
|
||||
|
||||
+13
-11
@@ -165,33 +165,35 @@ v0.12.0 起,所有会改配置的操作都放在 `/admin`,**必须绑定并
|
||||
|
||||
这不是「没做」,而是这些能力依赖本地状态:2FA 档案与会话存在 `data/` 目录与进程内存中,而 Workers 无文件系统、isolate 之间不共享内存(KV 又是最终一致,无法承载「恢复码一次性使用」这类语义)。
|
||||
|
||||
### 远程访问(Cloudflare Tunnel)
|
||||
### 远程访问
|
||||
|
||||
探针在内网时若需从外网访问 `/admin`,用 Cloudflare Tunnel 让内网主动出网建隧道。
|
||||
|
||||
**探针默认只监听 `127.0.0.1`**,必须显式打开才能被隧道访问:
|
||||
探针在内网时若需从外网访问 `/admin`,用 `settings.admin.allowRemote` 打开远程访问(**默认关闭**,此时只监听 `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,就是为了不把公开面板一起挂出去。
|
||||
**怎么把流量送到这个端口由你决定**(反向代理 / 隧道 / VPN / 直连公网 IP),本项目不做任何假设。唯一要提醒的是:打开后该端口同时伺服公开面板,也就是说 `/` 与 `/api/status` 也会跟着对外可达 —— 这两项本身只读,但目标名称、地址与可用率会一并暴露。若不希望如此,请在你自己的那层按路径收窄。
|
||||
|
||||
### 配置接口不得泄露密钥
|
||||
|
||||
`/api/monitors` 与 Worker 上的同名接口都**无鉴权**,因此对外响应一律剥离 `uptimeRobot.apiKey`(保留 `enabled` 等非敏感字段供面板读取)。探针内部同步 UptimeRobot 仍使用完整配置,剥离只作用于 HTTP 出口。`/api/status` 与静态 `monitors.json` 同样不含私有层内容。
|
||||
|
||||
### 威胁模型与对应措施
|
||||
|
||||
| 威胁 | 措施 |
|
||||
| --- | --- |
|
||||
| 未绑定就被抢先接管管理端 | 绑定需 `SHIROKO_ADMIN_SETUP_KEY`;未绑定时 `/api/admin/*` 除绑定流程外**一律 403**;未设置该环境变量时绑定入口直接关闭(503) |
|
||||
| 未授权者经隧道访问 | 隧道暴露的是同一端口,防护完全依赖 2FA:TOTP 校验 + 会话哈希 + 失败限流三重 |
|
||||
| 隧道配置过宽导致面板外泄 | 示例配置只放行管理路径,并注释说明不要把 `/` 与 `/api/status` 一起暴露 |
|
||||
| 忘记关`allowRemote` | 启动日志显式警告监听地址;默认值为关闭 |
|
||||
| 暴力枚举 6 位码(100 万种) | 按 IP 计数,连续 5 次失败后指数退避锁定(1m → 2m → 4m …上限 30m);锁定期间即使验证码正确也拒绝 |
|
||||
| 未授权者经外部网络访问管理端 | 打开 `allowRemote` 后,防护完全依赖管理端的 TOTP 校验 + 会话哈希 + 失败限流三重;默认不监听外部地址 |
|
||||
| 配置接口泄露 UptimeRobot Key | 对外响应剥离 `uptimeRobot.apiKey`;内部同步仍用完整配置(有断言覆盖两侧) |
|
||||
| 面板成为探测他人管理面的跳板 | 管理入口链接只接受 `http`/`https`,`javascript:` 等协议被服务端拒绝;不配置则不渲染入口 |
|
||||
| 恶意枚举 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`**(可被任意伪造)—— 经隧道访问时对端地址是 Cloudflare 边缘节点,因此限流是按边缘 IP 而非真实客户端统计的,这是隧道形态下已知的粒度损失 |
|
||||
| 篡改请求 | `Path=/` + `HttpOnly` + `SameSite=Strict`;请求体上限 64KB,超出返回 413;参数逐项白名单校验(如 `probeScope` 只接受三个枚举值) |
|
||||
| 伪造来源 IP 绕过限流 | **只信任 socket 对端地址,不解析 `X-Forwarded-For`**(可被任意伪造)。若前面挂了反向代理/隧道,对端地址是代理节点,因此限流是按代理 IP 而非真实客户端统计的 —— 这是有代理层时的已知粒度损失 |
|
||||
| 篡改请求 | `Path=/` + `HttpOnly` + `SameSite=Strict`;请求体上限 64KB,超出返回 413;参数逐项白名单校验(如 `probeScope` 只接受三个枚举值、`adminUrl` 只接受 http/https) |
|
||||
| 通过管理端把密钥写进仓库 | **所有写操作只落 `local.monitors.json`**(私有、已 gitignore),绝不改 `public/monitors.json` |
|
||||
|
||||
### 已知限制(请按需叠加外部措施)
|
||||
|
||||
@@ -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
|
||||
@@ -40,7 +40,7 @@ export default {
|
||||
async fetch(request, env, ctx) {
|
||||
const url = new URL(request.url);
|
||||
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') {
|
||||
// 公网上的强制探测必须显式开启,避免被任意访客当作「探测放大器」使用
|
||||
const gate = refreshGate(env, request);
|
||||
@@ -110,6 +110,14 @@ async function getMonitors(env, baseUrl) {
|
||||
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) {
|
||||
const state = (await env.MONITOR_KV.get('state', 'json')) ?? { monitors: {}, generatedAt: null };
|
||||
if (!state.generatedAt) ctx.waitUntil(runChecks(env));
|
||||
|
||||
+15
-1
@@ -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() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(STATUS_PATH, 'utf8'));
|
||||
@@ -972,7 +986,7 @@ function createServer(runtime) {
|
||||
return void fs.createReadStream(file).pipe(res);
|
||||
}
|
||||
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') {
|
||||
const decide = refreshDecision(config, req.socket.remoteAddress);
|
||||
if (!decide.ok) {
|
||||
|
||||
@@ -138,6 +138,7 @@ export function createAdminRouter({ auth, localConfigPath, reloadConfig, runtime
|
||||
hideTargets: s.hideTargets === true,
|
||||
notifyEnabled: notifyConfig(cfg).enabled,
|
||||
tlsEnabled: tlsConfig(cfg).enabled,
|
||||
adminUrl: s.adminUrl ?? '',
|
||||
},
|
||||
maintenance: Array.isArray(s.maintenance) ? s.maintenance : [],
|
||||
monitors: cfg.monitors.map((m) => ({
|
||||
@@ -300,6 +301,30 @@ export function createAdminRouter({ auth, localConfigPath, reloadConfig, runtime
|
||||
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') {
|
||||
await Promise.race([forceRound(runtime), new Promise((r) => setTimeout(r, forcedMaxWaitMs))]);
|
||||
if (res.writableEnded || res.destroyed) return;
|
||||
|
||||
@@ -125,6 +125,15 @@
|
||||
</select>
|
||||
<p class="admin-hint">只影响面板展示哪些目标;与探测范围相互独立。</p>
|
||||
</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">
|
||||
<button type="button" class="admin-btn" id="probe-now">立即重新探测</button>
|
||||
<button type="button" class="admin-btn" id="notify-test">发送通知自检</button>
|
||||
|
||||
@@ -380,6 +380,8 @@ function renderConsole(d) {
|
||||
|
||||
$('probe-scope').value = d.settings?.probeScope ?? 'all';
|
||||
$('data-filter').value = d.settings?.dataFilter ?? 'both';
|
||||
$('admin-url').value = d.settings?.adminUrl ?? '';
|
||||
$('admin-url').dataset.saved = $('admin-url').value;
|
||||
|
||||
// 维护窗口目标下拉
|
||||
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');
|
||||
});
|
||||
|
||||
$('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 () => {
|
||||
const btn = $('probe-now');
|
||||
btn.disabled = true;
|
||||
|
||||
@@ -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() {
|
||||
const st = state.status;
|
||||
const stats = localStats();
|
||||
@@ -727,6 +746,7 @@ function renderAll() {
|
||||
renderViewSwitch();
|
||||
renderView();
|
||||
renderEvents();
|
||||
renderAdminLink();
|
||||
}
|
||||
|
||||
function renderError(err) {
|
||||
@@ -938,6 +958,14 @@ async function init() {
|
||||
renderError(err);
|
||||
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';
|
||||
$('site-title').textContent = state.config.settings?.title || 'Shiroko Monitor';
|
||||
$('site-desc').textContent = state.config.settings?.description || '';
|
||||
|
||||
@@ -333,6 +333,17 @@ body {
|
||||
color: var(--text-2);
|
||||
}
|
||||
.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; }
|
||||
.warn-chip { padding: 1px 8px; border-radius: 999px; background: var(--down-bg); color: var(--down-text); font-weight: 600; }
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
<span id="notify-badge" class="footer-item" hidden>通知 –</span>
|
||||
<span id="updated-at" 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>
|
||||
</footer>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"dataFilter": "uptimerobot",
|
||||
"refreshInterval": 300,
|
||||
"dataSource": "auto",
|
||||
"adminUrl": "",
|
||||
"uptimeRobot": {
|
||||
"enabled": false,
|
||||
"apiKey": "",
|
||||
|
||||
+282
-297
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"prober": "shiroko-monitor-node/0.12.0",
|
||||
"prober": "shiroko-monitor-node/0.12.1",
|
||||
"demo": false,
|
||||
"generatedAt": "2026-10-14T06:50:04.642Z",
|
||||
"dataUpdatedAt": "2026-10-14T06:50:02.233Z",
|
||||
"generatedAt": "2026-10-14T07:22:36.648Z",
|
||||
"dataUpdatedAt": "2026-10-14T07:22:28.178Z",
|
||||
"updateIntervalSec": 300,
|
||||
"dataFilter": "uptimerobot",
|
||||
"probeScope": "all",
|
||||
@@ -11,12 +11,12 @@
|
||||
"up": 5,
|
||||
"down": 1,
|
||||
"pending": 0,
|
||||
"avgLatency": 671,
|
||||
"avgLatency": 805,
|
||||
"avgUptime90": 83.3,
|
||||
"memory": {
|
||||
"rssMB": 82.8,
|
||||
"heapUsedMB": 12.5,
|
||||
"heapTotalMB": 16
|
||||
"rssMB": 75.5,
|
||||
"heapUsedMB": 13.7,
|
||||
"heapTotalMB": 14.7
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -24,10 +24,10 @@
|
||||
"enabled": true,
|
||||
"ok": true,
|
||||
"error": null,
|
||||
"lastSyncAt": "2026-10-14T06:46:25.561Z",
|
||||
"lastSyncAt": "2026-10-14T07:20:45.501Z",
|
||||
"count": 6,
|
||||
"syncing": false,
|
||||
"lastSyncMs": 1791960384004
|
||||
"lastSyncMs": 1791962444262
|
||||
}
|
||||
},
|
||||
"notify": {
|
||||
@@ -55,16 +55,11 @@
|
||||
"monitors": {
|
||||
"ur-792059439": {
|
||||
"status": "up",
|
||||
"latency": 262,
|
||||
"latency": 433,
|
||||
"code": null,
|
||||
"detail": "UptimeRobot:运行正常",
|
||||
"lastCheck": "2026-10-14T06:40:00.000Z",
|
||||
"lastCheck": "2026-10-14T07:15:00.000Z",
|
||||
"history": [
|
||||
{
|
||||
"t": "2026-10-13T07:55:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 227
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T08:35:00.000Z",
|
||||
"ok": true,
|
||||
@@ -226,23 +221,23 @@
|
||||
"ms": 311
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T06:04:00.000Z",
|
||||
"t": "2026-10-14T06:34:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 357
|
||||
"ms": 177
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T06:40:00.000Z",
|
||||
"t": "2026-10-14T07:15:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 262
|
||||
"ms": 433
|
||||
}
|
||||
],
|
||||
"daily": {
|
||||
"2026-10-14": {
|
||||
"total": 26,
|
||||
"up": 26,
|
||||
"latSum": 7486,
|
||||
"latCount": 26,
|
||||
"latMax": 454
|
||||
"total": 4,
|
||||
"up": 4,
|
||||
"latSum": 1744,
|
||||
"latCount": 4,
|
||||
"latMax": 439
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
@@ -267,211 +262,211 @@
|
||||
"90": 0
|
||||
},
|
||||
"lat": {
|
||||
"avg": 288,
|
||||
"max": 454,
|
||||
"avg": 436,
|
||||
"max": 439,
|
||||
"days": [
|
||||
{
|
||||
"key": "2026-10-14",
|
||||
"avg": 288
|
||||
"avg": 436
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"ur-795497022": {
|
||||
"status": "up",
|
||||
"latency": 714,
|
||||
"latency": 672,
|
||||
"code": null,
|
||||
"detail": "UptimeRobot:运行正常",
|
||||
"lastCheck": "2026-10-14T06:46:00.000Z",
|
||||
"lastCheck": "2026-10-14T07:18:00.000Z",
|
||||
"history": [
|
||||
{
|
||||
"t": "2026-10-13T07:35:00.000Z",
|
||||
"t": "2026-10-13T08:00:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 696
|
||||
"ms": 758
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T08:15:00.000Z",
|
||||
"t": "2026-10-13T08:40:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 718
|
||||
"ms": 826
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T08:55: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",
|
||||
"t": "2026-10-13T09:20:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 749
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T18:20:00.000Z",
|
||||
"t": "2026-10-13T10:00:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 750
|
||||
"ms": 791
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T19:00:00.000Z",
|
||||
"t": "2026-10-13T10:40:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 920
|
||||
"ms": 678
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T19:40: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",
|
||||
"t": "2026-10-13T11:20:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 740
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T03:00:00.000Z",
|
||||
"t": "2026-10-13T12:00:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 718
|
||||
"ms": 828
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T03:40:00.000Z",
|
||||
"t": "2026-10-13T12:40:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 593
|
||||
"ms": 722
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T04:20:00.000Z",
|
||||
"t": "2026-10-13T13:20:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 627
|
||||
"ms": 849
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T05:00:00.000Z",
|
||||
"t": "2026-10-13T14:00:00.000Z",
|
||||
"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,
|
||||
"ms": 728
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T06:13:00.000Z",
|
||||
"t": "2026-10-14T00:00:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 732
|
||||
"ms": 611
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T06:46:00.000Z",
|
||||
"t": "2026-10-14T00:40:00.000Z",
|
||||
"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": {
|
||||
"2026-10-14": {
|
||||
"total": 26,
|
||||
"up": 26,
|
||||
"latSum": 18278,
|
||||
"latCount": 26,
|
||||
"latMax": 778
|
||||
"total": 4,
|
||||
"up": 4,
|
||||
"latSum": 2889,
|
||||
"latCount": 4,
|
||||
"latMax": 751
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
@@ -496,206 +491,201 @@
|
||||
"90": 5432
|
||||
},
|
||||
"lat": {
|
||||
"avg": 703,
|
||||
"max": 778,
|
||||
"avg": 722,
|
||||
"max": 751,
|
||||
"days": [
|
||||
{
|
||||
"key": "2026-10-14",
|
||||
"avg": 703
|
||||
"avg": 722
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"ur-795556828": {
|
||||
"status": "up",
|
||||
"latency": 252,
|
||||
"latency": 435,
|
||||
"code": null,
|
||||
"detail": "UptimeRobot:运行正常",
|
||||
"lastCheck": "2026-10-14T06:40:00.000Z",
|
||||
"lastCheck": "2026-10-14T07:15:00.000Z",
|
||||
"history": [
|
||||
{
|
||||
"t": "2026-10-13T07:50:00.000Z",
|
||||
"t": "2026-10-13T08:35:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 512
|
||||
"ms": 224
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T08:30:00.000Z",
|
||||
"t": "2026-10-13T09:15:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 216
|
||||
"ms": 247
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T09:10:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 256
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T09:55:00.000Z",
|
||||
"t": "2026-10-13T10:00:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 455
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T10:35:00.000Z",
|
||||
"t": "2026-10-13T10:40:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 372
|
||||
"ms": 384
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T11:15:00.000Z",
|
||||
"t": "2026-10-13T11:20:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 240
|
||||
"ms": 334
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T11:55:00.000Z",
|
||||
"t": "2026-10-13T12:00:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 261
|
||||
"ms": 234
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T12:35:00.000Z",
|
||||
"t": "2026-10-13T12:40:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 335
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T13:15:00.000Z",
|
||||
"t": "2026-10-13T13:20:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 413
|
||||
"ms": 379
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T13:55:00.000Z",
|
||||
"t": "2026-10-13T14:00:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 283
|
||||
"ms": 304
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T14:35:00.000Z",
|
||||
"t": "2026-10-13T14:40:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 309
|
||||
"ms": 341
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T15:20:00.000Z",
|
||||
"t": "2026-10-13T15:25:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 451
|
||||
"ms": 417
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T16:00:00.000Z",
|
||||
"t": "2026-10-13T16:05:00.000Z",
|
||||
"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,
|
||||
"ms": 378
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T17:20:00.000Z",
|
||||
"t": "2026-10-13T18:45:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 293
|
||||
"ms": 310
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T18:00:00.000Z",
|
||||
"t": "2026-10-13T19:25:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 495
|
||||
"ms": 335
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T18:40:00.000Z",
|
||||
"t": "2026-10-13T20:05:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 391
|
||||
"ms": 351
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T19:20:00.000Z",
|
||||
"t": "2026-10-13T20:45:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 338
|
||||
"ms": 523
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T20:00:00.000Z",
|
||||
"t": "2026-10-13T21:25:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 363
|
||||
"ms": 400
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T20:40:00.000Z",
|
||||
"t": "2026-10-13T22:05:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 499
|
||||
"ms": 222
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T21:20:00.000Z",
|
||||
"t": "2026-10-13T22:45:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 372
|
||||
"ms": 169
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T22:00:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 194
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T22:40:00.000Z",
|
||||
"t": "2026-10-13T23:25:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 225
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T23:20:00.000Z",
|
||||
"t": "2026-10-14T00:05:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 237
|
||||
"ms": 209
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T00:00:00.000Z",
|
||||
"t": "2026-10-14T00:45:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 215
|
||||
"ms": 272
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T00:40:00.000Z",
|
||||
"t": "2026-10-14T01:25:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 298
|
||||
"ms": 431
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T01:20:00.000Z",
|
||||
"t": "2026-10-14T02:05:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 450
|
||||
"ms": 241
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T02:00:00.000Z",
|
||||
"t": "2026-10-14T02:45:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 319
|
||||
"ms": 444
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T02:40:00.000Z",
|
||||
"t": "2026-10-14T03:25:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 398
|
||||
"ms": 359
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T03:20:00.000Z",
|
||||
"t": "2026-10-14T04:05:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 347
|
||||
"ms": 400
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T04:00:00.000Z",
|
||||
"t": "2026-10-14T04:45:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 407
|
||||
"ms": 214
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T04:40:00.000Z",
|
||||
"t": "2026-10-14T05:25:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 344
|
||||
"ms": 404
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T05:20:00.000Z",
|
||||
"t": "2026-10-14T06:35:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 337
|
||||
"ms": 278
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T06:04:00.000Z",
|
||||
"t": "2026-10-14T07:15:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 365
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T06:40:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 252
|
||||
"ms": 435
|
||||
}
|
||||
],
|
||||
"daily": {
|
||||
"2026-10-14": {
|
||||
"total": 26,
|
||||
"up": 26,
|
||||
"latSum": 8635,
|
||||
"latCount": 26,
|
||||
"latMax": 638
|
||||
"total": 4,
|
||||
"up": 4,
|
||||
"latSum": 1749,
|
||||
"latCount": 4,
|
||||
"latMax": 438
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
@@ -720,28 +710,23 @@
|
||||
"90": 0
|
||||
},
|
||||
"lat": {
|
||||
"avg": 332,
|
||||
"max": 638,
|
||||
"avg": 437,
|
||||
"max": 438,
|
||||
"days": [
|
||||
{
|
||||
"key": "2026-10-14",
|
||||
"avg": 332
|
||||
"avg": 437
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"ur-792059414": {
|
||||
"status": "up",
|
||||
"latency": 1494,
|
||||
"latency": 2007,
|
||||
"code": null,
|
||||
"detail": "UptimeRobot:运行正常",
|
||||
"lastCheck": "2026-10-14T06:42:00.000Z",
|
||||
"lastCheck": "2026-10-14T07:16:00.000Z",
|
||||
"history": [
|
||||
{
|
||||
"t": "2026-10-13T07:40:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 1715
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T08:25:00.000Z",
|
||||
"ok": true,
|
||||
@@ -888,28 +873,28 @@
|
||||
"ms": 1955
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T05:20:00.000Z",
|
||||
"t": "2026-10-14T05:25:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 3605
|
||||
"ms": 3612
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T06:03:00.000Z",
|
||||
"t": "2026-10-14T06:36:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 2050
|
||||
"ms": 1548
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T06:42:00.000Z",
|
||||
"t": "2026-10-14T07:16:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 1494
|
||||
"ms": 2007
|
||||
}
|
||||
],
|
||||
"daily": {
|
||||
"2026-10-14": {
|
||||
"total": 26,
|
||||
"up": 26,
|
||||
"latSum": 61398,
|
||||
"latCount": 26,
|
||||
"latMax": 4043
|
||||
"total": 4,
|
||||
"up": 4,
|
||||
"latSum": 7923,
|
||||
"latCount": 4,
|
||||
"latMax": 2007
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
@@ -934,22 +919,22 @@
|
||||
"90": 7593
|
||||
},
|
||||
"lat": {
|
||||
"avg": 2361,
|
||||
"max": 4043,
|
||||
"avg": 1981,
|
||||
"max": 2007,
|
||||
"days": [
|
||||
{
|
||||
"key": "2026-10-14",
|
||||
"avg": 2361
|
||||
"avg": 1981
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"ur-798788113": {
|
||||
"status": "up",
|
||||
"latency": 635,
|
||||
"latency": 480,
|
||||
"code": null,
|
||||
"detail": "UptimeRobot:运行正常",
|
||||
"lastCheck": "2026-10-14T06:46:00.000Z",
|
||||
"lastCheck": "2026-10-14T07:16:00.000Z",
|
||||
"history": [
|
||||
{
|
||||
"t": "2026-10-13T08:00:00.000Z",
|
||||
@@ -1117,23 +1102,23 @@
|
||||
"ms": 347
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T06:10:00.000Z",
|
||||
"t": "2026-10-14T06:36:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 361
|
||||
"ms": 484
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T06:46:00.000Z",
|
||||
"t": "2026-10-14T07:16:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 635
|
||||
"ms": 480
|
||||
}
|
||||
],
|
||||
"daily": {
|
||||
"2026-10-14": {
|
||||
"total": 26,
|
||||
"up": 26,
|
||||
"latSum": 11895,
|
||||
"latCount": 26,
|
||||
"latMax": 675
|
||||
"total": 4,
|
||||
"up": 4,
|
||||
"latSum": 1875,
|
||||
"latCount": 4,
|
||||
"latMax": 480
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
@@ -1158,12 +1143,12 @@
|
||||
"90": 0
|
||||
},
|
||||
"lat": {
|
||||
"avg": 458,
|
||||
"max": 675,
|
||||
"avg": 469,
|
||||
"max": 480,
|
||||
"days": [
|
||||
{
|
||||
"key": "2026-10-14",
|
||||
"avg": 458
|
||||
"avg": 469
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1183,7 +1168,7 @@
|
||||
],
|
||||
"daily": {
|
||||
"2026-10-14": {
|
||||
"total": 26,
|
||||
"total": 4,
|
||||
"up": 0
|
||||
}
|
||||
},
|
||||
|
||||
@@ -148,6 +148,30 @@ async function main() {
|
||||
check('只读模式下面板数据接口仍可用', r5.status === 200, `status=${r5.status}`);
|
||||
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:来源判定 ----------
|
||||
console.log('\n[E] 来源判定');
|
||||
const { isPrivateAddress } = await import(`file://${PROBER.replace(/\\/g, '/')}`);
|
||||
|
||||
@@ -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('首次访问触发后台探测(waitUntil 被调用)', pending.length > 0, `${pending.length} 个后台任务`);
|
||||
|
||||
// 配置接口无鉴权,绝不能回吐 API Key
|
||||
group('[E] 配置接口不得泄露密钥');
|
||||
const SECRET = 'urFAKE-0123456789abcdefSECRET';
|
||||
const envSecret = makeEnv({ uptimeRobot: { enabled: true, apiKey: SECRET } }, LOCAL_TARGETS);
|
||||
const mResp = await worker.default.fetch(new Request('https://x/api/monitors'), envSecret, { waitUntil() {} });
|
||||
const mText = await mResp.text();
|
||||
check('/api/monitors 不回吐 API Key', !mText.includes(SECRET), mText.includes(SECRET) ? '泄露了!' : '未泄露');
|
||||
const mJson = JSON.parse(mText);
|
||||
check('uptimeRobot.apiKey 被剥离', !('apiKey' in (mJson.settings?.uptimeRobot ?? {})), JSON.stringify(mJson.settings?.uptimeRobot));
|
||||
check('仍返回非敏感配置(面板要读)', typeof mJson.settings?.uptimeRobot?.enabled === 'boolean' && Array.isArray(mJson.monitors), `enabled=${mJson.settings?.uptimeRobot?.enabled}`);
|
||||
// 但 Worker 内部探测仍要能用 key 同步 UR —— 用同一份配置跑一轮确认不报错
|
||||
const snapSecret = await worker.__test.runChecks(envSecret);
|
||||
check('内部探测仍能读到 key 并同步 UR(剥离只作用于对外响应)', snapSecret.sources.uptimerobot.enabled === true, JSON.stringify(snapSecret.sources.uptimerobot).slice(0, 60));
|
||||
|
||||
globalThis.fetch = origFetch;
|
||||
urSrv.close();
|
||||
httpSrv.close();
|
||||
|
||||
Reference in New Issue
Block a user