Compare commits

...
7 Commits
Author SHA1 Message Date
tamakyi 20b499c350 fix: 零配置克隆可直接看到数据 + 新增本地运行文档
实测「全新克隆 → node prober/node/index.js」时发现两个问题:

1. **面板是空的**(新手陷阱)
   public/monitors.json 默认 dataFilter=uptimerobot,但 uptimeRobot.enabled=false
   (API Key 放在不入库的 local.monitors.json 里)。新克隆的用户没有私有配置,
   于是日志显示「探测范围: uptimerobot —— 仅 UptimeRobot(0 个)」,面板永远空白。
   改默认为 dataFilter=both:零配置时探测 8 个示例目标,配了 UR 再自行切换口径。

2. DEPLOY-PAGES.md 里写了 `cp local.monitors.json.example` —— 该文件根本不存在。
   改成直接启动 + 用 <details> 折叠说明可选的私有配置写法。

另外:
- package.json 版本号从 0.1.0 同步到 0.12.3(此前一直漏更)
- 新增 docs/DEPLOY-LOCAL.md:最短路径(3 条命令)、三种运行模式、常用参数、
  配置示例(http/tcp/banner)、配置分层说明、UptimeRobot 接入、
  管理端 2FA 绑定四步、通知配置、开机自启(systemd + NSSM)、八条常见问题
- README 加「本地运行」入口,指向两份部署文档

实测验证:干净克隆 + 默认配置 → 8 个目标、5 在线 3 离线,心率图与事件区块均有数据。
2026-10-14 17:01:46 +08:00
tamakyi a237d7ba86 fix(ui): 修事件表四列文字错位 + 跨域隐藏刷新按钮 + 补 Pages 部署文档
① 事件表「故障」「狼的评论系统」「UptimeRobot:403 Forbidden」不在同一行

根因(实测定位,非推断):四个 td 的 top/height 完全相同(696/72),错位在单元格内部。
.clamp2 为保证行等高固定了 height:2.8em(36px),而「故障」徽标只有 17px;
单元格按 vertical-align:middle 对齐时,两者居中基准不同,徽标比其它三列低约 9px。

修法:事件表单元格改顶部对齐 + clamp2 在事件表内改为 max-height。
实测修复后四列文字顶端错位 0px。

② 跨域模式隐藏刷新按钮
/api/refresh 在跨域下必然被 refreshLocalOnly 挡下(对端是代理地址而非私网),
给一个必然失败的按钮不如隐藏。setSource 里按 source==='remote' 切换,
同源模式保留(并补断言守住这个行为)。

③ 新增 docs/DEPLOY-PAGES.md
GitHub Pages 面板 + 本地探针的完整部署指南:架构图、先决条件(含混合内容硬限制)、
本地探针启动与 2FA 绑定、Pages 配置三处填法、验证清单、安全要点、
Actions 快照备选方案、六条常见问题排查(Mixed Content / CORS / Failed to fetch 等)。

测试:verify-views 47→49 项(事件行文字顶端对齐 0px、同源保留刷新按钮)。
全项目 8 套 336 项通过。
2026-10-14 16:48:07 +08:00
tamakyi 6b0a23c38e feat: v0.12.2 —— 跨域只读支持(面板可部署在别处)
用户诉求:管理端与前端完全分离,本地探针只跑 /admin,前端部署到 GitHub Pages
并硬编码管理地址,探测仍在本地常驻。用户自己搭隧道给本地套 HTTPS。

硬限制(务必先看):GitHub Pages 强制 HTTPS,而浏览器「混合内容」策略禁止
HTTPS 页面 fetch HTTP 资源 —— 这与 CORS 无关,改设置也没用。
因此 settings.dataUrl 必须与面板页面同为 HTTPS。

服务端
- 只读接口(/api/status、/api/monitors、/api/events、/api/v2/getMonitors、
  /api/refresh)按 settings.corsOrigins 白名单回显 Access-Control-Allow-Origin
- /api/admin/* 一律不放行跨域,预检直接 403 —— 外部页面既读不到也改不了
- 绝不使用 '*':白名单里写 '*' 不放行任何来源

前端
- settings.dataUrl 存在时,所有 api/ 相对路径自动改写到该地址
- 数据源标记为「远程探针(跨域)」,页脚可区分
- 管理界面新增两项配置:跨域数据源地址、允许跨域的页面地址(改完立即生效)

关键设计:origin 匹配必须考虑端口
origin 是「协议 + 主机 + 端口」的完整组合。最初白名单存 http://localhost(无端口),
导致本地前端跑在 localhost:5173 时被拒 —— 浏览器实测 Failed to fetch 而 curl 能过。
修法:回环主机(localhost / 127.0.0.1)忽略端口放行,其余要求完整 origin 精确匹配。

测试:verify-security 25→40 项,新增 [D3] CORS 白名单 14 条 + [D4] '*' 写法 1 条。
全项目 8 套 334 项通过。

附记一个测试上的教训:跨域 fetch 时 JS 读不到 Access-Control-Allow-Origin
(CORS 规范规定只有同源能读),所以断言「是否放行」要看 fetch 有没有拿到数据,
不能看响应头 —— 我一开始用 headers.get() 判断,拿到 null 误以为失败。
2026-10-14 16:19:31 +08:00
tamakyi f345626840 fix: 管理入口改为「填链接」而非绑定特定隧道方案,并修掉配置接口的密钥泄露
用户反馈:「你不需要帮我处理 tunnel 这部分,也许我不是通过 tunnel 开放内网呢?
你只需要给我一个填写开放的管理端链接的地方。」

之前把「Cloudflare Tunnel」当成既定方案写进了配置与文档,这是替用户做了假设。
现在改为:项目只提供一个填地址的地方,怎么暴露完全由使用者决定。

- 删除 edge/cloudflare/tunnel.yml 与全部 tunnel 相关文档
- 新增 settings.adminUrl:管理界面里填一个地址,公开面板页脚出现「管理」按钮
  指向它;留空则完全不显示(默认,避免在公网面板暴露管理面位置)
- 只接受 http/https,javascript: 等协议被服务端拒绝(会被渲染成 <a href>)
- 设置落在 local.monitors.json(私有层),只有自己的面板会出现该按钮
- 面板启动时额外拉 /api/monitors 合并 settings:原先只读静态 monitors.json
  (公开层),导致私有层的设置面板看不到;纯静态部署无该接口时沿用公开配置

顺带修掉一个既存漏洞(与本轮改动无关,是引入私有层叠加机制时埋下的):
/api/monitors 无鉴权却回吐合并后的完整配置,包含私有层的 UptimeRobot API Key ——
只要配了 key,任何访客 curl /api/monitors 就能拿到。现在两侧(Node 与 Worker)
都对对外响应剥离 uptimeRobot.apiKey,保留 enabled 等非敏感字段供面板读取;
探针内部同步仍用完整配置,剥离只作用于 HTTP 出口。

测试:verify-worker 36→40 项(新增 [E] 配置接口密钥泄露 4 条)、
verify-security 20→25 项(新增 [D2] 密钥泄露 5 条)。全项目 8 套 314 项通过。
2026-10-14 15:23:22 +08:00
tamakyi d2aa0a1163 feat: v0.12.1 —— Worker 支持 UptimeRobot + 管理端远程访问 + 前后端分离
起因:核实「管理端在 Cloudflare 上是否有效」时发现一个更要紧的问题 ——
Worker 完全没有 UptimeRobot 同步能力(runChecks 的探测目标只有 config.monitors)。
而实际配置是 dataFilter=uptimerobot,也就是说直接部署到 Worker 面板会是空的:
它探测 8 个本地条目,而真正在监控的 6 个 UR 目标它根本不认识。

① Worker 支持 UptimeRobot(Step 1)
- edge/cloudflare/uptimerobot.js(新增):Workers 版 UR 只读数据源,
  与 prober/node/lib/uptimerobot.js 同一套语义(状态映射/ratios 解析/故障原因)
- runChecks 的探测集合扩展为「自建目标 + UR 镜像目标」;UR 目标只拉取不探测,
  采用其状态并补齐本地 daily 分桶
- probeScope 与 dataFilter 解耦(与本地探针同语义):config 范围不拉 UR,
  uptimerobot 范围不探测本地
- 同步失败时标记 sources.uptimerobot.ok=false 并保留上次数据,
  绝不把目标判为离线或从面板消失;UR 侧删除的监控自动清理
- 快照新增 probeScope;UR 目标带 meta(面板区分自建/外部)与 uptimeRatios

② 管理端远程访问(Step 2)
- settings.admin.allowRemote:探针默认只监听 127.0.0.1,显式打开才监听全网卡,
  启动日志就此告警。这是 Cloudflare Tunnel 的安全前提(已实测两种行为)
- edge/cloudflare/tunnel.yml(新增):命名隧道配置,只放行
  ^/(admin|api/admin/.*|assets/.*)$,其余 404 —— 不把公开面板一起挂到公网

③ 前后端分离(Step 3)
- Worker 侧 /admin 与 /api/admin/* 显式返回 403 + 指向文档的提示,
  而非默默落到静态资源变成 404,确保边缘侧没有任何可写面

测试:新增 scripts/verify-worker.cjs(36 项)—— 把 Worker 以 data: URL 动态载入以
绕开 cloudflare:sockets,注入假 KV/静态资源/UptimeRobot 服务器,
不需要 wrangler 也不需要联网。全项目 8 套 310 项全部通过。

过程中抓到两个真 bug:
1. UR 同步失败的降级路径引用了不含 meta 的旧对象,导致 name/url 为 undefined、
   UR 目标从面板上消失
2. stats.total 按 config.monitors.length 计数,在 probeScope=uptimerobot 时
   与实际参与的目标数不符

另有一条测试夹具教训:内存版 KV 必须实现 get(key, 'json') 的类型参数
(真实 Workers KV 会自动反序列化),否则 state.monitors 恒为 undefined。
2026-10-14 14:51:28 +08:00
tamakyi 752fad412b feat: v0.12.0 —— 表格每行绝对等高 + 管理 WebUI(2FA 绑定才能进入)
① 每行绝对等高
- 行高与内容长度彻底解耦:新增 --row-h: 72px 作为唯一基准
- 名称列改为「文本区限两行 + 徽标区固定单行」两段式;文本区用 .clamp2
- 关键点:必须用 height 而非 max-height —— 后者会让短内容缩成一行、
  长内容撑到两行,反而产生 66px/71px 两种行高(实测踩过)
- 分组标题行用 :not(.group-row) 排除在数据行规则之外
- 事件表与监控表格共用同一个 --row-h,两张表视觉上也严格对齐
- 完整值仍可通过悬停 title 查看(已加断言强制所有截断单元格带 title)
- 卡片视图同样等高(13 视口 × 250px 全一致)

② 管理 WebUI + 2FA
- 新增 /admin 与 /api/admin/*;未绑定 2FA 时除绑定流程外一律 403
- 零依赖实现 TOTP(RFC 6238),用 RFC 4226/6238 官方测试向量验证(16 个向量)
- 内置 QR 码生成器(byte 模式 / ECC L / 版本 1-10,GF(256) RS 纠错 + 掩码评分),
  用独立解码器实测:版本 1 与版本 6 均能读出逐字节一致的内容
- 绑定流程:口令 → 生成密钥(扫码/手输/otpauth 链接)→ 验证 6 位码 → 10 枚恢复码;
  完成后凭本次验证码直接换取会话
- 安全:会话 id 32 字节随机数且服务端只存哈希;HttpOnly+SameSite=Strict;
  30 分钟空闲/2 小时最长;同时间步的码防重放;恢复码一次性;
  登录失败按 IP 指数退避锁定;不解析 X-Forwarded-For
- 管理功能:运行状态、探测范围、展示口径、维护窗口增删、立即重探、通知自检、
  目标只读列表、最近事件
- 所有写操作只落 local.monitors.json(私有不入库),绝不改 public/monitors.json

过程中修掉三个真 bug:
1. 会话 cookie 的 Path=/admin 覆盖不到 /api/admin/*,登录 200 但后续全部 401
2. 恢复码登录被 verifyTotp 的 invalid-format 提前挡掉(恢复码非 6 位数字)
3. 超大请求体用 req.destroy() 硬断连接,客户端只看到 fetch failed 拿不到状态码

测试:新增 scripts/verify-admin.cjs(102 项),全项目共 7 套 274 项全部通过
2026-10-14 12:37:07 +08:00
tamakyi a3a683ff0e fix(ui): 表格行高统一 + 事件区块改为与表格同一套样式
1) 行高不一致(「狼的评论系统」明显矮一截)
   实测在线行 58px / 离线行 43px。根因是可用率列的 90 格 mini 条:
   它是 inline-flex,参与行内布局时基线把行盒抬高约 15px,于是「有可用率
   数据的行」比不带条的行高出一截。
   修复:单元格内容统一 vertical-align: middle + mini 条容器改 flex;
   实测 6 行全部 43px。

2) 「最近事件」与表格样式不统一
   去掉独立的时间线卡片风格,改为与监控表格同构的表格(相同容器圆角、
   表头、行分隔、hover、状态底色);故障行淡红、证书行淡琥珀、恢复行不染。
   刻意不共用 .monitor-table 类名 —— 共用时面板条目统计被多算 1~8 个
   (测试当场抓到),改为独立 .events-table 并显式声明所需样式。

说明:内容长度相当时保证等高;名称/地址特别长时允许换行(上一版按用户
要求做的「完整显示」),此时行更高属于数据本身需要,不再用截断换整齐。

测试:verify-views 新增「内容等长时各行等高」「事件表与监控表同构」
「事件行复用状态底色」断言(并修正了断言脚本改错文件的问题 —— 名称来自
monitors.json 而非 status.json)。六套 171 项全通过。
2026-10-14 10:49:01 +08:00
30 changed files with 5517 additions and 734 deletions
+199 -6
View File
@@ -447,6 +447,116 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自
- **表格视图**:可用率列的 tooltip 里追加 `90 天日均延迟 X ms(峰值 Y ms)`
- 数据从探针开始累计当天起累积,跑满 90 天后覆盖全周期;离线样本不计入延迟统计
## 管理后台(2FA 保护)
面板右侧没有「编辑」入口:所有会改配置的操作都放在独立的管理后台 `/admin`,**必须绑定并通过两步验证才能进入**。
### 启用
```bash
# 1. 设置绑定口令(只在首次绑定时需要,用于防止被人抢先绑定自己的验证器)
export SHIROKO_ADMIN_SETUP_KEY='换成你自己的长口令'
node prober/node/index.js
# 2. 打开 http://<host>:8787/admin
```
不设置 `SHIROKO_ADMIN_SETUP_KEY` 时绑定入口直接关闭(接口返回 503),管理后台处于「完全不可用」状态。
### 绑定流程
1. 输入 `SHIROKO_ADMIN_SETUP_KEY` → 服务端生成一个 20 字节 Base32 密钥
2. 用 Google Authenticator / Microsoft Authenticator / 1Password / Aegis 扫码,或手动输入密钥(页面同时提供可复制的 `otpauth://` 链接)
3. 输入 App 显示的 6 位验证码完成绑定
4. **立刻保存 10 枚恢复码**(只显示这一次),每枚只能用一次,用于手机丢失或验证器清库
绑定完成后每次进入管理界面都需要重新输入 6 位动态码或一枚恢复码。
### 管理功能
| 功能 | 说明 |
| --- | --- |
| 运行状态 | 监控数 / 在线 / 离线 / 平均延迟 / 90 天可用率,数据更新时间,各数据源目标数与同步状态 |
| 探测范围 | `all` / `config` / `uptimerobot`,决定探针真正发请求的范围,改动立即热重载 |
| 展示口径 | `all` / `config` / `uptimerobot`,只影响面板展示,与探测范围相互独立 |
| 维护窗口 | 新增/删除窗口,可限定单个目标或全部目标;窗口内不计入可用率、不发告警 |
| 立即重新探测 | 手动跑一轮探测(复用面板刷新同一条路径) |
| 通知自检 | 给每个已启用渠道发一条测试消息 |
| 监控目标 | 只读列表(名称 / 分组 / 类型 / 地址 / 间隔) |
**所有写操作只落 `local.monitors.json`(私有、不入库),绝不改 `public/monitors.json`。**
### 安全设计
- **未绑定时管理接口一律 403**,不存在「先跳过 2FA 进管理界面」的路径
- 会话 id 为 32 字节随机数,**服务端只存 SHA-256 哈希**;进程重启即清空全部会话
- 会话 cookie 为 `HttpOnly; SameSite=Strict`,30 分钟无操作失效,最长 2 小时
- 同一时间步的 6 位码**用过即作废**(防截图重放);恢复码一次性,用后立刻从落盘档案中移除
- 登录失败按 IP 计数,连续 5 次失败后指数退避锁定(1m → 2m → 4m …上限 30m)
- 只信任 socket 对端地址,**不解析 `X-Forwarded-For`**(可被伪造)
- 2FA 档案存 `data/admin-2fa.json`(`data/` 已在 `.gitignore` 中),恢复码只存哈希
- 丢失全部验证码时的逃生口:用 `SHIROKO_ADMIN_SETUP_KEY` 重置 2FA(会清空密钥与全部恢复码,需重新绑定)
> 部署到公网时建议放在 HTTPS 反向代理之后:会话 cookie 未设 `Secure`(纯 HTTP 下设了会导致 cookie 完全不发送),由代理层负责加密传输。
### 面板上的管理入口(`settings.adminUrl`)
如果你把管理端放在别的地址(内网 IP、Tailscale、VPS 域名、隧道……随便哪种),可以在管理界面里把这个地址填进**「面板上的管理入口链接」**,公开面板的页脚就会出现一个「管理」按钮指向它。
- **留空 = 面板不显示任何管理入口**(默认状态,避免在公网面板上暴露管理面的位置)
- 地址只接受 `http` / `https`,其它协议(`javascript:` 等)会被拒绝
- 写入位置是 `local.monitors.json`(私有层),所以填了之后**只有你自己的面板**会出现这个按钮
面板本身不关心你怎么暴露管理端 —— 它只负责「你填什么就跳到什么」。
### 让管理端能被外部访问
探针**默认只监听 `127.0.0.1`**,外部访问不到。需要时用 `settings.admin.allowRemote` 打开:
```jsonc
// local.monitors.json
{ "settings": { "admin": { "allowRemote": true } } }
```
打开后探针监听 `0.0.0.0`,启动日志会就此告警。**怎么把流量送到这个端口由你决定** —— 反向代理、隧道、VPN、直连公网 IP 都行,本项目不做任何假设。
> ⚠️ 注意:打开后同一端口上的公开面板(`/` 与 `/api/status`)也会跟着对外可达。它们本身是只读的,但目标名称、地址与可用率会一并暴露。如果不希望这样,请在你自己那层(反向代理/隧道)按路径收窄。
### 面板部署在别处(跨域只读)
> 完整步骤见 **[docs/DEPLOY-PAGES.md](docs/DEPLOY-PAGES.md)**(GitHub Pages 面板 + 本地探针的部署指南,含常见问题排查)
面板与探针可以放在两台机器上:面板放 GitHub Pages / Cloudflare(静态托管),探针放你家里或内网的机器上持续探测。两项配置:
| 配置 | 作用 |
| --- | --- |
| `settings.dataUrl` | 面板去哪里取数据,例 `https://monitor.example.com`。留空表示与探针同源 |
| `settings.corsOrigins` | 允许跨域读取的**页面地址**,逗号分隔的完整 origin,如 `https://you.github.io` |
两者都在管理界面里填(改完立即生效)。
**硬限制(务必先看)**:GitHub Pages 强制 HTTPS,而浏览器的**混合内容**策略禁止 HTTPS 页面 `fetch` HTTP 资源 —— 这与 CORS 无关,改设置也没用。**因此 `dataUrl` 必须与面板页面同为 HTTPS**(你自己搭隧道给本地探针套 HTTPS 即可)。
安全设计:
- **绝不使用 `Access-Control-Allow-Origin: *`** —— 那等于让任意网站都能读走你的监控数据。默认只放行本机回环地址(`localhost` / `127.0.0.1`,**忽略端口**,因为本地前端常跑在 5173 这类端口)
- 白名单里的 origin 必须**精确匹配**,`https://you.github.io.evil.com` 这种前缀伪造会被拒
- 回显具体 origin 并带 `Vary: Origin`,避免中间缓存串味
- **管理端永不放行跨域**:`/admin` 与 `/api/admin/*` 不带任何 `Access-Control-*` 头,外部页面无法读取也无法写入
- 跨域读到的配置同样经过密钥剥离
### 部署形态与可写能力
| 部署形态 | 面板可读 | 探测 | 通知/维护窗口/事件 | 管理端(2FA) |
| --- | --- | --- | --- | --- |
| 本地 Node | ✅ | ✅ | ✅ | ✅ |
| Cloudflare Workers | ✅ | ✅(含 UptimeRobot 数据源) | ❌ | ❌ 403 |
| GitHub Pages / EdgeOne Pages | ✅ | ❌(仅展示 CI 快照) | ❌ | ❌ 不存在 |
**管理端只存在于「本地 Node」形态。** 边缘环境访问 `/admin` 或 `/api/admin/*` 会得到 403 + 指向文档的提示(而非 404,明确表达「这里不提供写能力」)。
原因是这些能力依赖本地状态:2FA 档案与会话存在 `data/` 目录与进程内存里,而 Workers 无文件系统、isolate 之间不共享内存(KV 又是最终一致,无法承载「恢复码一次性使用」这类语义)。
## 部署方案
| 方案 | 架构 | 数据更新 | 成本 | 适合场景 |
@@ -540,6 +650,69 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自
- [x] 页脚改为展示「数据更新于 … + 下次检查 Ns」,tooltip 内含服务端更新节奏与快照生成时间
- [x] `scripts/verify.cjs` 适配纯外部数据源部署(自动跳过不适用的探测类断言),并新增「倒计时对齐服务端」断言
### v0.12.1 已实现
**让边缘部署真正可用 + 管理端远程访问 + 前后端分离**
起因:核实「管理端在 Cloudflare 上是否有效」时发现一个更要紧的问题 —— Worker **完全没有 UptimeRobot 同步能力**(`runChecks` 的探测目标只有 `config.monitors`)。而实际配置是 `dataFilter: uptimerobot`,也就是说**直接部署到 Worker 面板会是空的**:它探测 8 个本地条目,而真正在监控的 6 个 UR 目标它根本不认识。
- [x] **`edge/cloudflare/uptimerobot.js`(新增)**:Workers 版 UR 只读数据源,与 `prober/node/lib/uptimerobot.js` 同一套语义(术语、状态映射、ratios 解析、故障原因提取)
- [x] **`worker.js` 探测集合扩展**:自建目标 + UR 镜像目标统一纳入一轮探测;UR 目标只拉取不探测,采用其状态并补齐本地 `daily` 分桶
- [x] **`probeScope` 与 `dataFilter` 解耦**(与本地探针同语义):未显式设置 `probeScope` 时跟随 `dataFilter`;`config` 范围下不拉 UR,`uptimerobot` 范围下不探测本地
- [x] **同步失败降级**:UR 拉取失败时标记 `sources.uptimerobot.ok = false` 并保留上次数据,**绝不把目标判为离线或从面板消失**
- [x] **UR 侧删除的监控自动清理**,避免僵尸条目
- [x] 快照新增 `probeScope` 字段;UR 目标带 `meta`(面板据此区分自建/外部)与 `uptimeRatios`(面板算官方可用率)
- [x] **`settings.admin.allowRemote`**:探针**默认只监听 `127.0.0.1`**;显式打开才监听全网卡,启动日志就此告警(实测:默认时非环回地址连不上,打开后可达)。**怎么把流量送到这个端口由使用者自己决定**,项目不做任何假设
- [x] **`settings.adminUrl`(面板管理入口)**:管理界面里填一个地址,公开面板页脚就出现「管理」按钮指向它;留空则完全不显示(默认,避免在公网面板暴露管理面位置)。只接受 `http`/`https`,`javascript:` 等协议被服务端拒绝
- [x] **前后端分离落地**:Worker 侧 `/admin` 与 `/api/admin/*` 显式返回 **403 + 指向文档的提示**(而非默默落到静态资源变成 404),确保边缘侧**没有任何可写面**;管理端只在本地 Node 侧运行
- [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` 补部署形态可写能力对照、配置接口不得泄露密钥、代理层下的限流粒度损失
**测试**:`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 已实现
**① 每行绝对等高**(你的要求:不再有「内容长度相当时才等高」)
- [x] 行高与内容长度**彻底解耦**:新增 `--row-h: 72px` 作为唯一基准,表格结构上就给每一行定死高度
- [x] 名称列改为「文本区限两行 + 徽标区固定单行」两段式;文本区用 `.clamp2`(`-webkit-line-clamp: 2` + **固定 `height`**)。这里必须是 `height` 而不是 `max-height`——后者会让短内容缩成一行、长内容撑到两行,反而产生两种行高(实测 66px / 71px 混杂)
- [x] 地址列同样限两行;`延迟/结果/最近检测` 改为 `nowrap` + 省略号(这类值本就短)
- [x] 分组标题行用 `:not(.group-row)` 排除在数据行规则之外,否则会一并被套上 72px
- [x] **事件表与监控表格共用同一个 `--row-h`**,两张表在视觉上也严格对齐(此前事件表是 66px)
- [x] 完整值仍可通过悬停 `title` 查看(已加断言:所有会被截断的单元格都必须带 `title`)
- [x] **卡片视图**同样等高(grid 拉伸,13 视口 × 250px 全一致)
**② 管理 WebUI + 2FA 绑定才能进入**
- [x] 新增 `/admin` 管理后台与 `/api/admin/*` 接口;**未绑定 2FA 时除绑定流程外一律 403**,不存在绕过路径
- [x] 零依赖实现 **TOTP(RFC 6238)**:HOTP 动态截断 + Base32 编解码,**用 RFC 4226 与 RFC 6238 官方测试向量验证**(10 + 6 个向量全对)
- [x] **内置 QR 码生成器**(byte 模式 / ECC L / 版本 1–10,GF(256) Reed-Solomon + 8 种掩码惩罚评分)。用独立解码器实测:版本 1 与版本 6 的二维码都能读出**逐字节完全一致**的内容
- [x] 绑定流程四步:口令 → 生成密钥(扫码/手输/`otpauth` 链接)→ 验证 6 位码 → 展示 10 枚一次性恢复码
- [x] 绑定完成后凭本次验证码**直接换取会话**,无需再输一遍
- [x] 安全设计:会话 id 32 字节随机数且**服务端只存哈希**;`HttpOnly + SameSite=Strict` cookie;30 分钟空闲 / 2 小时最长;**同一时间步的码用过即作废**(防重放);恢复码一次性;登录失败按 IP 指数退避锁定(5 次起,1m→2m→…→30m);**不解析 `X-Forwarded-For`**
- [x] 管理功能:运行状态、探测范围、展示口径、维护窗口增删、立即重探、通知自检、目标只读列表、最近事件
- [x] **所有写操作只落 `local.monitors.json`(私有、不入库)**,绝不改 `public/monitors.json`(已加断言逐项校验)
- [x] 逃生口:用 `SHIROKO_ADMIN_SETUP_KEY` 重置 2FA
> 过程中抓到并修掉三个真 bug:① **会话 cookie 的 `Path=/admin` 覆盖不到 `/api/admin/*` 接口**,登录返回 200 但紧接着所有操作 401;② 恢复码登录被 `verifyTotp` 的 `invalid-format` 提前挡掉(恢复码是 10 位字母数字,不是 6 位数字);③ 超大请求体用 `req.destroy()` 硬断连接,客户端只看到 `fetch failed` 而拿不到状态码。
### v0.11.2 已实现
**修复你反馈的两个问题**
- [x] **「狼的评论系统」那一条高度/样式与其他行不一致**:实测在线行 **58px**、离线行 **43px**。根因是**可用率列的 90 格 mini 条**——它是 `inline-flex` 元素,参与行内布局时其基线会把行盒抬高约 15px,于是「当天有可用率数据、条正常渲染」的行比不带条的行高出一截。修复:给单元格内容统一 `vertical-align: middle`、mini 条容器改 flex 布局,现在**所有行等高(实测 43px)**
- [x] **「最近事件」改为与表格同一套样式**:不再用独立的「时间线卡片」风格,改为**与监控表格完全同构的表格**(相同的圆角容器、表头样式、行分隔线、hover、状态底色)——故障行淡红、证书行淡琥珀、恢复行不染;类型以小徽标呈现,详情列完整显示
- 实现上刻意**不共用 `.monitor-table` 类名**,避免被其他按类名统计/定位的逻辑误算(这一点被测试当场抓到:共用类名时面板条目数被多算 1~8 个)
> 说明:这一版的行高只在「内容长度相当」时保证一致;名称或地址特别长时允许换行(上一版要求的「数据完整显示」),此时行自然更高。**已在 v0.12.0 改为绝对等高**(文本列限两行 + 悬停看全文)。
### v0.11.1 已实现
**修复你反馈的三个问题**
@@ -649,6 +822,23 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自
- [ ] 面板:更多形态(如时间线视图、地图视图)、明暗主题手动切换、可用率区间可配置(7/30/90 天)
- [ ] 状态页 JSON API 的 CORS 开放与缓存策略细化
## 本地运行
**零依赖,不需要 `npm install`**:
```bash
git clone https://github.com/tamakyi/shiroko-monitor.git
cd shiroko-monitor
node prober/node/index.js
```
打开 <http://localhost:8787/>(管理端在 `/admin`)。要求 Node.js ≥ 18。
改 `public/monitors.json` 里的 `monitors` 即可添加自己的目标,**文件会被自动热重载,不用重启**。
- 完整说明(三种运行模式 / 配置分层 / 开机自启 / 常见问题)→ **[docs/DEPLOY-LOCAL.md](docs/DEPLOY-LOCAL.md)**
- 面板部署到 GitHub Pages → **[docs/DEPLOY-PAGES.md](docs/DEPLOY-PAGES.md)**
## 开发
```bash
@@ -659,14 +849,17 @@ node prober/node/index.js
node prober/node/index.js --once
# UI 自动化实测(需 playwright,仅开发验证用,驱动系统 Chrome)
NODE_PATH=<playwright 的 node_modules> node scripts/verify.cjs # 热重载 / 数据一致性 / 刷新语义 / 面板(21 项)
NODE_PATH=<playwright 的 node_modules> node scripts/verify-views.cjs # 视图与数据源过滤(10 项)
node scripts/verify-security.cjs # banner 探测 / 脱敏 / 私有分层 / 刷新限流(20 项)
node scripts/verify-notify.cjs # 通知与证书(39 项:假 SMTP / 假 MeoW / 去抖 / 恢复 / 维护窗口 / 真实证书)
UPTIMEROBOT_API_KEY=ur... node scripts/verify-uptimerobot.cjs # UR 映射/兼容接口/失败路径/冒烟(22 项)
NODE_PATH=<playwright 的 node_modules> node scripts/verify.cjs # 热重载 / 数据一致性 / 刷新语义 / 面板(17 项)
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(40 项:UR 映射 / 探测范围 / 失败降级 / 管理端拒绝 / 密钥泄露)
node scripts/verify-security.cjs # banner 探测 / 脱敏 / 私有分层 / 刷新限流 / 密钥泄露 / CORS 白名单(40 项)
node scripts/verify-notify.cjs # 通知与证书(44 项:假 SMTP / 假 MeoW / 去抖 / 恢复 / 维护窗口 / 真实证书)
UPTIMEROBOT_API_KEY=ur... node scripts/verify-uptimerobot.cjs # UR 映射/兼容接口/失败路径/冒烟(18 项)
```
`verify-security.cjs` 会临时起一个假 SSH 服务与静默端口,验证 banner 探测的在线/不匹配/超时三种判定、默认不回显版本、脱敏与私有配置分层,以及刷新接口的 429/403 行为。`verify-uptimerobot.cjs` 用合成 payload 做无网映射单测,真实 Key 冒烟为可选(未设置环境变量则跳过)。`verify-notify.cjs` 会临时起**假 SMTP 服务器与假 MeoW 服务器**,完整走一遍邮件握手与推送协议,并真跑三轮探针验证去抖、恢复、维护窗口抑制与事件落盘;真实证书握手需要外网,无网络时自动跳过(不计失败)。
`verify-security.cjs` 会临时起一个假 SSH 服务与静默端口,验证 banner 探测的在线/不匹配/超时三种判定、默认不回显版本、脱敏与私有配置分层,以及刷新接口的 429/403 行为。`verify-uptimerobot.cjs` 用合成 payload 做无网映射单测,真实 Key 冒烟为可选(未设置环境变量则跳过)。`verify-notify.cjs` 会临时起**假 SMTP 服务器与假 MeoW 服务器**,完整走一遍邮件握手与推送协议,并真跑三轮探针验证去抖、恢复、维护窗口抑制与事件落盘;真实证书握手需要外网,无网络时自动跳过(不计失败)。`verify-layout.cjs` 用极端内容(40 目标 / 6 分组 / 超长名称与 URL)在 13 种视口 × 2 种视图下断言零横向溢出**且行高完全一致**。`verify-admin.cjs` 先用 RFC 4226 / RFC 6238 官方测试向量校验自实现的 TOTP,再覆盖 2FA 绑定、会话生命周期、防重放、失败限流、HTTP 鉴权边界与页面流程。`verify-worker.cjs` 把 Worker 以 data: URL 动态载入(绕开 `cloudflare:sockets`),注入假的 KV / 静态资源 / UptimeRobot 服务器,**不需要 wrangler 也不需要联网**即可验证边缘逻辑。
代码约定:探针与面板通过 `status.json` / `/api/status` 的同一份 JSON 契约协作;新增探测类型时只需在 `prober/node/lib/` 与 `edge/cloudflare/worker.js` 各实现一个 `probeXxx(monitor)`,返回 `{ status, latency, code, detail }` 即可被面板直接渲染。
+282
View File
@@ -0,0 +1,282 @@
# 本地运行指南
全部跑在自己机器上:探针、面板、管理端都在 `http://localhost:8787`。
**零依赖** —— 不需要 `npm install`,只用 Node 自带模块。
---
## 最短路径(三条命令)
```bash
git clone https://github.com/tamakyi/shiroko-monitor.git
cd shiroko-monitor
node prober/node/index.js
```
打开 <http://localhost:8787/> 就能看到面板(内置了 8 个示例目标,第一次启动后约一分钟内有数据)。
要求:**Node.js ≥ 18**(`node -v` 查看)。
---
## 启动后有什么
| 地址 | 用途 |
| --- | --- |
| <http://localhost:8787/> | 公开面板(只读) |
| <http://localhost:8787/admin> | 管理端(需先绑定 2FA) |
| <http://localhost:8787/api/status> | 状态快照(JSON) |
启动日志会告诉你当前的探测范围与数据源,例如:
```
shiroko-monitor v0.12.3
面板: http://localhost:8787/
管理后台: http://localhost:8787/admin (仅本机可访问;需远程访问请设 settings.admin.allowRemote=true)
展示口径: both 探测范围: all —— 全部(自建 8 个)
```
---
## 三种运行模式
### 1. 常驻(默认)
```bash
node prober/node/index.js
```
按各目标自己的 `interval` 持续探测,Ctrl+C 停止。**适合长期使用。**
### 2. 跑一轮就退(CI 用)
```bash
node prober/node/index.js --once
```
探测一轮、写出 `public/status.json` 后退出。
### 3. 演示数据(不想真探测)
```bash
node prober/node/index.js --once --demo
```
生成一份假的状态快照,用于静态部署的首版占位。
### 常用参数
| 参数 | 作用 |
| --- | --- |
| `--config=<path>` | 指定公开配置(默认 `public/monitors.json`) |
| `--local-config=<path>` | 指定私有配置(默认 `local.monitors.json`) |
| `--out=<path>` | 指定快照输出(默认 `public/status.json`) |
| `--data-dir=<path>` | 指定私有数据目录(默认 `./data`,存通知去抖状态与事件) |
| `--test-notify` | 给所有已启用渠道发一条测试消息 |
| `PORT=9000 node prober/node/index.js` | 换端口(`PORT` 是环境变量,不是参数) |
---
## 配置自己的监控目标
编辑 `public/monitors.json` 的 `monitors` 数组:
```jsonc
{
"id": "my-site", // 唯一标识,改了会丢失该目标的历史
"name": "我的站点",// 显示名
"group": "生产环境",// 分组(面板按它分页签)
"type": "http",// http | tcp | banner
"url": "https://example.com",
"expectStatus": [200], // 哪些状态码算正常
"interval": 60,// 探测间隔(秒)
"timeout": 10 // 超时(秒)
}
```
TCP 与 banner 类型:
```jsonc
{ "id": "db-5432", "name": "数据库", "type": "tcp", "host": "10.0.0.5", "port": 5432, "interval": 60 }
{ "id": "ssh", "name": "SSH", "type": "banner", "host": "10.0.0.6", "port": 22,
"expectBanner": "^SSH-2.0", "timeout": 10 }// 匹配标识串正则
```
改完**自动热重载**(监听文件变化,约 200ms 生效),不用重启。
---
## 配置怎么分层
| 文件 | 是否入库 | 放什么 |
| --- | --- | --- |
| `public/monitors.json` | ✅ | 目标定义、非敏感设置(会随 GitHub Pages 公开) |
| `local.monitors.json` | ❌(`.gitignore`) | 真实地址、`apiKey`、私有覆盖 |
`local.monitors.json` **可选**,不存在也能跑。要用时自建:
```jsonc
{
"_comment": "按 id 覆盖 public/monitors.json 的同名条目,或覆盖 settings",
"settings": {
"uptimeRobot": { "enabled": true, "apiKey": "ur1769665-...", "pollIntervalSec": 300 },
"probeScope": "all"
},
"monitors": [
{ "id": "example-com", "url": "https://real-host.internal" } // 只写要覆盖的字段
]
}
```
> ⚠️ **API Key 绝不要写进 `public/monitors.json`** —— 它会随 Pages 部署公开。
---
## 接UptimeRobot
项目支持把UptimeRobot 上已有的监控结果接进来(作为「异地探测视角」与自建探测并列展示)。**只读**,用 `ur` 开头的只读 Key 即可。
```jsonc
// local.monitors.json
{
"settings": {
"uptimeRobot": {
"enabled": true,
"apiKey": "ur1769665-xxxxxxxx",
"pollIntervalSec": 300
},
"probeScope": "uptimerobot" // 只同步 UR,不探测本地配置的目标
}
}
```
也可以直接用环境变量:`UPTIMEROBOT_API_KEY=ur... node prober/node/index.js`
---
## 启用管理端(可选)
管理端能改配置、加维护窗口、发通知自检。**必须先绑定 2FA。**
```bash
# Windows PowerShell
$env:SHIROKO_ADMIN_SETUP_KEY='换成一串足够长的随机口令'
node prober/node/index.js
# macOS / Linux
export SHIROKO_ADMIN_SETUP_KEY='换成一串足够长的随机口令'
node prober/node/index.js
```
打开 <http://localhost:8787/admin>,四步绑定:
1. 输入 `SHIROKO_ADMIN_SETUP_KEY` → 生成密钥
2. 用验证器 App 扫码(或手动输入密钥 / 复制 `otpauth://` 链接)
3. 输入 App 显示的 6 位码 → 完成绑定
4. **保存 10 枚恢复码**(只显示这一次)
之后每次进入管理界面都要输 6 位动态码或一枚恢复码。
> 不设 `SHIROKO_ADMIN_SETUP_KEY` 时绑定入口直接关闭,管理端处于「完全不可用」状态 —— 这是安全的默认值。
---
## 通知(可选)
支持邮件(SMTP)与 [MeoW](https://www.chuckfang.com/MeoW/api_doc.html) 推送,写在 `local.monitors.json`:
```jsonc
{
"settings": {
"notify": {
"enabled": true,
"failureThreshold": 2,// 连续失败几次才告警
"recoveryNotify": true, // 恢复时也通知
"quietHours": "23:00-08:00",// 静默时段(不打扰)
"channels": {
"email": {
"enabled": true,
"host": "smtp.example.com",
"port": 465,
"secure": true, // 465 隐式 TLS;587 用 STARTTLS
"user": "[email protected]",
"pass": "授权码",
"from": "[email protected]",
"to": ["[email protected]"]
},
"meow": { "enabled": false, "nickname": "你的MeoW昵称" }
}
}
}
}
```
配好后自检一下:
```bash
node prober/node/index.js --test-notify
```
---
## 开机自启(可选)
**macOS / Linux(systemd)**
```ini
# /etc/systemd/system/shiroko-monitor.service
[Unit]
Description=shiroko-monitor
After=network-online.target
[Service]
WorkingDirectory=/opt/shiroko-monitor
ExecStart=/usr/bin/node prober/node/index.js
Restart=always
RestartSec=5
Environment=SHIROKO_ADMIN_SETUP_KEY=你的口令
Environment=SHIROKO_DATA_DIR=/var/lib/shiroko
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable --now shiroko-monitor
sudo journalctl -u shiroko-monitor -f
```
**Windows(NSSM)**
```powershell
nssm install ShirokoMonitor "C:\Program Files\nodejs\node.exe" "C:\shiroko-monitor\prober\node\index.js"
nssm set ShirokoMonitor AppDirectory C:\shiroko-monitor
nssm set ShirokoMonitor AppEnvironmentExtra SHIROKO_ADMIN_SETUP_KEY=你的口令
nssm start ShirokoMonitor
```
---
## 常见问题
**面板一直显示「等待检测」**
第一次启动后需要等第一轮探测完成(按各目标的 `interval`,最长可能几分钟)。看日志确认探测范围。
**面板是空的**
检查日志里的「探测范围」。若显示「仅 UptimeRobot(0 个)」说明你把 `dataFilter` 设成了 `uptimerobot` 但没配 API Key —— 把它改成 `both`,或配置 `uptimeRobot.apiKey`。
**想改端口**
`PORT=9000 node prober/node/index.js`(环境变量,不是命令行参数)。
**端口被占用**
`PORT=9000 node prober/node/index.js` 换一个。
**改了配置没生效**
文件监听有 200ms 防抖,稍等即可;若是 `local.monitors.json`,确认 JSON 格式正确(尾逗号会导致解析失败,此时日志会打印 `[config] 重载失败`)。
**数据存在哪**
`public/status.json`(快照,会入库)与 `data/`(通知去抖状态、事件时间线、2FA 档案,不入库)。换机器时前者可以不带。
**内存占用**
常驻约 60–80 MB RSS。目标多、心率历史长时会略增。
+200
View File
@@ -0,0 +1,200 @@
# 部署指南:GitHub Pages 面板 + 本地探针
面向的场景:**面板托管在 GitHub Pages(公开、免运维),探测跑在自己家里/内网的机器上**。
```
┌─────────────────────────────┐ ┌──────────────────────────────────┐
│ GitHub Pages(静态托管) │ HTTPS │ 你自己的机器(内网/家用) │
│ ├─ index.html + assets/ │◄────────┤ ├─ Node 探针:持续探测 │
│ └─ 读 status.json 取配置 │ fetch │ ├─ /admin 管理端(2FA 保护) │
│ │ │ └─ 写 local.monitors.json │
└─────────────────────────────┘ └──────────────────────────────────┘
```
为什么面板不能直接读本地探针?因为 GitHub Pages 是**纯静态**的,只能读仓库里的文件。
数据要上去,靠两件事:**跨域读接口**(本指南的方式)或 **Actions 提交快照**(下文备选方案)。
---
## 一、先决条件
| 条件 | 说明 |
| --- | --- |
| **本地探针有 HTTPS 地址** | Pages 强制 HTTPS,浏览器禁止 HTTPS 页面请求 HTTP(混合内容)。用你自己的隧道 / 反代给探针套一层 HTTPS |
| Node.js ≥ 18| 零依赖,只需 Node 自带模块 |
| 一个 GitHub 仓库 | 公开仓库即可(Pages 免费额度对公开仓库无限) |
> ⚠️ **混合内容是硬限制**:即使 CORS 配对了,只要探针是 `http://`,浏览器仍会直接拦截请求,控制台只留一行 `Mixed Content ... was blocked`。这一条没有绕过办法。
---
## 二、本地:跑探针
```bash
git clone <你的仓库>
cd shiroko-monitor
# 私有配置(可选):真实地址、API Key 等敏感信息放这里,不入库
node prober/node/index.js
```
<details>
<summary>想用私有配置文件?</summary>
`local.monitors.json` 是可选的,不存在也能跑(会直接用 `public/monitors.json`)。
需要拆分敏感信息时自建即可,结构如下:
```jsonc
// local.monitors.json(已在 .gitignore 中)
{
"_comment": "按 id 覆盖 public/monitors.json 的同名条目,或覆盖 settings",
"settings": {
"uptimeRobot": { "enabled": true, "apiKey": "ur...", "pollIntervalSec": 300 },
"probeScope": "all"
},
"monitors": [
{ "id": "example-com", "url": "https://real-internal-host.example" }
]
}
```
</details>
启动后确认:
```
面板:http://localhost:8787/
管理后台: http://localhost:8787/admin
```
### 让探针能被外部访问
默认它**只监听 `127.0.0.1`**。要让隧道/反代连进来,在 `local.monitors.json` 里打开:
```jsonc
{
"settings": {
"admin": { "allowRemote": true } // 监听 0.0.0.0
}
}
```
> 打开后同一端口上的公开面板也会对外可达(只读,但目标名称/地址/可用率会暴露)。
> 如果不希望这样,请在隧道那层按路径只放行 `/admin` 与 `/api/admin/*`。
### 绑定管理端 2FA(必做)
管理端能改配置、发通知,必须先绑定两步验证:
```bash
# 首次绑定需要这个口令(只从环境变量读,不写进任何文件)
export SHIROKO_ADMIN_SETUP_KEY='换成一串足够长的随机口令'
node prober/node/index.js
```
然后打开 `https://你的隧道地址/admin`,按提示扫码绑定、保存恢复码。
---
## 三、页面:部署到 GitHub Pages
### 1. 开启 Pages
仓库 **Settings → Pages → Build and deployment → Source** 选 **GitHub Actions**。
### 2. 推送代码
```bash
git push
```
### 3. 填两处配置(关键)
打开 `https://你的隧道地址/admin`,在「口径与范围」区块填:
| 字段 | 填什么 |
| --- | --- |
| **面板上的管理入口链接** | `https://你的隧道地址/admin` —— Pages 面板页脚会出现「管理」按钮跳过去 |
| **跨域数据源地址** | `https://你的隧道地址`(不带路径)—— 面板从这里取数据 |
| **允许跨域读取的页面地址** | `https://你的用户名.github.io` —— 必须是完整来源,含协议 |
填完立刻生效。回本地仓库提交改动:
```bash
git add public/monitors.json && git commit -m "chore: 配置跨域数据源" && git push
```
> 配置写在 `local.monitors.json`(私有层)的话,Pages 上的面板读不到 ——
> 它只能读仓库里的 `public/monitors.json`。所以上面的两步(管理界面填 → 提交仓库)都要做。
### 4. 验证
打开 `https://你的用户名.github.io/<仓库名>/`,检查:
- 页脚数据源显示「**远程探针(跨域)**」—— 说明跨域打通了
- 表格有数据且状态正常
- 页脚有「管理」按钮,点击能跳到管理端
- **页面上没有「刷新」按钮** —— 跨域模式下该接口必然被`refreshLocalOnly` 挡下,隐藏比给一个失败的按钮好
---
## 四、安全要点
| 事项 | 说明 |
| --- | --- |
| **管理端 2FA 必须绑定** | TOTP(Google Authenticator 等)+ 10 枚一次性恢复码 |
| **恢复码离线保存** | 只在绑定时显示一次,用掉一枚少一枚 |
| **跨域白名单不要图省事写 `*`** | 代码里不存在 `*` 分支,写了也不生效。必须写完整来源,如 `https://you.github.io` |
| **管理端永不放行跨域** | `/admin` 与 `/api/admin/*` 不带任何 `Access-Control-*` 头,外部页面既读不到也改不了 |
| **会话** | 30 分钟无操作失效,最长 2 小时;进程重启全部失效 |
| **登录限流** | 同一 IP 连续 5 次失败后指数退避锁定 |
| **API Key** | 放 `local.monitors.json` 或环境变量,**绝不要写进 `public/monitors.json`**(会随Pages 公开) |
---
## 五、备选:不用跨域,改用 Actions 提交快照
如果不想暴露探针的 HTTPS 地址,可以让数据「流」到 Pages 而不是「拉」:
```yaml
# .github/workflows/monitor.yml(示意)
on:
schedule: [{ cron: '*/5 * * * *' }]
workflow_dispatch:
jobs:
probe:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: node prober/node/index.js --once --config=public/monitors.json
- run: |
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
git add public/status.json
git diff --staged --quiet || git commit -m "chore: status" && git push
```
面板用 `?source=static` 读仓库里的 `status.json`。
**取舍**:探测跑在 GitHub Runner 上(不是你的机器),延迟 ≤5 分钟,且**没有通知、维护窗口、事件、2FA 管理端**。适合纯展示需求。
---
## 六、常见问题
**面板一直转圈 / 报错**
浏览器控制台看具体报错:
- `Mixed Content ... was blocked` → 探针地址不是 HTTPS,回第一节
- `blocked by CORS policy` → 「允许跨域读取的页面地址」没填对,或与实际访问地址不一致(含 `www`、端口、大小写)
- `Failed to fetch` 且数据源显示「浏览器直连」 → 面板拿不到数据,退到了兜底模式
**页脚没有「管理」按钮**
「面板上的管理入口链接」为空(默认就是空的,为了不在公网暴露管理面位置)。
**跨域下没有刷新按钮**
这是预期行为。跨域模式下 `/api/refresh` 会被服务端的 `refreshLocalOnly` 挡下(对端是代理地址而非私网),前端自动隐藏了这个按钮。
**改了配置但面板没变**
管理端写的是 `local.monitors.json`(私有层)。如果那部分设置需要 Pages 上的面板看到,得手动同步到 `public/monitors.json` 并提交。
**本机开发时前端跨域被拒**
默认白名单放行 `localhost` / `127.0.0.1`且**忽略端口**,所以 `localhost:5173` 这类能直接用。若你在局域网 IP 上跑前端,需把那个地址显式加进白名单。
+55
View File
@@ -73,9 +73,36 @@ wrangler deploy
- 手动触发一轮:`curl https://<worker 域名>/api/refresh`(等待本轮完成后返回最新快照,与本地探针同语义)
- cron 表达式在 `wrangler.toml` 的 `[triggers].crons` 中调整(`* * * * *` = 每分钟)
### 接入 UptimeRobot 数据源
Worker 与本地探针共用同一套 UptimeRobot 语义(只读,映射逻辑见 `edge/cloudflare/uptimerobot.js`)。API Key 用 secret 传入,**不要写进 `monitors.json`**(它会随静态资源公开):
```bash
wrangler secret put UPTIMEROBOT_API_KEY # 填 ur... 开头的只读 Key
```
同时在 `public/monitors.json` 的 `settings` 里开启(`apiKey` 留空,由 secret 提供):
```jsonc
{
"settings": {
"dataFilter": "uptimerobot", // 只展示 UptimeRobot 目标
"uptimeRobot": { "enabled": true, "apiKey": "" }
}
}
```
行为与本地探针一致:
- `probeScope` 决定真正发请求的范围(`all` / `config` / `uptimerobot`),未显式设置时跟随 `dataFilter`
- UptimeRobot 目标只拉取不探测,采用其状态并补齐本地 `daily` 分桶
- 同步失败时**保留上次数据**并把 `sources.uptimerobot.ok` 置为 `false`,绝不因此把目标判为离线
- UR 侧删除的监控会从面板移除,避免残留僵尸条目
注意事项:
- **TCP 出站连接(`cloudflare:sockets`)需要 Workers 付费计划($5/月)**;免费计划下 `http` 类型探测与面板完全正常,`tcp` 类型会报错并记为离线
- **banner 类型边缘端不支持**(需原始 socket 读取),会记为离线并给出说明文案;这类目标请用本地探针
- KV 为最终一致存储,刚写入后立即读取可能有秒级延迟,对分钟级巡检无影响
- 90 天可用率分桶随 state 一起存于 KV,按 **UTC 日期** 分桶(边缘环境无本地时区);每分钟 cron 约 1440 次/天
- 免费版 Worker 每日 10 万次请求,面板静态资源走 Assets 不计入 Worker 请求数
@@ -126,7 +153,35 @@ EdgeOne Pages 当前以静态托管为主,采用与方案二相同的「静态
| TCP 探测 | ✅ | ✅(Actions 内) | 付费版 ✅ / 免费版 ❌ | 同 GH Pages |
| 真实状态码 | ✅ | ✅ | ✅ | ✅ |
| 内网目标 | ✅(浏览器同网即可) | ❌ | ❌ | ❌ |
| UptimeRobot 数据源 | ✅ | ✅ | ✅ | ❌(需改用自建探测) |
| 通知 / 维护窗口 / 事件 | ✅ | ❌ | ❌ | ❌ |
| **管理端(2FA)** | ✅ | ❌ | ❌ | ❌ |
| 国内访问速度 | 本机最快 | 一般 | 一般 | 快 |
| 运维成本 | 需保活进程 | 零 | 零 | 零 |
建议:个人/演示用 **GitHub Pages**;生产公网监控用 **Cloudflare Workers**;服务对象在国内且以 HTTP 监控为主用 **EdgeOne Pages**;内网资产用 **本地 Node**。
> ⚠️ **管理端只存在于「本地 Node」部署形态。** 其余形态的部署环境只提供只读面板与探测,没有任何写接口 —— Cloudflare Worker 上访问 `/admin` 或 `/api/admin/*` 会得到 403(而非 404,因为那里确实不提供管理能力)。需要管理操作(改配置、维护窗口、通知自检、立即重探)时,在本地 Node 那一侧完成。
>
> 原因不是「没做」,而是这些能力依赖本地状态:2FA 档案与会话存在 `data/` 目录与进程内存里,而 Workers 无文件系统、会话状态也无法跨 isolate 共享(KV 又是最终一致,无法承载「恢复码一次性」这类语义)。把这个结论说清楚,比在边缘环境里做一个半可靠的管理端更有价值。
---
## 远程访问管理端
探针**默认只监听 `127.0.0.1`**,外部访问不到。需要时用 `settings.admin.allowRemote` 打开:
```jsonc
// local.monitors.json
{ "settings": { "admin": { "allowRemote": true } } }
```
打开后探针监听 `0.0.0.0`,启动日志会明确警告 —— 提醒你该端口同时伺服公开面板,前置层必须启用 HTTPS。
**怎么把流量送到这个端口由你决定**:反向代理、隧道、VPN、直连公网 IP 都行,本项目不做任何假设,也不会替你生成某种特定的部署配置。
### 让面板知道管理端在哪
管理界面里有一项「**面板上的管理入口链接**」:填上管理端的实际地址后,公开面板页脚会出现一个「管理」按钮指向它;留空则不显示(默认,避免在公网面板上暴露管理面的位置)。地址只接受 `http` / `https`。
这样部署形态与面板展示就解耦了:面板部署在 Cloudflare、管理端在别处、访客从面板点进去 —— 中间怎么连通由你的网络决定。
+86 -3
View File
@@ -147,9 +147,92 @@ banner 探测的副作用仅有一次 TCP 连接与读取,与端口扫描器
---
## 四、其他基线
## 四、管理后台(`/admin`)的鉴权边界
v0.12.0 起,所有会改配置的操作都放在 `/admin`,**必须绑定并通过两步验证才能进入**。
### 部署形态与可写能力
管理端**只存在于「本地 Node 探针」这一种部署形态**。各形态的可写面:
| 部署形态 | 面板可读 | 探测 | 管理端(2FA) |
| --- | --- | --- | --- |
| 本地 Node | ✅ | ✅ | ✅ |
| Cloudflare Workers | ✅ | ✅ | ❌ 403 |
| GitHub Pages / EdgeOne Pages(纯静态) | ✅ | ❌(仅展示 CI 产出的快照) | ❌ 不存在 |
边缘形态访问 `/admin` 或 `/api/admin/*` 会得到 **403 + 指向本文的提示**,而不是 404 —— 明确表达「这里不提供写能力」,避免访客把 404 误读成路径写错。
这不是「没做」,而是这些能力依赖本地状态:2FA 档案与会话存在 `data/` 目录与进程内存中,而 Workers 无文件系统、isolate 之间不共享内存(KV 又是最终一致,无法承载「恢复码一次性使用」这类语义)。
### 远程访问
探针在内网时若需从外网访问 `/admin`,用 `settings.admin.allowRemote` 打开远程访问(**默认关闭**,此时只监听 `127.0.0.1`):
```jsonc
// local.monitors.json
{ "settings": { "admin": { "allowRemote": true } } }
```
**怎么把流量送到这个端口由你决定**(反向代理 / 隧道 / VPN / 直连公网 IP),本项目不做任何假设。唯一要提醒的是:打开后该端口同时伺服公开面板,也就是说 `/` 与 `/api/status` 也会跟着对外可达 —— 这两项本身只读,但目标名称、地址与可用率会一并暴露。若不希望如此,请在你自己的那层按路径收窄。
### 配置接口不得泄露密钥
`/api/monitors` 与 Worker 上的同名接口都**无鉴权**,因此对外响应一律剥离 `uptimeRobot.apiKey`(保留 `enabled` 等非敏感字段供面板读取)。探针内部同步 UptimeRobot 仍使用完整配置,剥离只作用于 HTTP 出口。`/api/status` 与静态 `monitors.json` 同样不含私有层内容。
### 跨域只读白名单(CORS)
面板部署在别处(GitHub Pages / Cloudflare)、探针在另一台机器时,需要让指定页面跨域读取状态与配置。配置项:`settings.dataUrl`(面板去哪里取数据)与 `settings.corsOrigins`(允许哪些页面读取)。
核心风险是「任意网站都能读走监控数据」,措施:
| 风险 | 措施 |
| --- | --- |
| 开放成 `*` 导致任意网站可读 | **代码里不存在 `*` 分支**:白名单里写 `*` 不会放行任何来源(已加断言) |
| 本机回环被任意端口冒用 | 默认只放行 `localhost` / `127.0.0.1` 且**忽略端口**(本地前端常跑 5173);其余必须精确匹配完整 origin |
| 前缀伪造域名(`https://you.github.io.evil.com`) | 精确字符串匹配,不做子串判断(已加断言) |
| 协议降级(`https://localhost` 冒用 http 的默认项) | 协议必须一致才走回环豁免(已加断言) |
| 中间缓存串味(把 A 的响应给 B) | 回显具体 origin 并带 `Vary: Origin` |
| **外部页面读取或改写管理端** | `/admin` 与 `/api/admin/*` **一律不带 `Access-Control-*` 头**,预检直接 403(已加断言) |
| 外部页面借 CORS 触发有副作用的调用 | `/api/admin/*` 完全不放行;`/api/refresh` 另有 `refreshDecision` 的限流与 `refreshLocalOnly` 兜底 |
| 跨域读到密钥 | 跨域响应与同源响应走同一套剥离逻辑,`uptimeRobot.apiKey` 不会回吐(已加断言) |
### 威胁模型与对应措施
| 威胁 | 措施 |
| --- | --- |
| 未绑定就被抢先接管管理端 | 绑定需 `SHIROKO_ADMIN_SETUP_KEY`;未绑定时 `/api/admin/*` 除绑定流程外**一律 403**;未设置该环境变量时绑定入口直接关闭(503) |
| 未授权者经外部网络访问管理端 | 打开 `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`**(可被任意伪造)。若前面挂了反向代理/隧道,对端地址是代理节点,因此限流是按代理 IP 而非真实客户端统计的 —— 这是有代理层时的已知粒度损失 |
| 篡改请求 | `Path=/` + `HttpOnly` + `SameSite=Strict`;请求体上限 64KB,超出返回 413;参数逐项白名单校验(如 `probeScope` 只接受三个枚举值、`adminUrl` 只接受 http/https) |
| 通过管理端把密钥写进仓库 | **所有写操作只落 `local.monitors.json`**(私有、已 gitignore),绝不改 `public/monitors.json` |
### 已知限制(请按需叠加外部措施)
- **会话 cookie 未设 `Secure`**:纯 HTTP 下设置 `Secure` 会导致 cookie 完全不发送,管理端直接不可用。部署到公网时请放在 HTTPS 反向代理之后,由代理层负责加密传输
- **绑定口令 `SHIROKO_ADMIN_SETUP_KEY` 只能从环境变量读取**,不写入任何文件;重启后需要重新设置才能再次绑定或重置
- **2FA 档案(`data/admin-2fa.json`)是明文 secret**:该文件权限应仅限运行用户,且 `data/` 已在 `.gitignore` 中
- **会话存在内存里**:多实例部署时各实例的会话不共享,需要粘性会话或改为单实例
- **忘记 2FA 且忘记绑定口令**时只能停服务、删除 `data/admin-2fa.json` 后重新绑定
### 验证方式
```bash
node scripts/verify-admin.cjs # 102 项:RFC 向量 / 绑定 / 会话 / 防重放 / 限流 / HTTP 边界 / 页面流程
```
该脚本会先用 **RFC 4226 与 RFC 6238 官方测试向量**校验自实现的 TOTP(这是整个 2FA 的地基),再覆盖绑定流程、会话生命周期、恢复码一次性、防重放、失败限流,以及「未绑定时所有写接口必须 403」这类鉴权边界。
## 五、其他基线
- `.gitignore` 已包含 `local.monitors.json` 与 `.dev.vars`(Cloudflare 本地密钥)
- Worker 的 `REFRESH_TOKEN` 用 `wrangler secret put REFRESH_TOKEN` 管理,不要写进 `wrangler.toml`
- 面板为只读前端:无 cookie、无写入接口、无服务端会话;渲染配置字段时统一做 HTML 转义(`escapeHtml`),XSS 面仅剩配置自身
- Node 探针同时充当静态服务器,**不要直接暴露公网**;确实需要时在其前方加带认证的反向代理,并设 `allowRefresh: false`
- **UptimeRobot API Key 在 Worker 上必须用 `wrangler secret put UPTIMEROBOT_API_KEY`**,写进 `public/monitors.json` 会随静态资源公开。只用 `ur*` 开头的只读 Key
- **公开面板**(`/`)仍是只读前端:无 cookie、无写入接口、无服务端会话;渲染配置字段时统一做 HTML 转义(`escapeHtml`),XSS 面仅剩配置自身。管理能力全部隔离在 `/admin` 与 `/api/admin/*`(需会话)
- Node 探针同时充当静态服务器,**默认只监听 `127.0.0.1`**;需要远程访问时用 `settings.admin.allowRemote=true` 显式打开,并确保前置代理已启用 HTTPS
Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 441 KiB

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 214 KiB

+128
View File
@@ -0,0 +1,128 @@
/**
* UptimeRobot 只读数据源(API v2)—— Cloudflare Workers 版
*
* 与 prober/node/lib/uptimerobot.js 共用同一份语义(术语对齐见该文件顶部注释),
* 但按 Workers 环境改了两处:
* 1. 没有 node:crypto / node:fs,API Key 从 env(wrangler secret)读取
* 2. 限流改为「按 KV 里记录的上次同步时间判断」,KV 最终一致对分钟级同步无影响
*
* 只读:仅调用 getMonitors,不做任何写操作,因此用 ur* 开头的只读 Key 即可。
*/
const API_URL = 'https://api.uptimerobot.com/v2/getMonitors';
const TYPE_LABEL = { 1: 'HTTP', 2: 'KEYWORD', 3: 'PING', 4: 'PORT' };
// 暂停/未检测都归入 pending:否则会被误报成「离线」
const STATUS_MAP = { 0: 'pending', 1: 'pending', 2: 'up', 8: 'down', 9: 'down' };
function splitNums(value) {
return String(value ?? '').split('-').map(Number).filter(Number.isFinite);
}
function ratiosToObject(value, ratios) {
const nums = splitNums(value);
const out = {};
ratios.forEach((r, i) => { if (Number.isFinite(nums[i])) out[r] = nums[i]; });
return out;
}
function targetOf(m) {
if (m.type === 4 && m.port) return `${m.url ? `${m.url}:` : ''}${m.port}`;
return m.url || '';
}
function latestDownReason(logs) {
const down = (logs ?? []).find((l) => l.type === 1);
const reason = down?.reason;
if (!reason) return null;
const code = String(reason.code ?? '').trim();
const detail = String(reason.detail ?? '').trim();
return { code: code || null, text: [code, detail].filter(Boolean).join(' ') || null };
}
/** 把 UptimeRobot 的 getMonitors 响应映射成内部结构(纯函数,可离线单测) */
export function mapUptimeRobotMonitors(payload, options = {}) {
const group = options.group || 'UptimeRobot';
const ratios = options.ratios ?? [1, 7, 30, 90];
const now = options.now ?? Date.now();
const historyLimit = options.historyLimit ?? 48;
const monitors = [];
for (const m of payload?.monitors ?? []) {
const status = STATUS_MAP[m.status] ?? 'pending';
const samples = Array.isArray(m.response_times) ? m.response_times : [];
const logs = Array.isArray(m.logs) ? m.logs : [];
// UR 侧的真实检测时刻:正常取最新响应样本,故障取最近一次故障日志
const lastCheckSec = status === 'down' ? logs.find((l) => l.type === 1)?.datetime : samples[0]?.datetime;
const lastCheckIso = new Date((Number(lastCheckSec) || Math.floor(now / 1000)) * 1000).toISOString();
const history = samples
.slice(0, historyLimit)
.map((s) => ({ t: new Date(s.datetime * 1000).toISOString(), ok: Number(s.value) > 0, ms: Math.max(0, Number(s.value) || 0) }))
.reverse();
if (status === 'down') history.push({ t: lastCheckIso, ok: false, ms: 0 });
const last = samples[0]?.value;
const avg = m.average_response_time;
const latency = Number.isFinite(Number(last)) && Number(last) > 0
? Math.round(Number(last))
: Number.isFinite(Number(avg)) ? Math.round(Number(avg)) : null;
const reason = latestDownReason(logs);
let detail;
if (status === 'up') detail = 'UptimeRobot:运行正常';
else if (status === 'down') detail = `UptimeRobot:${reason?.text ?? '连接失败'}`;
else detail = m.status === 0 ? 'UptimeRobot:监控已暂停' : 'UptimeRobot:尚未检测';
monitors.push({
id: `ur-${m.id}`,
name: m.friendly_name || `UptimeRobot #${m.id}`,
group,
type: 'ur',
url: targetOf(m),
source: 'uptimerobot',
sourceId: m.id,
sourceType: m.type,
sourceLabel: TYPE_LABEL[m.type] ?? `TYPE${m.type}`,
interval: Number(m.interval) || 300,
status,
latency,
code: status === 'down' && reason?.code ? Number(reason.code) || null : null,
detail,
lastCheckIso,
history,
uptimeRatios: ratiosToObject(m.custom_uptime_ratio, ratios),
downSeconds: ratiosToObject(m.custom_down_durations, ratios),
});
}
return monitors;
}
/**
* 拉取 UptimeRobot 监控列表。
* @param {string} apiKey
* @param {{ratios?: number[], timeoutMs?: number}} [options]
*/
export async function fetchUptimeRobotMonitors({ apiKey, ratios = [1, 7, 30, 90], timeoutMs = 15000 }) {
if (!apiKey) throw new Error('缺少 UptimeRobot API Key');
const body = new URLSearchParams({
api_key: apiKey,
format: 'json',
response_times: '1',
response_times_average: '30',
custom_uptime_ratios: ratios.join('-'),
logs: '1',
log_types: '1-2',
// 注意:custom_down_durations 不能作为请求参数传入(v2 会返回 invalid_parameter)
});
const res = await fetch(API_URL, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
body,
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) throw new Error(`UptimeRobot HTTP ${res.status}`);
const payload = await res.json();
if (payload.stat !== 'ok') throw new Error(`UptimeRobot 返回错误:${payload.error?.message || payload.error?.type || 'unknown'}`);
return mapUptimeRobotMonitors(payload, { ratios });
}
+124 -7
View File
@@ -2,16 +2,21 @@
* shiroko-monitor 边缘探针(Cloudflare Workers)
*
* - scheduled:cron 每分钟触发一轮探测(HTTP 用 fetch,TCP 用 cloudflare:sockets)
* 探测目标 = 配置里的自建目标 + UptimeRobot 只读镜像目标(与本地 Node 探针同口径)
* - 状态与历史心率整体存于 KV 的 state 键(小规模单键足够,上限 25MB)
* - /api/status、/api/monitors、/api/refresh 由本 Worker 提供,其余请求交给静态资源
*
* 管理端说明:本 Worker 不提供任何写接口,/admin 与 /api/admin/* 一律拒绝
* (管理操作在本地 Node 探针侧完成,见 docs/DEPLOYMENT.md)。
*
* 注意:cloudflare:sockets 的 TCP 出站连接需要 Workers 付费计划;
* 免费计划下 http 类型探测与静态面板均可正常使用,tcp 类型会返回 down。
*/
import { connect } from 'cloudflare:sockets';
import { fetchUptimeRobotMonitors } from './uptimerobot.js';
const VERSION = '0.11.1';
const VERSION = '0.12.2';
const HISTORY_LIMIT = 48;
const UPTIME_DAYS = 90;
const USER_AGENT = 'shiroko-monitor/0.3 (lightweight uptime panel)';
@@ -35,17 +40,28 @@ 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);
if (!gate.ok) return json({ error: gate.error }, { status: gate.status });
return json(await runChecksDedup(env));
}
// 管理端只存在于本地 Node 探针:这里显式拒绝而非落到静态资源,
// 避免访客把 404 误读成「路径写错」,也确保 Worker 侧没有任何可写面
if (url.pathname === '/admin' || url.pathname.startsWith('/api/admin')) {
return json({
error: 'admin-not-here',
hint: '管理端(含 2FA)只在本地 Node 探针上运行,不在 Cloudflare Worker 上。部署方式见 docs/DEPLOYMENT.md',
}, { status: 403 });
}
return env.ASSETS.fetch(request);
},
};
// 供 scripts/verify-worker.cjs 离线单测 runChecks(生产路径不依赖它)
export const __test = { runChecks, mapStatus: null };
// 未配置 REFRESH_TOKEN 时一律拒绝(wrangler secret put REFRESH_TOKEN 开启);
// 比较采用等长逐字节异或,避免按字符提前返回带来的时序差异
function refreshGate(env, request) {
@@ -94,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));
@@ -154,14 +178,98 @@ async function runChecks(env) {
const state = (await env.MONITOR_KV.get('state', 'json')) ?? { monitors: {} };
const monitors = state.monitors ?? {};
const redact = config.settings?.redactDetails === true;
// 探测范围与展示口径解耦:dataFilter 只管展示,probeScope 决定真正发请求的范围
const filter = ['both', 'config', 'uptimerobot'].includes(config.settings?.dataFilter) ? config.settings.dataFilter : 'both';
const scope = ['all', 'config', 'uptimerobot'].includes(config.settings?.probeScope)
? config.settings.probeScope
: (filter === 'both' ? 'all' : filter);
// UptimeRobot 目标:只拉取不探测。同步失败时保留上次数据,绝不因此把目标判为离线
const urCfg = config.settings?.uptimeRobot ?? {};
const urKey = String(urCfg.apiKey || '').trim();
const urEnabled = urCfg.enabled === true && Boolean(urKey);
const urStatus = state.sources?.uptimerobot ?? { enabled: false, ok: null, count: 0 };
let urList = [];
if (scope !== 'config' && urEnabled) {
try {
const ratios = Array.isArray(urCfg.ratios) && urCfg.ratios.length ? urCfg.ratios.map(Number).filter(Number.isFinite) : [1, 7, 30, 90];
urList = await fetchUptimeRobotMonitors({ apiKey: urKey, ratios });
urStatus.enabled = true;
urStatus.ok = true;
urStatus.error = null;
urStatus.count = urList.length;
urStatus.lastSyncAt = new Date().toISOString();
} catch (err) {
urStatus.enabled = true;
urStatus.ok = false;
urStatus.error = String(err?.message ?? err);
}
}
// 本轮要处理的全部目标:自建(scope 含 config)+ UR 镜像(scope 含 uptimerobot)
const localTargets = scope === 'uptimerobot' ? [] : (config.monitors ?? []);
const urTargets = scope === 'config' ? [] : urList;
const urById = new Map(urTargets.map((m) => [m.id, m]));
let up = 0;
let down = 0;
let pending = 0;
let latencySum = 0;
let latencyCount = 0;
// 本次同步没拿到数据时(失败或被限流跳过),沿用上一轮已写入快照的 UR 目标:
// 直接保留其 status / meta / uptimeRatios,只更新 daily 分桶,绝不把目标改成离线或删掉。
// 注意不能直接复用 urTargets 里的旧对象——那需要 meta 里的名称/地址,缺了就渲染不出行。
const urSource = urTargets.length
? urTargets.map((m) => ({ ...m, _fresh: true }))
: Object.entries(monitors)
.filter(([, s]) => s.meta?.source === 'uptimerobot')
.map(([id, s]) => ({
id,
name: s.meta.name,
group: s.meta.group,
url: s.meta.url,
sourceLabel: s.meta.sourceLabel,
interval: s.meta.interval,
status: s.status,
latency: s.latency,
code: s.code,
detail: s.detail,
lastCheckIso: s.lastCheck,
history: s.history ?? [],
uptimeRatios: s.uptimeRatios,
_fresh: false,
}));
for (const m of urSource) {
const prev = monitors[m.id];
const daily = { ...(prev?.daily ?? {}) };
const key = dayKey();
const bucket = daily[key] ?? (daily[key] = { total: 0, up: 0 });
bucket.total++;
if (m.status === 'up') bucket.up++;
pruneDaily(daily);
monitors[m.id] = {
status: m.status,
latency: m.latency ?? null,
code: m.code ?? null,
detail: redact ? (m.status === 'up' ? '正常' : '异常') : m.detail,
lastCheck: m.lastCheckIso ?? new Date().toISOString(),
// meta 让面板认出这是外部数据源目标(名称/分组/协议都来自 UR 侧)
meta: { name: m.name, group: m.group ?? 'UptimeRobot', url: m.url ?? '', source: 'uptimerobot', sourceLabel: m.sourceLabel ?? 'HTTP', interval: m.interval ?? 300, type: 'ur' },
uptimeRatios: m.uptimeRatios,
history: m.history ?? [],
daily,
};
if (m.status === 'up') { up++; if (m.latency != null) { latencySum += m.latency; latencyCount++; } }
else if (m.status === 'down') down++;
else pending++;
}
await Promise.all(
(config.monitors ?? []).map(async (m) => {
localTargets.map(async (m) => {
const prev = monitors[m.id];
const r = await probeMonitor(m);
r.checkedAt = new Date().toISOString();
@@ -187,12 +295,21 @@ async function runChecks(env) {
up++;
latencySum += r.latency ?? 0;
latencyCount++;
} else {
} else if (r.status === 'down') {
down++;
} else {
pending++;
}
})
);
// UR 侧已删除的监控要从面板移除,避免残留僵尸条目
if (scope !== 'config' && urEnabled && urStatus.ok && urById.size) {
for (const [id, s] of Object.entries(monitors)) {
if (s.meta?.source === 'uptimerobot' && !urById.has(id)) delete monitors[id];
}
}
let uptimeUp = 0;
let uptimeTotal = 0;
for (const m of Object.values(monitors)) {
@@ -204,7 +321,6 @@ async function runChecks(env) {
// 与本地探针保持同一份快照口径:dataUpdatedAt / updateIntervalSec / dataFilter
// 面板据此把倒计时对齐服务端节奏,并按同一过滤口径展示
const filter = ['both', 'config', 'uptimerobot'].includes(config.settings?.dataFilter) ? config.settings.dataFilter : 'both';
const intervals = [];
if (filter !== 'uptimerobot') {
for (const m of config.monitors ?? []) {
@@ -221,15 +337,16 @@ async function runChecks(env) {
dataUpdatedAt: state.generatedAt ?? new Date().toISOString(),
updateIntervalSec: Math.max(5, Math.round(intervals.length ? Math.min(...intervals) : 60)),
dataFilter: filter,
probeScope: scope,
sources: { uptimerobot: { ...urStatus, syncing: false } },
stats: {
total: (config.monitors ?? []).length,
total: localTargets.length + urSource.length,
up,
down,
pending,
avgLatency: latencyCount ? Math.round(latencySum / latencyCount) : null,
avgUptime90: uptimeTotal ? Math.round((uptimeUp / uptimeTotal) * 1000) / 10 : null,
},
sources: {},
monitors,
};
await env.MONITOR_KV.put('state', JSON.stringify(next));
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "shiroko-monitor",
"version": "0.1.0",
"description": "轻量级服务监控面板:纯静态前端 + Node/边缘探针,支持 HTTP 与 TCP 探测",
"version": "0.12.3",
"description": "轻量级服务监控面板:纯静态前端 + Node/边缘探针,支持 HTTP/TCP 探测、UptimeRobot 数据源与 2FA 管理端",
"type": "module",
"scripts": {
"start": "node prober/node/index.js",
+129 -8
View File
@@ -22,6 +22,8 @@ import { probeBanner } from './lib/banner-probe.js';
import { fetchUptimeRobotMonitors } from './lib/uptimerobot.js';
import { notifyAll, sendTestNotifications, createMeoWLimiter } from './lib/notify.js';
import { probeCertificate, tlsTargetOf } from './lib/tls-probe.js';
import { createAdminAuth } from './lib/admin.js';
import { createAdminRouter } from './lib/admin-routes.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '../..');
@@ -37,7 +39,7 @@ const CONCURRENCY = 8;
const WRITE_DEBOUNCE_MS = 300;
const DEFAULT_INTERVAL_SEC = 60;
const PORT = Number(process.env.PORT || 8787);
const VERSION = '0.11.1';
const VERSION = '0.12.2';
const UR_DEFAULT_POLL_SEC = 300;
const UR_MIN_REFRESH_GAP_MS = 60000;
@@ -99,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'));
@@ -405,6 +421,8 @@ function writeJson(file, data) {
const NOTIFY_STATE_PATH = () => path.join(DATA_DIR, 'notify-state.json');
const EVENTS_PATH = () => path.join(DATA_DIR, 'events.json');
// 2FA 档案:与通知状态同放私有数据目录(data/ 已在 .gitignore 中)
const ADMIN_2FA_PATH = () => path.join(DATA_DIR, 'admin-2fa.json');
function monitorNotifyState(runtime, id) {
const list = runtime.notifyState.monitors;
@@ -857,6 +875,56 @@ function sendJson(res, data, { status = 200, headers = {} } = {}) {
res.end(JSON.stringify(data));
}
/* ---------- 跨域只读(前端可部署在别处,数据源指向本机) ---------- */
// 允许跨域读取的前端来源。默认放行本机:本地开发时前端页面与探针常常跑在不同端口上
// (例如 Vite 的 5173),而 origin 是「协议 + 主机 + 端口」的完整组合,
// 因此默认项按「忽略端口」匹配,主机相同即放行。
// 部署在 GitHub Pages / Cloudflare 等其它域名时,在 settings.corsOrigins 里显式列出
// 完整 origin(含协议与端口,如 https://you.github.io)。绝不使用 '*':
// 那等于让任意网站都能读走监控数据。
function corsOrigins(config) {
const extra = Array.isArray(config.settings?.corsOrigins)
? config.settings.corsOrigins.map((s) => String(s).trim()).filter(Boolean)
: [];
return { exact: new Set(extra), loopbackHosts: new Set(['127.0.0.1', 'localhost', '[::1]']) };
}
// 请求的 origin 是否在白名单内。主机是本机回环地址时忽略端口(本地开发常见形态),
// 其余情况要求完整 origin 精确匹配。
function originAllowed(reqOrigin, config) {
let u;
try { u = new URL(reqOrigin); } catch { return false; }
const { exact, loopbackHosts } = corsOrigins(config);
if (exact.has(reqOrigin)) return true;
return u.protocol === 'http:' && loopbackHosts.has(u.hostname);
}
// 返回跨域响应头;origin 不在白名单时返回空对象(请求本身照常处理,只是浏览器读不到响应)
function corsHeaders(req, config) {
const origin = req.headers.origin;
if (!origin || !originAllowed(origin, config)) return {};
return {
'access-control-allow-origin': origin, // 回显具体 origin,由白名单保证安全
'access-control-allow-methods': 'GET, POST, OPTIONS',
'access-control-allow-headers': 'content-type',
'access-control-max-age': '86400',
vary: 'Origin', // 缓存必须区分 Origin,否则会串味
};
}
// 只读接口的预检响应。写接口(/api/refresh、/api/admin/*)不放行 OPTIONS,
// 因此外部页面无法用简单请求之外的方式触发有副作用的调用。
function handlePreflight(req, res, config) {
const headers = corsHeaders(req, config);
if (!headers['access-control-allow-origin']) {
res.writeHead(403);
return res.end('origin not allowed');
}
res.writeHead(204, headers);
res.end();
}
/* ---------- /api/refresh 滥用防护 ---------- */
// 仅信任 socket 的对端地址,不解析 X-Forwarded-For(可被伪造);若前方挂了反向代理,请改用 allowRefresh:false
@@ -930,19 +998,59 @@ function toUptimeRobotResponse(runtime) {
// runtime 是持有器对象:热重载只更新其属性,服务器与巡检循环始终读到最新引用
function createServer(runtime) {
const adminAuth = createAdminAuth({ storePath: ADMIN_2FA_PATH() });
// 私有层被管理端改写后要立刻生效:沿用与 fs.watch 热重载相同的复用逻辑,保留内存态
const reloadConfig = () => {
const next = loadConfig();
runtime.state = hydrate(next, runtime.state);
runtime.config = next;
};
const handleAdminApi = createAdminRouter({
auth: adminAuth,
localConfigPath: ACTIVE_LOCAL_CONFIG_PATH,
reloadConfig,
runtime,
fns: {
snapshot,
dataFilter,
probeScopeSetting,
localProbeTargets,
notifyConfig,
tlsConfig,
notifyLastResult: () => snapshot(runtime).notify ?? null,
forceRound,
scheduleWrite,
forcedMaxWaitMs: FORCED_MAX_WAIT_MS,
version: VERSION,
},
});
return http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const { config, state } = runtime;
try {
if (url.pathname === '/api/status') return sendJson(res, snapshot(runtime));
if (url.pathname === '/api/monitors') return sendJson(res, config);
if (url.pathname.startsWith('/api/admin/')) return await handleAdminApi(req, res, url);
if (url.pathname === '/admin' || url.pathname === '/admin/') {
const file = path.join(PUBLIC_DIR, 'admin.html');
res.writeHead(200, { 'content-type': MIME['.html'], 'cache-control': 'no-store' });
return void fs.createReadStream(file).pipe(res);
}
// 预检:只对只读接口与 /api/refresh 放行(/api/admin/* 一律不放行,管理端必须同源访问)。
// /api/refresh 虽然有副作用,但已有 refreshDecision 的限流与 refreshLocalOnly 兜底,
// 且它不带自定义头时属于「简单请求」——浏览器无论如何都会把它发出去,
// 加跨域头只影响响应能否被 JS 读到(前端据此退回只读),不影响安全性。
if (req.method === 'OPTIONS' && (url.pathname === '/api/status' || url.pathname === '/api/monitors' || url.pathname === '/api/events' || url.pathname === '/api/refresh' || url.pathname === '/api/v2/getMonitors' || url.pathname === '/v2/getMonitors')) {
return handlePreflight(req, res, config);
}
if (url.pathname === '/api/status') return sendJson(res, snapshot(runtime), { headers: corsHeaders(req, config) });
if (url.pathname === '/api/monitors') return sendJson(res, publicConfig(config), { headers: corsHeaders(req, config) });
if (url.pathname === '/api/refresh') {
const decide = refreshDecision(config, req.socket.remoteAddress);
if (!decide.ok) {
// 只回错误码,不回显目标地址与内部细节
return sendJson(res, { error: decide.error }, {
status: decide.status,
headers: decide.retryAfter ? { 'retry-after': String(decide.retryAfter) } : {},
headers: { ...(decide.retryAfter ? { 'retry-after': String(decide.retryAfter) } : {}), ...corsHeaders(req, config) },
});
}
// scope=external:只同步 UptimeRobot,不跑本地探测轮次(面板在「只看 UR」模式下用)
@@ -959,19 +1067,19 @@ function createServer(runtime) {
]);
// 浏览器中断请求(切页/关标签)是常态:等待期间连接可能已断开,此时不能再写响应
if (res.writableEnded || res.destroyed) return;
return sendJson(res, snapshot(runtime));
return sendJson(res, snapshot(runtime), { headers: corsHeaders(req, config) });
}
// UptimeRobot 兼容的只读接口(GET/POST 均可):已有脚本/看板可直接消费本服务数据。
// 不做鉴权,因为其暴露内容与公开面板一致;如需鉴权请在其前方加代理。
if (url.pathname === '/api/v2/getMonitors' || url.pathname === '/v2/getMonitors') {
return sendJson(res, toUptimeRobotResponse(runtime));
return sendJson(res, toUptimeRobotResponse(runtime), { headers: corsHeaders(req, config) });
}
// 事件时间线:面板「事件」视图取这里(含通知去抖前的全部状态翻转记录)
if (url.pathname === '/api/events') {
const list = [...(runtime.events ?? [])].reverse();
return sendJson(res, { total: list.length, events: list.slice(0, 200) });
return sendJson(res, { total: list.length, events: list.slice(0, 200) }, { headers: corsHeaders(req, config) });
}
let rel = decodeURIComponent(url.pathname);
@@ -1083,7 +1191,13 @@ async function main() {
}
}, 1000);
createServer(runtime).listen(PORT, () => {
// 管理端是否允许经由非本机地址访问(如 Cloudflare Tunnel 挂到公网)。
// 默认关闭:探针通常只该在内网/本机使用,暴露管理面必须由使用者显式打开。
const adminCfg = runtime.config.settings?.admin ?? {};
const adminPublic = adminCfg.allowRemote === true;
const BIND_HOST = adminPublic ? '0.0.0.0' : '127.0.0.1';
createServer(runtime).listen(PORT, BIND_HOST, () => {
console.log(`shiroko-monitor v${VERSION}`);
console.log(`面板: http://localhost:${PORT}/`);
console.log(`状态 API: http://localhost:${PORT}/api/status`);
@@ -1099,6 +1213,13 @@ async function main() {
if (!localCount && !runtime.ur.enabled) {
console.warn('[warn] 当前没有任何数据源:本地探测已关闭且 UptimeRobot 未启用,面板将显示「等待检测」');
}
if (adminPublic) {
console.log(`管理后台: http://localhost:${PORT}/admin`);
console.warn(`[warn] settings.admin.allowRemote=true —— 探针正在监听 ${BIND_HOST},管理端可经由反向代理/隧道从外部访问。`);
console.warn(' 该端口同时伺服公开面板,请确保管理端已绑定 2FA,且前置代理已启用 HTTPS。');
} else {
console.log(`管理后台: http://localhost:${PORT}/admin (仅本机可访问;需远程访问请设 settings.admin.allowRemote=true)`);
}
});
}
+396
View File
@@ -0,0 +1,396 @@
/**
* 管理端 HTTP 接口(全部挂在 /api/admin/*)
*
* 鉴权模型:
* · 未绑定 2FA 时,除绑定流程外的所有接口一律 403
* · 绑定流程需要一次性口令 SHIROKO_ADMIN_SETUP_KEY(服务端只做定长比较,不落盘)
* · 绑定完成后,每次操作都必须带有效会话 cookie(sid)
* · 登录失败按 IP 计数并指数退避
*
* 所有写操作只落 local.monitors.json(私有、不入库),绝不改 public/monitors.json。
*/
import fs from 'node:fs';
import path from 'node:path';
const COOKIE_NAME = 'shiroko_admin';
const MAX_BODY = 64 * 1024; // 管理接口都是小 JSON,64KB 足够挡住滥用
const SETUP_KEY = () => String(process.env.SHIROKO_ADMIN_SETUP_KEY || '').trim();
function sendJson(res, data, { status = 200, headers = {} } = {}) {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers });
res.end(JSON.stringify(data));
}
function readBody(req) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', (c) => {
size += c.length;
if (size > MAX_BODY) {
// 不能 destroy 连接:客户端只会看到 fetch failed 而拿不到状态码。
// 停止累积并标记,等 end 事件按正常路径回 413。
req.__tooLarge = true;
return;
}
chunks.push(c);
});
req.on('end', () => {
if (req.__tooLarge) return reject(Object.assign(new Error('body-too-large'), { code: 'body-too-large' }));
if (!chunks.length) return resolve({});
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch { reject(Object.assign(new Error('bad-json'), { code: 'bad-json' })); }
});
req.on('error', () => reject(Object.assign(new Error('aborted'), { code: 'aborted' })));
});
}
// 把 readBody 的失败翻译成明确的 4xx,而不是掉进 500 或让连接直接断开
async function readJsonBody(req, res) {
try {
return { ok: true, value: await readBody(req) };
} catch (err) {
const code = err.code;
if (code === 'body-too-large') sendJson(res, { error: 'body-too-large', hint: `请求体超过 ${MAX_BODY} 字节` }, { status: 413 });
else if (code === 'bad-json') sendJson(res, { error: 'bad-json' }, { status: 400 });
else sendJson(res, { error: 'bad-request' }, { status: 400 });
return { ok: false };
}
}
function parseCookies(header = '') {
const out = {};
for (const part of String(header).split(';')) {
const i = part.indexOf('=');
if (i < 0) continue;
out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
}
return out;
}
// 只认 socket 对端地址:X-Forwarded-For 可被任意伪造
const clientIp = (req) => String(req.socket.remoteAddress ?? '').replace(/^::ffff:/i, '');
// Path 必须覆盖接口路径:会话 cookie 在 /admin 页面与 /api/admin/* 接口之间共用,
// 只写 Path=/admin 会导致接口请求不带 cookie(登录成功但随即 401)
const SESSION_COOKIE = (sid) => `${COOKIE_NAME}=${encodeURIComponent(sid)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=7200`;
const CLEAR_COOKIE = () => `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`;
// 管理端的写操作统一落私有层:public/monitors.json 是会随仓库分发的公开文件,不该被接口改写
function savePrivateConfig(localPath, mutate) {
let current = {};
try { current = JSON.parse(fs.readFileSync(localPath, 'utf8')); } catch { /* 文件不存在则从空开始 */ }
const next = mutate(current) ?? current;
fs.mkdirSync(path.dirname(localPath), { recursive: true });
fs.writeFileSync(localPath, `${JSON.stringify(next, null, 2)}\n`);
return next;
}
/**
* @param {object} deps
* @param {object} deps.auth createAdminAuth 的返回值
* @param {string} deps.localConfigPath私有配置路径(管理端写入的唯一目标)
* @param {() => void} deps.reloadConfig 热重载配置(私有层改动后必须调用)
* @param {object} deps.runtime 探针运行态
* @param {object} deps.fns index.js 内部的配置/快照函数(避免循环依赖,显式注入)
*/
export function createAdminRouter({ auth, localConfigPath, reloadConfig, runtime, fns }) {
const { snapshot, dataFilter, probeScopeSetting, localProbeTargets, notifyConfig, tlsConfig, notifyLastResult, forceRound, scheduleWrite, forcedMaxWaitMs, version } = fns;
function requireBound(res) {
if (auth.isBound()) return true;
sendJson(res, { error: 'not-bound', hint: '需先在 /admin 完成 2FA 绑定' }, { status: 403 });
return false;
}
function requireSession(req, res) {
const sid = parseCookies(req.headers.cookie)[COOKIE_NAME];
if (!auth.verifySession(sid)) {
sendJson(res, { error: 'unauthorized' }, { status: 401, headers: { 'set-cookie': CLEAR_COOKIE() } });
return false;
}
return true;
}
function overview() {
const cfg = runtime.config;
const s = cfg.settings ?? {};
const snap = snapshot(runtime);
return {
version,
status: {
dataFilter: dataFilter(cfg),
probeScope: probeScopeSetting(cfg),
urEnabled: runtime.ur.enabled,
urOk: runtime.ur.ok,
urError: runtime.ur.error,
urCount: runtime.ur.count,
localCount: localProbeTargets(cfg).length,
configCount: cfg.monitors.length,
dataUpdatedAt: new Date(runtime.dataUpdatedAt).toISOString(),
},
stats: snap.stats,
notify: notifyLastResult(),
settings: {
dataFilter: dataFilter(cfg),
probeScope: probeScopeSetting(cfg),
refreshInterval: s.refreshInterval ?? null,
hideTargets: s.hideTargets === true,
notifyEnabled: notifyConfig(cfg).enabled,
tlsEnabled: tlsConfig(cfg).enabled,
adminUrl: s.adminUrl ?? '',
dataUrl: s.dataUrl ?? '',
corsOrigins: Array.isArray(s.corsOrigins) ? s.corsOrigins : [],
},
maintenance: Array.isArray(s.maintenance) ? s.maintenance : [],
monitors: cfg.monitors.map((m) => ({
id: m.id,
name: m.name,
group: m.group ?? '未分组',
type: m.type,
target: m.url ?? (m.host ? `${m.host}${m.port ? `:${m.port}` : ''}` : ''),
interval: m.interval ?? null,
timeout: m.timeout ?? null,
})),
twofa: auth.status(),
events: [...(runtime.events ?? [])].reverse().slice(0, 50),
};
}
return async function handleAdminApi(req, res, url) {
const ip = clientIp(req);
const p = url.pathname;
/* ---- 2FA 状态:未登录也要能查(页面据此决定显示绑定还是登录) ---- */
if (p === '/api/admin/status' && req.method === 'GET') {
return sendJson(res, {
bound: auth.isBound(),
setupKeyConfigured: Boolean(SETUP_KEY()),
twofa: auth.status(),
});
}
/* ---- 绑定流程 ---- */
if (p === '/api/admin/2fa/setup' && req.method === 'POST') {
if (auth.isBound()) return sendJson(res, { error: 'already-bound' }, { status: 409 });
const parsed = await readJsonBody(req, res);
if (!parsed.ok) return;
const body = parsed.value;
const out = auth.beginSetup(body.key, ip);
if (!out.ok) return sendJson(res, { error: out.error }, { status: out.error === 'setup-disabled' ? 503 : 401 });
return sendJson(res, out);
}
if (p === '/api/admin/2fa/confirm' && req.method === 'POST') {
const parsed = await readJsonBody(req, res);
if (!parsed.ok) return;
const body = parsed.value;
const out = auth.confirmSetup(body.setupToken, body.code);
if (!out.ok) return sendJson(res, { error: out.error }, { status: 400 });
console.log(`[admin] 2FA 已绑定(来自 ${ip}),恢复码已生成,请妥善保存`);
// 回传刚才用过的那枚验证码:前端据此直接换取会话,省掉用户再输一遍
return sendJson(res, { ok: true, recoveryCodes: out.recoveryCodes, confirmCode: String(body.code ?? '').trim() });
}
/* ---- 登录 / 登出 ---- */
if (p === '/api/admin/login' && req.method === 'POST') {
if (!requireBound(res)) return;
const parsed = await readJsonBody(req, res);
if (!parsed.ok) return;
const body = parsed.value;
const out = auth.login(body.code, ip);
if (!out.ok) {
return sendJson(res, { error: out.error, fails: out.fails ?? null }, {
status: out.error === 'locked' ? 429 : 401,
headers: out.retryAfter ? { 'retry-after': String(out.retryAfter) } : {},
});
}
console.log(`[admin] 登录成功(${out.via},来自 ${ip})`);
return sendJson(res, { ok: true, via: out.via }, { headers: { 'set-cookie': SESSION_COOKIE(out.sessionId) } });
}
if (p === '/api/admin/logout' && req.method === 'POST') {
auth.destroySession(parseCookies(req.headers.cookie)[COOKIE_NAME]);
return sendJson(res, { ok: true }, { headers: { 'set-cookie': CLEAR_COOKIE() } });
}
/* ---- 以下全部需要「已绑定 + 有效会话」 ---- */
if (!requireBound(res)) return;
if (!requireSession(req, res)) return;
if (p === '/api/admin/overview' && req.method === 'GET') {
return sendJson(res, overview());
}
if (p === '/api/admin/maintenance' && req.method === 'POST') {
const parsed = await readJsonBody(req, res);
if (!parsed.ok) return;
const body = parsed.value;
const from = Date.parse(body.from);
const to = Date.parse(body.to);
if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) {
return sendJson(res, { error: 'invalid-window', hint: '开始/结束时间无效,或结束早于开始' }, { status: 400 });
}
const entry = {
from: new Date(from).toISOString(),
to: new Date(to).toISOString(),
...(body.note ? { note: String(body.note).slice(0, 200) } : {}),
...(body.id ? { id: String(body.id).slice(0, 64) } : {}),
};
savePrivateConfig(localConfigPath, (local) => {
const list = Array.isArray(local.settings?.maintenance) ? local.settings.maintenance : [];
// 只保留最近 20 条,避免配置文件被历史窗口撑大
local.settings = { ...(local.settings ?? {}), maintenance: [...list, entry].slice(-20) };
return local;
});
reloadConfig();
scheduleWrite(runtime);
return sendJson(res, { ok: true, entry });
}
if (p === '/api/admin/maintenance' && req.method === 'DELETE') {
const parsed = await readJsonBody(req, res);
if (!parsed.ok) return;
const body = parsed.value;
const target = Number(body.index);
if (!Number.isInteger(target) || target < 0) return sendJson(res, { error: 'invalid-index' }, { status: 400 });
// 越界要显式拒绝:splice 对越界下标是静默 no-op,会让「删了但没删掉」看起来像成功
const current = (() => {
try { return JSON.parse(fs.readFileSync(localConfigPath, 'utf8')).settings?.maintenance; } catch { return null; }
})();
const size = Array.isArray(current) ? current.length : 0;
if (target >= size) return sendJson(res, { error: 'invalid-index', hint: `当前共 ${size} 个维护窗口` }, { status: 400 });
savePrivateConfig(localConfigPath, (local) => {
const list = Array.isArray(local.settings?.maintenance) ? [...local.settings.maintenance] : [];
list.splice(target, 1);
local.settings = { ...(local.settings ?? {}), maintenance: list };
return local;
});
reloadConfig();
scheduleWrite(runtime);
return sendJson(res, { ok: true });
}
if (p === '/api/admin/probe-scope' && req.method === 'POST') {
const parsed = await readJsonBody(req, res);
if (!parsed.ok) return;
const body = parsed.value;
const scope = String(body.probeScope ?? '');
if (!['all', 'config', 'uptimerobot'].includes(scope)) return sendJson(res, { error: 'invalid-scope' }, { status: 400 });
savePrivateConfig(localConfigPath, (local) => {
local.settings = { ...(local.settings ?? {}), probeScope: scope };
return local;
});
reloadConfig();
scheduleWrite(runtime);
console.log(`[admin] 探测范围改为 ${scope}(来自 ${ip})`);
return sendJson(res, { ok: true, probeScope: scope });
}
if (p === '/api/admin/data-filter' && req.method === 'POST') {
const parsed = await readJsonBody(req, res);
if (!parsed.ok) return;
const body = parsed.value;
const filter = String(body.dataFilter ?? '');
if (!['both', 'config', 'uptimerobot'].includes(filter)) return sendJson(res, { error: 'invalid-filter' }, { status: 400 });
savePrivateConfig(localConfigPath, (local) => {
local.settings = { ...(local.settings ?? {}), dataFilter: filter };
return local;
});
reloadConfig();
scheduleWrite(runtime);
console.log(`[admin] 展示口径改为 ${filter}(来自 ${ip})`);
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/remote' && req.method === 'POST') {
const parsed = await readJsonBody(req, res);
if (!parsed.ok) return;
const body = parsed.value;
// 跨域数据源:允许省略协议(默认 https),省得使用者每次都写全
const rawUrl = String(body.dataUrl ?? '').trim();
let dataUrl = '';
if (rawUrl) {
let u;
try { u = new URL(/^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`); } catch {
return sendJson(res, { error: 'invalid-url', hint: '不是合法的地址' }, { status: 400 });
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
return sendJson(res, { error: 'invalid-url', hint: '只支持 http/https' }, { status: 400 });
}
dataUrl = u.origin + u.pathname.replace(/\/+$/, '');
}
// CORS 白名单:只收完整的 origin,且拒绝 '*'
const origins = Array.isArray(body.corsOrigins)
? body.corsOrigins.map((s) => String(s).trim()).filter(Boolean)
: String(body.corsOrigins ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const bad = origins.filter((o) => o === '*' || !/^https?:\/\/[^\s/]+(:\d+)?$/.test(o));
if (bad.length) {
return sendJson(res, { error: 'invalid-origin', hint: `这些写法不合法(必须是完整来源,且不允许 *):${bad.join(', ')}` }, { status: 400 });
}
savePrivateConfig(localConfigPath, (local) => {
local.settings = { ...(local.settings ?? {}), dataUrl, corsOrigins: origins };
return local;
});
reloadConfig();
scheduleWrite(runtime);
console.log(`[admin] 跨域设置已保存:dataUrl=${dataUrl || '(空)'},corsOrigins=${origins.length ? origins.join(' ') : '(默认)'}`);
return sendJson(res, { ok: true, dataUrl, corsOrigins: origins });
}
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;
return sendJson(res, { ok: true, stats: snapshot(runtime).stats });
}
if (p === '/api/admin/notify-test' && req.method === 'POST') {
const cfg = notifyConfig(runtime.config);
if (!cfg.enabled) return sendJson(res, { error: 'notify-disabled', hint: '未启用任何通知渠道' }, { status: 400 });
const results = await sendTestNotificationsLazy(cfg);
return sendJson(res, { ok: results.every((r) => r.ok), results });
}
if (p === '/api/admin/2fa/reset' && req.method === 'POST') {
const parsed = await readJsonBody(req, res);
if (!parsed.ok) return;
const body = parsed.value;
const out = auth.reset(body.key);
if (!out.ok) return sendJson(res, { error: out.error }, { status: 401 });
console.log(`[admin] 2FA 已重置,需重新绑定(来自 ${ip})`);
return sendJson(res, { ok: true }, { headers: { 'set-cookie': CLEAR_COOKIE() } });
}
return sendJson(res, { error: 'not-found' }, { status: 404 });
};
// 通知自检只在调用时才 import,避免主进程启动就拉起 SMTP 代码路径
async function sendTestNotificationsLazy(cfg) {
const { sendTestNotifications } = await import('./notify.js');
return sendTestNotifications(cfg, { logger: console });
}
}
+264
View File
@@ -0,0 +1,264 @@
/**
* 管理端鉴权:2FA 绑定、登录、会话、失败限流
*
* 安全约束(与项目的零依赖、一切自建原则一致):
* · 未绑定 2FA 时,任何管理接口都不可用 —— 绑定动作本身也需要一次性 setup key
* · 会话 id 用 32 字节随机数,服务端只存哈希(内存态,进程重启即全部失效)
* · 登录失败按 IP 计数,超过阈值指数退避,避免在线爆破 6 位码
* · 恢复码一次性使用,用后立刻作废并落盘
* · 管理写操作一律落 local.monitors.json(私有、不入库),不污染 public 配置
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import {
DEFAULT_DIGITS,
DEFAULT_PERIOD_SEC,
generateRecoveryCodes,
generateSecret,
hashRecoveryCode,
otpauthUri,
totpCounter,
verifyTotp,
} from './totp.js';
const SESSION_TTL_MS = 2 * 60 * 60 * 1000; // 会话最长 2 小时
const SESSION_IDLE_MS = 30 * 60 * 1000; // 30 分钟无操作即失效
const MAX_FAILS = 5;
const LOCK_BASE_MS = 60 * 1000; // 首次锁定 1 分钟,之后翻倍
const LOCK_MAX_MS = 30 * 60 * 1000;
const SETUP_TTL_MS = 10 * 60 * 1000; // 未完成的绑定流程 10 分钟作废
const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
const token = (bytes = 32) => crypto.randomBytes(bytes).toString('base64url');
/**
* @param {object} opts
* @param {string} opts.storePath 2FA 档案落盘位置(data/admin-2fa.json,已 gitignore)
* @param {string} opts.setupKey 环境变量 SHIROKO_ADMIN_SETUP_KEY 提供的绑定口令
* @param {() => number} [opts.now] 便于测试注入时间
*/
export function createAdminAuth({ storePath, setupKey = '', now = Date.now }) {
let store = load();
const sessions = new Map(); // hash(sessionId) -> { createdAt, lastSeenAt, ip }
const fails = new Map(); // ip -> { count, until }
let setup = null; // { key, secret, createdAt, ip } —— 绑定过程中的临时态
function load() {
try {
return JSON.parse(fs.readFileSync(storePath, 'utf8'));
} catch {
return { bound: false };
}
}
function save() {
fs.mkdirSync(path.dirname(storePath), { recursive: true });
fs.writeFileSync(storePath, `${JSON.stringify(store, null, 2)}\n`);
}
function isBound() {
return store.bound === true && typeof store.secret === 'string' && store.secret.length > 0;
}
/* ---------- 绑定 ---------- */
function configuredSetupKey() {
return String(setupKey || process.env.SHIROKO_ADMIN_SETUP_KEY || '').trim();
}
/**
* 第一步:提交绑定口令,换取一个待确认的 secret。
* 口令从环境变量读取且只做定长比较,服务端不保存它的副本。
*/
function beginSetup(inputKey, ip) {
if (isBound()) return { ok: false, error: 'already-bound' };
const want = configuredSetupKey();
if (!want) return { ok: false, error: 'setup-disabled' };
if (!safeEqual(String(inputKey ?? ''), want)) return { ok: false, error: 'bad-setup-key' };
setup = { key: token(24), secret: generateSecret(), createdAt: now(), ip };
const label = 'shiroko-monitor';
return {
ok: true,
setupToken: setup.key,
secret: setup.secret,
uri: otpauthUri({ secret: setup.secret, label, issuer: label }),
};
}
/**
* 第二步:验证器 App 里能出码即证明绑定成功,落盘 secret 并给出一批恢复码。
*/
function confirmSetup(setupTokenValue, code) {
if (isBound()) return { ok: false, error: 'already-bound' };
if (!setup || setup.key !== String(setupTokenValue ?? '')) return { ok: false, error: 'no-setup' };
if (now() - setup.createdAt > SETUP_TTL_MS) { setup = null; return { ok: false, error: 'setup-expired' }; }
const reason = verifyTotp(setup.secret, code, { atMs: now(), digits: DEFAULT_DIGITS, periodSec: DEFAULT_PERIOD_SEC });
if (reason) return { ok: false, error: reason };
const salt = token(16);
const recoveryCodes = generateRecoveryCodes();
store = {
bound: true,
secret: setup.secret,
salt,
recovery: recoveryCodes.map((c) => hashRecoveryCode(c, salt)),
digits: DEFAULT_DIGITS,
periodSec: DEFAULT_PERIOD_SEC,
issuer: 'shiroko-monitor',
boundAt: new Date(now()).toISOString(),
lastUsedCounter: null,
};
setup = null;
save();
return { ok: true, recoveryCodes };
}
/* ---------- 登录 ---------- */
function lockState(ip) {
const rec = fails.get(ip);
if (!rec) return { locked: false, waitMs: 0 };
if (rec.until && rec.until > now()) return { locked: true, waitMs: rec.until - now() };
if (rec.until && rec.until <= now()) { rec.until = 0; rec.count = 0; }
return { locked: false, waitMs: 0 };
}
function noteFail(ip) {
const rec = fails.get(ip) ?? { count: 0, until: 0 };
rec.count += 1;
if (rec.count >= MAX_FAILS) {
const mult = 2 ** Math.min(rec.count - MAX_FAILS, 5); // 1m → 2m → 4m … 上限 30m
rec.until = now() + Math.min(LOCK_BASE_MS * mult, LOCK_MAX_MS);
}
fails.set(ip, rec);
return rec;
}
function noteSuccess(ip) {
fails.delete(ip);
}
function consumeRecovery(inputCode) {
const code = String(inputCode ?? '').trim().toUpperCase();
const hash = hashRecoveryCode(code, store.salt);
const idx = store.recovery.indexOf(hash);
if (idx < 0) return false;
store.recovery.splice(idx, 1); // 一次性
save();
return true;
}
/**
* 登录:6 位 TOTP 码,或任一枚未使用的恢复码。
* 通过后签发会话 id(明文只在这一刻返回一次,服务端只留哈希)。
*/
function login(inputCode, ip) {
if (!isBound()) return { ok: false, error: 'not-bound' };
const lock = lockState(ip);
if (lock.locked) return { ok: false, error: 'locked', retryAfter: Math.ceil(lock.waitMs / 1000) };
const digits = store.digits ?? DEFAULT_DIGITS;
const periodSec = store.periodSec ?? DEFAULT_PERIOD_SEC;
const reason = verifyTotp(store.secret, inputCode, { atMs: now(), digits, periodSec });
let via = null;
let replay = false;
if (reason === null) {
// 防重放:同一时间步的码用过即作废,防止截图被二次使用
const counter = totpCounter({ atMs: now(), periodSec });
if (store.lastUsedCounter != null && counter <= store.lastUsedCounter) replay = true;
else via = 'totp';
} else if (consumeRecovery(inputCode)) {
// 恢复码兜底:手机丢了、验证器清库了仍可进入。
// 恢复码是 10 位字母数字,会先被 verifyTotp 判为 invalid-format,属正常路径。
via = 'recovery';
}
if (!via) {
const rec = noteFail(ip);
// 回同一个错误码,不告诉攻击者「格式不对」还是「值不对」
return { ok: false, error: replay ? 'replayed' : 'invalid-code', fails: rec.count, locked: rec.until > now() };
}
if (via === 'totp') {
store.lastUsedCounter = totpCounter({ atMs: now(), periodSec });
save();
}
noteSuccess(ip);
return { ok: true, sessionId: issue(ip), via };
}
/* ---------- 会话 ---------- */
function issue(ip) {
const id = token(32);
sessions.set(sha256(id), { createdAt: now(), lastSeenAt: now(), ip });
return id;
}
/** 校验并续期;返回会话对象或 null(同时清理过期项) */
function verifySession(sessionId) {
if (!sessionId) return null;
const key = sha256(sessionId);
const s = sessions.get(key);
if (!s) return null;
const t = now();
if (t - s.createdAt > SESSION_TTL_MS || t - s.lastSeenAt > SESSION_IDLE_MS) {
sessions.delete(key);
return null;
}
s.lastSeenAt = t;
return s;
}
function destroySession(sessionId) {
if (sessionId) sessions.delete(sha256(sessionId));
}
function activeSessions() {
const t = now();
return [...sessions.values()].filter((s) => t - s.createdAt <= SESSION_TTL_MS && t - s.lastSeenAt <= SESSION_IDLE_MS).length;
}
/** 重置 2FA(丢失全部验证码时的逃生口,需提供 setup key) */
function reset(inputKey) {
const want = configuredSetupKey();
if (!want || !safeEqual(String(inputKey ?? ''), want)) return { ok: false, error: 'bad-setup-key' };
store = { bound: false };
sessions.clear();
fails.clear();
setup = null;
save();
return { ok: true };
}
function status() {
return {
bound: isBound(),
boundAt: store.boundAt ?? null,
recoveryLeft: Array.isArray(store.recovery) ? store.recovery.length : 0,
sessions: activeSessions(),
// 恢复码用量超过一半时提示重新生成,避免某天发现全用完了
recoveryLow: isBound() && Array.isArray(store.recovery) && store.recovery.length <= 3,
};
}
return {
isBound, beginSetup, confirmSetup, login, verifySession, destroySession,
activeSessions, reset, status, lockState,
logoutAll: () => sessions.clear(),
};
}
function safeEqual(a, b) {
const x = Buffer.from(String(a), 'utf8');
const y = Buffer.from(String(b), 'utf8');
// 长度不等时也走一次比较,避免通过响应时间推断口令长度
const pad = Math.max(x.length, y.length);
const xa = Buffer.alloc(pad);
const ya = Buffer.alloc(pad);
x.copy(xa);
y.copy(ya);
return crypto.timingSafeEqual(xa, ya) && x.length === y.length;
}
+131
View File
@@ -0,0 +1,131 @@
/**
* TOTP(RFC 6238)+ Base32,零依赖实现
*
* 只用 node:crypto 的 HMAC,不引第三方库。验证器 App(Google Authenticator /
* Microsoft Authenticator / 1Password / Aegis 等)都用同一套算法。
*
* 参考:RFC 6238 TOTP、RFC 4226 HOTP、RFC 3548 Base32。
*/
import crypto from 'node:crypto';
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
/* ---------- Base32 ---------- */
export function base32Encode(buf) {
let bits = 0;
let value = 0;
let out = '';
for (const byte of buf) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
out += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) out += BASE32_ALPHABET[(value << (5 - bits)) & 31];
return out;
}
// 容忍用户输入里的小写、空格与连字符(很多 App 展示密钥时会分组)
export function base32Decode(str) {
const clean = String(str ?? '').toUpperCase().replace(/[\s-]/g, '');
let bits = 0;
let value = 0;
const out = [];
for (const ch of clean) {
const idx = BASE32_ALPHABET.indexOf(ch);
if (idx < 0) continue; // 跳过无法识别的字符(如误粘贴的 '0'/'1'/'8'/'9')
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
out.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(out);
}
/* ---------- 密钥 ---------- */
// 20 字节 = 160 位,符合 RFC 4226 对 HMAC-SHA1 的建议强度
export function generateSecret(bytes = 20) {
return base32Encode(crypto.randomBytes(bytes));
}
/* ---------- TOTP ---------- */
export const DEFAULT_DIGITS = 6;
export const DEFAULT_PERIOD_SEC = 30;
// 允许 ±1 个时间窗:手机时间与服务端有几秒偏差是常态,也容忍一次网络延迟
export const DEFAULT_WINDOW = 1;
export function hotp(secretBuf, counter, digits = DEFAULT_DIGITS) {
const msg = Buffer.alloc(8);
msg.writeBigUInt64BE(BigInt(counter));
const hmac = crypto.createHmac('sha1', secretBuf).update(msg).digest();
// RFC 4226 动态截断:取最后一个字节的低 4 位作偏移
const offset = hmac[hmac.length - 1] & 0x0f;
const code = ((hmac[offset] & 0x7f) << 24) | (hmac[offset + 1] << 16) | (hmac[offset + 2] << 8) | hmac[offset + 3];
return String(code % 10 ** digits).padStart(digits, '0');
}
export function totp(secretBuf, { atMs = Date.now(), periodSec = DEFAULT_PERIOD_SEC, digits = DEFAULT_DIGITS } = {}) {
return hotp(secretBuf, Math.floor(atMs / 1000 / periodSec), digits);
}
/**
* 校验用户输入的验证码。
* 返回 null 表示通过;返回字符串表示拒绝原因(供接口回显,不泄露密钥相关信息)。
*/
export function verifyTotp(secret, code, { atMs = Date.now(), periodSec = DEFAULT_PERIOD_SEC, digits = DEFAULT_DIGITS, window = DEFAULT_WINDOW } = {}) {
const clean = String(code ?? '').replace(/\s|-/g, '');
if (!new RegExp(`^\\d{${digits}}$`).test(clean)) return 'invalid-format';
const secretBuf = base32Decode(secret);
if (secretBuf.length < 10) return 'invalid-secret';
const counter = Math.floor(atMs / 1000 / periodSec);
// 用定长比较,避免逐字符提前返回带来的时序侧信道
const a = Buffer.from(clean, 'utf8');
for (let w = -window; w <= window; w++) {
const expect = Buffer.from(hotp(secretBuf, counter + w, digits), 'utf8');
if (expect.length === a.length && crypto.timingSafeEqual(expect, a)) return null;
}
return 'invalid-code';
}
// 当前验证码所属的时间步:用于「同一步内不得重复使用」的防重放
export function totpCounter({ atMs = Date.now(), periodSec = DEFAULT_PERIOD_SEC } = {}) {
return Math.floor(atMs / 1000 / periodSec);
}
/* ---------- otpauth URI(给验证器扫码用) ---------- */
export function otpauthUri({ secret, label, issuer, digits = DEFAULT_DIGITS, periodSec = DEFAULT_PERIOD_SEC }) {
const q = new URLSearchParams({ secret, digits: String(digits), period: String(periodSec) });
// issuer 同时出现在路径与查询参数里:兼容各家 App 的两种解析方式
if (issuer) q.set('issuer', issuer);
return `otpauth://totp/${encodeURIComponent(issuer ?? label)}:${encodeURIComponent(label)}?${q}`;
}
/* ---------- 恢复码 ---------- */
// 每个码 10 字符,形如 X7K2-9QMD4;只存哈希,明文只在绑定成功时展示一次
export function generateRecoveryCodes(count = 10) {
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 去掉易混淆的 0/O/1/I
const codes = [];
for (let i = 0; i < count; i++) {
let s = '';
const bytes = crypto.randomBytes(10);
for (let j = 0; j < 10; j++) s += alphabet[bytes[j] % alphabet.length];
codes.push(`${s.slice(0, 5)}-${s.slice(5)}`);
}
return codes;
}
// 归一化后再哈希:用户从别处抄码常漏掉连字符或用小写,两种都要能登录
export function hashRecoveryCode(code, salt) {
const norm = String(code ?? '').toUpperCase().replace(/[\s-]/g, '');
return crypto.createHash('sha256').update(`${salt}:${norm}`).digest('hex');
}
+221
View File
@@ -0,0 +1,221 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>管理后台 · Shiroko Monitor</title>
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<link rel="stylesheet" href="/assets/style.css">
<link rel="stylesheet" href="/assets/theme-shiroko.css">
<link rel="stylesheet" href="/assets/admin.css">
</head>
<body class="admin-body">
<header class="admin-topbar">
<div class="brand">
<span class="logo" aria-hidden="true"></span>
<div class="brand-text">
<h1>管理后台</h1>
<p>需绑定并通过 2FA 验证后才能进入</p>
</div>
</div>
<div class="admin-topbar-right">
<span class="admin-ver" id="admin-ver">–</span>
<a class="admin-link" href="/">← 返回面板</a>
<button type="button" class="admin-btn admin-btn-ghost" id="logout-btn" hidden>退出登录</button>
</div>
</header>
<main class="admin-main">
<!-- 状态探测中 -->
<section class="admin-card" id="loading"><p class="admin-muted">正在检查 2FA 状态…</p></section>
<!-- ① 未绑定:先绑定 2FA -->
<section class="admin-card" id="bind-panel" hidden>
<h2>第一步:绑定 2FA</h2>
<p class="admin-muted">管理后台必须绑定两步验证后才能进入。请先在启动探针的环境里设置 <code>SHIROKO_ADMIN_SETUP_KEY</code>,然后在这里输入它以生成密钥。</p>
<div class="admin-step" id="step-key">
<label for="setup-key">绑定口令(SHIROKO_ADMIN_SETUP_KEY)</label>
<div class="admin-row">
<input type="password" id="setup-key" autocomplete="off" placeholder="环境变量里设置的那个值" inputmode="text">
<button type="button" class="admin-btn" id="setup-start">生成密钥</button>
</div>
<p class="admin-msg" id="setup-key-msg"></p>
</div>
<div class="admin-step" id="step-secret" hidden>
<h3>第二步:把密钥加入你的验证器 App</h3>
<ol class="admin-ol">
<li>用 Google Authenticator / Microsoft Authenticator / 1Password / Aegis 扫描二维码,或</li>
<li>手动输入下面的密钥(Base32):</li>
</ol>
<div class="qr-box"><canvas id="qr" width="220" height="220" aria-label="2FA 绑定二维码"></canvas></div>
<div class="secret-box">
<code id="secret-text">–</code>
<button type="button" class="admin-btn admin-btn-sm" id="copy-secret">复制密钥</button>
</div>
<details class="admin-details">
<summary>扫码不方便?复制 otpauth 链接</summary>
<div class="secret-box"><code id="uri-text">–</code><button type="button" class="admin-btn admin-btn-sm" id="copy-uri">复制链接</button></div>
</details>
<h3>第三步:输入 App 显示的 6 位验证码完成绑定</h3>
<div class="admin-row">
<input type="text" id="confirm-code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="6 位数字">
<button type="button" class="admin-btn" id="confirm-btn">完成绑定</button>
</div>
<p class="admin-msg" id="confirm-msg"></p>
</div>
<div class="admin-step" id="step-recovery" hidden>
<h3>第四步:保存恢复码(只显示这一次)</h3>
<p class="admin-muted">每个恢复码只能用一次,用于手机丢失、验证器清库时登录。请离线保存,不要只存在这台机器上。</p>
<div class="recovery-box" id="recovery-list"></div>
<div class="admin-row">
<button type="button" class="admin-btn admin-btn-sm" id="copy-recovery">复制全部恢复码</button>
<button type="button" class="admin-btn admin-btn-primary" id="recovery-done">我已保存</button>
</div>
</div>
</section>
<!-- ② 已绑定未登录:输 2FA 码登录 -->
<section class="admin-card admin-card-narrow" id="login-panel" hidden>
<h2>两步验证</h2>
<p class="admin-muted">输入验证器 App 的 6 位动态码,或使用一枚未用过的恢复码。</p>
<div class="admin-row">
<input type="text" id="login-code" inputmode="numeric" autocomplete="one-time-code" placeholder="验证码 / 恢复码" autofocus>
<button type="button" class="admin-btn admin-btn-primary" id="login-btn">进入</button>
</div>
<p class="admin-msg" id="login-msg"></p>
<details class="admin-details">
<summary>验证码设备丢了?</summary>
<p class="admin-muted">用一枚未使用的恢复码登录;或用 <code>SHIROKO_ADMIN_SETUP_KEY</code> 重置 2FA(重置后需重新绑定)。</p>
<div class="admin-row">
<input type="password" id="reset-key" placeholder="SHIROKO_ADMIN_SETUP_KEY" autocomplete="off">
<button type="button" class="admin-btn admin-btn-danger" id="reset-btn">重置 2FA</button>
</div>
</details>
</section>
<!-- ③ 已登录:管理面板 -->
<section id="console-panel" hidden>
<div class="admin-card" id="stat-card">
<h2>运行状态</h2>
<div class="admin-stats" id="admin-stats"></div>
<p class="admin-muted" id="updated-at">–</p>
</div>
<div class="admin-card">
<h2>口径与范围</h2>
<div class="admin-field">
<label for="probe-scope">探测范围</label>
<select id="probe-scope">
<option value="all">全部(自建 + UptimeRobot)</option>
<option value="config">仅自建配置</option>
<option value="uptimerobot">仅 UptimeRobot</option>
</select>
<p class="admin-hint">决定探针真正发请求的范围;改动立即生效并热重载配置。</p>
</div>
<div class="admin-field">
<label for="data-filter">展示口径</label>
<select id="data-filter">
<option value="both">全部</option>
<option value="config">仅自建配置</option>
<option value="uptimerobot">仅 UptimeRobot</option>
</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-field">
<label for="data-url">跨域数据源地址</label>
<input type="url" id="data-url" placeholder="https://monitor.example.com" autocomplete="off">
<p class="admin-hint">
仅当<b>面板部署在别处</b>(GitHub Pages / Cloudflare)而探针在另一台机器时才需要填。
填了之后面板会去这个地址取数据,因此<b>该地址必须与页面同为 HTTPS</b> ——
HTTPS 页面读 HTTP 会被浏览器以「混合内容」为由直接拦截,与跨域设置无关。
</p>
</div>
<div class="admin-field">
<label for="cors-origins">允许跨域读取的页面地址</label>
<input type="text" id="cors-origins" placeholder="https://you.github.io, https://monitor.example.com" autocomplete="off">
<p class="admin-hint">
逗号分隔,<b>必须是完整来源(含协议与端口)</b>。只有这里列出的页面才能跨域读走监控数据;
默认只放行 <code>http://127.0.0.1</code> 与 <code>http://localhost</code>。管理端本身永远不同源开放。
</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>
</div>
<p class="admin-msg" id="action-msg"></p>
</div>
<div class="admin-card">
<h2>维护窗口</h2>
<p class="admin-muted">窗口内的目标不计入可用率、也不发故障告警。</p>
<div class="admin-field-row">
<div class="admin-field">
<label for="mw-from">开始</label>
<input type="datetime-local" id="mw-from">
</div>
<div class="admin-field">
<label for="mw-to">结束</label>
<input type="datetime-local" id="mw-to">
</div>
</div>
<div class="admin-field-row">
<div class="admin-field">
<label for="mw-id">仅限目标(留空 = 全部)</label>
<select id="mw-id"><option value="">全部目标</option></select>
</div>
<div class="admin-field">
<label for="mw-note">备注</label>
<input type="text" id="mw-note" maxlength="200" placeholder="例如:升级数据库">
</div>
</div>
<div class="admin-row">
<button type="button" class="admin-btn admin-btn-primary" id="mw-add">新增窗口</button>
</div>
<div class="mw-list" id="mw-list"></div>
</div>
<div class="admin-card">
<h2>监控目标</h2>
<p class="admin-muted">当前配置中的目标(探测范围为 uptimerobot 时,本地目标不会被真正探测)。</p>
<div class="table-scroll">
<table class="admin-table">
<thead><tr><th>名称</th><th>分组</th><th>类型</th><th>目标</th><th>间隔</th></tr></thead>
<tbody id="monitor-list"></tbody>
</table>
</div>
</div>
<div class="admin-card">
<h2>最近事件</h2>
<div class="table-scroll">
<table class="admin-table ev-table">
<thead><tr><th>时间</th><th>类型</th><th>目标</th><th>详情</th></tr></thead>
<tbody id="event-list"></tbody>
</table>
</div>
</div>
<div class="admin-card">
<h2>安全</h2>
<p class="admin-muted" id="twofa-summary">–</p>
<p class="admin-muted">会话最长 2 小时,30 分钟无操作自动失效;进程重启会清空所有会话。</p>
</div>
</section>
</main>
<script type="module" src="/assets/admin.js"></script>
</body>
</html>
+171
View File
@@ -0,0 +1,171 @@
/* 管理后台:复用面板的配色变量,跟随浅色/深色与砂狼主题 */
body.admin-body { min-height: 100vh; }
.admin-topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px 20px;
padding: 12px 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow);
}
.admin-topbar .brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
.admin-topbar-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.admin-ver { font-family: var(--mono); font-size: 12px; color: var(--text-2); }
.admin-link { font-size: 13px; color: var(--accent); text-decoration: none; }
.admin-link:hover { text-decoration: underline; }
.admin-main {
max-width: 1000px;
margin: 0 auto;
padding: 18px 20px 60px;
display: flex;
flex-direction: column;
gap: 14px;
}
.admin-card {
padding: 16px 18px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow);
}
.admin-card-narrow { max-width: 460px; }
.admin-card h2 { margin: 0 0 10px; font-size: 14px; font-weight: 650; }
.admin-card h3 { margin: 16px 0 6px; font-size: 13px; font-weight: 650; color: var(--text-2); }
.admin-muted { margin: 0 0 10px; font-size: 12px; line-height: 1.7; color: var(--text-2); }
.admin-muted code { font-family: var(--mono); background: var(--bg); padding: 0 4px; border-radius: 4px; }
.admin-hint { margin: 4px 0 0; font-size: 11px; color: var(--text-2); line-height: 1.6; }
.admin-step { margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--border); }
.admin-step:first-of-type { border-top: 0; }
.admin-ol { margin: 6px 0 10px; padding-left: 20px; font-size: 12px; line-height: 1.9; color: var(--text-2); }
.admin-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.admin-row input, .admin-row select { flex: 1 1 200px; min-width: 0; }
.admin-field { margin: 10px 0; min-width: 0; }
.admin-field label { display: block; margin-bottom: 4px; font-size: 12px; font-weight: 600; color: var(--text-2); }
.admin-field-row { display: flex; gap: 12px; flex-wrap: wrap; }
.admin-field-row .admin-field { flex: 1 1 220px; }
input[type="text"], input[type="password"], input[type="datetime-local"], select {
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 13px;
}
input:focus, select:focus { outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 1px; }
.admin-btn {
padding: 7px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text);
font-family: var(--sans);
font-size: 13px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
white-space: nowrap;
}
.admin-btn:hover { background: var(--bg); border-color: var(--accent); }
.admin-btn:disabled { opacity: 0.55; cursor: default; }
.admin-btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.admin-btn-primary:hover { filter: brightness(1.08); background: var(--accent); }
.admin-btn-danger { border-color: color-mix(in srgb, var(--down) 45%, var(--border)); color: var(--down-text); }
.admin-btn-sm { padding: 4px 10px; font-size: 12px; }
.admin-btn-ghost { padding: 5px 12px; font-size: 12px; }
.admin-msg { margin: 8px 0 0; font-size: 12px; min-height: 18px; color: var(--text-2); }
.admin-msg.err { color: var(--down-text); }
.admin-msg.ok { color: var(--up-text); }
.qr-box { display: flex; justify-content: center; margin: 10px 0; padding: 10px; background: #fff; border: 1px solid var(--border); border-radius: 10px; }
.qr-box canvas { width: 220px; height: 220px; image-rendering: pixelated; }
.secret-box {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 8px 10px; margin-bottom: 8px;
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
}
.secret-box code { flex: 1 1 200px; min-width: 0; font-family: var(--mono); font-size: 12px; word-break: break-all; line-height: 1.6; }
.admin-details { margin-top: 10px; font-size: 12px; color: var(--text-2); }
.admin-details summary { cursor: pointer; color: var(--accent); }
.admin-details > *:not(summary) { margin-top: 8px; }
.recovery-box {
display: grid; grid-template-columns: repeat(auto-fill, minmax(min(150px, 100%), 1fr));
gap: 6px; margin-bottom: 10px;
}
.recovery-box code {
padding: 6px 8px; background: var(--bg); border: 1px solid var(--border);
border-radius: 6px; font-family: var(--mono); font-size: 12px; text-align: center;
}
.admin-stats { display: flex; flex-wrap: wrap; gap: 8px 18px; margin-bottom: 8px; }
.admin-stat { text-align: center; min-width: 56px; }
.admin-stat b { display: block; font-size: 17px; font-weight: 700; font-variant-numeric: tabular-nums; }
.admin-stat label { font-size: 11px; color: var(--text-2); }
.admin-stat.up b { color: var(--up-text); }
.admin-stat.down b { color: var(--down-text); }
.admin-kv { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 12px; color: var(--text-2); }
.admin-kv code { font-family: var(--mono); color: var(--text); }
.mw-list { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
.mw-item {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
font-size: 12px;
}
.mw-item .mw-time { font-family: var(--mono); }
.mw-item .mw-note { color: var(--text-2); }
.mw-item.active { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--bg)); }
.mw-tag { padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 650; }
.mw-tag.active { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); }
.mw-tag.idle { background: var(--bg); color: var(--text-2); border: 1px solid var(--border); }
.table-scroll { overflow-x: auto; }
.admin-table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 12.5px; }
.admin-table th {
padding: 8px 10px; text-align: left; font-size: 12px; font-weight: 600;
color: var(--text-2); background: var(--bg); border-bottom: 1px solid var(--border); white-space: nowrap;
}
.admin-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.admin-table tbody tr:last-child td { border-bottom: 0; }
.admin-table tbody tr[data-status="down"] { background: color-mix(in srgb, var(--down) 6%, transparent); }
.admin-table tbody tr[data-status="up"] { background: color-mix(in srgb, var(--up) 5%, transparent); }
.admin-table tbody tr[data-status="pending"] { background: color-mix(in srgb, var(--pending) 6%, transparent); }
.admin-table td.mono { font-family: var(--mono); }
.admin-table th:nth-child(1) { width: 26%; }
.admin-table th:nth-child(2) { width: 16%; }
.admin-table th:nth-child(3) { width: 11%; }
.admin-table th:nth-child(4) { width: 30%; }
.admin-table th:nth-child(5) { width: 17%; }
.admin-table.ev-table th:nth-child(1) { width: 15%; }
.admin-table.ev-table th:nth-child(2) { width: 10%; }
.admin-table.ev-table th:nth-child(3) { width: 25%; }
.admin-table.ev-table th:nth-child(4) { width: 50%; }
.ev-badge { padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 650; }
tr[data-status="down"] .ev-badge { background: var(--down-bg); color: var(--down-text); }
tr[data-status="up"] .ev-badge { background: var(--up-bg); color: var(--up-text); }
tr[data-status="pending"] .ev-badge { background: var(--pending-bg); color: var(--pending-text); }
@media (max-width: 640px) {
.admin-topbar { padding: 10px 14px; }
.admin-main { padding: 14px 14px 40px; }
.admin-field-row { flex-direction: column; gap: 0; }
.admin-table.ev-table td.mono, .admin-table.ev-table th:nth-child(4) { display: none; }
}
+625
View File
@@ -0,0 +1,625 @@
/**
* 管理后台前端:绑定 2FA → 登录 → 管理面板
*
* 零依赖,QR 码用内置的最小实现生成(只支持 byte 模式、ECC L、版本自动 1–10),
* 覆盖 otpauth 链接(通常 60–120 字节)的长度。
*/
const $ = (id) => document.getElementById(id);
const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
const API = '/api/admin';
let setupDraft = null; // 绑定过程中的临时状态:{ setupToken, secret, uri }
async function api(path, { method = 'GET', body } = {}) {
const res = await fetch(`${API}${path}`, {
method,
credentials: 'same-origin',
cache: 'no-store',
headers: body ? { 'content-type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(30000),
});
let data = null;
try { data = await res.json(); } catch { /* 空响应体 */ }
return { ok: res.ok, status: res.status, data, retryAfter: res.headers.get('retry-after') };
}
function msg(el, text, kind = '') {
el.textContent = text;
el.className = `admin-msg${kind ? ` ${kind}` : ''}`;
}
const ERROR_TEXT = {
'bad-setup-key': '绑定口令不正确',
'setup-disabled': '未设置 SHIROKO_ADMIN_SETUP_KEY 环境变量,管理端未开放绑定',
'already-bound': '已完成绑定,不能重复绑定',
'no-setup': '绑定流程已失效,请重新开始',
'setup-expired': '绑定流程超过 10 分钟已失效,请重新开始',
'invalid-code': '验证码不正确',
'invalid-format': '格式不正确',
replayed: '该验证码已被使用过,请等下一个时间窗',
locked: '失败次数过多,请稍后再试',
'not-bound': '尚未绑定 2FA',
unauthorized: '会话已失效,请重新登录',
'invalid-window': '时间无效,或结束早于开始',
'invalid-index': '窗口序号无效',
'invalid-scope': '探测范围取值无效',
'invalid-filter': '展示口径取值无效',
'notify-disabled': '未启用任何通知渠道',
};
const errText = (code, hint) => ERROR_TEXT[code] ?? hint ?? '操作失败';
/* ============================================================
* QR 码生成(byte 模式 / ECC L / 版本 1–10)
* 参考 ISO/IEC 18004;这里只保留生成 otpauth 链接所需的最小子集。
* ============================================================ */
// GF(256) 对数/指数表,本原多项式 0x11D
const GF_EXP = new Uint8Array(512);
const GF_LOG = new Uint8Array(256);
(function initGf() {
let x = 1;
for (let i = 0; i < 255; i++) {
GF_EXP[i] = x;
GF_LOG[x] = i;
x <<= 1;
if (x & 0x100) x ^= 0x11d;
}
for (let i = 255; i < 512; i++) GF_EXP[i] = GF_EXP[i - 255];
})();
const gfMul = (a, b) => (a === 0 || b === 0 ? 0 : GF_EXP[GF_LOG[a] + GF_LOG[b]]);
function rsGeneratorPoly(degree) {
let poly = [1];
for (let i = 0; i < degree; i++) {
const next = new Array(poly.length + 1).fill(0);
for (let j = 0; j < poly.length; j++) {
next[j] ^= poly[j];
next[j + 1] ^= gfMul(poly[j], GF_EXP[i]);
}
poly = next;
}
return poly;
}
function rsEncode(data, ecLen) {
const gen = rsGeneratorPoly(ecLen);
const res = new Uint8Array(data.length + ecLen);
res.set(data);
for (let i = 0; i < data.length; i++) {
const factor = res[i];
if (factor === 0) continue;
for (let j = 0; j < gen.length; j++) res[i + j] ^= gfMul(gen[j], factor);
}
return res.slice(data.length);
}
// 版本 1–10 在 ECC L 下的参数表:[总码字数, EC 每块码字数, 组1块数, 组1数据码字, 组2块数, 组2数据码字]
// 索引 0 = 版本 1
const VERSIONS = [
null,
[26, 7, 1, 19, 0, 0],
[44, 10, 1, 34, 0, 0],
[70, 15, 1, 55, 0, 0],
[100, 20, 1, 80, 0, 0],
[134, 26, 1, 108, 0, 0],
[172, 18, 2, 68, 0, 0],
[196, 20, 2, 78, 0, 0],
[242, 24, 2, 97, 0, 0],
[292, 30, 2, 116, 0, 0],
[346, 18, 2, 68, 2, 69],
];
const ALIGN_POS = {
1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30],
6: [6, 34], 7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50],
};
function pickVersion(byteLen) {
for (let v = 1; v <= 10; v++) {
const [, ecLen, b1, d1, b2, d2] = VERSIONS[v];
const capacityBits = b1 * d1 * 8 + b2 * d2 * 8;
const headerBits = 4 + (v < 10 ? 8 : 16);
if (headerBits + byteLen * 8 <= capacityBits) return v;
}
throw new Error('内容过长,无法生成二维码');
}
function buildCodewords(bytes, version) {
const [, ecLen, b1, d1, b2, d2] = VERSIONS[version];
const totalData = b1 * d1 + b2 * d2;
const bits = [];
const put = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1); };
put(0b0100, 4); // byte 模式
put(bytes.length, version < 10 ? 8 : 16);
for (const b of bytes) put(b, 8);
// 终止符最多 4 个 0,再补齐到字节边界
for (let i = 0; i < 4 && bits.length < totalData * 8; i++) bits.push(0);
while (bits.length % 8 !== 0) bits.push(0);
const codewords = new Uint8Array(totalData);
for (let i = 0; i < bits.length; i += 8) {
let byte = 0;
for (let j = 0; j < 8; j++) byte = (byte << 1) | bits[i + j];
codewords[i / 8] = byte;
}
// 填充字节 0xEC / 0x11 交替
const PAD = [0xec, 0x11];
for (let i = bits.length / 8, k = 0; i < totalData; i++, k++) codewords[i] = PAD[k % 2];
// 分块 + 纠错 + 交织
const blocks = [];
let off = 0;
for (let i = 0; i < b1; i++) { blocks.push(codewords.slice(off, off + d1)); off += d1; }
for (let i = 0; i < b2; i++) { blocks.push(codewords.slice(off, off + d2)); off += d2; }
const ecBlocks = blocks.map((b) => rsEncode(b, ecLen));
const out = [];
const maxData = Math.max(d1, d2);
for (let i = 0; i < maxData; i++) for (const b of blocks) if (i < b.length) out.push(b[i]);
for (let i = 0; i < ecLen; i++) for (const b of ecBlocks) out.push(b[i]);
return out;
}
function makeMatrix(version, codewords) {
const size = version * 4 + 17;
const m = Array.from({ length: size }, () => new Array(size).fill(null));
const setFn = (r, c, v) => { if (r >= 0 && r < size && c >= 0 && c < size) m[r][c] = v; };
const finder = (r0, c0) => {
for (let r = -1; r <= 7; r++) {
for (let c = -1; c <= 7; c++) {
const inRing = (r >= 0 && r <= 6 && (c === 0 || c === 6)) || (c >= 0 && c <= 6 && (r === 0 || r === 6));
const inCore = r >= 2 && r <= 4 && c >= 2 && c <= 4;
setFn(r0 + r, c0 + c, inRing || inCore ? 1 : 0);
}
}
};
finder(0, 0);
finder(0, size - 7);
finder(size - 7, 0);
// 校正图形(跳过与 finder 图案重叠的位置)
for (const r of ALIGN_POS[version] ?? []) {
for (const c of ALIGN_POS[version] ?? []) {
if ((r <= 8 && c <= 8) || (r <= 8 && c >= size - 9) || (r >= size - 9 && c <= 8)) continue;
for (let dr = -2; dr <= 2; dr++) {
for (let dc = -2; dc <= 2; dc++) {
setFn(r + dr, c + dc, Math.max(Math.abs(dr), Math.abs(dc)) !== 1 ? 1 : 0);
}
}
}
}
// 定时图形
for (let i = 8; i < size - 8; i++) {
setFn(6, i, i % 2 === 0 ? 1 : 0);
setFn(i, 6, i % 2 === 0 ? 1 : 0);
}
// 预留格式信息位(15 位)与版本信息位(版本 ≥ 7)
for (let i = 0; i <= 8; i++) {
if (m[8][i] === null) setFn(8, i, 0);
if (m[i][8] === null) setFn(i, 8, 0);
}
for (let i = 0; i < 8; i++) {
if (m[8][size - 1 - i] === null) setFn(8, size - 1 - i, 0);
if (m[size - 1 - i][8] === null) setFn(size - 1 - i, 8, 0);
}
if (version >= 7) {
for (let i = 0; i < 18; i++) {
setFn(Math.floor(i / 3), size - 11 + (i % 3), 0);
setFn(size - 11 + (i % 3), Math.floor(i / 3), 0);
}
}
const reserved = m.map((row) => row.map((v) => v !== null));
// 数据按之字形填入(从右下角起,每两列一组,方向交替)
let bitIndex = 0;
const dataBits = [];
for (const cw of codewords) for (let i = 7; i >= 0; i--) dataBits.push((cw >> i) & 1);
let upward = true;
for (let col = size - 1; col > 0; col -= 2) {
if (col === 6) col--; // 跳过垂直定时图形所在列
for (let k = 0; k < size; k++) {
const row = upward ? size - 1 - k : k;
for (const c of [col, col - 1]) {
if (reserved[row][c]) continue;
m[row][c] = dataBits[bitIndex] ? 1 : 0;
bitIndex++;
}
}
upward = !upward;
}
// 8 种掩码,按 ISO 惩罚分选最低
const maskFns = [
(r, c) => (r + c) % 2 === 0,
(r) => r % 2 === 0,
(r, c) => c % 3 === 0,
(r, c) => (r + c) % 3 === 0,
(r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
(r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
(r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0,
(r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
];
const applyMask = (base, fn) => base.map((row, r) => row.map((v, c) => (reserved[r][c] ? v : (v ^ (fn(r, c) ? 1 : 0)))));
const penalty = (g) => {
let score = 0;
// 规则 1:同色连续 ≥5
for (const line of [...g, ...g[0].map((_, c) => g.map((row) => row[c]))]) {
let run = 1;
for (let i = 1; i < line.length; i++) {
if (line[i] === line[i - 1]) run++;
else { if (run >= 5) score += run - 2; run = 1; }
}
if (run >= 5) score += run - 2;
}
// 规则 2:2×2 同色块
for (let r = 0; r < g.length - 1; r++) {
for (let c = 0; c < g.length - 1; c++) {
const v = g[r][c];
if (v === g[r][c + 1] && v === g[r + 1][c] && v === g[r + 1][c + 1]) score += 3;
}
}
// 规则 4:黑白比例偏离 50%
let dark = 0;
for (const row of g) for (const v of row) dark += v;
const pct = (dark * 100) / (g.length * g.length);
score += Math.floor(Math.abs(pct - 50) / 5) * 10;
return score;
};
let best = null;
for (let i = 0; i < 8; i++) {
const cand = applyMask(m, maskFns[i]);
const sc = penalty(cand);
if (!best || sc < best.score) best = { score: sc, grid: cand, mask: i };
}
// 写回格式信息:ECC L 的指示位为 0b01,拼接 3 位掩码编号后按 BCH(15,5) 计算校验并与 0x5412 掩码异或
const fmtData = (0b01 << 3) | best.mask;
let bch = fmtData << 10;
for (let i = 4; i >= 0; i--) {
if (bch & (1 << (i + 10))) bch ^= 0b10100110111 << i;
}
const format = ((fmtData << 10) | (bch & 0x3ff)) ^ 0b101010000010010;
const f = bitsOf(format, 15);
for (let i = 0; i <= 5; i++) best.grid[8][i] = f[i];
best.grid[8][7] = f[6];
best.grid[8][8] = f[7];
best.grid[7][8] = f[8];
for (let i = 9; i <= 14; i++) best.grid[14 - i][8] = f[i];
for (let i = 0; i <= 7; i++) best.grid[size - 1 - i][8] = f[i];
for (let i = 8; i <= 14; i++) best.grid[8][size - 15 + i] = f[i];
best.grid[size - 8][8] = 1; // 固定的黑点
return best.grid;
}
function bitsOf(val, len) {
const out = [];
for (let i = len - 1; i >= 0; i--) out.push((val >> i) & 1);
return out;
}
/** 把文本渲染成 canvas 上的二维码 */
function renderQr(text, canvas) {
const bytes = [...new TextEncoder().encode(text)];
const version = pickVersion(bytes.length);
const grid = makeMatrix(version, buildCodewords(bytes, version));
const n = grid.length;
const scale = Math.floor(canvas.width / (n + 2));
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#000000';
const offset = Math.floor((canvas.width - n * scale) / 2);
for (let r = 0; r < n; r++) {
for (let c = 0; c < n; c++) {
if (grid[r][c]) ctx.fillRect(offset + c * scale, offset + r * scale, scale, scale);
}
}
}
/* ============================================================
* 流程控制
* ============================================================ */
async function boot() {
const { ok, data } = await api('/status');
if (!ok) { msg($('loading'), '无法连接服务端'); return; }
$('loading').hidden = true;
if (!data.bound) {
if (!data.setupKeyConfigured) {
$('bind-panel').hidden = false;
msg($('setup-key-msg'), '服务端未设置 SHIROKO_ADMIN_SETUP_KEY,绑定未开放。请在启动探针的环境里设置后重启。', 'err');
$('setup-start').disabled = true;
return;
}
$('bind-panel').hidden = false;
return;
}
// 已绑定:直接试拉概览,能拉到说明会话还有效
const ov = await api('/overview');
if (ov.ok) return showConsole(ov.data);
$('login-panel').hidden = false;
$('login-code').focus();
}
function showConsole(data) {
$('login-panel').hidden = true;
$('bind-panel').hidden = true;
$('console-panel').hidden = false;
$('logout-btn').hidden = false;
$('admin-ver').textContent = `shiroko-monitor v${data.version}`;
renderConsole(data);
}
function renderConsole(d) {
const s = d.stats ?? {};
$('admin-stats').innerHTML = [
`<div class="admin-stat"><b>${s.total ?? '–'}</b><label>监控数</label></div>`,
`<div class="admin-stat up"><b>${s.up ?? '–'}</b><label>在线</label></div>`,
`<div class="admin-stat down"><b>${s.down ?? '–'}</b><label>离线</label></div>`,
`<div class="admin-stat"><b>${s.avgLatency != null ? `${s.avgLatency} ms` : '–'}</b><label>平均延迟</label></div>`,
`<div class="admin-stat"><b>${s.avgUptime90 != null ? `${s.avgUptime90}%` : '–'}</b><label>90天可用率</label></div>`,
].join('');
const st = d.status ?? {};
const channels = d.notify?.channels ?? [];
$('updated-at').innerHTML =
`数据更新于 ${esc(new Date(st.dataUpdatedAt ?? Date.now()).toLocaleString('zh-CN', { hour12: false }))} · ` +
`自建目标 ${st.localCount ?? 0} 个 / 配置 ${st.configCount ?? 0} 个 / UptimeRobot ${st.urCount ?? 0} 个` +
(st.urEnabled ? (st.urOk === false ? `(<b>同步异常:${esc(st.urError ?? '')}</b>)` : '(已启用)') : '(未启用)') +
(channels.length ? ` · 通知渠道 ${esc(channels.join(' + '))}` : '');
$('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;
$('data-url').value = d.settings?.dataUrl ?? '';
$('cors-origins').value = (d.settings?.corsOrigins ?? []).join(', ');
// 维护窗口目标下拉
const sel = $('mw-id');
const cur = sel.value;
sel.innerHTML = '<option value="">全部目标</option>' + d.monitors.map((m) => `<option value="${esc(m.id)}">${esc(m.name)}</option>`).join('');
if (cur) sel.value = cur;
const now = Date.now();
$('mw-list').innerHTML = d.maintenance.length
? d.maintenance.map((w, i) => {
const active = Date.parse(w.from) <= now && now <= Date.parse(w.to);
const scope = w.id ? (d.monitors.find((m) => m.id === w.id)?.name ?? w.id) : '全部目标';
return `<div class="mw-item${active ? ' active' : ''}">
<span class="mw-tag ${active ? 'active' : 'idle'}">${active ? '进行中' : '待生效'}</span>
<span class="mw-time">${esc(String(w.from).replace('T', ' ').slice(0, 16))} → ${esc(String(w.to).replace('T', ' ').slice(0, 16))}</span>
<span class="mw-note">${esc(scope)}${w.note ? ` · ${esc(w.note)}` : ''}</span>
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" data-del="${i}">删除</button>
</div>`;
}).join('')
: '<p class="admin-muted">暂无维护窗口。</p>';
for (const btn of $('mw-list').querySelectorAll('[data-del]')) {
btn.addEventListener('click', async () => {
const r = await api('/maintenance', { method: 'DELETE', body: { index: Number(btn.dataset.del) } });
if (r.ok) refresh();
else msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err');
});
}
$('monitor-list').innerHTML = d.monitors.map((m) => `<tr>
<td title="${esc(m.name)}">${esc(m.name)}</td>
<td>${esc(m.group)}</td>
<td>${esc(m.type)}</td>
<td class="mono" title="${esc(m.target)}">${esc(m.target)}</td>
<td>${m.interval != null ? `${m.interval}s` : '–'}</td>
</tr>`).join('');
const labels = { down: '故障', up: '恢复', tls: '证书', maintenance: '维护' };
const statusOf = { down: 'down', up: 'up', tls: 'pending', maintenance: 'up' };
$('event-list').innerHTML = d.events.length
? d.events.map((e) => `<tr data-status="${esc(statusOf[e.type] ?? 'up')}">
<td title="${esc(new Date(e.t).toLocaleString('zh-CN', { hour12: false }))}">${esc(new Date(e.t).toLocaleString('zh-CN', { hour12: false }))}</td>
<td><span class="ev-badge">${esc(labels[e.type] ?? e.type)}</span></td>
<td title="${esc(e.name)}">${esc(e.name)}</td>
<td class="mono" title="${esc(e.detail ?? '')}">${esc(e.detail ?? '')}</td>
</tr>`).join('')
: '<tr><td colspan="4">暂无事件</td></tr>';
const tf = d.twofa ?? {};
$('twofa-summary').innerHTML = `已绑定于 ${esc(tf.boundAt ?? '–')} · 剩余恢复码 ${tf.recoveryLeft ?? 0} 枚 · 当前会话 ${tf.sessions ?? 0} 个`
+ (tf.recoveryLow ? ' · <b style="color:var(--pending-text)">恢复码不足 3 枚,请重新绑定以获取新的一批</b>' : '');
}
async function refresh() {
const r = await api('/overview');
if (r.ok) renderConsole(r.data);
else if (r.status === 401) { $('console-panel').hidden = true; $('login-panel').hidden = false; }
}
/* ---------- 绑定 ---------- */
$('setup-start').addEventListener('click', async () => {
const key = $('setup-key').value;
msg($('setup-key-msg'), '');
const r = await api('/2fa/setup', { method: 'POST', body: { key } });
if (!r.ok) { msg($('setup-key-msg'), errText(r.data?.error, r.data?.hint), 'err'); return; }
setupDraft = { setupToken: r.data.setupToken, secret: r.data.secret, uri: r.data.uri };
$('setup-key').value = '';
$('step-key').hidden = true;
$('step-secret').hidden = false;
$('secret-text').textContent = r.data.secret.replace(/(.{4})/g, '$1 ').trim();
$('uri-text').textContent = r.data.uri;
try { renderQr(r.data.uri, $('qr')); } catch (err) { msg($('confirm-msg'), `二维码生成失败,可手动输入密钥:${err.message}`, 'err'); }
$('confirm-code').focus();
});
$('copy-secret').addEventListener('click', () => copy(setupDraft?.secret ?? ''));
$('copy-uri').addEventListener('click', () => copy(setupDraft?.uri ?? ''));
async function copy(text) {
if (!text) return;
try { await navigator.clipboard.writeText(text); } catch { /* 非安全上下文下 clipboard 不可用 */ }
}
$('confirm-btn').addEventListener('click', async () => {
const code = $('confirm-code').value.trim();
msg($('confirm-msg'), '');
const r = await api('/2fa/confirm', { method: 'POST', body: { setupToken: setupDraft?.setupToken, code } });
if (!r.ok) { msg($('confirm-msg'), errText(r.data?.error), 'err'); return; }
$('step-secret').hidden = true;
$('step-recovery').hidden = false;
setupDraft.confirmCode = r.data.confirmCode;
$('recovery-list').innerHTML = r.data.recoveryCodes.map((c) => `<code>${esc(c)}</code>`).join('');
$('copy-recovery').onclick = () => copy(r.data.recoveryCodes.join('\n'));
});
// 绑定完成即视为本次操作已通过 2FA(刚用 App 的验证码证明了持有),
// 直接用绑定时那枚验证码换会话,不再要求用户再输一次
$('recovery-done').addEventListener('click', async () => {
$('recovery-done').disabled = true;
const r = await api('/login', { method: 'POST', body: { code: setupDraft?.confirmCode } });
$('recovery-done').disabled = false;
if (r.ok) {
const ov = await api('/overview');
if (ov.ok) return showConsole(ov.data);
}
// 兜底:让用户手动登录(同一时间窗内的码已被防重放消费,也属正常)
$('bind-panel').hidden = true;
$('login-panel').hidden = false;
$('login-code').focus();
});
/* ---------- 登录 ---------- */
$('login-btn').addEventListener('click', doLogin);
$('login-code').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
async function doLogin() {
const code = $('login-code').value.trim();
if (!code) return;
msg($('login-msg'), '');
$('login-btn').disabled = true;
try {
const r = await api('/login', { method: 'POST', body: { code } });
if (!r.ok) {
const extra = r.data?.fails ? `(已失败 ${r.data.fails} 次)` : (r.retryAfter ? `(约 ${r.retryAfter} 秒后可试)` : '');
msg($('login-msg'), errText(r.data?.error) + extra, 'err');
return;
}
$('login-code').value = '';
const ov = await api('/overview');
if (ov.ok) showConsole(ov.data);
else msg($('login-msg'), '登录成功但读取概览失败,请刷新页面', 'err');
} finally {
$('login-btn').disabled = false;
}
}
$('reset-btn').addEventListener('click', async () => {
const key = $('reset-key').value;
if (!key) return;
if (!confirm('重置 2FA 会清空已绑定的密钥与全部恢复码,需重新绑定。确定继续?')) return;
const r = await api('/2fa/reset', { method: 'POST', body: { key } });
if (r.ok) location.reload();
else msg($('login-msg'), errText(r.data?.error), 'err');
});
$('logout-btn').addEventListener('click', async () => {
await api('/logout', { method: 'POST' });
location.reload();
});
/* ---------- 管理操作 ---------- */
$('probe-scope').addEventListener('change', async (e) => {
const r = await api('/probe-scope', { method: 'POST', body: { probeScope: e.target.value } });
if (r.ok) { msg($('action-msg'), `探测范围已改为 ${e.target.value}`, 'ok'); refresh(); }
else msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err');
});
$('data-filter').addEventListener('change', async (e) => {
const r = await api('/data-filter', { method: 'POST', body: { dataFilter: e.target.value } });
if (r.ok) { msg($('action-msg'), `展示口径已改为 ${e.target.value}`, 'ok'); refresh(); }
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 ?? '';
}
});
async function saveRemoteSettings() {
const dataUrl = $('data-url').value.trim();
const corsOrigins = $('cors-origins').value.split(',').map((s) => s.trim()).filter(Boolean);
const r = await api('/remote', { method: 'POST', body: { dataUrl, corsOrigins } });
if (r.ok) {
msg($('action-msg'), dataUrl ? `跨域数据源:${r.data.dataUrl};已放行 ${r.data.corsOrigins.length} 个来源` : '已清空跨域数据源', 'ok');
refresh();
} else {
msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err');
}
}
$('data-url').addEventListener('change', saveRemoteSettings);
$('cors-origins').addEventListener('change', saveRemoteSettings);
$('probe-now').addEventListener('click', async () => {
const btn = $('probe-now');
btn.disabled = true;
msg($('action-msg'), '正在探测…');
try {
const r = await api('/probe-now', { method: 'POST' });
if (r.ok) { msg($('action-msg'), `探测完成:在线 ${r.data.stats.up},离线 ${r.data.stats.down}`, 'ok'); refresh(); }
else msg($('action-msg'), '探测失败', 'err');
} finally { btn.disabled = false; }
});
$('notify-test').addEventListener('click', async () => {
const btn = $('notify-test');
btn.disabled = true;
msg($('action-msg'), '正在发送…');
try {
const r = await api('/notify-test', { method: 'POST' });
if (!r.ok) { msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err'); return; }
const detail = (r.data.results ?? []).map((x) => `${x.channel} ${x.ok ? '成功' : `失败:${x.error}`}`).join(';');
msg($('action-msg'), detail || '无渠道', r.data.ok ? 'ok' : 'err');
} finally { btn.disabled = false; }
});
// 维护窗口默认填「现在起 1 小时」
(function initDefaults() {
const pad = (n) => String(n).padStart(2, '0');
const fmt = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
const now = new Date();
$('mw-from').value = fmt(now);
$('mw-to').value = fmt(new Date(now.getTime() + 60 * 60 * 1000));
})();
$('mw-add').addEventListener('click', async () => {
const from = $('mw-from').value;
const to = $('mw-to').value;
if (!from || !to) { msg($('action-msg'), '请填写开始与结束时间', 'err'); return; }
const body = { from: new Date(from).toISOString(), to: new Date(to).toISOString() };
const note = $('mw-note').value.trim();
const id = $('mw-id').value;
if (note) body.note = note;
if (id) body.id = id;
const r = await api('/maintenance', { method: 'POST', body });
if (r.ok) { msg($('action-msg'), '维护窗口已新增', 'ok'); $('mw-note').value = ''; refresh(); }
else msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err');
});
boot();
+80 -16
View File
@@ -1,4 +1,4 @@
const VERSION = '0.11.1';
const VERSION = '0.12.2';
const HB_LIMIT = 48;
const UPTIME_DAYS = 90;
const BROWSER_CONCURRENCY = 6;
@@ -28,6 +28,7 @@ const $ = (id) => document.getElementById(id);
const SOURCE_LABEL = {
api: '边缘 API(实时)',
remote: '远程探针(跨域)',
static: '静态快照',
browser: '浏览器直连',
};
@@ -36,7 +37,10 @@ const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;
const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
async function fetchJson(url, timeoutMs = 8000) {
const res = await fetch(url, { cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) });
// 相对路径 + 存在远程数据源时,自动改写到该地址(跨域读取由对方开放 CORS)
const base = remoteBase();
const full = base && url.startsWith('api/') ? `${base}/${url}` : url;
const res = await fetch(full, { cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) });
if (!res.ok) {
const err = new Error(`HTTP ${res.status}`);
err.status = res.status;
@@ -81,11 +85,38 @@ async function mapLimit(items, limit, fn) {
function setSource(s) {
state.source = s;
$('source-badge').textContent = `数据源:${SOURCE_LABEL[s] ?? s}`;
// 跨域模式下「刷新」没有意义:/api/refresh 会被服务端的 refreshLocalOnly 挡住
// (对端是代理地址而非私网),前端也会退回只读。与其给一个必然失败的按钮,不如隐藏。
const crossOrigin = s === 'remote';
const btn = $('refresh-btn');
if (btn) {
btn.hidden = crossOrigin;
btn.title = crossOrigin ? '' : '立即重新跑一轮探测(需要服务端允许)';
}
const ctrl = document.querySelector('.controls');
if (ctrl) ctrl.classList.toggle('no-refresh', crossOrigin);
}
// 跨域数据源:settings.dataUrl 指向另一台机器上的探针(通常是本机,但页面部署在别处)。
// 例:"https://monitor.example.com" —— 前端会从 https://monitor.example.com/api/status 取数据。
// 该地址必须与本页同为 HTTPS,否则浏览器会以「混合内容」为由拦截(与 CORS 无关)。
function remoteBase() {
const raw = String(state.config?.settings?.dataUrl ?? '').trim();
if (!raw) return '';
try {
const u = new URL(raw);
return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin + u.pathname.replace(/\/+$/, '') : '';
} catch {
return '';
}
}
async function resolveSource() {
const forced = new URLSearchParams(location.search).get('source');
const mode = forced || state.config.settings?.dataSource || 'auto';
// 配置了远程数据源时,「api」与「auto」都优先尝试远程地址
const remote = remoteBase();
const apiUrl = remote ? `${remote}/api/status` : 'api/status';
if (mode === 'browser') return setSource('browser');
if (mode === 'static') {
try {
@@ -97,17 +128,17 @@ async function resolveSource() {
}
if (mode === 'api') {
try {
await fetchJson('api/status', 5000);
return setSource('api');
await fetchJson(apiUrl, 6000);
return setSource(remote ? 'remote' : 'api');
} catch {
return setSource('browser');
}
}
// auto:优先边缘 API,其次静态快照,最后浏览器直连
// auto:优先远程/边缘 API,其次静态快照,最后浏览器直连
try {
await fetchJson('api/status', 3000);
return setSource('api');
} catch { /* 无边缘 API */ }
await fetchJson(apiUrl, 4000);
return setSource(remote ? 'remote' : 'api');
} catch { /* 无边缘/远程 API */ }
try {
await fetchJson('status.json', 3000);
return setSource('static');
@@ -263,14 +294,16 @@ function renderEvents() {
if (count) count.textContent = all.length > shown.length ? `最近 ${shown.length} / ${all.length} 条` : `最近 ${all.length} 条`;
const label = { down: '故障', up: '恢复', tls: '证书', maintenance: '维护' };
// 与监控表格同一套结构(tr/td + data-status),因此行高、边框、hover、状态底色天然一致
const statusOf = { down: 'down', up: 'up', tls: 'pending', maintenance: 'up' };
list.innerHTML = shown.map((e) => {
const maint = e.maintenance ? '<span class="ev-maint" title="维护窗口内,未计入可用率、未发送告警">维护</span>' : '';
return `<li class="ev ev-${escapeHtml(e.type)}">
<span class="ev-time" title="${escapeHtml(fmtTime(e.t))}">${escapeHtml(fmtRelative(e.t))}</span>
<span class="ev-type">${escapeHtml(label[e.type] ?? e.type)}${maint}</span>
<span class="ev-name" title="${escapeHtml(e.name)}">${escapeHtml(e.name)}</span>
<span class="ev-detail" title="${escapeHtml(e.detail ?? '')}">${escapeHtml(e.detail ?? '')}</span>
</li>`;
return `<tr class="ev" data-status="${escapeHtml(statusOf[e.type] ?? 'up')}">
<td class="ev-time" title="${escapeHtml(fmtTime(e.t))}">${escapeHtml(fmtRelative(e.t))}</td>
<td class="ev-type"><span class="ev-badge">${escapeHtml(label[e.type] ?? e.type)}</span>${maint}</td>
<td class="ev-name" title="${escapeHtml(e.name)}"><span class="clamp2">${escapeHtml(e.name)}</span></td>
<td class="ev-detail" title="${escapeHtml(e.detail ?? '')}"><span class="clamp2">${escapeHtml(e.detail ?? '')}</span></td>
</tr>`;
}).join('');
const more = $('events-more');
@@ -541,9 +574,12 @@ function tableRow(m, hide) {
const tr = document.createElement('tr');
tr.dataset.status = s.cls;
tr.dataset.id = m.id;
const targetCell = hide ? '' : `<td class="t-target" title="${escapeHtml(targetText(m))}">${escapeHtml(targetText(m))}</td>`; tr.innerHTML =
const targetCell = hide ? '' : `<td class="t-target" title="${escapeHtml(targetText(m))}"><span class="clamp2">${escapeHtml(targetText(m))}</span></td>`;
// 名称与徽标分行:名称限两行、徽标独占一行,两者高度都固定,行高不再随名称/地址长度变化
const tags = `${typeLabel(m) ? `<span class="type-badge">${escapeHtml(typeLabel(m))}</span>` : ''}${certBadge(st)}${maintBadge(st)}`;
tr.innerHTML =
`<td class="t-state"><span class="dot"></span>${s.label}</td>` +
`<td class="t-name" title="${escapeHtml(m.name)}">${escapeHtml(m.name)}<span class="type-badge">${escapeHtml(typeLabel(m))}</span>${certBadge(st)}${maintBadge(st)}</td>` +
`<td class="t-name" title="${escapeHtml(m.name)}"><span class="clamp2">${escapeHtml(m.name)}</span><span class="t-tags">${tags}</span></td>` +
targetCell +
`<td class="t-num">${st?.latency != null ? `${st.latency} ms` : '–'}</td>` +
`<td class="t-num">${escapeHtml(resultText(st, m))}</td>` +
@@ -659,6 +695,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();
@@ -722,6 +777,7 @@ function renderAll() {
renderViewSwitch();
renderView();
renderEvents();
renderAdminLink();
}
function renderError(err) {
@@ -933,6 +989,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 || '';
+108 -64
View File
@@ -21,6 +21,11 @@
--mono: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, "Courier New", monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
--shadow: 0 1px 2px rgb(0 0 0 / 0.04);
/* 表格行高基准:固定像素,行高与内容长度彻底解耦(任意长度内容下行高都相同)。
72px = 名称两行(36) + 徽标行(18) + 上下内边距(16),监控表与事件表共用同一个值,
两张表因此在视觉上也严格对齐。 */
--row-h: 72px;
--group-row-h: 32px;
}
@media (prefers-color-scheme: dark) {
@@ -171,6 +176,8 @@ body {
.view-btn + .view-btn { border-left: 1px solid var(--border); }
.view-btn:hover { color: var(--text); }
.view-btn.active { background: var(--accent); color: #fff; }
/* 跨域模式下刷新按钮被隐藏(见 app.js setSource),此时控件区只剩视图切换 */
.controls.no-refresh { gap: 0; }
/* ---------- 分组页签 ---------- */
.group-tabs {
@@ -328,6 +335,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; }
@@ -368,6 +386,11 @@ body[data-view="table"] .grid { display: block; }
border-collapse: collapse;
font-size: 13px;
}
/* 行高基准:所有数据行恒为 --row-h,与内容长度彻底解耦。
内容再长也不会把某一行撑高(文本列各自限行截断 + 单元格 overflow:hidden)。
分组标题行另设 --group-row-h,用 :not() 排除掉,否则同样会被这条规则套上。 */
.monitor-table tbody tr:not(.group-row) { height: var(--row-h); }
.group-row td { height: var(--group-row-h); }
/* 表格列宽:状态 / 服务 / 目标 / 延迟 / 结果 / 最近检测 / 心率 / 90天可用率 = 100%
地址与结果列给足比例,避免长 URL、长名称被截断 */
.monitor-table th:nth-child(1) { width: 7%; }
@@ -390,29 +413,56 @@ body[data-view="table"] .grid { display: block; }
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.monitor-table td { padding: 9px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; overflow: hidden; }
.monitor-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; overflow: hidden; line-height: 1.4; }
/* 所有行统一基线:可用率的 mini 条是 inline-flex,其基线会把行盒撑高十几像素,
导致带条的行比不带的行高出一截(实测 58px vs 43px) */
.monitor-table td > * { vertical-align: middle; }
.u-mini { display: inline-flex; align-items: center; gap: 6px; vertical-align: middle; }
/* 限行截断:文本再多也只占固定的两行,行高因此与内容长度彻底解耦。
height 而非 max-height —— 只有 1 行内容时也照样占满 2 行,行高才恒定;
max-height 会让短内容缩成 1 行、长内容撑到 2 行,反而出现两种行高。
-webkit-line-clamp 是 Chrome/Safari/Edge 的实现,height + overflow 兜底 Firefox。 */
.clamp2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
height: 2.8em;
}
.monitor-table tbody tr[data-status="pending"] { background: color-mix(in srgb, var(--pending) 6%, transparent); }
.monitor-table tbody tr:last-child td { border-bottom: 0; }
.monitor-table tbody tr:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
.monitor-table tbody tr[data-status="down"] { background: color-mix(in srgb, var(--down) 6%, transparent); }
.group-row td { padding: 7px 12px; font-size: 12px; font-weight: 650; color: var(--text-2); background: var(--bg); }
.group-row td { padding: 7px 12px; font-size: 12px; font-weight: 650; color: var(--text-2); background: var(--bg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.t-state { white-space: nowrap; }
.t-state .dot { display: inline-block; margin-right: 6px; vertical-align: middle; }
tr[data-status="up"] .t-state { color: var(--up-text); }
tr[data-status="down"] .t-state { color: var(--down-text); }
tr[data-status="pending"] .t-state { color: var(--pending-text); }
.t-name { white-space: normal; overflow-wrap: anywhere; }
.t-name .type-badge { margin-left: 8px; }
/* 名称列:文本区限两行,徽标区固定单行高度 —— 整格高度恒定 */
.t-name { white-space: normal; }
.t-name .clamp2 { overflow-wrap: anywhere; }
.t-tags {
display: flex;
align-items: center;
gap: 6px;
height: 16px;
margin-top: 2px;
overflow: hidden;
white-space: nowrap;
}
.t-name .type-badge { margin-left: 0; }
.t-target {
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.45;
color: var(--text-2);
white-space: normal;
overflow-wrap: anywhere;
word-break: break-all;
}
.t-num { font-variant-numeric: tabular-nums; white-space: normal; overflow-wrap: anywhere; }
.t-target .clamp2 { overflow-wrap: anywhere; word-break: break-all; }
.t-num { font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* 证书 / 维护徽标:出现在名称旁,不占独立列 */
.tls-badge, .maint-badge {
@@ -432,7 +482,7 @@ tr[data-status="pending"] .t-state { color: var(--pending-text); }
.t-hb svg { display: block; width: 100%; max-width: 150px; height: 24px; }
.t-uptime { white-space: normal; }
/* 可用率条随视口伸缩;极窄时只留百分比,避免挤压其他列 */
.uptime-bar-mini { display: inline-flex; width: clamp(32px, 6vw, 84px); height: 8px; margin-right: 6px; vertical-align: middle; }
.uptime-bar-mini { display: flex; flex: none; width: clamp(32px, 6vw, 84px); height: 8px; margin-right: 0; vertical-align: middle; }
.t-pct { font-size: 12px; color: var(--text-2); font-variant-numeric: tabular-nums; }
@media (max-width: 640px) {
.uptime-bar-mini { display: none; }
@@ -459,69 +509,63 @@ tr[data-status="pending"] .t-state { color: var(--pending-text); }
}
.events-more:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); }
/* 左侧竖线 + 每行圆点,构成时间线;行本身仍是浅色卡片 */
.events-list {
list-style: none;
margin: 0;
padding: 2px 0 2px 16px;
position: relative;
}
.events-list::before {
content: "";
position: absolute;
left: 5px;
top: 8px;
bottom: 8px;
width: 1px;
background: var(--border);
}
.ev {
position: relative;
display: grid;
/* 名称列按内容自适应,详情列吃掉剩余宽度 —— 避免短名称后面拖出一大片空白 */
grid-template-columns: 62px 46px auto minmax(0, 1fr);
gap: 10px;
align-items: center;
padding: 6px 12px;
border-radius: 9px;
background: var(--surface);
border: 1px solid var(--border);
/* 事件表:直接复用监控表格的外观(圆角、边框、行分隔、hover、状态底色),
只额外定义列宽与类型徽标,保证两处视觉完全一致 */
/* 事件表:显式声明与监控表格相同的观感(不共用 .monitor-table 类,
避免被其他按类名统计/定位的逻辑误算),列宽与类型徽标单独定义 */
.events-table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 13px; }
.events-table tbody tr { height: var(--row-h); }
.events-table th {
padding: 10px 12px;
text-align: left;
font-size: 12px;
min-width: 0;
font-weight: 600;
color: var(--text-2);
background: var(--bg);
border-bottom: 1px solid var(--border);
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
}
.ev + .ev { margin-top: 4px; }
.ev::before {
content: "";
position: absolute;
left: -14px;
top: 50%;
width: 7px;
height: 7px;
margin-top: -3.5px;
border-radius: 50%;
background: var(--text-2);
box-shadow: 0 0 0 3px var(--bg);
}
.ev-down { border-left: 3px solid var(--down); }
.ev-up { border-left: 3px solid var(--up); }
.ev-tls { border-left: 3px solid var(--pending); }
.ev-down::before { background: var(--down); }
.ev-up::before { background: var(--up); }
.ev-tls::before { background: var(--pending); }
.events-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; line-height: 1.4; overflow: hidden; }
.events-table tbody tr:last-child td { border-bottom: 0; }
.events-table tbody tr:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
.events-table tbody tr[data-status="down"] { background: color-mix(in srgb, var(--down) 6%, transparent); }
.events-table tbody tr[data-status="pending"] { background: color-mix(in srgb, var(--pending) 6%, transparent); }
.events-wrap { margin-top: 0; }
.events-table th:nth-child(1) { width: 11%; }
.events-table th:nth-child(2) { width: 9%; }
.events-table th:nth-child(3) { width: 26%; }
.events-table th:nth-child(4) { width: 54%; }
.ev-time { color: var(--text-2); font-variant-numeric: tabular-nums; white-space: nowrap; }
.ev-type { font-weight: 650; text-align: center; border-radius: 999px; padding: 2px 0; font-size: 11px; }
.ev-down .ev-type { background: var(--down-bg); color: var(--down-text); }
.ev-up .ev-type { background: var(--up-bg); color: var(--up-text); }
.ev-tls .ev-type { background: var(--pending-bg); color: var(--pending-text); }
.ev-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 650;
}
tr[data-status="down"] .ev-badge { background: var(--down-bg); color: var(--down-text); }
tr[data-status="up"] .ev-badge { background: var(--up-bg); color: var(--up-text); }
tr[data-status="pending"] .ev-badge { background: var(--pending-bg); color: var(--pending-text); }
.ev-maint { margin-left: 6px; border-radius: 999px; padding: 0 6px; font-size: 10px; font-weight: 650; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
.ev-name { font-weight: 600; max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.ev-detail { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.events-empty { margin: 0; padding: 12px; font-size: 12px; color: var(--text-2); background: var(--surface); border: 1px dashed var(--border); border-radius: 10px; }
.ev-name { font-weight: 600; }
.ev-detail { color: var(--text-2); }
/* 事件表:单元格一律顶部对齐。clamp2 为了保证等高固定了 height:2.8em,
若单元格按 middle 对齐,17px 的徽标与 36px 的文本框居中基准不同,
同一行里「类型」会比「时间/目标/详情」低约 9px(实测截图确认)。 */
.events-table td { vertical-align: top; }
.events-table td > * { vertical-align: top; }
/* 事件行的行高已由 --row-h 固定,clamp2 可以只做上限不必占满两行 */
.events-table .clamp2 { height: auto; max-height: 2.8em; }
/* 事件表的长文本同样限两行,保证与监控表格一致的等高行 */
.ev-name, .ev-detail { white-space: normal; }
.ev-name > .clamp2, .ev-detail > .clamp2 { overflow-wrap: anywhere; }
.events-empty { margin: 0; padding: 12px; font-size: 12px; color: var(--text-2); background: var(--surface); border: 1px dashed var(--border); border-radius: 12px; }
@media (max-width: 640px) {
.ev { grid-template-columns: 56px 44px minmax(0, 1fr); gap: 8px; }
.ev-detail { display: none; }
.events { padding: 0 14px; }
.events-table th:nth-child(4), .ev-detail { display: none; }
}
/* ---------- 卡片:90 天日均延迟趋势 ---------- */
+10 -2
View File
@@ -57,7 +57,14 @@
<span class="events-count" id="events-count"></span>
<button type="button" class="events-more" id="events-more" hidden>展开全部</button>
</div>
<ul class="events-list" id="events-list"></ul>
<div class="table-wrap events-wrap">
<table class="events-table">
<thead>
<tr><th>时间</th><th>类型</th><th>目标</th><th>详情</th></tr>
</thead>
<tbody id="events-list"></tbody>
</table>
</div>
<p class="events-empty" id="events-empty" hidden>暂无事件记录(故障、恢复与证书提醒会出现在这里)</p>
</section>
@@ -68,7 +75,8 @@
<span id="notify-badge" class="footer-item" hidden>通知 –</span>
<span id="updated-at" class="footer-item">–</span>
<span id="countdown" class="footer-item">–</span>
<span class="footer-item version">shiroko-monitor v0.11.1</span>
<a id="admin-link" class="footer-item admin-link" hidden>管理</a>
<span class="footer-item version">shiroko-monitor v0.12.2</span>
</footer>
<script type="module" src="assets/app.js"></script>
+4 -1
View File
@@ -6,9 +6,12 @@
"theme": "shiroko",
"artwork": "assets/shiroko-figure.webp",
"decoOpacity": 0.78,
"dataFilter": "uptimerobot",
"dataFilter": "both",
"refreshInterval": 300,
"dataSource": "auto",
"adminUrl": "",
"dataUrl": "",
"corsOrigins": [],
"uptimeRobot": {
"enabled": false,
"apiKey": "",
+1465 -605
View File
File diff suppressed because it is too large Load Diff
+369
View File
@@ -0,0 +1,369 @@
/**
* shiroko-monitor 管理端验证:2FA 绑定、登录、会话、限流与管理接口
*
* 覆盖:
* [A] TOTP / Base32 —— RFC 4226 与 RFC 6238 官方测试向量(算法正确性的地基)
* [B] 2FA 档案 —— 绑定流程、恢复码一次性、session 生命周期、防重放、失败限流
* [C] HTTP 接口 —— 未绑定时一律 403、越权路径、cookie 属性、私有配置写入
* [D] 页面 —— 未绑定只见绑定流程;绑定后需 2FA 才能进管理界面
*
* 用法:node scripts/verify-admin.cjs
*/
const fs = require('node:fs');
const http = require('node:http');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { chromium } = require('playwright');
const ROOT = path.join(__dirname, '..');
const PROBER = path.join(ROOT, 'prober', 'node', 'index.js');
const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const PORT = 8891;
const SETUP_KEY = 'verify-admin-setup-key';
const results = [];
const check = (name, pass, detail) => {
results.push({ name, pass });
console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`);
};
const group = (t) => console.log(`\n${t}`);
// 与服务端 lib/totp.js 同一套实现,用它算出「App 会显示的码」
const lib = path.join(ROOT, 'prober', 'node', 'lib', 'totp.js');
async function loadLib() {
return import(`file:///${lib.replace(/\\/g, '/')}`);
}
async function withServer(env, fn) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-admin-'));
fs.cpSync(path.join(ROOT, 'public'), tmp, { recursive: true });
const cfg = path.join(tmp, 'monitors.json');
const base = JSON.parse(fs.readFileSync(cfg, 'utf8'));
base.settings.dataFilter = 'config';
fs.writeFileSync(cfg, `${JSON.stringify(base, null, 2)}\n`);
// 本地探测配置指向本地夹具,避免真实网络请求拖慢测试
const localCfg = path.join(tmp, 'local.json');
fs.writeFileSync(localCfg, JSON.stringify({ settings: {}, monitors: [] }));
const child = spawn(process.execPath, [PROBER, '--once'], {
env: { ...process.env, PORT: String(PORT), SHIROKO_DATA_DIR: tmp, SHIROKO_ADMIN_SETUP_KEY: env.setupKey ?? SETUP_KEY },
stdio: 'ignore',
});
// --once 会退出,因此另起一个常驻进程供 HTTP 测试
const server = spawn(process.execPath, [PROBER, `--config=${cfg}`, `--local-config=${localCfg}`, `--out=${path.join(tmp, 'status.json')}`], {
env: { ...process.env, PORT: String(PORT), SHIROKO_DATA_DIR: tmp, SHIROKO_ADMIN_SETUP_KEY: env.setupKey ?? SETUP_KEY },
stdio: 'ignore',
});
await new Promise((r) => setTimeout(r, 4000));
try {
await fn({ tmp, cfg, localCfg });
} finally {
server.kill();
child.kill();
fs.rmSync(tmp, { recursive: true, force: true });
}
}
const req = async (path_, { method = 'GET', body, cookie } = {}) => {
const res = await fetch(`http://localhost:${PORT}${path_}`, {
method,
headers: {
...(body ? { 'content-type': 'application/json' } : {}),
...(cookie ? { cookie } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
let data = null;
try { data = await res.json(); } catch { /* 无 JSON 体 */ }
return { status: res.status, data, cookie: res.headers.get('set-cookie') };
};
async function main() {
const { totp, base32Decode, hotp, base32Encode, base32Decode: dec, generateSecret, generateRecoveryCodes, hashRecoveryCode, verifyTotp, otpauthUri } = await loadLib();
const { createAdminAuth } = await import(`file:///${path.join(ROOT, 'prober', 'node', 'lib', 'admin.js').replace(/\\/g, '/')}`);
/* ============ [A] TOTP 算法(RFC 官方向量) ============ */
group('[A] TOTP / Base32 算法(RFC 4226 & RFC 6238 官方测试向量)');
const ascii20 = Buffer.from('12345678901234567890', 'ascii');
const hotpExpected = ['755224', '287082', '359152', '969429', '338314', '254676', '287922', '162583', '399871', '520489'];
check('RFC 4226 HOTP 十个向量全部匹配', hotpExpected.every((v, i) => hotp(ascii20, i) === v), hotpExpected.slice(0, 3).join(',') + '…');
const totpTimes = [59, 1111111109, 1111111111, 1234567890, 2000000000, 20000000000];
const totpExpected = ['94287082', '07081804', '14050471', '89005924', '69279037', '65353130'];
check('RFC 6238 TOTP(SHA1/8 位)六个向量全部匹配', totpTimes.every((t, i) => totp(ascii20, { atMs: t * 1000, digits: 8 }) === totpExpected[i]), totpExpected.join(','));
const sec = generateSecret();
check('生成密钥为 20 字节(160 位,RFC 4226 建议强度)', base32Decode(sec).length === 20, `${base32Decode(sec).length} 字节`);
check('Base32 编解码可往返', base32Encode(base32Decode(sec)) === sec, sec.slice(0, 8) + '…');
check('Base32 解码容忍小写/空格/连字符', Buffer.compare(dec('ABCD EFGH-IJKL'), dec('abcdefghijkl')) === 0, `归一化后 ${dec('abcd efgh-ijkl').length} 字节`);
const uri = otpauthUri({ secret: sec, label: 'shiroko', issuer: 'shiroko-monitor' });
check('otpauth 链接含 secret/digits/period/issuer', /secret=/.test(uri) && /digits=6/.test(uri) && /period=30/.test(uri) && /issuer=shiroko-monitor/.test(uri), uri.slice(0, 70) + '…');
const good = totp(base32Decode(sec));
check('正确验证码通过校验', verifyTotp(sec, good) === null, good);
check('错误验证码被拒', verifyTotp(sec, '000000') === 'invalid-code' || verifyTotp(sec, good) === 'invalid-code');
check('格式非法的输入被拒', verifyTotp(sec, 'abc') === 'invalid-format', '');
check('验证码容错 ±1 个时间窗', verifyTotp(sec, totp(base32Decode(sec), { atMs: Date.now() - 30000 })) === null, '30 秒前');
check('超窗(5 分钟前)的码被拒', verifyTotp(sec, totp(base32Decode(sec), { atMs: Date.now() - 5 * 60000 })) === 'invalid-code', '');
const rc = generateRecoveryCodes(10);
check('恢复码 10 枚且格式为 XXXXX-XXXXX', rc.length === 10 && rc.every((c) => /^[A-Z2-9]{5}-[A-Z2-9]{5}$/.test(c)), rc[0]);
check('恢复码不含易混淆字符 0/O/1/I', rc.join('').replace(/-/g, '').split('').every((ch) => !"01IO".includes(ch)), '');
check('恢复码哈希对小写/去连字符/带空格均一致', (() => {
const s = 'salt123';
const h = hashRecoveryCode(rc[0], s);
return h === hashRecoveryCode(rc[0].toLowerCase(), s) && h === hashRecoveryCode(rc[0].replace('-', ''), s) && h === hashRecoveryCode(` ${rc[0]} `, s);
})(), '');
/* ============ [B] 2FA 档案与会话 ============ */
group('[B] 2FA 档案:绑定 / 会话 / 防重放 / 限流');
const btmp = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-2fa-'));
let clock = Date.now();
const A = createAdminAuth({ storePath: path.join(btmp, 'admin.json'), setupKey: SETUP_KEY, now: () => clock });
check('初始状态为未绑定', A.isBound() === false, JSON.stringify(A.status()));
check('错误绑定口令被拒', A.beginSetup('wrong', '1.1.1.1').error === 'bad-setup-key');
const s1 = A.beginSetup(SETUP_KEY, '10.0.0.1');
check('正确口令换取密钥', s1.ok && s1.secret.length === 32 && s1.uri.startsWith('otpauth://'), `${s1.secret.slice(0, 8)}…`);
check('未完成绑定时不可登录', A.login('123456', '10.0.0.1').error === 'not-bound');
check('错误验证码无法完成绑定', A.confirmSetup(s1.setupToken, '000000').ok === false);
check('错误的 setupToken 被拒', A.confirmSetup('forged', '000000').error === 'no-setup');
clock += 11 * 60 * 1000;
check('绑定流程 10 分钟后失效', A.confirmSetup(s1.setupToken, '000000').error === 'setup-expired');
const s2 = A.beginSetup(SETUP_KEY, '10.0.0.1');
const bound = A.confirmSetup(s2.setupToken, totp(base32Decode(s2.secret), { atMs: clock }));
check('正确验证码完成绑定并生成 10 枚恢复码', bound.ok && bound.recoveryCodes.length === 10, `首枚 ${bound.recoveryCodes[0]}`);
check('绑定后不能再次绑定', A.beginSetup(SETUP_KEY, '10.0.0.1').error === 'already-bound');
check('档案落盘在 data 目录(已 gitignore)', fs.existsSync(path.join(btmp, 'admin.json')), 'admin.json');
const stored = JSON.parse(fs.readFileSync(path.join(btmp, 'admin.json'), 'utf8'));
check('落盘文件不含恢复码明文', !stored.recovery?.some?.((h) => bound.recoveryCodes.includes(h)) && typeof stored.recovery[0] === 'string' && stored.recovery[0].length === 64, '仅存 sha256');
const l1 = A.login(bound.recoveryCodes[0], '2.2.2.2');
check('恢复码可登录', l1.ok && l1.via === 'recovery', '');
check('会话 id 为高熵随机串', l1.sessionId.length >= 40, `${l1.sessionId.length} 字符`);
check('有效会话可校验通过', A.verifySession(l1.sessionId) !== null);
check('伪造的会话 id 被拒', A.verifySession('forged-session') === null);
check('同一枚恢复码不能复用', A.login(bound.recoveryCodes[0], '2.2.2.2').ok === false);
check('剩余恢复码减 1', A.status().recoveryLeft === 9, `${A.status().recoveryLeft} 枚`);
const store2 = JSON.parse(fs.readFileSync(path.join(btmp, 'admin.json'), 'utf8'));
const code2 = totp(base32Decode(store2.secret), { atMs: clock });
const l2 = A.login(code2, '3.3.3.3');
check('TOTP 可登录', l2.ok && l2.via === 'totp', code2);
check('同一时间步的码不能重放', A.login(code2, '3.3.3.3').error === 'replayed');
clock += 31000;
const code3 = totp(base32Decode(store2.secret), { atMs: clock });
check('下一个时间窗的码可用', A.login(code3, '3.3.3.3').ok === true, code3);
// 登出与空闲超时
A.destroySession(l1.sessionId);
check('登出后会话立即失效', A.verifySession(l1.sessionId) === null);
clock += 31 * 60 * 1000;
check('超过 30 分钟空闲的会话失效', A.verifySession(l2.sessionId) === null);
check('超过 2 小时的最长会话失效', (() => {
clock -= 31 * 60 * 1000;
const l = A.login(totp(base32Decode(store2.secret), { atMs: clock }), '4.4.4.4');
clock += 2 * 60 * 60 * 1000 + 1000;
return A.verifySession(l.sessionId) === null;
})(), '');
// 限流
const L = createAdminAuth({ storePath: path.join(btmp, 'admin.json'), setupKey: SETUP_KEY, now: () => clock });
let last = null;
for (let i = 0; i < 5; i++) last = L.login('000000', '9.9.9.9');
check('连续 5 次失败后锁定该 IP', last.locked === true, `失败 ${last.fails} 次`);
check('锁定期间即使验证码正确也进不去', (() => {
const fresh = JSON.parse(fs.readFileSync(path.join(btmp, 'admin.json'), 'utf8'));
clock += 31 * 1000;
const r = L.login(totp(base32Decode(fresh.secret), { atMs: clock }), '9.9.9.9');
return r.ok === false && r.error === 'locked';
})(), '');
check('其他 IP 不受该 IP 锁定影响', (() => {
const fresh = JSON.parse(fs.readFileSync(path.join(btmp, 'admin.json'), 'utf8'));
clock += 31 * 1000;
return L.login(totp(base32Decode(fresh.secret), { atMs: clock }), '8.8.8.8').ok === true;
})(), '');
check('登录成功即清零该 IP 的失败计数', (() => {
const fresh = JSON.parse(fs.readFileSync(path.join(btmp, 'admin.json'), 'utf8'));
clock += 31 * 1000;
L.login('000000', '7.7.7.7');
clock += 31 * 1000;
const r = L.login(totp(base32Decode(fresh.secret), { atMs: clock }), '7.7.7.7');
return r.ok === true && L.status() !== null;
})(), '');
check('重置需正确的 setup key', A.reset('wrong').ok === false);
check('重置后回到未绑定并清空会话', (() => { const r = A.reset(SETUP_KEY); return r.ok && A.isBound() === false && A.activeSessions() === 0; })(), '');
/* ============ [C] HTTP 接口 ============ */
group('[C] HTTP 接口:鉴权边界与写入落点');
await withServer({}, async ({ tmp, localCfg }) => {
const st = await req('/api/admin/status');
check('未登录可查 2FA 状态(供页面决定显示绑定还是登录)', st.status === 200 && st.data.bound === false, JSON.stringify(st.data).slice(0, 80));
check('状态接口不回显 setup key 本身', !JSON.stringify(st.data).includes(SETUP_KEY), '');
const locked = await req('/api/admin/overview');
check('未绑定时概览接口 403', locked.status === 403 && locked.data.error === 'not-bound', `HTTP ${locked.status}`);
const locked2 = await req('/api/admin/probe-now', { method: 'POST' });
check('未绑定时立即重探 403', locked2.status === 403, `HTTP ${locked2.status}`);
const locked3 = await req('/api/admin/maintenance', { method: 'POST', body: { from: '2030-01-01T00:00:00Z', to: '2030-01-01T01:00:00Z' } });
check('未绑定时新增维护窗口 403', locked3.status === 403, `HTTP ${locked3.status}`);
check('未绑定时写操作没有产生任何文件改动', !fs.existsSync(localCfg) || JSON.parse(fs.readFileSync(localCfg, 'utf8')).settings?.maintenance === undefined, '');
check('错误绑定口令返回 401', (await req('/api/admin/2fa/setup', { method: 'POST', body: { key: 'wrong' } })).status === 401);
const setupRes = await req('/api/admin/2fa/setup', { method: 'POST', body: { key: SETUP_KEY } });
check('正确口令换取密钥', setupRes.status === 200 && setupRes.data.secret?.length === 32, setupRes.data.uri?.slice(0, 50) + '…');
check('绑定响应含 secret 与 uri 但不含恢复码', !setupRes.data.recoveryCodes, '');
const c0 = totp(base32Decode(setupRes.data.secret));
const badConfirm = await req('/api/admin/2fa/confirm', { method: 'POST', body: { setupToken: setupRes.data.setupToken, code: '000000' } });
check('错误验证码无法完成绑定', badConfirm.status === 400, `HTTP ${badConfirm.status}`);
const confirm = await req('/api/admin/2fa/confirm', { method: 'POST', body: { setupToken: setupRes.data.setupToken, code: c0 } });
check('正确验证码完成绑定并返回恢复码', confirm.status === 200 && confirm.data.recoveryCodes?.length === 10, '');
check('重复绑定返回 409', (await req('/api/admin/2fa/setup', { method: 'POST', body: { key: SETUP_KEY } })).status === 409);
// 会话边界
check('已绑定但无会话时概览 401', (await req('/api/admin/overview')).status === 401);
check('伪造会话 cookie 无效', (await req('/api/admin/overview', { cookie: 'shiroko_admin=forged' })).status === 401);
const badLogin = await req('/api/admin/login', { method: 'POST', body: { code: '000000' } });
check('错误验证码登录 401', badLogin.status === 401 && badLogin.data.error === 'invalid-code', `失败计数 ${badLogin.data.fails}`);
const login = await req('/api/admin/login', { method: 'POST', body: { code: c0 } });
check('正确验证码登录 200', login.status === 200, `方式 ${login.data.via}`);
const setCookie = login.cookie ?? '';
check('会话 cookie 为 HttpOnly', /HttpOnly/i.test(setCookie), setCookie.slice(0, 60) + '…');
check('会话 cookie 为 SameSite=Strict', /SameSite=Strict/i.test(setCookie), '');
check('会话 cookie 的 Path 覆盖接口路径', /Path=\//i.test(setCookie), /Path=\/admin/.test(setCookie) ? '仍是 /admin(接口收不到)' : 'Path=/');
const sid = /shiroko_admin=([^;]+)/.exec(setCookie)?.[1];
check('同码不能重放', (await req('/api/admin/login', { method: 'POST', body: { code: c0 } })).data.error === 'replayed');
const ov = await req('/api/admin/overview', { cookie: `shiroko_admin=${sid}` });
check('带会话可读概览', ov.status === 200 && typeof ov.data.stats?.total === 'number', `目标 ${ov.data.stats?.total} 个`);
check('概览含 2FA 状态但不泄露 secret', Boolean(ov.data.twofa) && !JSON.stringify(ov.data.twofa).includes('secret'), '');
// 管理写操作:只落私有层
const pubBefore = fs.readFileSync(path.join(tmp, 'monitors.json'), 'utf8');
const mw = await req('/api/admin/maintenance', { cookie: `shiroko_admin=${sid}`, method: 'POST', body: { from: '2030-01-01T00:00:00Z', to: '2030-01-01T01:00:00Z', note: '验证窗口' } });
check('新增维护窗口 200', mw.status === 200 && mw.data.entry.note === '验证窗口', '');
check('维护窗口落在私有配置里', JSON.parse(fs.readFileSync(localCfg, 'utf8')).settings?.maintenance?.length === 1, 'local.json');
check('公开配置未被改动', fs.readFileSync(path.join(tmp, 'monitors.json'), 'utf8') === pubBefore, '');
check('无效时间窗口被拒 400', (await req('/api/admin/maintenance', { cookie: `shiroko_admin=${sid}`, method: 'POST', body: { from: '2030-01-02T00:00:00Z', to: '2030-01-01T00:00:00Z' } })).status === 400);
check('非法 probeScope 被拒 400', (await req('/api/admin/probe-scope', { cookie: `shiroko_admin=${sid}`, method: 'POST', body: { probeScope: 'evil' } })).status === 400);
const scope = await req('/api/admin/probe-scope', { cookie: `shiroko_admin=${sid}`, method: 'POST', body: { probeScope: 'all' } });
check('切换探测范围 200', scope.status === 200, scope.data.probeScope);
check('无会话时切换范围 401', (await req('/api/admin/probe-scope', { method: 'POST', body: { probeScope: 'all' } })).status === 401);
check('未知管理接口 404', (await req('/api/admin/nope', { cookie: `shiroko_admin=${sid}` })).status === 404);
// 删除维护窗口
check('删除维护窗口 200', (await req('/api/admin/maintenance', { cookie: `shiroko_admin=${sid}`, method: 'DELETE', body: { index: 0 } })).status === 200);
check('删除后窗口为空', (JSON.parse(fs.readFileSync(localCfg, 'utf8')).settings?.maintenance ?? []).length === 0);
check('删除越界序号被拒 400', (await req('/api/admin/maintenance', { cookie: `shiroko_admin=${sid}`, method: 'DELETE', body: { index: 99 } })).status === 400);
// 登出
check('登出 200', (await req('/api/admin/logout', { cookie: `shiroko_admin=${sid}`, method: 'POST' })).status === 200);
check('登出后会话失效', (await req('/api/admin/overview', { cookie: `shiroko_admin=${sid}` })).status === 401);
// 超大请求体
const big = await req('/api/admin/login', { method: 'POST', body: { code: 'x'.repeat(200000) } });
check('超大请求体返回 413(而不是断开连接)', big.status === 413 && big.data?.error === 'body-too-large', `HTTP ${big.status}`);
check('超大请求后服务仍存活', (await req('/api/admin/status')).status === 200, '');
const badJson = await fetch(`http://localhost:${PORT}/api/admin/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{not json' });
check('非法 JSON 返回 400', badJson.status === 400, `HTTP ${badJson.status}`);
});
// 未设置 setup key 时绑定应关闭
group('[C2] 未设置 SHIROKO_ADMIN_SETUP_KEY 时绑定关闭');
await withServer({ setupKey: '' }, async () => {
const st = await req('/api/admin/status');
check('状态接口声明未配置绑定口令', st.data.setupKeyConfigured === false, '');
const r = await req('/api/admin/2fa/setup', { method: 'POST', body: { key: '' } });
check('绑定接口返回 503(管理端未开放)', r.status === 503, `HTTP ${r.status}`);
});
/* ============ [D] 页面 ============ */
group('[D] 管理页面:未绑定只见绑定流程,绑定后必须过 2FA');
const browser = await chromium.launch({ executablePath: CHROME, args: ['--no-sandbox'] });
await withServer({}, async () => {
const page = await browser.newPage({ viewport: { width: 1200, height: 900 } });
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto(`http://localhost:${PORT}/admin`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(700);
check('/admin 页面可访问且是绑定流程', await page.isVisible('#bind-panel'), '仅显示绑定面板');
check('未绑定时不出现管理面板', !(await page.isVisible('#console-panel')));
check('页面 noindex(不被搜索引擎收录)', (await page.getAttribute('meta[name="robots"]', 'content'))?.includes('noindex'), '');
await page.fill('#setup-key', SETUP_KEY);
await page.click('#setup-start');
await page.waitForSelector('#secret-text', { state: 'visible', timeout: 10000 });
const shownSecret = (await page.textContent('#secret-text')).replace(/\s/g, '');
check('页面展示 Base32 密钥并分组', shownSecret.length === 32, shownSecret.slice(0, 8) + '…');
const qrInk = await page.evaluate(() => {
const c = document.getElementById('qr');
const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data;
let dark = 0;
for (let i = 0; i < d.length; i += 4) if (d[i] < 128) dark++;
return dark / (d.length / 4);
});
check('二维码已渲染(非空白)', qrInk > 0.3 && qrInk < 0.6, `${(qrInk * 100).toFixed(1)}% 黑点`);
await page.fill('#confirm-code', totp(base32Decode(shownSecret)));
await page.click('#confirm-btn');
await page.waitForSelector('#recovery-list code', { timeout: 10000 });
const codes = await page.$$eval('#recovery-list code', (els) => els.map((e) => e.textContent));
check('绑定完成展示 10 枚恢复码', codes.length === 10, codes[0]);
check('确认绑定前不展示管理面板', !(await page.isVisible('#console-panel')));
await page.click('#recovery-done');
await page.waitForSelector('#console-panel', { state: 'visible', timeout: 15000 });
check('绑定完成后凭本次 2FA 直接进入管理界面', await page.isVisible('#console-panel'));
const stats = (await page.textContent('#admin-stats')).replace(/\s+/g, ' ');
check('管理界面渲染运行统计', /\d+监控数/.test(stats), stats.slice(0, 60));
check('管理界面列出监控目标', (await page.$$('#monitor-list tr')).length > 0, `${(await page.$$('#monitor-list tr')).length} 个`);
check('管理界面展示 2FA 摘要(恢复码余量)', /剩余恢复码\s*10\s*枚/.test((await page.textContent('#twofa-summary')).replace(/\s+/g, ' ')), '');
// 管理操作
await page.click('#mw-add');
await page.waitForTimeout(800);
check('可新增维护窗口', (await page.$$('#mw-list .mw-item')).length === 1, '');
await page.selectOption('#probe-scope', 'all');
await page.waitForTimeout(800);
check('可切换探测范围', /已改为 all/.test(await page.textContent('#action-msg')), '');
await page.selectOption('#data-filter', 'both');
await page.waitForTimeout(800);
check('可切换展示口径', /已改为 both/.test(await page.textContent('#action-msg')), '');
// 会话持久性 + 登出
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(900);
check('刷新后仍处于登录态(cookie 有效)', await page.isVisible('#console-panel'));
await page.click('#logout-btn');
await page.waitForTimeout(1200);
check('登出后回到 2FA 登录页', await page.isVisible('#login-panel'));
check('登出后看不到管理面板', !(await page.isVisible('#console-panel')));
// 错误验证码
await page.fill('#login-code', '000000');
await page.click('#login-btn');
await page.waitForTimeout(600);
check('错误验证码给出可读提示', /验证码不正确/.test(await page.textContent('#login-msg')), (await page.textContent('#login-msg')).trim());
check('管理页面无 JS 错误', errors.length === 0, errors.join(' | ') || '无');
await page.close();
});
await browser.close();
fs.rmSync(btmp, { recursive: true, force: true });
const failed = results.filter((x) => !x.pass);
console.log(`\n结果:${results.length - failed.length}/${results.length} 通过`);
if (failed.length) {
console.log('失败项:');
for (const f of failed) console.log(` ✗ ${f.name}`);
process.exit(1);
}
}
main().catch((err) => {
console.error('FAILED:', err.stack || err.message);
process.exit(1);
});
+14 -1
View File
@@ -95,10 +95,23 @@ async function main() {
page: document.documentElement.scrollWidth - document.documentElement.clientWidth,
wrap: (() => { const w = document.querySelector('.table-wrap'); return w ? w.scrollWidth - w.clientWidth : 0; })(),
grid: (() => { const g = document.querySelector('.grid'); return g ? g.scrollWidth - g.clientWidth : 0; })(),
// 表格视图额外要求绝对等高:40 个目标、6 个分组、名称与 URL 长短悬殊,
// 行高仍须只有一个取值(硬要求:每行绝对等高)
rowHs: document.body.dataset.view === 'table'
? [...new Set([...document.querySelectorAll('table.monitor-table tbody tr:not(.group-row)')]
.map((tr) => Math.round(tr.getBoundingClientRect().height)))]
: null,
cardHs: document.body.dataset.view === 'cards'
? [...new Set([...document.querySelectorAll('.card')].map((c) => Math.round(c.getBoundingClientRect().height)))]
: null,
}));
const bad = Math.max(r.page, r.wrap, r.grid);
if (!worst || bad > worst.bad) worst = { bad, view, width, r };
check(`${String(width).padStart(4)}px ${view} 无横向溢出`, bad <= 0, `页面 ${r.page} / 表格 ${r.wrap} / 网格 ${r.grid}`);
const extra = view === 'table'
? (r.rowHs?.length === 1 ? `行高 ${r.rowHs[0]}px` : `行高 ${(r.rowHs ?? []).join('/')}`)
: (r.cardHs?.length === 1 ? `卡片高 ${r.cardHs[0]}px` : `卡片高 ${(r.cardHs ?? []).join('/')}`);
check(`${String(width).padStart(4)}px ${view} 无横向溢出且等高`, bad <= 0 && ((view === 'table' ? r.rowHs : r.cardHs) ?? []).length === 1,
`页面 ${r.page} / 表格 ${r.wrap} / 网格 ${r.grid},${extra}`);
await page.close();
}
}
+95
View File
@@ -148,6 +148,101 @@ 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();
// ---------- D3:跨域只读(CORS 白名单) ----------
// 场景:前端部署在 GitHub Pages / Cloudflare,探针在另一台机器,
// 需要让指定页面能跨域读状态与配置。核心风险是「任意网站都能读走监控数据」。
console.log('\n[D3] 跨域只读白名单');
const corsCfg = path.join(TMP, 'cors.json');
fs.writeFileSync(corsCfg, JSON.stringify({
settings: {
title: 't',
corsOrigins: ['https://you.github.io', 'https://monitor.example.com'],
uptimeRobot: { enabled: true, apiKey: SECRET },
},
monitors: [],
}, null, 2));
const srvD = startProber([`--config=${corsCfg}`, `--local-config=${corsCfg}`, `--out=${path.join(TMP, 'd.json')}`], { PORT: '8895' });
await waitForHttp('http://127.0.0.1:8895/api/status');
const acaoOf = async (pathname, origin, method = 'GET') => {
const res = await fetch(`http://127.0.0.1:8895${pathname}`, { method, headers: { Origin: origin } });
return res.headers.get('access-control-allow-origin');
};
check('白名单内的 GitHub Pages 来源被放行', await acaoOf('/api/status', 'https://you.github.io') === 'https://you.github.io');
check('白名单内的自定义域名被放行', await acaoOf('/api/status', 'https://monitor.example.com') === 'https://monitor.example.com');
check('白名单外的站点被拒(无跨域头)', await acaoOf('/api/status', 'https://evil.example') === null);
check('前缀伪造的域名被拒(如 https://you.github.io.evil.com)', await acaoOf('/api/status', 'https://you.github.io.evil.com') === null);
check('协议不符的同域被拒(https 的 localhost 不匹配 http 默认项)', await acaoOf('/api/status', 'https://localhost') === null);
// 本机回环:默认放行且忽略端口(本地前端与探针常跑在不同端口)
check('本机 localhost 放行(任意端口)', await acaoOf('/api/status', 'http://localhost:5173') === 'http://localhost:5173');
check('本机 127.0.0.1 放行(任意端口)', await acaoOf('/api/status', 'http://127.0.0.1:3000') === 'http://127.0.0.1:3000');
check('局域网 IP 未列入白名单时被拒', await acaoOf('/api/status', 'http://192.168.1.5:5173') === null);
// 预检
const pre = await fetch('http://127.0.0.1:8895/api/status', { method: 'OPTIONS', headers: { Origin: 'https://you.github.io', 'Access-Control-Request-Method': 'GET' } });
check('合法来源的预检返回 204 且带跨域头', pre.status === 204 && pre.headers.get('access-control-allow-origin') === 'https://you.github.io', `HTTP ${pre.status}`);
const preBad = await fetch('http://127.0.0.1:8895/api/status', { method: 'OPTIONS', headers: { Origin: 'https://evil.example', 'Access-Control-Request-Method': 'GET' } });
check('非法来源的预检被拒 403', preBad.status === 403, `HTTP ${preBad.status}`);
// 各只读接口都放行
const readOnly = ['/api/status', '/api/monitors', '/api/events', '/api/v2/getMonitors'];
const allowed = [];
for (const p of readOnly) allowed.push((await acaoOf(p, 'https://you.github.io')) === 'https://you.github.io');
check('四个只读接口都支持跨域', allowed.every(Boolean), readOnly.filter((_, i) => !allowed[i]).join(',') || '全部放行');
// 关键:管理端绝不能跨域
const adminAcao = [];
for (const p of ['/api/admin/status', '/api/admin/overview', '/api/admin/login']) {
const r = await fetch(`http://127.0.0.1:8895${p}`, { method: 'GET', headers: { Origin: 'https://you.github.io' } });
adminAcao.push(r.headers.get('access-control-allow-origin'));
}
check('管理端接口一律不带跨域头(必须同源访问)', adminAcao.every((v) => v === null), JSON.stringify(adminAcao));
const adminPre = await fetch('http://127.0.0.1:8895/api/admin/overview', { method: 'OPTIONS', headers: { Origin: 'https://you.github.io', 'Access-Control-Request-Method': 'POST' } });
check('管理端的预检被拒 403(外部页面无法发写操作)', adminPre.status === 403, `HTTP ${adminPre.status}`);
// 跨域读到的配置同样不得含密钥
const corsMonitors = await (await fetch('http://127.0.0.1:8895/api/monitors', { headers: { Origin: 'https://you.github.io' } })).text();
check('跨域读取配置时不泄露 API Key', !corsMonitors.includes(SECRET), corsMonitors.includes(SECRET) ? '泄露了!' : '未泄露');
srvD.kill();
// corsOrigins 里不允许 '*'
console.log('\n[D4] corsOrigins 的写法校验');
const starCfg = path.join(TMP, 'star.json');
fs.writeFileSync(starCfg, JSON.stringify({ settings: { title: 't', corsOrigins: ['*'] }, monitors: [] }, null, 2));
const { corsOrigins: corsFn } = await import(`file://${PROBER.replace(/\\/g, '/')}`).catch(() => ({}));
// corsOrigins 未导出时以 HTTP 层验证:配置 '*' 后不应变成任意来源可读
const srvE = startProber([`--config=${starCfg}`, `--local-config=${starCfg}`, `--out=${path.join(TMP, 'e.json')}`], { PORT: '8894' });
await waitForHttp('http://127.0.0.1:8894/api/status');
const starRes = await fetch('http://127.0.0.1:8894/api/status', { headers: { Origin: 'https://anything.example' } });
check("corsOrigins 配成 '*' 不会放行任意来源(必须写完整 origin)", starRes.headers.get('access-control-allow-origin') === null, starRes.headers.get('access-control-allow-origin') ?? '无跨域头');
srvE.kill();
// ---------- E:来源判定 ----------
console.log('\n[E] 来源判定');
const { isPrivateAddress } = await import(`file://${PROBER.replace(/\\/g, '/')}`);
+102 -19
View File
@@ -300,28 +300,56 @@ async function main() {
const dt = await dp.evaluate(() => document.querySelector('tr[data-status] .t-uptime')?.title ?? '');
check('表格可用率 tooltip 含 90 天日均延迟', /90 天日均延迟 \d+ ms/.test(dt), dt.slice(0, 60));
// ---- 极端内容:表格不得把数据截断,同时不得出现横向滚动 ----
// ---- 行高一致性:先在「内容等长」下取基准 ----
// 注意:名称来自 monitors.json,不是 status.json(改错文件会假通过)
const statusPath = path.join(tmpDir, 'status.json');
const snap = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
const cfgPath2 = path.join(tmpDir, 'monitors.json');
const cfgJson = JSON.parse(fs.readFileSync(cfgPath2, 'utf8'));
const ids = Object.keys(snap.monitors);
cfgJson.monitors.forEach((m, i) => { m.name = `目标 ${i + 1}`; });
fs.writeFileSync(cfgPath2, `${JSON.stringify(cfgJson, null, 2)}\n`);
await dp.goto('http://localhost:8894/?source=static&view=table', { waitUntil: 'domcontentloaded' });
await dp.waitForSelector('table.monitor-table tbody tr[data-status]', { timeout: 20000 });
await dp.waitForTimeout(900);
const heights = await dp.evaluate(() => {
const hs = [...document.querySelectorAll('table.monitor-table tbody tr[data-status]')].map((tr) => Math.round(tr.getBoundingClientRect().height));
return { hs, unique: [...new Set(hs)] };
});
check('内容等长时表格各行等高(可用率条不再撑高行)', heights.unique.length === 1, `行高 ${heights.unique.join('/')}(共 ${heights.hs.length} 行)`);
const baseRowH = heights.unique[0] ?? 0;
const groupHs = await dp.evaluate(() => [...new Set([...document.querySelectorAll('table.monitor-table tbody tr.group-row')].map((tr) => Math.round(tr.getBoundingClientRect().height)))]);
check('分组标题行自身等高且不与数据行混淆', groupHs.length === 1 && groupHs[0] !== baseRowH, `分组行高 ${groupHs.join('/')},数据行 ${baseRowH}px`);
// ---- 极端内容:行高仍须完全一致,且不得出现横向滚动 ----
// 注入超长名称与超长地址(外加一条超长事件详情),要求行高与基准逐像素相同
let injected = 0;
for (const id of ids.slice(0, 2)) {
snap.monitors[id].name = `这是一个相当长的监控目标名称用于验证表格完整显示-${id}`;
injected++;
}
// 注入事件,验证事件区块的时间线样式与折叠
cfgJson.monitors.forEach((m, i) => {
if (i % 3 === 0) { m.name = `这是一个相当长的监控目标名称用于验证绝对等高-${'X'.repeat(40)}-${m.id}`; injected++; }
if (i % 3 === 1) { m.url = `https://very-long-subdomain.example.com/${'segment/'.repeat(12)}?q=${'y'.repeat(30)}`; injected++; }
});
fs.writeFileSync(cfgPath2, `${JSON.stringify(cfgJson, null, 2)}\n`);
// 注入事件,验证事件区块的时间线样式、折叠与等高
snap.events = Array.from({ length: 12 }, (_, i) => ({
t: new Date(Date.now() - i * 60000).toISOString(),
id: ids[0],
name: snap.monitors[ids[0]].name,
name: i % 2 ? `事件目标-${'长'.repeat(50)}` : (snap.monitors[ids[0]]?.name ?? 'x'),
type: i % 2 ? 'down' : 'up',
detail: i % 2 ? 'ETIMEDOUT' : '已恢复',
detail: i % 2 ? `connect ETIMEDOUT ${'203.0.113.'.repeat(6)}` : '已恢复',
maintenance: false,
}));
fs.writeFileSync(statusPath, JSON.stringify(snap));
await dp.goto('http://localhost:8894/?source=static&view=table', { waitUntil: 'domcontentloaded' });
await dp.waitForSelector('table.monitor-table tbody tr[data-status]', { timeout: 20000 });
await dp.waitForTimeout(900);
// 极端内容下的行高必须与等长内容下逐像素相同
const eqLong = await dp.evaluate(() => {
const hs = [...document.querySelectorAll('table.monitor-table tbody tr[data-status]')].map((tr) => Math.round(tr.getBoundingClientRect().height));
const ev = [...document.querySelectorAll('.events-table tbody tr')].map((tr) => Math.round(tr.getBoundingClientRect().height));
return { hs, hsU: [...new Set(hs)], ev, evU: [...new Set(ev)] };
});
check('极端内容下表格各行仍绝对等高(与等长内容逐像素相同)', eqLong.hsU.length === 1 && eqLong.hsU[0] === baseRowH, `行高 ${eqLong.hsU.join('/')},基准 ${baseRowH}px(注入 ${injected} 条长内容,共 ${eqLong.hs.length} 行)`);
check('极端内容下事件表各行等高且与监控表格一致', eqLong.evU.length === 1 && eqLong.evU[0] === baseRowH, `事件行高 ${eqLong.evU.join('/')}`);
const clip = await dp.evaluate(() => {
let clipped = 0;
let worst = 0;
@@ -334,29 +362,84 @@ async function main() {
const wrap = document.querySelector('.table-wrap');
return { clipped, worst, pageOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, tableOverflow: wrap ? wrap.scrollWidth - wrap.clientWidth : 0 };
});
check(`长名称/长地址不再被截断(注入 ${injected} 个)`, clip.clipped === 0, `截断单元格 ${clip.clipped} 个,最大 ${clip.worst}px`);
// 横向方向:单元格内容一律不得溢出(限高不换行时最容易被长 URL 顶破)
check(`表格单元格无横向溢出(注入 ${injected} 条长内容)`, clip.clipped === 0, `溢出单元格 ${clip.clipped} 个,最大 ${clip.worst}px`);
check('表格仍无横向滚动', clip.pageOverflow <= 0 && clip.tableOverflow <= 0, `页面 ${clip.pageOverflow} / 表格 ${clip.tableOverflow}`);
// 纵向方向:长内容改为「限两行 + title 悬停看全文」,完整值仍必须可取
// 注意 display 的计算值不可作为判据:Chrome 实现了标准 line-clamp 后会把
// display 从 -webkit-box 规范化为 flow-root,这里改查 -webkit-line-clamp 计算值
const clamp = await dp.evaluate(() => {
const els = [...document.querySelectorAll('.monitor-table .clamp2, .events-table .clamp2')];
const clamped = els.filter((e) => e.scrollHeight - e.clientHeight > 1);
const noTitle = [...document.querySelectorAll('.t-name, .t-target, .ev-name, .ev-detail')].filter((td) => !td.title);
const cs = els[0] ? getComputedStyle(els[0]) : null;
return {
total: els.length,
clamped: clamped.length,
noTitle: noTitle.length,
overflow: cs?.overflow ?? '',
clamp: cs?.webkitLineClamp ?? cs?.lineClamp ?? '',
h: els[0] ? Math.round(els[0].getBoundingClientRect().height) : 0,
};
});
check('长文本限两行显示且 overflow:hidden(等高的前提)', clamp.clamped > 0 && clamp.overflow === 'hidden' && clamp.clamp === '2', `限行 ${clamp.clamped}/${clamp.total},-webkit-line-clamp=${clamp.clamp},元素高 ${clamp.h}px`);
check('被截断的单元格仍带 title(悬停可看完整值)', clamp.noTitle === 0, `缺 title 的单元格 ${clamp.noTitle} 个`);
const evr = await dp.evaluate(() => {
const ev = document.querySelector('.ev');
const cs = ev ? getComputedStyle(ev) : null;
const dot = ev ? getComputedStyle(ev, '::before') : null;
const table = document.querySelector('.events-table');
const evRow = table?.querySelector('tbody tr');
const cs = evRow ? getComputedStyle(evRow) : null;
const more = document.getElementById('events-more');
return {
shown: document.querySelectorAll('.ev').length,
grid: cs?.display ?? 'none',
dotVisible: dot ? dot.width !== '0px' && dot.backgroundColor !== 'rgba(0, 0, 0, 0)' : false,
leftBorder: cs?.borderLeftWidth ?? '',
shown: (evRow ? table.querySelectorAll('tbody tr').length : 0),
tag: table?.tagName ?? 'none',
hasThead: Boolean(table?.querySelector('thead th')),
headerSameSize: (() => {
const mon = document.querySelector('.monitor-table thead th');
const ev = table?.querySelector('thead th');
return mon && ev ? getComputedStyle(mon).fontSize === getComputedStyle(ev).fontSize : false;
})(),
rowBg: cs?.backgroundColor ?? '',
cellBorder: evRow ? getComputedStyle(evRow.querySelector('td')).borderBottomWidth : '',
moreHidden: more?.hidden ?? true,
moreText: more?.textContent ?? '',
count: document.getElementById('events-count')?.textContent ?? '',
};
});
check('事件区块使用时间线布局(grid + 圆点)', evr.grid === 'grid' && evr.dotVisible === true, `display=${evr.grid} 圆点=${evr.dotVisible}`);
check('事件行按类型着色(左描边)', evr.leftBorder === '3px', evr.leftBorder);
check('事件区块与监控表格同构(table + thead)', evr.tag === 'TABLE' && evr.hasThead === true && evr.headerSameSize === true, `${evr.tag},表头字号一致=${evr.headerSameSize}`);
const evBg = await dp.evaluate(() => {
const rows = [...document.querySelectorAll('.events-table tbody tr')];
const down = rows.find((r) => r.dataset.status === 'down');
const up = rows.find((r) => r.dataset.status === 'up');
return { down: down ? getComputedStyle(down).backgroundColor : '无', up: up ? getComputedStyle(up).backgroundColor : '无', downCells: down ? down.querySelectorAll('td').length : 0 };
});
check('事件行复用表格状态底色(故障行着色、恢复行不染)', /^(color\(|rgba?\(\d)/.test(evBg.down) && evBg.down !== 'rgba(0, 0, 0, 0)' && evBg.up === 'rgba(0, 0, 0, 0)' && evBg.downCells === 4, `故障=${evBg.down.slice(0, 30)} 恢复=${evBg.up} 列数=${evBg.downCells}`);
// 事件行的四列文字必须落在同一水平线上。此前 clamp2 固定 height:2.8em(36px)、
// 而徽标只有 17px,单元格按 middle 对齐时两者居中基准不同,
// 「类型」会比「时间/目标/详情」低约 9px(实测截图确认)。
const evAlign = await dp.evaluate(() => {
const rows = [...document.querySelectorAll('.events-table tbody tr')];
const spreads = [];
for (const tr of rows) {
const tops = [];
for (const td of tr.querySelectorAll('td')) {
// Range 要用独立实例:selectNodeContents 返回的是布尔值,不是 Range
const range = document.createRange();
range.selectNodeContents(td);
const rects = [...range.getClientRects()];
if (rects.length) tops.push(Math.round(rects[0].top));
}
if (tops.length) spreads.push(Math.max(...tops) - Math.min(...tops));
}
return { worst: spreads.length ? Math.max(...spreads) : -1, rows: spreads.length };
});
check('事件行各列文字顶端对齐(徽标与文本同一水平线)', evAlign.worst === 0, `最大错位 ${evAlign.worst}px(${evAlign.rows} 行)`);
// 跨域模式下隐藏刷新按钮(该模式下 /api/refresh 必然被 refreshLocalOnly 挡下)
const refreshBtnShown = await dp.evaluate(() => !document.getElementById('refresh-btn').hidden);
check('同源模式下保留刷新按钮', refreshBtnShown === true, `可见=${refreshBtnShown}`);
check('事件默认折叠为 8 条并可展开', evr.shown === 8 && evr.moreHidden === false && /展开全部 12 条/.test(evr.moreText), `显示 ${evr.shown} 条,按钮「${evr.moreText}」,计数「${evr.count}」`);
await dp.click('#events-more');
await dp.waitForTimeout(300);
const evExpanded = await dp.evaluate(() => ({ shown: document.querySelectorAll('.ev').length, text: document.getElementById('events-more').textContent }));
const evExpanded = await dp.evaluate(() => ({ shown: document.querySelectorAll('.events-table tbody tr').length, text: document.getElementById('events-more').textContent }));
check('展开后显示全部事件', evExpanded.shown === 12 && evExpanded.text === '收起', `显示 ${evExpanded.shown} 条`);
await dp.close();
await new Promise((r) => demoServer.close(r));
+257
View File
@@ -0,0 +1,257 @@
/**
* Cloudflare Worker 边缘探针验证(离线,不需要 wrangler)
*
* 做法:把 worker.js 与 uptimerobot.js 以 data: URL 动态载入,
* 注入假的 ASSETS / MONITOR_KV / UptimeRobot 服务器,实测:
* [A] UptimeRobot 映射(暂停/离线/可用率/端口目标)
* [B] 探测范围(probeScope)与展示口径解耦
* [C] UR 同步失败时保留上次数据,绝不误判为离线
* [D] 管理端路由(/admin 与 /api/admin/* 一律拒绝,Worker 无可写面)
*
* 用法:node scripts/verify-worker.cjs
*/
const fs = require('node:fs');
const http = require('node:http');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const EDGE = path.join(ROOT, 'edge', 'cloudflare');
const UR_PORT = 8896;
const HTTP_PORT = 8895;
const results = [];
const check = (name, pass, detail) => {
results.push({ name, pass });
console.log(` ${pass ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`);
};
const group = (t) => console.log(`\n${t}`);
const UR_PAYLOAD = {
stat: 'ok',
monitors: [
{
id: 700001, friendly_name: 'UR-主站', url: 'https://a.example.com', type: 1, status: 2, interval: 300,
average_response_time: '120',
response_times: [{ datetime: Math.floor(Date.now() / 1000) - 60, value: '118' }, { datetime: Math.floor(Date.now() / 1000) - 360, value: '130' }],
custom_uptime_ratio: '100-100-99.980-99.990', custom_down_durations: '0-0-120-260',
logs: [{ type: 2, datetime: Math.floor(Date.now() / 1000) - 86400 }],
},
{
id: 700002, friendly_name: 'UR-端口', url: 'b.example.com', port: 8443, type: 4, status: 9, interval: 300,
average_response_time: '0', response_times: [{ datetime: Math.floor(Date.now() / 1000) - 120, value: '0' }],
custom_uptime_ratio: '98-99-99.5-99.7', custom_down_durations: '300-600-1200-2400',
logs: [{ type: 1, datetime: Math.floor(Date.now() / 1000) - 120, reason: { code: '503', detail: 'Service Unavailable' } }],
},
{
id: 700003, friendly_name: 'UR-暂停', url: 'https://c.example.com', type: 1, status: 0, interval: 300,
response_times: [], custom_uptime_ratio: '100-100-100-100', custom_down_durations: '0-0-0-0', logs: [],
},
],
};
// 假 UptimeRobot:正常返回 payload;切到 error 模式返回 stat=fail
let urMode = 'ok';
function startUrServer() {
return http.createServer((req, res) => {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
res.writeHead(200, { 'content-type': 'application/json' });
if (urMode === 'fail') return res.end(JSON.stringify({ stat: 'fail', error: { type: 'invalid_parameter', message: 'bad key' } }));
res.end(JSON.stringify(UR_PAYLOAD));
});
});
}
// 载入 Worker:替换 cloudflare:sockets 与 UR 模块的 API 地址
async function loadWorker() {
let ur = fs.readFileSync(path.join(EDGE, 'uptimerobot.js'), 'utf8');
ur = ur.replace("const API_URL = 'https://api.uptimerobot.com/v2/getMonitors';", `const API_URL = 'http://localhost:${UR_PORT}/v2/getMonitors';`);
const urUrl = `data:text/javascript;base64,${Buffer.from(ur).toString('base64')}`;
let w = fs.readFileSync(path.join(EDGE, 'worker.js'), 'utf8');
// Workers 专有模块在 Node 下无法加载,替换为抛错桩(测试只覆盖 http 与 UR 路径)
w = w.replace("import { connect } from 'cloudflare:sockets';", "const connect = () => { throw new Error('TCP unavailable in offline test'); };");
w = w.replace("import { fetchUptimeRobotMonitors } from './uptimerobot.js';", `import { fetchUptimeRobotMonitors } from '${urUrl}';`);
return import(`data:text/javascript;base64,${Buffer.from(w).toString('base64')}`);
}
// 内存版 KV + 静态资源夹具
function makeEnv(settings, monitors) {
const kv = new Map();
const config = JSON.stringify({ settings, monitors });
return {
MONITOR_KV: {
// 与真实 KV 一致:type='json' 时自动反序列化,否则返回原始字符串
async get(key, type) {
const v = kv.get(key);
if (v == null) return null;
return type === 'json' ? JSON.parse(v) : v;
},
async put(k, v) { kv.set(k, v); },
},
ASSETS: {
// 与真实 Workers 一致:这里收到的是 URL 对象而非 Request
async fetch(input) {
const raw = String(input && typeof input.href === 'string' ? input.href : input);
const pathname = new URL(raw, 'http://placeholder').pathname;
if (pathname === '/monitors.json') {
return new Response(config, { headers: { 'content-type': 'application/json' } });
}
return new Response('not found', { status: 404 });
},
},
_kv: kv,
};
}
const LOCAL_TARGETS = [
{ id: 'local-1', name: '本地A', type: 'http', url: `http://127.0.0.1:${HTTP_PORT}/ok`, interval: 60 },
{ id: 'local-2', name: '本地B', type: 'http', url: `http://127.0.0.1:${HTTP_PORT}/ok`, interval: 60 },
];
// 记录哪些 URL 被真正探测过(用于验证探测范围)
let probed = [];
const origFetch = globalThis.fetch;
async function main() {
const urSrv = startUrServer();
await new Promise((r) => urSrv.listen(UR_PORT, r));
const httpSrv = http.createServer((req, res) => { res.writeHead(200); res.end('ok'); });
await new Promise((r) => httpSrv.listen(HTTP_PORT, r));
const worker = await loadWorker();
const { fetchUptimeRobotMonitors } = await import(
`data:text/javascript;base64,${Buffer.from(
fs.readFileSync(path.join(EDGE, 'uptimerobot.js'), 'utf8')
.replace("const API_URL = 'https://api.uptimerobot.com/v2/getMonitors';", `const API_URL = 'http://localhost:${UR_PORT}/v2/getMonitors';`)
).toString('base64')}`
);
/* ---------- [A] UptimeRobot 映射 ---------- */
group('[A] UptimeRobot 映射');
const list = await fetchUptimeRobotMonitors({ apiKey: 'ur-test-key' });
check('映射出 3 个目标', list.length === 3, list.map((m) => m.name).join('、'));
check('在线目标延迟取自最新响应样本', list[0].latency === 118, `${list[0].latency}ms`);
check('离线目标取故障日志 code 作状态码', list[1].code === 503, `code=${list[1].code}`);
check('离线目标详情带原因', /503 Service Unavailable/.test(list[1].detail), list[1].detail);
check('暂停目标归为 pending 而非离线', list[2].status === 'pending', list[2].status);
check('暂停目标提示已暂停', /暂停/.test(list[2].detail), list[2].detail);
check('90 天可用率按 ratios 顺序解析', list[0].uptimeRatios[90] === 99.99, `${list[0].uptimeRatios[1]}/${list[0].uptimeRatios[7]}/${list[0].uptimeRatios[30]}/${list[0].uptimeRatios[90]}`);
check('累计离线时长解析正确', list[1].downSeconds[90] === 2400, `${list[1].downSeconds[90]}s`);
check('端口目标地址带端口号', list[1].url === 'b.example.com:8443', list[1].url);
check('历史心率按时间升序(供心率图直接绘制)', list[0].history[0].t < list[0].history[1].t, '');
check('类型标注为来源协议', list[1].sourceLabel === 'PORT', list[1].sourceLabel);
check('目标 id 加 ur- 前缀(避免与自建 id 冲突)', list.every((m) => m.id.startsWith('ur-')), list.map((m) => m.id).join(','));
urMode = 'fail';
let threw = null;
try { await fetchUptimeRobotMonitors({ apiKey: 'bad' }); } catch (e) { threw = e; }
check('UR 返回 stat=fail 时抛出可读错误', Boolean(threw) && /UptimeRobot/.test(threw.message), threw?.message);
urMode = 'ok';
/* ---------- [B] 探测范围与展示口径解耦 ---------- */
group('[B] 探测范围(probeScope)与展示口径(dataFilter)解耦');
const urSettings = (extra = {}) => ({ dataFilter: 'uptimerobot', uptimeRobot: { enabled: true, apiKey: 'ur-k' }, ...extra });
// 记录探测行为
globalThis.fetch = async (input, init) => {
const u = String(input);
if (u.startsWith(`http://localhost:${UR_PORT}`)) return origFetch(input, init);
probed.push(u);
return origFetch(input, init);
};
probed = [];
let env = makeEnv(urSettings(), LOCAL_TARGETS);
let snap = await worker.__test.runChecks(env);
const urIds = (s) => Object.keys(s.monitors).filter((k) => k.startsWith('ur-'));
check('未设 probeScope 时跟随 dataFilter=uptimerobot(不探测本地目标)', probed.length === 0, `探测了 ${probed.length} 个本地目标`);
check('UR 目标进入快照', urIds(snap).length === 3, `${urIds(snap).length} 个`);
check('快照带 sources.uptimerobot 同步状态', snap.sources.uptimerobot.ok === true && snap.sources.uptimerobot.count === 3, `count=${snap.sources.uptimerobot.count}`);
check('快照带 probeScope(面板据此解释口径)', snap.probeScope === 'uptimerobot', snap.probeScope);
check('统计总数 = 实际参与的目标数(scope=uptimerobot 时只有 3 个 UR)', snap.stats.total === 3, `total=${snap.stats.total}`);
check('UR 侧口径:在线 1 / 离线 1 / 待检测 1', snap.stats.up === 1 && snap.stats.down === 1 && snap.stats.pending === 1, `up=${snap.stats.up} down=${snap.stats.down} pending=${snap.stats.pending}`);
const urState = snap.monitors['ur-700001'];
check('UR 目标带 meta(面板靠它区分自建/外部)', urState.meta?.source === 'uptimerobot' && urState.meta.name === 'UR-主站', JSON.stringify(urState.meta).slice(0, 70));
check('UR 目标带 uptimeRatios(面板算官方可用率用)', urState.uptimeRatios?.[90] === 99.99, `${urState.uptimeRatios?.[90]}`);
check('UR 目标有 daily 分桶(本探针自行累积)', urState.daily && Object.keys(urState.daily).length > 0, Object.keys(urState.daily)[0]);
probed = [];
snap = await worker.__test.runChecks(makeEnv(urSettings({ probeScope: 'all' }), LOCAL_TARGETS));
check('显式 probeScope=all 时探测本地目标', probed.length === 2, `探测了 ${probed.length} 个`);
check('probeScope=all 时 UR 目标同样在快照中', urIds(snap).length === 3, '');
probed = [];
snap = await worker.__test.runChecks(makeEnv(urSettings({ probeScope: 'config' }), LOCAL_TARGETS));
check('probeScope=config 时不拉 UR 也不展示 UR 目标', urIds(snap).length === 0 && probed.length === 2, `UR=${urIds(snap).length} 本地探测=${probed.length}`);
probed = [];
snap = await worker.__test.runChecks(makeEnv(urSettings({ uptimeRobot: { enabled: false, apiKey: '' } }), LOCAL_TARGETS));
check('UR 未启用时不拉取', urIds(snap).length === 0 && probed.length === 0, `UR=${urIds(snap).length} 本地探测=${probed.length}`);
/* ---------- [C] UR 同步失败的降级行为 ---------- */
group('[C] UR 同步失败时保留上次数据(绝不误判为离线)');
const envC = makeEnv(urSettings(), LOCAL_TARGETS);
await worker.__test.runChecks(envC); // 先成功一轮,把 UR 数据写进 KV
urMode = 'fail';
snap = await worker.__test.runChecks(envC);
check('同步失败时标记 ok=false 并给出原因', snap.sources.uptimerobot.ok === false && /UptimeRobot/.test(snap.sources.uptimerobot.error), snap.sources.uptimerobot.error);
check('同步失败时 UR 目标仍在快照里(用上次数据)', urIds(snap).length === 3, `${urIds(snap).length} 个`);
check('同步失败时 UR 目标状态未被改成 offline', snap.monitors['ur-700002'].status === 'down', `仍是上次的 ${snap.monitors['ur-700002'].status}(真实状态)`);
urMode = 'ok';
/* ---------- [D] 管理端路由 ---------- */
group('[D] 管理端:Worker 侧无可写面');
// 真实运行时会用 ctx.waitUntil 托管后台任务,测试里同步执行以便断言生效
const pending = [];
const call = (pathname) => worker.default.fetch(new Request(`https://x${pathname}`), makeEnv(urSettings(), LOCAL_TARGETS), { waitUntil(p) { pending.push(p); } });
await Promise.all(pending.map((p) => p.catch(() => {})));
const adminPage = await call('/admin');
const adminBody = await adminPage.json();
check('/admin 返回 403(而非 404,提示去哪找管理端)', adminPage.status === 403, `HTTP ${adminPage.status}`);
check('/admin 的提示指向本地 Node 探针', /docs\/DEPLOYMENT\.md|本地 Node/.test(adminBody.hint), adminBody.hint?.slice(0, 50));
const adminApi = await call('/api/admin/overview');
check('/api/admin/* 一律 403', adminApi.status === 403 && (await adminApi.json()).error === 'admin-not-here', `HTTP ${adminApi.status}`);
const adminStatus = await call('/api/admin/status');
check('未绑定的 2FA 状态接口在 Worker 上也不开放', adminStatus.status === 403, `HTTP ${adminStatus.status}`);
const panel = await call('/');
check('面板本身不受影响', panel.status === 404 || panel.status === 200, `静态资源响应 HTTP ${panel.status}(夹具无 /index.html,属预期)`);
const st = await call('/api/status');
const stBody = await st.json();
check('/api/status 正常提供(首次无数据时返回空 state 并后台补跑)', st.status === 200 && typeof stBody === 'object' && stBody.monitors !== undefined, `HTTP ${st.status},monitors=${stBody.monitors ? Object.keys(stBody.monitors).length : '无'}`);
check('首次访问触发后台探测(waitUntil 被调用)', pending.length > 0, `${pending.length} 个后台任务`);
// 配置接口无鉴权,绝不能回吐 API Key
group('[E] 配置接口不得泄露密钥');
const SECRET = 'urFAKE-0123456789abcdefSECRET';
const envSecret = makeEnv({ uptimeRobot: { enabled: true, apiKey: SECRET } }, LOCAL_TARGETS);
const mResp = await worker.default.fetch(new Request('https://x/api/monitors'), envSecret, { waitUntil() {} });
const mText = await mResp.text();
check('/api/monitors 不回吐 API Key', !mText.includes(SECRET), mText.includes(SECRET) ? '泄露了!' : '未泄露');
const mJson = JSON.parse(mText);
check('uptimeRobot.apiKey 被剥离', !('apiKey' in (mJson.settings?.uptimeRobot ?? {})), JSON.stringify(mJson.settings?.uptimeRobot));
check('仍返回非敏感配置(面板要读)', typeof mJson.settings?.uptimeRobot?.enabled === 'boolean' && Array.isArray(mJson.monitors), `enabled=${mJson.settings?.uptimeRobot?.enabled}`);
// 但 Worker 内部探测仍要能用 key 同步 UR —— 用同一份配置跑一轮确认不报错
const snapSecret = await worker.__test.runChecks(envSecret);
check('内部探测仍能读到 key 并同步 UR(剥离只作用于对外响应)', snapSecret.sources.uptimerobot.enabled === true, JSON.stringify(snapSecret.sources.uptimerobot).slice(0, 60));
globalThis.fetch = origFetch;
urSrv.close();
httpSrv.close();
const failed = results.filter((x) => !x.pass);
console.log(`\n结果:${results.length - failed.length}/${results.length} 通过`);
if (failed.length) {
console.log('失败项:');
for (const f of failed) console.log(` ✗ ${f.name}`);
process.exit(1);
}
}
main().catch((err) => {
console.error('FAILED:', err.stack || err.message);
process.exit(1);
});