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 项全部通过
This commit is contained in:
tamakyi
2026-10-14 12:37:07 +08:00
parent a3a683ff0e
commit 752fad412b
20 changed files with 2631 additions and 339 deletions
+86 -6
View File
@@ -447,6 +447,58 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自
- **表格视图**:可用率列的 tooltip 里追加 `90 天日均延迟 X ms(峰值 Y ms)` - **表格视图**:可用率列的 tooltip 里追加 `90 天日均延迟 X ms(峰值 Y ms)`
- 数据从探针开始累计当天起累积,跑满 90 天后覆盖全周期;离线样本不计入延迟统计 - 数据从探针开始累计当天起累积,跑满 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 完全不发送),由代理层负责加密传输。
## 部署方案 ## 部署方案
| 方案 | 架构 | 数据更新 | 成本 | 适合场景 | | 方案 | 架构 | 数据更新 | 成本 | 适合场景 |
@@ -540,6 +592,32 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自
- [x] 页脚改为展示「数据更新于 … + 下次检查 Ns」,tooltip 内含服务端更新节奏与快照生成时间 - [x] 页脚改为展示「数据更新于 … + 下次检查 Ns」,tooltip 内含服务端更新节奏与快照生成时间
- [x] `scripts/verify.cjs` 适配纯外部数据源部署(自动跳过不适用的探测类断言),并新增「倒计时对齐服务端」断言 - [x] `scripts/verify.cjs` 适配纯外部数据源部署(自动跳过不适用的探测类断言),并新增「倒计时对齐服务端」断言
### 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 已实现 ### v0.11.2 已实现
**修复你反馈的两个问题** **修复你反馈的两个问题**
@@ -548,7 +626,7 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自
- [x] **「最近事件」改为与表格同一套样式**:不再用独立的「时间线卡片」风格,改为**与监控表格完全同构的表格**(相同的圆角容器、表头样式、行分隔线、hover、状态底色)——故障行淡红、证书行淡琥珀、恢复行不染;类型以小徽标呈现,详情列完整显示 - [x] **「最近事件」改为与表格同一套样式**:不再用独立的「时间线卡片」风格,改为**与监控表格完全同构的表格**(相同的圆角容器、表头样式、行分隔线、hover、状态底色)——故障行淡红、证书行淡琥珀、恢复行不染;类型以小徽标呈现,详情列完整显示
- 实现上刻意**不共用 `.monitor-table` 类名**,避免被其他按类名统计/定位的逻辑误算(这一点被测试当场抓到:共用类名时面板条目数被多算 1~8 个) - 实现上刻意**不共用 `.monitor-table` 类名**,避免被其他按类名统计/定位的逻辑误算(这一点被测试当场抓到:共用类名时面板条目数被多算 1~8 个)
> 说明:行高在「内容长度相当」时保证一致;名称或地址特别长时允许换行(这是上一版你要求的「数据完整显示」),此时行自然更高——属于数据本身需要的空间,不再靠截断换取整齐。 > 说明:这一版的行高只在「内容长度相当」时保证一致;名称或地址特别长时允许换行(上一版要求的「数据完整显示」),此时行自然更高。**已在 v0.12.0 改为绝对等高**(文本列限两行 + 悬停看全文)。
### v0.11.1 已实现 ### v0.11.1 已实现
@@ -669,14 +747,16 @@ node prober/node/index.js
node prober/node/index.js --once node prober/node/index.js --once
# UI 自动化实测(需 playwright,仅开发验证用,驱动系统 Chrome) # UI 自动化实测(需 playwright,仅开发验证用,驱动系统 Chrome)
NODE_PATH=<playwright 的 node_modules> node scripts/verify.cjs # 热重载 / 数据一致性 / 刷新语义 / 面板(21 项) NODE_PATH=<playwright 的 node_modules> node scripts/verify.cjs # 热重载 / 数据一致性 / 刷新语义 / 面板(17 项)
NODE_PATH=<playwright 的 node_modules> node scripts/verify-views.cjs # 视图与数据源过滤(10 项) 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-security.cjs # banner 探测 / 脱敏 / 私有分层 / 刷新限流(20 项) node scripts/verify-security.cjs # banner 探测 / 脱敏 / 私有分层 / 刷新限流(20 项)
node scripts/verify-notify.cjs # 通知与证书(39 项:假 SMTP / 假 MeoW / 去抖 / 恢复 / 维护窗口 / 真实证书) node scripts/verify-notify.cjs # 通知与证书(44 项:假 SMTP / 假 MeoW / 去抖 / 恢复 / 维护窗口 / 真实证书)
UPTIMEROBOT_API_KEY=ur... node scripts/verify-uptimerobot.cjs # UR 映射/兼容接口/失败路径/冒烟(22 项) 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 鉴权边界与页面流程。
代码约定:探针与面板通过 `status.json` / `/api/status` 的同一份 JSON 契约协作;新增探测类型时只需在 `prober/node/lib/` 与 `edge/cloudflare/worker.js` 各实现一个 `probeXxx(monitor)`,返回 `{ status, latency, code, detail }` 即可被面板直接渲染。 代码约定:探针与面板通过 `status.json` / `/api/status` 的同一份 JSON 契约协作;新增探测类型时只需在 `prober/node/lib/` 与 `edge/cloudflare/worker.js` 各实现一个 `probeXxx(monitor)`,返回 `{ status, latency, code, detail }` 即可被面板直接渲染。
+35 -2
View File
@@ -147,9 +147,42 @@ banner 探测的副作用仅有一次 TCP 连接与读取,与端口扫描器
--- ---
## 四、其他基线 ## 四、管理后台(`/admin`)的鉴权边界
v0.12.0 起,所有会改配置的操作都放在 `/admin`,**必须绑定并通过两步验证才能进入**。
### 威胁模型与对应措施
| 威胁 | 措施 |
| --- | --- |
| 未绑定就被抢先接管管理端 | 绑定需 `SHIROKO_ADMIN_SETUP_KEY`;未绑定时 `/api/admin/*` 除绑定流程外**一律 403**;未设置该环境变量时绑定入口直接关闭(503) |
| 暴力枚举 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`**(可被任意伪造) |
| 篡改请求 | `Path=/` + `HttpOnly` + `SameSite=Strict`;请求体上限 64KB,超出返回 413;参数逐项白名单校验(如 `probeScope` 只接受三个枚举值) |
| 通过管理端把密钥写进仓库 | **所有写操作只落 `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 本地密钥) - `.gitignore` 已包含 `local.monitors.json` 与 `.dev.vars`(Cloudflare 本地密钥)
- Worker 的 `REFRESH_TOKEN` 用 `wrangler secret put REFRESH_TOKEN` 管理,不要写进 `wrangler.toml` - Worker 的 `REFRESH_TOKEN` 用 `wrangler secret put REFRESH_TOKEN` 管理,不要写进 `wrangler.toml`
- 面板为只读前端:无 cookie、无写入接口、无服务端会话;渲染配置字段时统一做 HTML 转义(`escapeHtml`),XSS 面仅剩配置自身 - **公开面板**(`/`)仍是只读前端:无 cookie、无写入接口、无服务端会话;渲染配置字段时统一做 HTML 转义(`escapeHtml`),XSS 面仅剩配置自身。管理能力全部隔离在 `/admin` 与 `/api/admin/*`(需会话)
- Node 探针同时充当静态服务器,**不要直接暴露公网**;确实需要时在其前方加带认证的反向代理,并设 `allowRefresh: false` - Node 探针同时充当静态服务器,**不要直接暴露公网**;确实需要时在其前方加带认证的反向代理,并设 `allowRefresh: false`
Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 KiB

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 235 KiB

+38 -1
View File
@@ -22,6 +22,8 @@ import { probeBanner } from './lib/banner-probe.js';
import { fetchUptimeRobotMonitors } from './lib/uptimerobot.js'; import { fetchUptimeRobotMonitors } from './lib/uptimerobot.js';
import { notifyAll, sendTestNotifications, createMeoWLimiter } from './lib/notify.js'; import { notifyAll, sendTestNotifications, createMeoWLimiter } from './lib/notify.js';
import { probeCertificate, tlsTargetOf } from './lib/tls-probe.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 __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '../..'); const ROOT = path.resolve(__dirname, '../..');
@@ -37,7 +39,7 @@ const CONCURRENCY = 8;
const WRITE_DEBOUNCE_MS = 300; const WRITE_DEBOUNCE_MS = 300;
const DEFAULT_INTERVAL_SEC = 60; const DEFAULT_INTERVAL_SEC = 60;
const PORT = Number(process.env.PORT || 8787); const PORT = Number(process.env.PORT || 8787);
const VERSION = '0.11.2'; const VERSION = '0.12.0';
const UR_DEFAULT_POLL_SEC = 300; const UR_DEFAULT_POLL_SEC = 300;
const UR_MIN_REFRESH_GAP_MS = 60000; const UR_MIN_REFRESH_GAP_MS = 60000;
@@ -405,6 +407,8 @@ function writeJson(file, data) {
const NOTIFY_STATE_PATH = () => path.join(DATA_DIR, 'notify-state.json'); const NOTIFY_STATE_PATH = () => path.join(DATA_DIR, 'notify-state.json');
const EVENTS_PATH = () => path.join(DATA_DIR, 'events.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) { function monitorNotifyState(runtime, id) {
const list = runtime.notifyState.monitors; const list = runtime.notifyState.monitors;
@@ -930,10 +934,43 @@ function toUptimeRobotResponse(runtime) {
// runtime 是持有器对象:热重载只更新其属性,服务器与巡检循环始终读到最新引用 // runtime 是持有器对象:热重载只更新其属性,服务器与巡检循环始终读到最新引用
function createServer(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) => { return http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const { config, state } = runtime; const { config, state } = runtime;
try { try {
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);
}
if (url.pathname === '/api/status') return sendJson(res, snapshot(runtime)); if (url.pathname === '/api/status') return sendJson(res, snapshot(runtime));
if (url.pathname === '/api/monitors') return sendJson(res, config); if (url.pathname === '/api/monitors') return sendJson(res, config);
if (url.pathname === '/api/refresh') { if (url.pathname === '/api/refresh') {
+334
View File
@@ -0,0 +1,334 @@
/**
* 管理端 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,
},
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/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');
}
+195
View File
@@ -0,0 +1,195 @@
<!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-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; }
}
+594
View File
@@ -0,0 +1,594 @@
/**
* 管理后台前端:绑定 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';
// 维护窗口目标下拉
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');
});
$('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();
+8 -5
View File
@@ -1,4 +1,4 @@
const VERSION = '0.11.2'; const VERSION = '0.12.0';
const HB_LIMIT = 48; const HB_LIMIT = 48;
const UPTIME_DAYS = 90; const UPTIME_DAYS = 90;
const BROWSER_CONCURRENCY = 6; const BROWSER_CONCURRENCY = 6;
@@ -270,8 +270,8 @@ function renderEvents() {
return `<tr class="ev" data-status="${escapeHtml(statusOf[e.type] ?? 'up')}"> 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-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-type"><span class="ev-badge">${escapeHtml(label[e.type] ?? e.type)}</span>${maint}</td>
<td class="ev-name" title="${escapeHtml(e.name)}">${escapeHtml(e.name)}</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 ?? '')}">${escapeHtml(e.detail ?? '')}</td> <td class="ev-detail" title="${escapeHtml(e.detail ?? '')}"><span class="clamp2">${escapeHtml(e.detail ?? '')}</span></td>
</tr>`; </tr>`;
}).join(''); }).join('');
@@ -543,9 +543,12 @@ function tableRow(m, hide) {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.dataset.status = s.cls; tr.dataset.status = s.cls;
tr.dataset.id = m.id; 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-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 + targetCell +
`<td class="t-num">${st?.latency != null ? `${st.latency} ms` : '–'}</td>` + `<td class="t-num">${st?.latency != null ? `${st.latency} ms` : '–'}</td>` +
`<td class="t-num">${escapeHtml(resultText(st, m))}</td>` + `<td class="t-num">${escapeHtml(resultText(st, m))}</td>` +
+44 -8
View File
@@ -21,6 +21,11 @@
--mono: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, "Courier New", monospace; --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; --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); --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) { @media (prefers-color-scheme: dark) {
@@ -368,6 +373,11 @@ body[data-view="table"] .grid { display: block; }
border-collapse: collapse; border-collapse: collapse;
font-size: 13px; 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% /* 表格列宽:状态 / 服务 / 目标 / 延迟 / 结果 / 最近检测 / 心率 / 90天可用率 = 100%
地址与结果列给足比例,避免长 URL、长名称被截断 */ 地址与结果列给足比例,避免长 URL、长名称被截断 */
.monitor-table th:nth-child(1) { width: 7%; } .monitor-table th:nth-child(1) { width: 7%; }
@@ -390,34 +400,56 @@ body[data-view="table"] .grid { display: block; }
background: var(--bg); background: var(--bg);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.monitor-table td { padding: 9px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; overflow: hidden; line-height: 1.4; } .monitor-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; overflow: hidden; line-height: 1.4; }
/* 所有行统一基线:可用率的 mini 条是 inline-flex,其基线会把行盒撑高十几像素, /* 所有行统一基线:可用率的 mini 条是 inline-flex,其基线会把行盒撑高十几像素,
导致带条的行比不带的行高出一截(实测 58px vs 43px) */ 导致带条的行比不带的行高出一截(实测 58px vs 43px) */
.monitor-table td > * { vertical-align: middle; } .monitor-table td > * { vertical-align: middle; }
.u-mini { display: inline-flex; align-items: center; gap: 6px; 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[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:last-child td { border-bottom: 0; }
.monitor-table tbody tr:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); } .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); } .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 { white-space: nowrap; }
.t-state .dot { display: inline-block; margin-right: 6px; vertical-align: middle; } .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="up"] .t-state { color: var(--up-text); }
tr[data-status="down"] .t-state { color: var(--down-text); } tr[data-status="down"] .t-state { color: var(--down-text); }
tr[data-status="pending"] .t-state { color: var(--pending-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 { .t-target {
font-family: var(--mono); font-family: var(--mono);
font-size: 11.5px; font-size: 11.5px;
line-height: 1.45; line-height: 1.45;
color: var(--text-2); color: var(--text-2);
white-space: normal; 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 { .tls-badge, .maint-badge {
@@ -469,6 +501,7 @@ tr[data-status="pending"] .t-state { color: var(--pending-text); }
/* 事件表:显式声明与监控表格相同的观感(不共用 .monitor-table 类, /* 事件表:显式声明与监控表格相同的观感(不共用 .monitor-table 类,
避免被其他按类名统计/定位的逻辑误算),列宽与类型徽标单独定义 */ 避免被其他按类名统计/定位的逻辑误算),列宽与类型徽标单独定义 */
.events-table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 13px; } .events-table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 13px; }
.events-table tbody tr { height: var(--row-h); }
.events-table th { .events-table th {
padding: 10px 12px; padding: 10px 12px;
text-align: left; text-align: left;
@@ -481,7 +514,7 @@ tr[data-status="pending"] .t-state { color: var(--pending-text); }
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.events-table td { padding: 11px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; line-height: 1.4; overflow: hidden; } .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:last-child td { border-bottom: 0; }
.events-table tbody tr:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); } .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="down"] { background: color-mix(in srgb, var(--down) 6%, transparent); }
@@ -505,6 +538,9 @@ tr[data-status="pending"] .ev-badge { background: var(--pending-bg); color: var(
.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-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; } .ev-name { font-weight: 600; }
.ev-detail { color: var(--text-2); } .ev-detail { color: var(--text-2); }
/* 事件表的长文本同样限两行,保证与监控表格一致的等高行 */
.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; } .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) { @media (max-width: 640px) {
+1 -1
View File
@@ -75,7 +75,7 @@
<span id="notify-badge" class="footer-item" hidden>通知 –</span> <span id="notify-badge" class="footer-item" hidden>通知 –</span>
<span id="updated-at" class="footer-item">–</span> <span id="updated-at" class="footer-item">–</span>
<span id="countdown" class="footer-item">–</span> <span id="countdown" class="footer-item">–</span>
<span class="footer-item version">shiroko-monitor v0.11.2</span> <span class="footer-item version">shiroko-monitor v0.12.0</span>
</footer> </footer>
<script type="module" src="assets/app.js"></script> <script type="module" src="assets/app.js"></script>
+303 -303
View File
@@ -1,22 +1,22 @@
{ {
"prober": "shiroko-monitor-node/0.11.1", "prober": "shiroko-monitor-node/0.12.0",
"demo": false, "demo": false,
"generatedAt": "2026-10-14T02:48:57.258Z", "generatedAt": "2026-10-14T04:36:38.881Z",
"dataUpdatedAt": "2026-10-14T02:45:46.268Z", "dataUpdatedAt": "2026-10-14T04:36:26.361Z",
"updateIntervalSec": 300, "updateIntervalSec": 300,
"dataFilter": "uptimerobot", "dataFilter": "uptimerobot",
"probeScope": "uptimerobot", "probeScope": "all",
"stats": { "stats": {
"total": 6, "total": 6,
"up": 5, "up": 5,
"down": 1, "down": 1,
"pending": 0, "pending": 0,
"avgLatency": 735, "avgLatency": 755,
"avgUptime90": 83.3, "avgUptime90": 83.3,
"memory": { "memory": {
"rssMB": 64.5, "rssMB": 80.2,
"heapUsedMB": 11.2, "heapUsedMB": 10.8,
"heapTotalMB": 14.2 "heapTotalMB": 15
} }
}, },
"sources": { "sources": {
@@ -24,10 +24,10 @@
"enabled": true, "enabled": true,
"ok": true, "ok": true,
"error": null, "error": null,
"lastSyncAt": "2026-10-14T02:45:46.268Z", "lastSyncAt": "2026-10-14T04:33:06.976Z",
"count": 6, "count": 6,
"syncing": false, "syncing": false,
"lastSyncMs": 1791945944983 "lastSyncMs": 1791952385563
} }
}, },
"notify": { "notify": {
@@ -55,21 +55,11 @@
"monitors": { "monitors": {
"ur-792059439": { "ur-792059439": {
"status": "up", "status": "up",
"latency": 190, "latency": 99,
"code": null, "code": null,
"detail": "UptimeRobot:运行正常", "detail": "UptimeRobot:运行正常",
"lastCheck": "2026-10-14T02:41:00.000Z", "lastCheck": "2026-10-14T04:33:00.000Z",
"history": [ "history": [
{
"t": "2026-10-13T03:50:00.000Z",
"ok": true,
"ms": 104
},
{
"t": "2026-10-13T04:35:00.000Z",
"ok": true,
"ms": 478
},
{ {
"t": "2026-10-13T05:15:00.000Z", "t": "2026-10-13T05:15:00.000Z",
"ok": true, "ok": true,
@@ -226,23 +216,38 @@
"ms": 421 "ms": 421
}, },
{ {
"t": "2026-10-14T02:05:00.000Z", "t": "2026-10-14T02:00:00.000Z",
"ok": true, "ok": true,
"ms": 272 "ms": 191
}, },
{ {
"t": "2026-10-14T02:41:00.000Z", "t": "2026-10-14T02:40:00.000Z",
"ok": true, "ok": true,
"ms": 190 "ms": 187
},
{
"t": "2026-10-14T03:20:00.000Z",
"ok": true,
"ms": 282
},
{
"t": "2026-10-14T03:57:00.000Z",
"ok": true,
"ms": 321
},
{
"t": "2026-10-14T04:33:00.000Z",
"ok": true,
"ms": 99
} }
], ],
"daily": { "daily": {
"2026-10-14": { "2026-10-14": {
"total": 7, "total": 4,
"up": 7, "up": 4,
"latSum": 1758, "latSum": 690,
"latCount": 7, "latCount": 4,
"latMax": 326 "latMax": 197
} }
}, },
"meta": { "meta": {
@@ -267,38 +272,23 @@
"90": 0 "90": 0
}, },
"lat": { "lat": {
"avg": 251, "avg": 173,
"max": 326, "max": 197,
"days": [ "days": [
{ {
"key": "2026-10-14", "key": "2026-10-14",
"avg": 251 "avg": 173
} }
] ]
} }
}, },
"ur-795497022": { "ur-795497022": {
"status": "up", "status": "up",
"latency": 811, "latency": 619,
"code": null, "code": null,
"detail": "UptimeRobot:运行正常", "detail": "UptimeRobot:运行正常",
"lastCheck": "2026-10-14T02:45:00.000Z", "lastCheck": "2026-10-14T04:32:00.000Z",
"history": [ "history": [
{
"t": "2026-10-13T03:35:00.000Z",
"ok": true,
"ms": 726
},
{
"t": "2026-10-13T04:15:00.000Z",
"ok": true,
"ms": 690
},
{
"t": "2026-10-13T04:55:00.000Z",
"ok": true,
"ms": 653
},
{ {
"t": "2026-10-13T05:35:00.000Z", "t": "2026-10-13T05:35:00.000Z",
"ok": true, "ok": true,
@@ -445,28 +435,38 @@
"ms": 676 "ms": 676
}, },
{ {
"t": "2026-10-14T01:31:00.000Z", "t": "2026-10-14T01:00:00.000Z",
"ok": true, "ok": true,
"ms": 802 "ms": 755
}, },
{ {
"t": "2026-10-14T02:08:00.000Z", "t": "2026-10-14T01:40:00.000Z",
"ok": true, "ok": true,
"ms": 634 "ms": 658
}, },
{ {
"t": "2026-10-14T02:45:00.000Z", "t": "2026-10-14T02:20:00.000Z",
"ok": true, "ok": true,
"ms": 811 "ms": 740
},
{
"t": "2026-10-14T03:55:00.000Z",
"ok": true,
"ms": 631
},
{
"t": "2026-10-14T04:32:00.000Z",
"ok": true,
"ms": 619
} }
], ],
"daily": { "daily": {
"2026-10-14": { "2026-10-14": {
"total": 7, "total": 4,
"up": 7, "up": 4,
"latSum": 5767, "latSum": 2484,
"latCount": 7, "latCount": 4,
"latMax": 845 "latMax": 627
} }
}, },
"meta": { "meta": {
@@ -491,33 +491,23 @@
"90": 5432 "90": 5432
}, },
"lat": { "lat": {
"avg": 824, "avg": 621,
"max": 845, "max": 627,
"days": [ "days": [
{ {
"key": "2026-10-14", "key": "2026-10-14",
"avg": 824 "avg": 621
} }
] ]
} }
}, },
"ur-795556828": { "ur-795556828": {
"status": "up", "status": "up",
"latency": 448, "latency": 335,
"code": null, "code": null,
"detail": "UptimeRobot:运行正常", "detail": "UptimeRobot:运行正常",
"lastCheck": "2026-10-14T02:41:00.000Z", "lastCheck": "2026-10-14T04:28:00.000Z",
"history": [ "history": [
{
"t": "2026-10-13T03:45:00.000Z",
"ok": true,
"ms": 229
},
{
"t": "2026-10-13T04:30:00.000Z",
"ok": true,
"ms": 234
},
{ {
"t": "2026-10-13T05:10:00.000Z", "t": "2026-10-13T05:10:00.000Z",
"ok": true, "ok": true,
@@ -554,143 +544,158 @@
"ms": 256 "ms": 256
}, },
{ {
"t": "2026-10-13T09:55:00.000Z", "t": "2026-10-13T09:50:00.000Z",
"ok": true, "ok": true,
"ms": 455 "ms": 500
}, },
{ {
"t": "2026-10-13T10:35:00.000Z", "t": "2026-10-13T10:30:00.000Z",
"ok": true, "ok": true,
"ms": 372 "ms": 337
}, },
{ {
"t": "2026-10-13T11:15:00.000Z", "t": "2026-10-13T11:10:00.000Z",
"ok": true, "ok": true,
"ms": 240 "ms": 197
}, },
{ {
"t": "2026-10-13T11:55:00.000Z", "t": "2026-10-13T11:50:00.000Z",
"ok": true, "ok": true,
"ms": 261 "ms": 344
}, },
{ {
"t": "2026-10-13T12:35:00.000Z", "t": "2026-10-13T12:30:00.000Z",
"ok": true, "ok": true,
"ms": 335 "ms": 341
}, },
{ {
"t": "2026-10-13T13:15:00.000Z", "t": "2026-10-13T13:10:00.000Z",
"ok": true, "ok": true,
"ms": 413 "ms": 347
}, },
{ {
"t": "2026-10-13T13:55:00.000Z", "t": "2026-10-13T13:50:00.000Z",
"ok": true, "ok": true,
"ms": 283 "ms": 220
}, },
{ {
"t": "2026-10-13T14:35:00.000Z", "t": "2026-10-13T14:30:00.000Z",
"ok": true,
"ms": 309
},
{
"t": "2026-10-13T15:20:00.000Z",
"ok": true,
"ms": 451
},
{
"t": "2026-10-13T16:00:00.000Z",
"ok": true,
"ms": 248
},
{
"t": "2026-10-13T16:40:00.000Z",
"ok": true,
"ms": 378
},
{
"t": "2026-10-13T17:20:00.000Z",
"ok": true,
"ms": 293
},
{
"t": "2026-10-13T18:00:00.000Z",
"ok": true,
"ms": 495
},
{
"t": "2026-10-13T18:40:00.000Z",
"ok": true,
"ms": 391
},
{
"t": "2026-10-13T19:20:00.000Z",
"ok": true, "ok": true,
"ms": 338 "ms": 338
}, },
{ {
"t": "2026-10-13T20:00:00.000Z", "t": "2026-10-13T15:10:00.000Z",
"ok": true, "ok": true,
"ms": 363 "ms": 510
}, },
{ {
"t": "2026-10-13T20:40:00.000Z", "t": "2026-10-13T15:50:00.000Z",
"ok": true, "ok": true,
"ms": 499 "ms": 230
}, },
{ {
"t": "2026-10-13T21:20:00.000Z", "t": "2026-10-13T16:30:00.000Z",
"ok": true, "ok": true,
"ms": 372 "ms": 401
}, },
{ {
"t": "2026-10-13T22:00:00.000Z", "t": "2026-10-13T17:10:00.000Z",
"ok": true, "ok": true,
"ms": 194 "ms": 302
}, },
{ {
"t": "2026-10-13T22:40:00.000Z", "t": "2026-10-13T17:50:00.000Z",
"ok": true, "ok": true,
"ms": 225 "ms": 495
}, },
{ {
"t": "2026-10-13T23:20:00.000Z", "t": "2026-10-13T18:30:00.000Z",
"ok": true, "ok": true,
"ms": 237 "ms": 389
}, },
{ {
"t": "2026-10-14T00:00:00.000Z", "t": "2026-10-13T19:10:00.000Z",
"ok": true,
"ms": 328
},
{
"t": "2026-10-13T19:50:00.000Z",
"ok": true,
"ms": 338
},
{
"t": "2026-10-13T20:35:00.000Z",
"ok": true,
"ms": 423
},
{
"t": "2026-10-13T21:15:00.000Z",
"ok": true,
"ms": 394
},
{
"t": "2026-10-13T21:55:00.000Z",
"ok": true,
"ms": 214
},
{
"t": "2026-10-13T22:35:00.000Z",
"ok": true,
"ms": 240
},
{
"t": "2026-10-13T23:15:00.000Z",
"ok": true, "ok": true,
"ms": 215 "ms": 215
}, },
{ {
"t": "2026-10-14T00:40:00.000Z", "t": "2026-10-13T23:55:00.000Z",
"ok": true, "ok": true,
"ms": 298 "ms": 240
}, },
{ {
"t": "2026-10-14T01:20:00.000Z", "t": "2026-10-14T00:35:00.000Z",
"ok": true, "ok": true,
"ms": 450 "ms": 329
}, },
{ {
"t": "2026-10-14T02:05:00.000Z", "t": "2026-10-14T01:15:00.000Z",
"ok": true, "ok": true,
"ms": 229 "ms": 387
}, },
{ {
"t": "2026-10-14T02:41:00.000Z", "t": "2026-10-14T01:55:00.000Z",
"ok": true, "ok": true,
"ms": 448 "ms": 337
},
{
"t": "2026-10-14T02:35:00.000Z",
"ok": true,
"ms": 381
},
{
"t": "2026-10-14T03:15:00.000Z",
"ok": true,
"ms": 355
},
{
"t": "2026-10-14T03:52:00.000Z",
"ok": true,
"ms": 538
},
{
"t": "2026-10-14T04:28:00.000Z",
"ok": true,
"ms": 335
} }
], ],
"daily": { "daily": {
"2026-10-14": { "2026-10-14": {
"total": 7, "total": 4,
"up": 7, "up": 4,
"latSum": 2113, "latSum": 1352,
"latCount": 7, "latCount": 4,
"latMax": 448 "latMax": 339
} }
}, },
"meta": { "meta": {
@@ -715,33 +720,23 @@
"90": 0 "90": 0
}, },
"lat": { "lat": {
"avg": 302, "avg": 338,
"max": 448, "max": 339,
"days": [ "days": [
{ {
"key": "2026-10-14", "key": "2026-10-14",
"avg": 302 "avg": 338
} }
] ]
} }
}, },
"ur-792059414": { "ur-792059414": {
"status": "up", "status": "up",
"latency": 1795, "latency": 2408,
"code": null, "code": null,
"detail": "UptimeRobot:运行正常", "detail": "UptimeRobot:运行正常",
"lastCheck": "2026-10-14T02:40:00.000Z", "lastCheck": "2026-10-14T04:27:00.000Z",
"history": [ "history": [
{
"t": "2026-10-13T03:55:00.000Z",
"ok": true,
"ms": 2124
},
{
"t": "2026-10-13T04:40:00.000Z",
"ok": true,
"ms": 2623
},
{ {
"t": "2026-10-13T05:25:00.000Z", "t": "2026-10-13T05:25:00.000Z",
"ok": true, "ok": true,
@@ -868,38 +863,48 @@
"ms": 2418 "ms": 2418
}, },
{ {
"t": "2026-10-14T00:00:00.000Z", "t": "2026-10-14T00:05:00.000Z",
"ok": true, "ok": true,
"ms": 1947 "ms": 2098
}, },
{ {
"t": "2026-10-14T00:40:00.000Z", "t": "2026-10-14T00:50:00.000Z",
"ok": true, "ok": true,
"ms": 2257 "ms": 1921
}, },
{ {
"t": "2026-10-14T01:20:00.000Z", "t": "2026-10-14T01:35:00.000Z",
"ok": true, "ok": true,
"ms": 2605 "ms": 3110
}, },
{ {
"t": "2026-10-14T02:01:00.000Z", "t": "2026-10-14T02:20:00.000Z",
"ok": true, "ok": true,
"ms": 2591 "ms": 1719
}, },
{ {
"t": "2026-10-14T02:40:00.000Z", "t": "2026-10-14T03:05:00.000Z",
"ok": true, "ok": true,
"ms": 1795 "ms": 1623
},
{
"t": "2026-10-14T03:48:00.000Z",
"ok": true,
"ms": 3512
},
{
"t": "2026-10-14T04:27:00.000Z",
"ok": true,
"ms": 2408
} }
], ],
"daily": { "daily": {
"2026-10-14": { "2026-10-14": {
"total": 7, "total": 4,
"up": 7, "up": 4,
"latSum": 12076, "latSum": 10418,
"latCount": 7, "latCount": 4,
"latMax": 1795 "latMax": 2670
} }
}, },
"meta": { "meta": {
@@ -924,206 +929,206 @@
"90": 7593 "90": 7593
}, },
"lat": { "lat": {
"avg": 1725, "avg": 2605,
"max": 1795, "max": 2670,
"days": [ "days": [
{ {
"key": "2026-10-14", "key": "2026-10-14",
"avg": 1725 "avg": 2605
} }
] ]
} }
}, },
"ur-798788113": { "ur-798788113": {
"status": "up", "status": "up",
"latency": 433, "latency": 312,
"code": null, "code": null,
"detail": "UptimeRobot:运行正常", "detail": "UptimeRobot:运行正常",
"lastCheck": "2026-10-14T02:42:00.000Z", "lastCheck": "2026-10-14T04:29:00.000Z",
"history": [ "history": [
{ {
"t": "2026-10-13T04:00:00.000Z", "t": "2026-10-13T05:30:00.000Z",
"ok": true,
"ms": 400
},
{
"t": "2026-10-13T04:40:00.000Z",
"ok": true,
"ms": 357
},
{
"t": "2026-10-13T05:20:00.000Z",
"ok": true,
"ms": 372
},
{
"t": "2026-10-13T06:00:00.000Z",
"ok": true,
"ms": 426
},
{
"t": "2026-10-13T06:40:00.000Z",
"ok": true,
"ms": 314
},
{
"t": "2026-10-13T07:20:00.000Z",
"ok": true, "ok": true,
"ms": 449 "ms": 449
}, },
{ {
"t": "2026-10-13T08:00:00.000Z", "t": "2026-10-13T06:10:00.000Z",
"ok": true, "ok": true,
"ms": 279 "ms": 386
}, },
{ {
"t": "2026-10-13T08:40:00.000Z", "t": "2026-10-13T06:50:00.000Z",
"ok": true, "ok": true,
"ms": 407 "ms": 311
}, },
{ {
"t": "2026-10-13T09:20:00.000Z", "t": "2026-10-13T07:30:00.000Z",
"ok": true, "ok": true,
"ms": 317 "ms": 384
}, },
{ {
"t": "2026-10-13T10:00:00.000Z", "t": "2026-10-13T08:10:00.000Z",
"ok": true, "ok": true,
"ms": 627 "ms": 390
}, },
{ {
"t": "2026-10-13T10:40:00.000Z", "t": "2026-10-13T08:50:00.000Z",
"ok": true, "ok": true,
"ms": 505 "ms": 461
}, },
{ {
"t": "2026-10-13T11:20:00.000Z", "t": "2026-10-13T09:30:00.000Z",
"ok": true, "ok": true,
"ms": 424 "ms": 545
}, },
{ {
"t": "2026-10-13T12:00:00.000Z", "t": "2026-10-13T10:10:00.000Z",
"ok": true, "ok": true,
"ms": 1135 "ms": 456
}, },
{ {
"t": "2026-10-13T12:40:00.000Z", "t": "2026-10-13T10:50:00.000Z",
"ok": true, "ok": true,
"ms": 462 "ms": 394
}, },
{ {
"t": "2026-10-13T13:20:00.000Z", "t": "2026-10-13T11:30:00.000Z",
"ok": true, "ok": true,
"ms": 436 "ms": 398
}, },
{ {
"t": "2026-10-13T14:00:00.000Z", "t": "2026-10-13T12:10:00.000Z",
"ok": true, "ok": true,
"ms": 521 "ms": 588
}, },
{ {
"t": "2026-10-13T14:40:00.000Z", "t": "2026-10-13T12:50:00.000Z",
"ok": true, "ok": true,
"ms": 480 "ms": 430
}, },
{ {
"t": "2026-10-13T15:20:00.000Z", "t": "2026-10-13T13:30:00.000Z",
"ok": true, "ok": true,
"ms": 462 "ms": 502
}, },
{ {
"t": "2026-10-13T16:00:00.000Z", "t": "2026-10-13T14:15:00.000Z",
"ok": true, "ok": true,
"ms": 331 "ms": 355
}, },
{ {
"t": "2026-10-13T16:40:00.000Z", "t": "2026-10-13T14:55:00.000Z",
"ok": true, "ok": true,
"ms": 373 "ms": 358
}, },
{ {
"t": "2026-10-13T17:20:00.000Z", "t": "2026-10-13T15:35:00.000Z",
"ok": true, "ok": true,
"ms": 429 "ms": 385
}, },
{ {
"t": "2026-10-13T18:00:00.000Z", "t": "2026-10-13T16:15:00.000Z",
"ok": true, "ok": true,
"ms": 558 "ms": 342
}, },
{ {
"t": "2026-10-13T18:40:00.000Z", "t": "2026-10-13T16:55:00.000Z",
"ok": true, "ok": true,
"ms": 370 "ms": 428
}, },
{ {
"t": "2026-10-13T19:20:00.000Z", "t": "2026-10-13T17:35:00.000Z",
"ok": true, "ok": true,
"ms": 1519 "ms": 508
}, },
{ {
"t": "2026-10-13T20:00:00.000Z", "t": "2026-10-13T18:15:00.000Z",
"ok": true, "ok": true,
"ms": 483 "ms": 602
}, },
{ {
"t": "2026-10-13T20:40:00.000Z", "t": "2026-10-13T18:55:00.000Z",
"ok": true,
"ms": 388
},
{
"t": "2026-10-13T21:20:00.000Z",
"ok": true,
"ms": 367
},
{
"t": "2026-10-13T22:00:00.000Z",
"ok": true,
"ms": 273
},
{
"t": "2026-10-13T22:40:00.000Z",
"ok": true,
"ms": 261
},
{
"t": "2026-10-13T23:20:00.000Z",
"ok": true,
"ms": 263
},
{
"t": "2026-10-14T00:00:00.000Z",
"ok": true,
"ms": 229
},
{
"t": "2026-10-14T00:40:00.000Z",
"ok": true, "ok": true,
"ms": 363 "ms": 363
}, },
{ {
"t": "2026-10-14T01:20:00.000Z", "t": "2026-10-13T19:40:00.000Z",
"ok": true, "ok": true,
"ms": 329 "ms": 1380
}, },
{ {
"t": "2026-10-14T02:06:00.000Z", "t": "2026-10-13T20:20:00.000Z",
"ok": true, "ok": true,
"ms": 234 "ms": 512
}, },
{ {
"t": "2026-10-14T02:42:00.000Z", "t": "2026-10-13T21:00:00.000Z",
"ok": true, "ok": true,
"ms": 433 "ms": 394
},
{
"t": "2026-10-13T21:40:00.000Z",
"ok": true,
"ms": 238
},
{
"t": "2026-10-13T22:20:00.000Z",
"ok": true,
"ms": 323
},
{
"t": "2026-10-13T23:00:00.000Z",
"ok": true,
"ms": 353
},
{
"t": "2026-10-13T23:40:00.000Z",
"ok": true,
"ms": 179
},
{
"t": "2026-10-14T00:20:00.000Z",
"ok": true,
"ms": 263
},
{
"t": "2026-10-14T01:00:00.000Z",
"ok": true,
"ms": 381
},
{
"t": "2026-10-14T01:40:00.000Z",
"ok": true,
"ms": 378
},
{
"t": "2026-10-14T02:20:00.000Z",
"ok": true,
"ms": 278
},
{
"t": "2026-10-14T03:17:00.000Z",
"ok": true,
"ms": 345
},
{
"t": "2026-10-14T03:53:00.000Z",
"ok": true,
"ms": 259
},
{
"t": "2026-10-14T04:29:00.000Z",
"ok": true,
"ms": 312
} }
], ],
"daily": { "daily": {
"2026-10-14": { "2026-10-14": {
"total": 7, "total": 4,
"up": 7, "up": 4,
"latSum": 2680, "latSum": 1245,
"latCount": 7, "latCount": 4,
"latMax": 435 "latMax": 323
} }
}, },
"meta": { "meta": {
@@ -1148,12 +1153,12 @@
"90": 0 "90": 0
}, },
"lat": { "lat": {
"avg": 383, "avg": 311,
"max": 435, "max": 323,
"days": [ "days": [
{ {
"key": "2026-10-14", "key": "2026-10-14",
"avg": 383 "avg": 311
} }
] ]
} }
@@ -1165,11 +1170,6 @@
"detail": "UptimeRobot:403 Forbidden", "detail": "UptimeRobot:403 Forbidden",
"lastCheck": "2026-05-07T07:54:09.000Z", "lastCheck": "2026-05-07T07:54:09.000Z",
"history": [ "history": [
{
"t": "2026-10-13T19:25:00.000Z",
"ok": true,
"ms": 185
},
{ {
"t": "2026-05-07T07:54:09.000Z", "t": "2026-05-07T07:54:09.000Z",
"ok": false, "ok": false,
@@ -1178,7 +1178,7 @@
], ],
"daily": { "daily": {
"2026-10-14": { "2026-10-14": {
"total": 7, "total": 4,
"up": 0 "up": 0
} }
}, },
+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, page: document.documentElement.scrollWidth - document.documentElement.clientWidth,
wrap: (() => { const w = document.querySelector('.table-wrap'); return w ? w.scrollWidth - w.clientWidth : 0; })(), 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; })(), 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); const bad = Math.max(r.page, r.wrap, r.grid);
if (!worst || bad > worst.bad) worst = { bad, view, width, r }; 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(); await page.close();
} }
} }
+44 -12
View File
@@ -300,13 +300,13 @@ async function main() {
const dt = await dp.evaluate(() => document.querySelector('tr[data-status] .t-uptime')?.title ?? ''); 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)); check('表格可用率 tooltip 含 90 天日均延迟', /90 天日均延迟 \d+ ms/.test(dt), dt.slice(0, 60));
// 行高一致性要在「内容长度相当」的前提下检查:先把配置里的名称统一成等长短名 // ---- 行高一致性:先在「内容等长」下取基准 ----
// (注意:名称来自 monitors.json,不是 status.json),再单独注入超长内容验证不截断 // 注意:名称来自 monitors.json,不是 status.json(改错文件会假通过)
const statusPath = path.join(tmpDir, 'status.json'); const statusPath = path.join(tmpDir, 'status.json');
const snap = JSON.parse(fs.readFileSync(statusPath, 'utf8')); const snap = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
const cfgPath2 = path.join(tmpDir, 'monitors.json'); const cfgPath2 = path.join(tmpDir, 'monitors.json');
const cfgJson = JSON.parse(fs.readFileSync(cfgPath2, 'utf8')); const cfgJson = JSON.parse(fs.readFileSync(cfgPath2, 'utf8'));
let ids = Object.keys(snap.monitors); const ids = Object.keys(snap.monitors);
cfgJson.monitors.forEach((m, i) => { m.name = `目标 ${i + 1}`; }); cfgJson.monitors.forEach((m, i) => { m.name = `目标 ${i + 1}`; });
fs.writeFileSync(cfgPath2, `${JSON.stringify(cfgJson, null, 2)}\n`); fs.writeFileSync(cfgPath2, `${JSON.stringify(cfgJson, null, 2)}\n`);
await dp.goto('http://localhost:8894/?source=static&view=table', { waitUntil: 'domcontentloaded' }); await dp.goto('http://localhost:8894/?source=static&view=table', { waitUntil: 'domcontentloaded' });
@@ -317,27 +317,39 @@ async function main() {
return { hs, unique: [...new Set(hs)] }; return { hs, unique: [...new Set(hs)] };
}); });
check('内容等长时表格各行等高(可用率条不再撑高行)', heights.unique.length === 1, `行高 ${heights.unique.join('/')}(共 ${heights.hs.length} 行)`); 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; let injected = 0;
for (const id of ids.slice(0, 2)) { cfgJson.monitors.forEach((m, i) => {
const m = cfgJson.monitors.find((x) => x.id === id); if (i % 3 === 0) { m.name = `这是一个相当长的监控目标名称用于验证绝对等高-${'X'.repeat(40)}-${m.id}`; injected++; }
if (m) { m.name = `这是一个相当长的监控目标名称用于验证表格完整显示-${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`); fs.writeFileSync(cfgPath2, `${JSON.stringify(cfgJson, null, 2)}\n`);
// 注入事件,验证事件区块的时间线样式与折叠 // 注入事件,验证事件区块的时间线样式、折叠与等高
snap.events = Array.from({ length: 12 }, (_, i) => ({ snap.events = Array.from({ length: 12 }, (_, i) => ({
t: new Date(Date.now() - i * 60000).toISOString(), t: new Date(Date.now() - i * 60000).toISOString(),
id: ids[0], 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', type: i % 2 ? 'down' : 'up',
detail: i % 2 ? 'ETIMEDOUT' : '已恢复', detail: i % 2 ? `connect ETIMEDOUT ${'203.0.113.'.repeat(6)}` : '已恢复',
maintenance: false, maintenance: false,
})); }));
fs.writeFileSync(statusPath, JSON.stringify(snap)); fs.writeFileSync(statusPath, JSON.stringify(snap));
await dp.goto('http://localhost:8894/?source=static&view=table', { waitUntil: 'domcontentloaded' }); 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.waitForSelector('table.monitor-table tbody tr[data-status]', { timeout: 20000 });
await dp.waitForTimeout(900); 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(() => { const clip = await dp.evaluate(() => {
let clipped = 0; let clipped = 0;
let worst = 0; let worst = 0;
@@ -350,8 +362,28 @@ async function main() {
const wrap = document.querySelector('.table-wrap'); const wrap = document.querySelector('.table-wrap');
return { clipped, worst, pageOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, tableOverflow: wrap ? wrap.scrollWidth - wrap.clientWidth : 0 }; 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}`); 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 evr = await dp.evaluate(() => {
const table = document.querySelector('.events-table'); const table = document.querySelector('.events-table');
const evRow = table?.querySelector('tbody tr'); const evRow = table?.querySelector('tbody tr');