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 项全部通过
@@ -447,6 +447,58 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自
|
||||
- **表格视图**:可用率列的 tooltip 里追加 `90 天日均延迟 X ms(峰值 Y ms)`
|
||||
- 数据从探针开始累计当天起累积,跑满 90 天后覆盖全周期;离线样本不计入延迟统计
|
||||
|
||||
## 管理后台(2FA 保护)
|
||||
|
||||
面板右侧没有「编辑」入口:所有会改配置的操作都放在独立的管理后台 `/admin`,**必须绑定并通过两步验证才能进入**。
|
||||
|
||||
### 启用
|
||||
|
||||
```bash
|
||||
# 1. 设置绑定口令(只在首次绑定时需要,用于防止被人抢先绑定自己的验证器)
|
||||
export SHIROKO_ADMIN_SETUP_KEY='换成你自己的长口令'
|
||||
node prober/node/index.js
|
||||
|
||||
# 2. 打开 http://<host>:8787/admin
|
||||
```
|
||||
|
||||
不设置 `SHIROKO_ADMIN_SETUP_KEY` 时绑定入口直接关闭(接口返回 503),管理后台处于「完全不可用」状态。
|
||||
|
||||
### 绑定流程
|
||||
|
||||
1. 输入 `SHIROKO_ADMIN_SETUP_KEY` → 服务端生成一个 20 字节 Base32 密钥
|
||||
2. 用 Google Authenticator / Microsoft Authenticator / 1Password / Aegis 扫码,或手动输入密钥(页面同时提供可复制的 `otpauth://` 链接)
|
||||
3. 输入 App 显示的 6 位验证码完成绑定
|
||||
4. **立刻保存 10 枚恢复码**(只显示这一次),每枚只能用一次,用于手机丢失或验证器清库
|
||||
|
||||
绑定完成后每次进入管理界面都需要重新输入 6 位动态码或一枚恢复码。
|
||||
|
||||
### 管理功能
|
||||
|
||||
| 功能 | 说明 |
|
||||
| --- | --- |
|
||||
| 运行状态 | 监控数 / 在线 / 离线 / 平均延迟 / 90 天可用率,数据更新时间,各数据源目标数与同步状态 |
|
||||
| 探测范围 | `all` / `config` / `uptimerobot`,决定探针真正发请求的范围,改动立即热重载 |
|
||||
| 展示口径 | `all` / `config` / `uptimerobot`,只影响面板展示,与探测范围相互独立 |
|
||||
| 维护窗口 | 新增/删除窗口,可限定单个目标或全部目标;窗口内不计入可用率、不发告警 |
|
||||
| 立即重新探测 | 手动跑一轮探测(复用面板刷新同一条路径) |
|
||||
| 通知自检 | 给每个已启用渠道发一条测试消息 |
|
||||
| 监控目标 | 只读列表(名称 / 分组 / 类型 / 地址 / 间隔) |
|
||||
|
||||
**所有写操作只落 `local.monitors.json`(私有、不入库),绝不改 `public/monitors.json`。**
|
||||
|
||||
### 安全设计
|
||||
|
||||
- **未绑定时管理接口一律 403**,不存在「先跳过 2FA 进管理界面」的路径
|
||||
- 会话 id 为 32 字节随机数,**服务端只存 SHA-256 哈希**;进程重启即清空全部会话
|
||||
- 会话 cookie 为 `HttpOnly; SameSite=Strict`,30 分钟无操作失效,最长 2 小时
|
||||
- 同一时间步的 6 位码**用过即作废**(防截图重放);恢复码一次性,用后立刻从落盘档案中移除
|
||||
- 登录失败按 IP 计数,连续 5 次失败后指数退避锁定(1m → 2m → 4m …上限 30m)
|
||||
- 只信任 socket 对端地址,**不解析 `X-Forwarded-For`**(可被伪造)
|
||||
- 2FA 档案存 `data/admin-2fa.json`(`data/` 已在 `.gitignore` 中),恢复码只存哈希
|
||||
- 丢失全部验证码时的逃生口:用 `SHIROKO_ADMIN_SETUP_KEY` 重置 2FA(会清空密钥与全部恢复码,需重新绑定)
|
||||
|
||||
> 部署到公网时建议放在 HTTPS 反向代理之后:会话 cookie 未设 `Secure`(纯 HTTP 下设了会导致 cookie 完全不发送),由代理层负责加密传输。
|
||||
|
||||
## 部署方案
|
||||
|
||||
| 方案 | 架构 | 数据更新 | 成本 | 适合场景 |
|
||||
@@ -540,6 +592,32 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自
|
||||
- [x] 页脚改为展示「数据更新于 … + 下次检查 Ns」,tooltip 内含服务端更新节奏与快照生成时间
|
||||
- [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 已实现
|
||||
|
||||
**修复你反馈的两个问题**
|
||||
@@ -548,7 +626,7 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自
|
||||
- [x] **「最近事件」改为与表格同一套样式**:不再用独立的「时间线卡片」风格,改为**与监控表格完全同构的表格**(相同的圆角容器、表头样式、行分隔线、hover、状态底色)——故障行淡红、证书行淡琥珀、恢复行不染;类型以小徽标呈现,详情列完整显示
|
||||
- 实现上刻意**不共用 `.monitor-table` 类名**,避免被其他按类名统计/定位的逻辑误算(这一点被测试当场抓到:共用类名时面板条目数被多算 1~8 个)
|
||||
|
||||
> 说明:行高在「内容长度相当」时保证一致;名称或地址特别长时允许换行(这是上一版你要求的「数据完整显示」),此时行自然更高——属于数据本身需要的空间,不再靠截断换取整齐。
|
||||
> 说明:这一版的行高只在「内容长度相当」时保证一致;名称或地址特别长时允许换行(上一版要求的「数据完整显示」),此时行自然更高。**已在 v0.12.0 改为绝对等高**(文本列限两行 + 悬停看全文)。
|
||||
|
||||
### v0.11.1 已实现
|
||||
|
||||
@@ -669,14 +747,16 @@ node prober/node/index.js
|
||||
node prober/node/index.js --once
|
||||
|
||||
# UI 自动化实测(需 playwright,仅开发验证用,驱动系统 Chrome)
|
||||
NODE_PATH=<playwright 的 node_modules> node scripts/verify.cjs # 热重载 / 数据一致性 / 刷新语义 / 面板(21 项)
|
||||
NODE_PATH=<playwright 的 node_modules> node scripts/verify-views.cjs # 视图与数据源过滤(10 项)
|
||||
NODE_PATH=<playwright 的 node_modules> node scripts/verify.cjs # 热重载 / 数据一致性 / 刷新语义 / 面板(17 项)
|
||||
NODE_PATH=<playwright 的 node_modules> node scripts/verify-views.cjs # 视图与数据源过滤 / 对比度 / 等高 / 事件区块(47 项)
|
||||
NODE_PATH=<playwright 的 node_modules> node scripts/verify-layout.cjs # 13 视口 × 2 视图零横向溢出 + 行高一致(26 项)
|
||||
NODE_PATH=<playwright 的 node_modules> node scripts/verify-admin.cjs # 管理端 2FA 全流程(102 项:RFC 向量 / 绑定 / 会话 / 限流 / 接口 / 页面)
|
||||
node scripts/verify-security.cjs # banner 探测 / 脱敏 / 私有分层 / 刷新限流(20 项)
|
||||
node scripts/verify-notify.cjs # 通知与证书(39 项:假 SMTP / 假 MeoW / 去抖 / 恢复 / 维护窗口 / 真实证书)
|
||||
UPTIMEROBOT_API_KEY=ur... node scripts/verify-uptimerobot.cjs # UR 映射/兼容接口/失败路径/冒烟(22 项)
|
||||
node scripts/verify-notify.cjs # 通知与证书(44 项:假 SMTP / 假 MeoW / 去抖 / 恢复 / 维护窗口 / 真实证书)
|
||||
UPTIMEROBOT_API_KEY=ur... node scripts/verify-uptimerobot.cjs # UR 映射/兼容接口/失败路径/冒烟(18 项)
|
||||
```
|
||||
|
||||
`verify-security.cjs` 会临时起一个假 SSH 服务与静默端口,验证 banner 探测的在线/不匹配/超时三种判定、默认不回显版本、脱敏与私有配置分层,以及刷新接口的 429/403 行为。`verify-uptimerobot.cjs` 用合成 payload 做无网映射单测,真实 Key 冒烟为可选(未设置环境变量则跳过)。`verify-notify.cjs` 会临时起**假 SMTP 服务器与假 MeoW 服务器**,完整走一遍邮件握手与推送协议,并真跑三轮探针验证去抖、恢复、维护窗口抑制与事件落盘;真实证书握手需要外网,无网络时自动跳过(不计失败)。
|
||||
`verify-security.cjs` 会临时起一个假 SSH 服务与静默端口,验证 banner 探测的在线/不匹配/超时三种判定、默认不回显版本、脱敏与私有配置分层,以及刷新接口的 429/403 行为。`verify-uptimerobot.cjs` 用合成 payload 做无网映射单测,真实 Key 冒烟为可选(未设置环境变量则跳过)。`verify-notify.cjs` 会临时起**假 SMTP 服务器与假 MeoW 服务器**,完整走一遍邮件握手与推送协议,并真跑三轮探针验证去抖、恢复、维护窗口抑制与事件落盘;真实证书握手需要外网,无网络时自动跳过(不计失败)。`verify-layout.cjs` 用极端内容(40 目标 / 6 分组 / 超长名称与 URL)在 13 种视口 × 2 种视图下断言零横向溢出**且行高完全一致**。`verify-admin.cjs` 先用 RFC 4226 / RFC 6238 官方测试向量校验自实现的 TOTP,再覆盖 2FA 绑定、会话生命周期、防重放、失败限流、HTTP 鉴权边界与页面流程。
|
||||
|
||||
代码约定:探针与面板通过 `status.json` / `/api/status` 的同一份 JSON 契约协作;新增探测类型时只需在 `prober/node/lib/` 与 `edge/cloudflare/worker.js` 各实现一个 `probeXxx(monitor)`,返回 `{ status, latency, code, detail }` 即可被面板直接渲染。
|
||||
|
||||
|
||||
@@ -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 本地密钥)
|
||||
- Worker 的 `REFRESH_TOKEN` 用 `wrangler secret put REFRESH_TOKEN` 管理,不要写进 `wrangler.toml`
|
||||
- 面板为只读前端:无 cookie、无写入接口、无服务端会话;渲染配置字段时统一做 HTML 转义(`escapeHtml`),XSS 面仅剩配置自身
|
||||
- **公开面板**(`/`)仍是只读前端:无 cookie、无写入接口、无服务端会话;渲染配置字段时统一做 HTML 转义(`escapeHtml`),XSS 面仅剩配置自身。管理能力全部隔离在 `/admin` 与 `/api/admin/*`(需会话)
|
||||
- Node 探针同时充当静态服务器,**不要直接暴露公网**;确实需要时在其前方加带认证的反向代理,并设 `allowRefresh: false`
|
||||
|
||||
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 450 KiB After Width: | Height: | Size: 452 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 235 KiB |
@@ -22,6 +22,8 @@ import { probeBanner } from './lib/banner-probe.js';
|
||||
import { fetchUptimeRobotMonitors } from './lib/uptimerobot.js';
|
||||
import { notifyAll, sendTestNotifications, createMeoWLimiter } from './lib/notify.js';
|
||||
import { probeCertificate, tlsTargetOf } from './lib/tls-probe.js';
|
||||
import { createAdminAuth } from './lib/admin.js';
|
||||
import { createAdminRouter } from './lib/admin-routes.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
@@ -37,7 +39,7 @@ const CONCURRENCY = 8;
|
||||
const WRITE_DEBOUNCE_MS = 300;
|
||||
const DEFAULT_INTERVAL_SEC = 60;
|
||||
const PORT = Number(process.env.PORT || 8787);
|
||||
const VERSION = '0.11.2';
|
||||
const VERSION = '0.12.0';
|
||||
const UR_DEFAULT_POLL_SEC = 300;
|
||||
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 EVENTS_PATH = () => path.join(DATA_DIR, 'events.json');
|
||||
// 2FA 档案:与通知状态同放私有数据目录(data/ 已在 .gitignore 中)
|
||||
const ADMIN_2FA_PATH = () => path.join(DATA_DIR, 'admin-2fa.json');
|
||||
|
||||
function monitorNotifyState(runtime, id) {
|
||||
const list = runtime.notifyState.monitors;
|
||||
@@ -930,10 +934,43 @@ function toUptimeRobotResponse(runtime) {
|
||||
|
||||
// runtime 是持有器对象:热重载只更新其属性,服务器与巡检循环始终读到最新引用
|
||||
function createServer(runtime) {
|
||||
const adminAuth = createAdminAuth({ storePath: ADMIN_2FA_PATH() });
|
||||
// 私有层被管理端改写后要立刻生效:沿用与 fs.watch 热重载相同的复用逻辑,保留内存态
|
||||
const reloadConfig = () => {
|
||||
const next = loadConfig();
|
||||
runtime.state = hydrate(next, runtime.state);
|
||||
runtime.config = next;
|
||||
};
|
||||
const handleAdminApi = createAdminRouter({
|
||||
auth: adminAuth,
|
||||
localConfigPath: ACTIVE_LOCAL_CONFIG_PATH,
|
||||
reloadConfig,
|
||||
runtime,
|
||||
fns: {
|
||||
snapshot,
|
||||
dataFilter,
|
||||
probeScopeSetting,
|
||||
localProbeTargets,
|
||||
notifyConfig,
|
||||
tlsConfig,
|
||||
notifyLastResult: () => snapshot(runtime).notify ?? null,
|
||||
forceRound,
|
||||
scheduleWrite,
|
||||
forcedMaxWaitMs: FORCED_MAX_WAIT_MS,
|
||||
version: VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
return http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||
const { config, state } = runtime;
|
||||
try {
|
||||
if (url.pathname.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/monitors') return sendJson(res, config);
|
||||
if (url.pathname === '/api/refresh') {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
/**
|
||||
* 管理后台前端:绑定 2FA → 登录 → 管理面板
|
||||
*
|
||||
* 零依赖,QR 码用内置的最小实现生成(只支持 byte 模式、ECC L、版本自动 1–10),
|
||||
* 覆盖 otpauth 链接(通常 60–120 字节)的长度。
|
||||
*/
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
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();
|
||||
@@ -1,4 +1,4 @@
|
||||
const VERSION = '0.11.2';
|
||||
const VERSION = '0.12.0';
|
||||
const HB_LIMIT = 48;
|
||||
const UPTIME_DAYS = 90;
|
||||
const BROWSER_CONCURRENCY = 6;
|
||||
@@ -270,8 +270,8 @@ function renderEvents() {
|
||||
return `<tr class="ev" data-status="${escapeHtml(statusOf[e.type] ?? 'up')}">
|
||||
<td class="ev-time" title="${escapeHtml(fmtTime(e.t))}">${escapeHtml(fmtRelative(e.t))}</td>
|
||||
<td class="ev-type"><span class="ev-badge">${escapeHtml(label[e.type] ?? e.type)}</span>${maint}</td>
|
||||
<td class="ev-name" title="${escapeHtml(e.name)}">${escapeHtml(e.name)}</td>
|
||||
<td class="ev-detail" title="${escapeHtml(e.detail ?? '')}">${escapeHtml(e.detail ?? '')}</td>
|
||||
<td class="ev-name" title="${escapeHtml(e.name)}"><span class="clamp2">${escapeHtml(e.name)}</span></td>
|
||||
<td class="ev-detail" title="${escapeHtml(e.detail ?? '')}"><span class="clamp2">${escapeHtml(e.detail ?? '')}</span></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
@@ -543,9 +543,12 @@ function tableRow(m, hide) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.status = s.cls;
|
||||
tr.dataset.id = m.id;
|
||||
const targetCell = hide ? '' : `<td class="t-target" title="${escapeHtml(targetText(m))}">${escapeHtml(targetText(m))}</td>`; tr.innerHTML =
|
||||
const targetCell = hide ? '' : `<td class="t-target" title="${escapeHtml(targetText(m))}"><span class="clamp2">${escapeHtml(targetText(m))}</span></td>`;
|
||||
// 名称与徽标分行:名称限两行、徽标独占一行,两者高度都固定,行高不再随名称/地址长度变化
|
||||
const tags = `${typeLabel(m) ? `<span class="type-badge">${escapeHtml(typeLabel(m))}</span>` : ''}${certBadge(st)}${maintBadge(st)}`;
|
||||
tr.innerHTML =
|
||||
`<td class="t-state"><span class="dot"></span>${s.label}</td>` +
|
||||
`<td class="t-name" title="${escapeHtml(m.name)}">${escapeHtml(m.name)}<span class="type-badge">${escapeHtml(typeLabel(m))}</span>${certBadge(st)}${maintBadge(st)}</td>` +
|
||||
`<td class="t-name" title="${escapeHtml(m.name)}"><span class="clamp2">${escapeHtml(m.name)}</span><span class="t-tags">${tags}</span></td>` +
|
||||
targetCell +
|
||||
`<td class="t-num">${st?.latency != null ? `${st.latency} ms` : '–'}</td>` +
|
||||
`<td class="t-num">${escapeHtml(resultText(st, m))}</td>` +
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
--mono: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, "Courier New", monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / 0.04);
|
||||
/* 表格行高基准:固定像素,行高与内容长度彻底解耦(任意长度内容下行高都相同)。
|
||||
72px = 名称两行(36) + 徽标行(18) + 上下内边距(16),监控表与事件表共用同一个值,
|
||||
两张表因此在视觉上也严格对齐。 */
|
||||
--row-h: 72px;
|
||||
--group-row-h: 32px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@@ -368,6 +373,11 @@ body[data-view="table"] .grid { display: block; }
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
/* 行高基准:所有数据行恒为 --row-h,与内容长度彻底解耦。
|
||||
内容再长也不会把某一行撑高(文本列各自限行截断 + 单元格 overflow:hidden)。
|
||||
分组标题行另设 --group-row-h,用 :not() 排除掉,否则同样会被这条规则套上。 */
|
||||
.monitor-table tbody tr:not(.group-row) { height: var(--row-h); }
|
||||
.group-row td { height: var(--group-row-h); }
|
||||
/* 表格列宽:状态 / 服务 / 目标 / 延迟 / 结果 / 最近检测 / 心率 / 90天可用率 = 100%
|
||||
地址与结果列给足比例,避免长 URL、长名称被截断 */
|
||||
.monitor-table th:nth-child(1) { width: 7%; }
|
||||
@@ -390,34 +400,56 @@ body[data-view="table"] .grid { display: block; }
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.monitor-table td { padding: 9px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; overflow: hidden; 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,其基线会把行盒撑高十几像素,
|
||||
导致带条的行比不带的行高出一截(实测 58px vs 43px) */
|
||||
.monitor-table td > * { vertical-align: middle; }
|
||||
.u-mini { display: inline-flex; align-items: center; gap: 6px; vertical-align: middle; }
|
||||
/* 限行截断:文本再多也只占固定的两行,行高因此与内容长度彻底解耦。
|
||||
height 而非 max-height —— 只有 1 行内容时也照样占满 2 行,行高才恒定;
|
||||
max-height 会让短内容缩成 1 行、长内容撑到 2 行,反而出现两种行高。
|
||||
-webkit-line-clamp 是 Chrome/Safari/Edge 的实现,height + overflow 兜底 Firefox。 */
|
||||
.clamp2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
height: 2.8em;
|
||||
}
|
||||
.monitor-table tbody tr[data-status="pending"] { background: color-mix(in srgb, var(--pending) 6%, transparent); }
|
||||
.monitor-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.monitor-table tbody tr:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
|
||||
.monitor-table tbody tr[data-status="down"] { background: color-mix(in srgb, var(--down) 6%, transparent); }
|
||||
.group-row td { padding: 7px 12px; font-size: 12px; font-weight: 650; color: var(--text-2); background: var(--bg); }
|
||||
.group-row td { padding: 7px 12px; font-size: 12px; font-weight: 650; color: var(--text-2); background: var(--bg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.t-state { white-space: nowrap; }
|
||||
.t-state .dot { display: inline-block; margin-right: 6px; vertical-align: middle; }
|
||||
tr[data-status="up"] .t-state { color: var(--up-text); }
|
||||
tr[data-status="down"] .t-state { color: var(--down-text); }
|
||||
tr[data-status="pending"] .t-state { color: var(--pending-text); }
|
||||
.t-name { white-space: normal; overflow-wrap: anywhere; }
|
||||
.t-name .type-badge { margin-left: 8px; }
|
||||
/* 名称列:文本区限两行,徽标区固定单行高度 —— 整格高度恒定 */
|
||||
.t-name { white-space: normal; }
|
||||
.t-name .clamp2 { overflow-wrap: anywhere; }
|
||||
.t-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 16px;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.t-name .type-badge { margin-left: 0; }
|
||||
.t-target {
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-2);
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-all;
|
||||
}
|
||||
.t-num { font-variant-numeric: tabular-nums; white-space: normal; overflow-wrap: anywhere; }
|
||||
.t-target .clamp2 { overflow-wrap: anywhere; word-break: break-all; }
|
||||
.t-num { font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* 证书 / 维护徽标:出现在名称旁,不占独立列 */
|
||||
.tls-badge, .maint-badge {
|
||||
@@ -469,6 +501,7 @@ tr[data-status="pending"] .t-state { color: var(--pending-text); }
|
||||
/* 事件表:显式声明与监控表格相同的观感(不共用 .monitor-table 类,
|
||||
避免被其他按类名统计/定位的逻辑误算),列宽与类型徽标单独定义 */
|
||||
.events-table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 13px; }
|
||||
.events-table tbody tr { height: var(--row-h); }
|
||||
.events-table th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
@@ -481,7 +514,7 @@ tr[data-status="pending"] .t-state { color: var(--pending-text); }
|
||||
overflow: hidden;
|
||||
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: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); }
|
||||
@@ -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-name { font-weight: 600; }
|
||||
.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; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
<span id="notify-badge" class="footer-item" hidden>通知 –</span>
|
||||
<span id="updated-at" class="footer-item">–</span>
|
||||
<span id="countdown" class="footer-item">–</span>
|
||||
<span class="footer-item version">shiroko-monitor v0.11.2</span>
|
||||
<span class="footer-item version">shiroko-monitor v0.12.0</span>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="assets/app.js"></script>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"prober": "shiroko-monitor-node/0.11.1",
|
||||
"prober": "shiroko-monitor-node/0.12.0",
|
||||
"demo": false,
|
||||
"generatedAt": "2026-10-14T02:48:57.258Z",
|
||||
"dataUpdatedAt": "2026-10-14T02:45:46.268Z",
|
||||
"generatedAt": "2026-10-14T04:36:38.881Z",
|
||||
"dataUpdatedAt": "2026-10-14T04:36:26.361Z",
|
||||
"updateIntervalSec": 300,
|
||||
"dataFilter": "uptimerobot",
|
||||
"probeScope": "uptimerobot",
|
||||
"probeScope": "all",
|
||||
"stats": {
|
||||
"total": 6,
|
||||
"up": 5,
|
||||
"down": 1,
|
||||
"pending": 0,
|
||||
"avgLatency": 735,
|
||||
"avgLatency": 755,
|
||||
"avgUptime90": 83.3,
|
||||
"memory": {
|
||||
"rssMB": 64.5,
|
||||
"heapUsedMB": 11.2,
|
||||
"heapTotalMB": 14.2
|
||||
"rssMB": 80.2,
|
||||
"heapUsedMB": 10.8,
|
||||
"heapTotalMB": 15
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -24,10 +24,10 @@
|
||||
"enabled": true,
|
||||
"ok": true,
|
||||
"error": null,
|
||||
"lastSyncAt": "2026-10-14T02:45:46.268Z",
|
||||
"lastSyncAt": "2026-10-14T04:33:06.976Z",
|
||||
"count": 6,
|
||||
"syncing": false,
|
||||
"lastSyncMs": 1791945944983
|
||||
"lastSyncMs": 1791952385563
|
||||
}
|
||||
},
|
||||
"notify": {
|
||||
@@ -55,21 +55,11 @@
|
||||
"monitors": {
|
||||
"ur-792059439": {
|
||||
"status": "up",
|
||||
"latency": 190,
|
||||
"latency": 99,
|
||||
"code": null,
|
||||
"detail": "UptimeRobot:运行正常",
|
||||
"lastCheck": "2026-10-14T02:41:00.000Z",
|
||||
"lastCheck": "2026-10-14T04:33:00.000Z",
|
||||
"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",
|
||||
"ok": true,
|
||||
@@ -226,23 +216,38 @@
|
||||
"ms": 421
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T02:05:00.000Z",
|
||||
"t": "2026-10-14T02:00:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 272
|
||||
"ms": 191
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T02:41:00.000Z",
|
||||
"t": "2026-10-14T02:40:00.000Z",
|
||||
"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": {
|
||||
"2026-10-14": {
|
||||
"total": 7,
|
||||
"up": 7,
|
||||
"latSum": 1758,
|
||||
"latCount": 7,
|
||||
"latMax": 326
|
||||
"total": 4,
|
||||
"up": 4,
|
||||
"latSum": 690,
|
||||
"latCount": 4,
|
||||
"latMax": 197
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
@@ -267,38 +272,23 @@
|
||||
"90": 0
|
||||
},
|
||||
"lat": {
|
||||
"avg": 251,
|
||||
"max": 326,
|
||||
"avg": 173,
|
||||
"max": 197,
|
||||
"days": [
|
||||
{
|
||||
"key": "2026-10-14",
|
||||
"avg": 251
|
||||
"avg": 173
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"ur-795497022": {
|
||||
"status": "up",
|
||||
"latency": 811,
|
||||
"latency": 619,
|
||||
"code": null,
|
||||
"detail": "UptimeRobot:运行正常",
|
||||
"lastCheck": "2026-10-14T02:45:00.000Z",
|
||||
"lastCheck": "2026-10-14T04:32:00.000Z",
|
||||
"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",
|
||||
"ok": true,
|
||||
@@ -445,28 +435,38 @@
|
||||
"ms": 676
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T01:31:00.000Z",
|
||||
"t": "2026-10-14T01:00:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 802
|
||||
"ms": 755
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T02:08:00.000Z",
|
||||
"t": "2026-10-14T01:40:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 634
|
||||
"ms": 658
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T02:45:00.000Z",
|
||||
"t": "2026-10-14T02:20:00.000Z",
|
||||
"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": {
|
||||
"2026-10-14": {
|
||||
"total": 7,
|
||||
"up": 7,
|
||||
"latSum": 5767,
|
||||
"latCount": 7,
|
||||
"latMax": 845
|
||||
"total": 4,
|
||||
"up": 4,
|
||||
"latSum": 2484,
|
||||
"latCount": 4,
|
||||
"latMax": 627
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
@@ -491,33 +491,23 @@
|
||||
"90": 5432
|
||||
},
|
||||
"lat": {
|
||||
"avg": 824,
|
||||
"max": 845,
|
||||
"avg": 621,
|
||||
"max": 627,
|
||||
"days": [
|
||||
{
|
||||
"key": "2026-10-14",
|
||||
"avg": 824
|
||||
"avg": 621
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"ur-795556828": {
|
||||
"status": "up",
|
||||
"latency": 448,
|
||||
"latency": 335,
|
||||
"code": null,
|
||||
"detail": "UptimeRobot:运行正常",
|
||||
"lastCheck": "2026-10-14T02:41:00.000Z",
|
||||
"lastCheck": "2026-10-14T04:28:00.000Z",
|
||||
"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",
|
||||
"ok": true,
|
||||
@@ -554,143 +544,158 @@
|
||||
"ms": 256
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T09:55:00.000Z",
|
||||
"t": "2026-10-13T09:50:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 455
|
||||
"ms": 500
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T10:35:00.000Z",
|
||||
"t": "2026-10-13T10:30:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 372
|
||||
"ms": 337
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T11:15:00.000Z",
|
||||
"t": "2026-10-13T11:10:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 240
|
||||
"ms": 197
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T11:55:00.000Z",
|
||||
"t": "2026-10-13T11:50:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 261
|
||||
"ms": 344
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T12:35:00.000Z",
|
||||
"t": "2026-10-13T12:30:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 335
|
||||
"ms": 341
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T13:15:00.000Z",
|
||||
"t": "2026-10-13T13:10:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 413
|
||||
"ms": 347
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T13:55:00.000Z",
|
||||
"t": "2026-10-13T13:50:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 283
|
||||
"ms": 220
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T14:35: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",
|
||||
"t": "2026-10-13T14:30:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 338
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T20:00:00.000Z",
|
||||
"t": "2026-10-13T15:10:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 363
|
||||
"ms": 510
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T20:40:00.000Z",
|
||||
"t": "2026-10-13T15:50:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 499
|
||||
"ms": 230
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T21:20:00.000Z",
|
||||
"t": "2026-10-13T16:30:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 372
|
||||
"ms": 401
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T22:00:00.000Z",
|
||||
"t": "2026-10-13T17:10:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 194
|
||||
"ms": 302
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T22:40:00.000Z",
|
||||
"t": "2026-10-13T17:50:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 225
|
||||
"ms": 495
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T23:20:00.000Z",
|
||||
"t": "2026-10-13T18:30:00.000Z",
|
||||
"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,
|
||||
"ms": 215
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T00:40:00.000Z",
|
||||
"t": "2026-10-13T23:55:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 298
|
||||
"ms": 240
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T01:20:00.000Z",
|
||||
"t": "2026-10-14T00:35:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 450
|
||||
"ms": 329
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T02:05:00.000Z",
|
||||
"t": "2026-10-14T01:15:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 229
|
||||
"ms": 387
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T02:41:00.000Z",
|
||||
"t": "2026-10-14T01:55:00.000Z",
|
||||
"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": {
|
||||
"2026-10-14": {
|
||||
"total": 7,
|
||||
"up": 7,
|
||||
"latSum": 2113,
|
||||
"latCount": 7,
|
||||
"latMax": 448
|
||||
"total": 4,
|
||||
"up": 4,
|
||||
"latSum": 1352,
|
||||
"latCount": 4,
|
||||
"latMax": 339
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
@@ -715,33 +720,23 @@
|
||||
"90": 0
|
||||
},
|
||||
"lat": {
|
||||
"avg": 302,
|
||||
"max": 448,
|
||||
"avg": 338,
|
||||
"max": 339,
|
||||
"days": [
|
||||
{
|
||||
"key": "2026-10-14",
|
||||
"avg": 302
|
||||
"avg": 338
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"ur-792059414": {
|
||||
"status": "up",
|
||||
"latency": 1795,
|
||||
"latency": 2408,
|
||||
"code": null,
|
||||
"detail": "UptimeRobot:运行正常",
|
||||
"lastCheck": "2026-10-14T02:40:00.000Z",
|
||||
"lastCheck": "2026-10-14T04:27:00.000Z",
|
||||
"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",
|
||||
"ok": true,
|
||||
@@ -868,38 +863,48 @@
|
||||
"ms": 2418
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T00:00:00.000Z",
|
||||
"t": "2026-10-14T00:05:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 1947
|
||||
"ms": 2098
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T00:40:00.000Z",
|
||||
"t": "2026-10-14T00:50:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 2257
|
||||
"ms": 1921
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T01:20:00.000Z",
|
||||
"t": "2026-10-14T01:35:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 2605
|
||||
"ms": 3110
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T02:01:00.000Z",
|
||||
"t": "2026-10-14T02:20:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 2591
|
||||
"ms": 1719
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T02:40:00.000Z",
|
||||
"t": "2026-10-14T03:05:00.000Z",
|
||||
"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": {
|
||||
"2026-10-14": {
|
||||
"total": 7,
|
||||
"up": 7,
|
||||
"latSum": 12076,
|
||||
"latCount": 7,
|
||||
"latMax": 1795
|
||||
"total": 4,
|
||||
"up": 4,
|
||||
"latSum": 10418,
|
||||
"latCount": 4,
|
||||
"latMax": 2670
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
@@ -924,206 +929,206 @@
|
||||
"90": 7593
|
||||
},
|
||||
"lat": {
|
||||
"avg": 1725,
|
||||
"max": 1795,
|
||||
"avg": 2605,
|
||||
"max": 2670,
|
||||
"days": [
|
||||
{
|
||||
"key": "2026-10-14",
|
||||
"avg": 1725
|
||||
"avg": 2605
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"ur-798788113": {
|
||||
"status": "up",
|
||||
"latency": 433,
|
||||
"latency": 312,
|
||||
"code": null,
|
||||
"detail": "UptimeRobot:运行正常",
|
||||
"lastCheck": "2026-10-14T02:42:00.000Z",
|
||||
"lastCheck": "2026-10-14T04:29:00.000Z",
|
||||
"history": [
|
||||
{
|
||||
"t": "2026-10-13T04:00: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",
|
||||
"t": "2026-10-13T05:30:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 449
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T08:00:00.000Z",
|
||||
"t": "2026-10-13T06:10:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 279
|
||||
"ms": 386
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T08:40:00.000Z",
|
||||
"t": "2026-10-13T06:50:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 407
|
||||
"ms": 311
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T09:20:00.000Z",
|
||||
"t": "2026-10-13T07:30:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 317
|
||||
"ms": 384
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T10:00:00.000Z",
|
||||
"t": "2026-10-13T08:10:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 627
|
||||
"ms": 390
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T10:40:00.000Z",
|
||||
"t": "2026-10-13T08:50:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 505
|
||||
"ms": 461
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T11:20:00.000Z",
|
||||
"t": "2026-10-13T09:30:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 424
|
||||
"ms": 545
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T12:00:00.000Z",
|
||||
"t": "2026-10-13T10:10:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 1135
|
||||
"ms": 456
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T12:40:00.000Z",
|
||||
"t": "2026-10-13T10:50:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 462
|
||||
"ms": 394
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T13:20:00.000Z",
|
||||
"t": "2026-10-13T11:30:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 436
|
||||
"ms": 398
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T14:00:00.000Z",
|
||||
"t": "2026-10-13T12:10:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 521
|
||||
"ms": 588
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T14:40:00.000Z",
|
||||
"t": "2026-10-13T12:50:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 480
|
||||
"ms": 430
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T15:20:00.000Z",
|
||||
"t": "2026-10-13T13:30:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 462
|
||||
"ms": 502
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T16:00:00.000Z",
|
||||
"t": "2026-10-13T14:15:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 331
|
||||
"ms": 355
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T16:40:00.000Z",
|
||||
"t": "2026-10-13T14:55:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 373
|
||||
"ms": 358
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T17:20:00.000Z",
|
||||
"t": "2026-10-13T15:35:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 429
|
||||
"ms": 385
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T18:00:00.000Z",
|
||||
"t": "2026-10-13T16:15:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 558
|
||||
"ms": 342
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T18:40:00.000Z",
|
||||
"t": "2026-10-13T16:55:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 370
|
||||
"ms": 428
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T19:20:00.000Z",
|
||||
"t": "2026-10-13T17:35:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 1519
|
||||
"ms": 508
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T20:00:00.000Z",
|
||||
"t": "2026-10-13T18:15:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 483
|
||||
"ms": 602
|
||||
},
|
||||
{
|
||||
"t": "2026-10-13T20:40: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",
|
||||
"t": "2026-10-13T18:55:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 363
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T01:20:00.000Z",
|
||||
"t": "2026-10-13T19:40:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 329
|
||||
"ms": 1380
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T02:06:00.000Z",
|
||||
"t": "2026-10-13T20:20:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 234
|
||||
"ms": 512
|
||||
},
|
||||
{
|
||||
"t": "2026-10-14T02:42:00.000Z",
|
||||
"t": "2026-10-13T21:00:00.000Z",
|
||||
"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": {
|
||||
"2026-10-14": {
|
||||
"total": 7,
|
||||
"up": 7,
|
||||
"latSum": 2680,
|
||||
"latCount": 7,
|
||||
"latMax": 435
|
||||
"total": 4,
|
||||
"up": 4,
|
||||
"latSum": 1245,
|
||||
"latCount": 4,
|
||||
"latMax": 323
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
@@ -1148,12 +1153,12 @@
|
||||
"90": 0
|
||||
},
|
||||
"lat": {
|
||||
"avg": 383,
|
||||
"max": 435,
|
||||
"avg": 311,
|
||||
"max": 323,
|
||||
"days": [
|
||||
{
|
||||
"key": "2026-10-14",
|
||||
"avg": 383
|
||||
"avg": 311
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1165,11 +1170,6 @@
|
||||
"detail": "UptimeRobot:403 Forbidden",
|
||||
"lastCheck": "2026-05-07T07:54:09.000Z",
|
||||
"history": [
|
||||
{
|
||||
"t": "2026-10-13T19:25:00.000Z",
|
||||
"ok": true,
|
||||
"ms": 185
|
||||
},
|
||||
{
|
||||
"t": "2026-05-07T07:54:09.000Z",
|
||||
"ok": false,
|
||||
@@ -1178,7 +1178,7 @@
|
||||
],
|
||||
"daily": {
|
||||
"2026-10-14": {
|
||||
"total": 7,
|
||||
"total": 4,
|
||||
"up": 0
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -95,10 +95,23 @@ async function main() {
|
||||
page: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
wrap: (() => { const w = document.querySelector('.table-wrap'); return w ? w.scrollWidth - w.clientWidth : 0; })(),
|
||||
grid: (() => { const g = document.querySelector('.grid'); return g ? g.scrollWidth - g.clientWidth : 0; })(),
|
||||
// 表格视图额外要求绝对等高:40 个目标、6 个分组、名称与 URL 长短悬殊,
|
||||
// 行高仍须只有一个取值(硬要求:每行绝对等高)
|
||||
rowHs: document.body.dataset.view === 'table'
|
||||
? [...new Set([...document.querySelectorAll('table.monitor-table tbody tr:not(.group-row)')]
|
||||
.map((tr) => Math.round(tr.getBoundingClientRect().height)))]
|
||||
: null,
|
||||
cardHs: document.body.dataset.view === 'cards'
|
||||
? [...new Set([...document.querySelectorAll('.card')].map((c) => Math.round(c.getBoundingClientRect().height)))]
|
||||
: null,
|
||||
}));
|
||||
const bad = Math.max(r.page, r.wrap, r.grid);
|
||||
if (!worst || bad > worst.bad) worst = { bad, view, width, r };
|
||||
check(`${String(width).padStart(4)}px ${view} 无横向溢出`, bad <= 0, `页面 ${r.page} / 表格 ${r.wrap} / 网格 ${r.grid}`);
|
||||
const extra = view === 'table'
|
||||
? (r.rowHs?.length === 1 ? `行高 ${r.rowHs[0]}px` : `行高 ${(r.rowHs ?? []).join('/')}`)
|
||||
: (r.cardHs?.length === 1 ? `卡片高 ${r.cardHs[0]}px` : `卡片高 ${(r.cardHs ?? []).join('/')}`);
|
||||
check(`${String(width).padStart(4)}px ${view} 无横向溢出且等高`, bad <= 0 && ((view === 'table' ? r.rowHs : r.cardHs) ?? []).length === 1,
|
||||
`页面 ${r.page} / 表格 ${r.wrap} / 网格 ${r.grid},${extra}`);
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,13 +300,13 @@ async function main() {
|
||||
const dt = await dp.evaluate(() => document.querySelector('tr[data-status] .t-uptime')?.title ?? '');
|
||||
check('表格可用率 tooltip 含 90 天日均延迟', /90 天日均延迟 \d+ ms/.test(dt), dt.slice(0, 60));
|
||||
|
||||
// 行高一致性要在「内容长度相当」的前提下检查:先把配置里的名称统一成等长短名
|
||||
// (注意:名称来自 monitors.json,不是 status.json),再单独注入超长内容验证不截断
|
||||
// ---- 行高一致性:先在「内容等长」下取基准 ----
|
||||
// 注意:名称来自 monitors.json,不是 status.json(改错文件会假通过)
|
||||
const statusPath = path.join(tmpDir, 'status.json');
|
||||
const snap = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
|
||||
const cfgPath2 = path.join(tmpDir, 'monitors.json');
|
||||
const cfgJson = JSON.parse(fs.readFileSync(cfgPath2, 'utf8'));
|
||||
let ids = Object.keys(snap.monitors);
|
||||
const ids = Object.keys(snap.monitors);
|
||||
cfgJson.monitors.forEach((m, i) => { m.name = `目标 ${i + 1}`; });
|
||||
fs.writeFileSync(cfgPath2, `${JSON.stringify(cfgJson, null, 2)}\n`);
|
||||
await dp.goto('http://localhost:8894/?source=static&view=table', { waitUntil: 'domcontentloaded' });
|
||||
@@ -317,27 +317,39 @@ async function main() {
|
||||
return { hs, unique: [...new Set(hs)] };
|
||||
});
|
||||
check('内容等长时表格各行等高(可用率条不再撑高行)', heights.unique.length === 1, `行高 ${heights.unique.join('/')}(共 ${heights.hs.length} 行)`);
|
||||
const baseRowH = heights.unique[0] ?? 0;
|
||||
const groupHs = await dp.evaluate(() => [...new Set([...document.querySelectorAll('table.monitor-table tbody tr.group-row')].map((tr) => Math.round(tr.getBoundingClientRect().height)))]);
|
||||
check('分组标题行自身等高且不与数据行混淆', groupHs.length === 1 && groupHs[0] !== baseRowH, `分组行高 ${groupHs.join('/')},数据行 ${baseRowH}px`);
|
||||
|
||||
// ---- 极端内容:表格不得把数据截断,同时不得出现横向滚动 ----
|
||||
// ---- 极端内容:行高仍须完全一致,且不得出现横向滚动 ----
|
||||
// 注入超长名称与超长地址(外加一条超长事件详情),要求行高与基准逐像素相同
|
||||
let injected = 0;
|
||||
for (const id of ids.slice(0, 2)) {
|
||||
const m = cfgJson.monitors.find((x) => x.id === id);
|
||||
if (m) { m.name = `这是一个相当长的监控目标名称用于验证表格完整显示-${id}`; injected++; }
|
||||
}
|
||||
cfgJson.monitors.forEach((m, i) => {
|
||||
if (i % 3 === 0) { m.name = `这是一个相当长的监控目标名称用于验证绝对等高-${'X'.repeat(40)}-${m.id}`; injected++; }
|
||||
if (i % 3 === 1) { m.url = `https://very-long-subdomain.example.com/${'segment/'.repeat(12)}?q=${'y'.repeat(30)}`; injected++; }
|
||||
});
|
||||
fs.writeFileSync(cfgPath2, `${JSON.stringify(cfgJson, null, 2)}\n`);
|
||||
// 注入事件,验证事件区块的时间线样式与折叠
|
||||
// 注入事件,验证事件区块的时间线样式、折叠与等高
|
||||
snap.events = Array.from({ length: 12 }, (_, i) => ({
|
||||
t: new Date(Date.now() - i * 60000).toISOString(),
|
||||
id: ids[0],
|
||||
name: snap.monitors[ids[0]].name,
|
||||
name: i % 2 ? `事件目标-${'长'.repeat(50)}` : (snap.monitors[ids[0]]?.name ?? 'x'),
|
||||
type: i % 2 ? 'down' : 'up',
|
||||
detail: i % 2 ? 'ETIMEDOUT' : '已恢复',
|
||||
detail: i % 2 ? `connect ETIMEDOUT ${'203.0.113.'.repeat(6)}` : '已恢复',
|
||||
maintenance: false,
|
||||
}));
|
||||
fs.writeFileSync(statusPath, JSON.stringify(snap));
|
||||
await dp.goto('http://localhost:8894/?source=static&view=table', { waitUntil: 'domcontentloaded' });
|
||||
await dp.waitForSelector('table.monitor-table tbody tr[data-status]', { timeout: 20000 });
|
||||
await dp.waitForTimeout(900);
|
||||
// 极端内容下的行高必须与等长内容下逐像素相同
|
||||
const eqLong = await dp.evaluate(() => {
|
||||
const hs = [...document.querySelectorAll('table.monitor-table tbody tr[data-status]')].map((tr) => Math.round(tr.getBoundingClientRect().height));
|
||||
const ev = [...document.querySelectorAll('.events-table tbody tr')].map((tr) => Math.round(tr.getBoundingClientRect().height));
|
||||
return { hs, hsU: [...new Set(hs)], ev, evU: [...new Set(ev)] };
|
||||
});
|
||||
check('极端内容下表格各行仍绝对等高(与等长内容逐像素相同)', eqLong.hsU.length === 1 && eqLong.hsU[0] === baseRowH, `行高 ${eqLong.hsU.join('/')},基准 ${baseRowH}px(注入 ${injected} 条长内容,共 ${eqLong.hs.length} 行)`);
|
||||
check('极端内容下事件表各行等高且与监控表格一致', eqLong.evU.length === 1 && eqLong.evU[0] === baseRowH, `事件行高 ${eqLong.evU.join('/')}`);
|
||||
const clip = await dp.evaluate(() => {
|
||||
let clipped = 0;
|
||||
let worst = 0;
|
||||
@@ -350,8 +362,28 @@ async function main() {
|
||||
const wrap = document.querySelector('.table-wrap');
|
||||
return { clipped, worst, pageOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, tableOverflow: wrap ? wrap.scrollWidth - wrap.clientWidth : 0 };
|
||||
});
|
||||
check(`长名称/长地址不再被截断(注入 ${injected} 个)`, clip.clipped === 0, `截断单元格 ${clip.clipped} 个,最大 ${clip.worst}px`);
|
||||
// 横向方向:单元格内容一律不得溢出(限高不换行时最容易被长 URL 顶破)
|
||||
check(`表格单元格无横向溢出(注入 ${injected} 条长内容)`, clip.clipped === 0, `溢出单元格 ${clip.clipped} 个,最大 ${clip.worst}px`);
|
||||
check('表格仍无横向滚动', clip.pageOverflow <= 0 && clip.tableOverflow <= 0, `页面 ${clip.pageOverflow} / 表格 ${clip.tableOverflow}`);
|
||||
// 纵向方向:长内容改为「限两行 + title 悬停看全文」,完整值仍必须可取
|
||||
// 注意 display 的计算值不可作为判据:Chrome 实现了标准 line-clamp 后会把
|
||||
// display 从 -webkit-box 规范化为 flow-root,这里改查 -webkit-line-clamp 计算值
|
||||
const clamp = await dp.evaluate(() => {
|
||||
const els = [...document.querySelectorAll('.monitor-table .clamp2, .events-table .clamp2')];
|
||||
const clamped = els.filter((e) => e.scrollHeight - e.clientHeight > 1);
|
||||
const noTitle = [...document.querySelectorAll('.t-name, .t-target, .ev-name, .ev-detail')].filter((td) => !td.title);
|
||||
const cs = els[0] ? getComputedStyle(els[0]) : null;
|
||||
return {
|
||||
total: els.length,
|
||||
clamped: clamped.length,
|
||||
noTitle: noTitle.length,
|
||||
overflow: cs?.overflow ?? '',
|
||||
clamp: cs?.webkitLineClamp ?? cs?.lineClamp ?? '',
|
||||
h: els[0] ? Math.round(els[0].getBoundingClientRect().height) : 0,
|
||||
};
|
||||
});
|
||||
check('长文本限两行显示且 overflow:hidden(等高的前提)', clamp.clamped > 0 && clamp.overflow === 'hidden' && clamp.clamp === '2', `限行 ${clamp.clamped}/${clamp.total},-webkit-line-clamp=${clamp.clamp},元素高 ${clamp.h}px`);
|
||||
check('被截断的单元格仍带 title(悬停可看完整值)', clamp.noTitle === 0, `缺 title 的单元格 ${clamp.noTitle} 个`);
|
||||
const evr = await dp.evaluate(() => {
|
||||
const table = document.querySelector('.events-table');
|
||||
const evRow = table?.querySelector('tbody tr');
|
||||
|
||||