diff --git a/README.md b/README.md index 416f7bb..20ef2f4 100644 --- a/README.md +++ b/README.md @@ -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= 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-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 项) ``` diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 8c40cf1..b64d186 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -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、管理端在别处、访客从面板点进去 —— 中间怎么连通由你的网络决定。 diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 6f9484f..1074b2b 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -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` | ### 已知限制(请按需叠加外部措施) diff --git a/edge/cloudflare/tunnel.yml b/edge/cloudflare/tunnel.yml deleted file mode 100644 index 2bb68e5..0000000 --- a/edge/cloudflare/tunnel.yml +++ /dev/null @@ -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/.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/worker.js b/edge/cloudflare/worker.js index debeecf..5ed7ccd 100644 --- a/edge/cloudflare/worker.js +++ b/edge/cloudflare/worker.js @@ -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)); diff --git a/prober/node/index.js b/prober/node/index.js index 4e55863..6e59017 100644 --- a/prober/node/index.js +++ b/prober/node/index.js @@ -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) { diff --git a/prober/node/lib/admin-routes.js b/prober/node/lib/admin-routes.js index 53ac30f..6d8496c 100644 --- a/prober/node/lib/admin-routes.js +++ b/prober/node/lib/admin-routes.js @@ -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:面板会把它渲染成 ,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; diff --git a/public/admin.html b/public/admin.html index 1ec96bf..96de905 100644 --- a/public/admin.html +++ b/public/admin.html @@ -125,6 +125,15 @@

只影响面板展示哪些目标;与探测范围相互独立。

+
+ + +

+ 填了之后,公开面板页脚会出现一个「管理」按钮指向这里;留空则不显示。 + 地址由你自行决定怎么暴露(内网 IP、Tailscale、VPS 域名、隧道……都可以), + 面板不做任何假设。 +

+
diff --git a/public/assets/admin.js b/public/assets/admin.js index 8c6077d..5fdcbdf 100644 --- a/public/assets/admin.js +++ b/public/assets/admin.js @@ -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; diff --git a/public/assets/app.js b/public/assets/app.js index 7220250..098e24d 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -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 || ''; diff --git a/public/assets/style.css b/public/assets/style.css index fa0217b..a5acb39 100644 --- a/public/assets/style.css +++ b/public/assets/style.css @@ -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; } diff --git a/public/index.html b/public/index.html index c3b1ed0..42a1311 100644 --- a/public/index.html +++ b/public/index.html @@ -75,6 +75,7 @@ – – + shiroko-monitor v0.12.1 diff --git a/public/monitors.json b/public/monitors.json index 30a2fdf..fc3040e 100644 --- a/public/monitors.json +++ b/public/monitors.json @@ -9,6 +9,7 @@ "dataFilter": "uptimerobot", "refreshInterval": 300, "dataSource": "auto", + "adminUrl": "", "uptimeRobot": { "enabled": false, "apiKey": "", diff --git a/public/status.json b/public/status.json index 4276b46..2c302d2 100644 --- a/public/status.json +++ b/public/status.json @@ -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 } }, diff --git a/scripts/verify-security.cjs b/scripts/verify-security.cjs index 22c05c0..cb3d1d9 100644 --- a/scripts/verify-security.cjs +++ b/scripts/verify-security.cjs @@ -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, '/')}`); diff --git a/scripts/verify-worker.cjs b/scripts/verify-worker.cjs index cf805d7..e81522e 100644 --- a/scripts/verify-worker.cjs +++ b/scripts/verify-worker.cjs @@ -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();