diff --git a/README.md b/README.md index 851d725..ded10f6 100644 --- a/README.md +++ b/README.md @@ -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://: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= node scripts/verify.cjs # 热重载 / 数据一致性 / 刷新语义 / 面板(21 项) -NODE_PATH= node scripts/verify-views.cjs # 视图与数据源过滤(10 项) +NODE_PATH= node scripts/verify.cjs # 热重载 / 数据一致性 / 刷新语义 / 面板(17 项) +NODE_PATH= node scripts/verify-views.cjs # 视图与数据源过滤 / 对比度 / 等高 / 事件区块(47 项) +NODE_PATH= node scripts/verify-layout.cjs # 13 视口 × 2 视图零横向溢出 + 行高一致(26 项) +NODE_PATH= node scripts/verify-admin.cjs # 管理端 2FA 全流程(102 项:RFC 向量 / 绑定 / 会话 / 限流 / 接口 / 页面) node scripts/verify-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 }` 即可被面板直接渲染。 diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 3aaab2a..fb78fa9 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -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` diff --git a/docs/screenshot-desktop.png b/docs/screenshot-desktop.png index 597faba..86be7b3 100644 Binary files a/docs/screenshot-desktop.png and b/docs/screenshot-desktop.png differ diff --git a/docs/screenshot-mobile.png b/docs/screenshot-mobile.png index 045d771..db98130 100644 Binary files a/docs/screenshot-mobile.png and b/docs/screenshot-mobile.png differ diff --git a/docs/screenshot-view-cards.png b/docs/screenshot-view-cards.png index 2bd38d5..b4a808d 100644 Binary files a/docs/screenshot-view-cards.png and b/docs/screenshot-view-cards.png differ diff --git a/docs/screenshot-view-table.png b/docs/screenshot-view-table.png index e7de7bb..d18269c 100644 Binary files a/docs/screenshot-view-table.png and b/docs/screenshot-view-table.png differ diff --git a/prober/node/index.js b/prober/node/index.js index 767d8c1..87605eb 100644 --- a/prober/node/index.js +++ b/prober/node/index.js @@ -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') { diff --git a/prober/node/lib/admin-routes.js b/prober/node/lib/admin-routes.js new file mode 100644 index 0000000..53ac30f --- /dev/null +++ b/prober/node/lib/admin-routes.js @@ -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 }); + } +} \ No newline at end of file diff --git a/prober/node/lib/admin.js b/prober/node/lib/admin.js new file mode 100644 index 0000000..b06d377 --- /dev/null +++ b/prober/node/lib/admin.js @@ -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; +} \ No newline at end of file diff --git a/prober/node/lib/totp.js b/prober/node/lib/totp.js new file mode 100644 index 0000000..8107353 --- /dev/null +++ b/prober/node/lib/totp.js @@ -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'); +} \ No newline at end of file diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..1ec96bf --- /dev/null +++ b/public/admin.html @@ -0,0 +1,195 @@ + + + + + + + 管理后台 · Shiroko Monitor + + + + + + +
+
+ +
+

管理后台

+

需绑定并通过 2FA 验证后才能进入

+
+
+
+ – + ← 返回面板 + +
+
+ +
+ +

正在检查 2FA 状态…

+ + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/public/assets/admin.css b/public/assets/admin.css new file mode 100644 index 0000000..2d311bd --- /dev/null +++ b/public/assets/admin.css @@ -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; } +} \ No newline at end of file diff --git a/public/assets/admin.js b/public/assets/admin.js new file mode 100644 index 0000000..8c6077d --- /dev/null +++ b/public/assets/admin.js @@ -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 = [ + `
${s.total ?? '–'}
`, + `
${s.up ?? '–'}
`, + `
${s.down ?? '–'}
`, + `
${s.avgLatency != null ? `${s.avgLatency} ms` : '–'}
`, + `
${s.avgUptime90 != null ? `${s.avgUptime90}%` : '–'}
`, + ].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 ? `(同步异常:${esc(st.urError ?? '')})` : '(已启用)') : '(未启用)') + + (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 = '' + d.monitors.map((m) => ``).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 `
+ ${active ? '进行中' : '待生效'} + ${esc(String(w.from).replace('T', ' ').slice(0, 16))} → ${esc(String(w.to).replace('T', ' ').slice(0, 16))} + ${esc(scope)}${w.note ? ` · ${esc(w.note)}` : ''} + +
`; + }).join('') + : '

暂无维护窗口。

'; + 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) => ` + ${esc(m.name)} + ${esc(m.group)} + ${esc(m.type)} + ${esc(m.target)} + ${m.interval != null ? `${m.interval}s` : '–'} + `).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) => ` + ${esc(new Date(e.t).toLocaleString('zh-CN', { hour12: false }))} + ${esc(labels[e.type] ?? e.type)} + ${esc(e.name)} + ${esc(e.detail ?? '')} + `).join('') + : '暂无事件'; + + const tf = d.twofa ?? {}; + $('twofa-summary').innerHTML = `已绑定于 ${esc(tf.boundAt ?? '–')} · 剩余恢复码 ${tf.recoveryLeft ?? 0} 枚 · 当前会话 ${tf.sessions ?? 0} 个` + + (tf.recoveryLow ? ' · 恢复码不足 3 枚,请重新绑定以获取新的一批' : ''); +} + +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) => `${esc(c)}`).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(); \ No newline at end of file diff --git a/public/assets/app.js b/public/assets/app.js index 3bd5cf2..276ff65 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -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 ` ${escapeHtml(fmtRelative(e.t))} ${escapeHtml(label[e.type] ?? e.type)}${maint} - ${escapeHtml(e.name)} - ${escapeHtml(e.detail ?? '')} + ${escapeHtml(e.name)} + ${escapeHtml(e.detail ?? '')} `; }).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 ? '' : `${escapeHtml(targetText(m))}`; tr.innerHTML = + const targetCell = hide ? '' : `${escapeHtml(targetText(m))}`; + // 名称与徽标分行:名称限两行、徽标独占一行,两者高度都固定,行高不再随名称/地址长度变化 + const tags = `${typeLabel(m) ? `${escapeHtml(typeLabel(m))}` : ''}${certBadge(st)}${maintBadge(st)}`; + tr.innerHTML = `${s.label}` + - `${escapeHtml(m.name)}${escapeHtml(typeLabel(m))}${certBadge(st)}${maintBadge(st)}` + + `${escapeHtml(m.name)}${tags}` + targetCell + `${st?.latency != null ? `${st.latency} ms` : '–'}` + `${escapeHtml(resultText(st, m))}` + diff --git a/public/assets/style.css b/public/assets/style.css index abb12f3..fa0217b 100644 --- a/public/assets/style.css +++ b/public/assets/style.css @@ -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) { diff --git a/public/index.html b/public/index.html index aec97bb..5cc64a9 100644 --- a/public/index.html +++ b/public/index.html @@ -75,7 +75,7 @@ – – - shiroko-monitor v0.11.2 + shiroko-monitor v0.12.0 diff --git a/public/status.json b/public/status.json index c8529fd..8e54318 100644 --- a/public/status.json +++ b/public/status.json @@ -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 } }, diff --git a/scripts/verify-admin.cjs b/scripts/verify-admin.cjs new file mode 100644 index 0000000..b0e178d --- /dev/null +++ b/scripts/verify-admin.cjs @@ -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); +}); \ No newline at end of file diff --git a/scripts/verify-layout.cjs b/scripts/verify-layout.cjs index 3bae09f..4a525b4 100644 --- a/scripts/verify-layout.cjs +++ b/scripts/verify-layout.cjs @@ -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(); } } diff --git a/scripts/verify-views.cjs b/scripts/verify-views.cjs index 71fbcc1..0c0b029 100644 --- a/scripts/verify-views.cjs +++ b/scripts/verify-views.cjs @@ -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');