feat: v0.12.0 —— 表格每行绝对等高 + 管理 WebUI(2FA 绑定才能进入)

① 每行绝对等高
- 行高与内容长度彻底解耦:新增 --row-h: 72px 作为唯一基准
- 名称列改为「文本区限两行 + 徽标区固定单行」两段式;文本区用 .clamp2
- 关键点:必须用 height 而非 max-height —— 后者会让短内容缩成一行、
  长内容撑到两行,反而产生 66px/71px 两种行高(实测踩过)
- 分组标题行用 :not(.group-row) 排除在数据行规则之外
- 事件表与监控表格共用同一个 --row-h,两张表视觉上也严格对齐
- 完整值仍可通过悬停 title 查看(已加断言强制所有截断单元格带 title)
- 卡片视图同样等高(13 视口 × 250px 全一致)

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

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

测试:新增 scripts/verify-admin.cjs(102 项),全项目共 7 套 274 项全部通过
This commit is contained in:
tamakyi
2026-10-14 12:37:07 +08:00
parent a3a683ff0e
commit 752fad412b
20 changed files with 2631 additions and 339 deletions
+195
View File
@@ -0,0 +1,195 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>管理后台 · Shiroko Monitor</title>
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<link rel="stylesheet" href="/assets/style.css">
<link rel="stylesheet" href="/assets/theme-shiroko.css">
<link rel="stylesheet" href="/assets/admin.css">
</head>
<body class="admin-body">
<header class="admin-topbar">
<div class="brand">
<span class="logo" aria-hidden="true"></span>
<div class="brand-text">
<h1>管理后台</h1>
<p>需绑定并通过 2FA 验证后才能进入</p>
</div>
</div>
<div class="admin-topbar-right">
<span class="admin-ver" id="admin-ver">–</span>
<a class="admin-link" href="/">← 返回面板</a>
<button type="button" class="admin-btn admin-btn-ghost" id="logout-btn" hidden>退出登录</button>
</div>
</header>
<main class="admin-main">
<!-- 状态探测中 -->
<section class="admin-card" id="loading"><p class="admin-muted">正在检查 2FA 状态…</p></section>
<!-- ① 未绑定:先绑定 2FA -->
<section class="admin-card" id="bind-panel" hidden>
<h2>第一步:绑定 2FA</h2>
<p class="admin-muted">管理后台必须绑定两步验证后才能进入。请先在启动探针的环境里设置 <code>SHIROKO_ADMIN_SETUP_KEY</code>,然后在这里输入它以生成密钥。</p>
<div class="admin-step" id="step-key">
<label for="setup-key">绑定口令(SHIROKO_ADMIN_SETUP_KEY)</label>
<div class="admin-row">
<input type="password" id="setup-key" autocomplete="off" placeholder="环境变量里设置的那个值" inputmode="text">
<button type="button" class="admin-btn" id="setup-start">生成密钥</button>
</div>
<p class="admin-msg" id="setup-key-msg"></p>
</div>
<div class="admin-step" id="step-secret" hidden>
<h3>第二步:把密钥加入你的验证器 App</h3>
<ol class="admin-ol">
<li>用 Google Authenticator / Microsoft Authenticator / 1Password / Aegis 扫描二维码,或</li>
<li>手动输入下面的密钥(Base32):</li>
</ol>
<div class="qr-box"><canvas id="qr" width="220" height="220" aria-label="2FA 绑定二维码"></canvas></div>
<div class="secret-box">
<code id="secret-text">–</code>
<button type="button" class="admin-btn admin-btn-sm" id="copy-secret">复制密钥</button>
</div>
<details class="admin-details">
<summary>扫码不方便?复制 otpauth 链接</summary>
<div class="secret-box"><code id="uri-text">–</code><button type="button" class="admin-btn admin-btn-sm" id="copy-uri">复制链接</button></div>
</details>
<h3>第三步:输入 App 显示的 6 位验证码完成绑定</h3>
<div class="admin-row">
<input type="text" id="confirm-code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="6 位数字">
<button type="button" class="admin-btn" id="confirm-btn">完成绑定</button>
</div>
<p class="admin-msg" id="confirm-msg"></p>
</div>
<div class="admin-step" id="step-recovery" hidden>
<h3>第四步:保存恢复码(只显示这一次)</h3>
<p class="admin-muted">每个恢复码只能用一次,用于手机丢失、验证器清库时登录。请离线保存,不要只存在这台机器上。</p>
<div class="recovery-box" id="recovery-list"></div>
<div class="admin-row">
<button type="button" class="admin-btn admin-btn-sm" id="copy-recovery">复制全部恢复码</button>
<button type="button" class="admin-btn admin-btn-primary" id="recovery-done">我已保存</button>
</div>
</div>
</section>
<!-- ② 已绑定未登录:输 2FA 码登录 -->
<section class="admin-card admin-card-narrow" id="login-panel" hidden>
<h2>两步验证</h2>
<p class="admin-muted">输入验证器 App 的 6 位动态码,或使用一枚未用过的恢复码。</p>
<div class="admin-row">
<input type="text" id="login-code" inputmode="numeric" autocomplete="one-time-code" placeholder="验证码 / 恢复码" autofocus>
<button type="button" class="admin-btn admin-btn-primary" id="login-btn">进入</button>
</div>
<p class="admin-msg" id="login-msg"></p>
<details class="admin-details">
<summary>验证码设备丢了?</summary>
<p class="admin-muted">用一枚未使用的恢复码登录;或用 <code>SHIROKO_ADMIN_SETUP_KEY</code> 重置 2FA(重置后需重新绑定)。</p>
<div class="admin-row">
<input type="password" id="reset-key" placeholder="SHIROKO_ADMIN_SETUP_KEY" autocomplete="off">
<button type="button" class="admin-btn admin-btn-danger" id="reset-btn">重置 2FA</button>
</div>
</details>
</section>
<!-- ③ 已登录:管理面板 -->
<section id="console-panel" hidden>
<div class="admin-card" id="stat-card">
<h2>运行状态</h2>
<div class="admin-stats" id="admin-stats"></div>
<p class="admin-muted" id="updated-at">–</p>
</div>
<div class="admin-card">
<h2>口径与范围</h2>
<div class="admin-field">
<label for="probe-scope">探测范围</label>
<select id="probe-scope">
<option value="all">全部(自建 + UptimeRobot)</option>
<option value="config">仅自建配置</option>
<option value="uptimerobot">仅 UptimeRobot</option>
</select>
<p class="admin-hint">决定探针真正发请求的范围;改动立即生效并热重载配置。</p>
</div>
<div class="admin-field">
<label for="data-filter">展示口径</label>
<select id="data-filter">
<option value="both">全部</option>
<option value="config">仅自建配置</option>
<option value="uptimerobot">仅 UptimeRobot</option>
</select>
<p class="admin-hint">只影响面板展示哪些目标;与探测范围相互独立。</p>
</div>
<div class="admin-row">
<button type="button" class="admin-btn" id="probe-now">立即重新探测</button>
<button type="button" class="admin-btn" id="notify-test">发送通知自检</button>
</div>
<p class="admin-msg" id="action-msg"></p>
</div>
<div class="admin-card">
<h2>维护窗口</h2>
<p class="admin-muted">窗口内的目标不计入可用率、也不发故障告警。</p>
<div class="admin-field-row">
<div class="admin-field">
<label for="mw-from">开始</label>
<input type="datetime-local" id="mw-from">
</div>
<div class="admin-field">
<label for="mw-to">结束</label>
<input type="datetime-local" id="mw-to">
</div>
</div>
<div class="admin-field-row">
<div class="admin-field">
<label for="mw-id">仅限目标(留空 = 全部)</label>
<select id="mw-id"><option value="">全部目标</option></select>
</div>
<div class="admin-field">
<label for="mw-note">备注</label>
<input type="text" id="mw-note" maxlength="200" placeholder="例如:升级数据库">
</div>
</div>
<div class="admin-row">
<button type="button" class="admin-btn admin-btn-primary" id="mw-add">新增窗口</button>
</div>
<div class="mw-list" id="mw-list"></div>
</div>
<div class="admin-card">
<h2>监控目标</h2>
<p class="admin-muted">当前配置中的目标(探测范围为 uptimerobot 时,本地目标不会被真正探测)。</p>
<div class="table-scroll">
<table class="admin-table">
<thead><tr><th>名称</th><th>分组</th><th>类型</th><th>目标</th><th>间隔</th></tr></thead>
<tbody id="monitor-list"></tbody>
</table>
</div>
</div>
<div class="admin-card">
<h2>最近事件</h2>
<div class="table-scroll">
<table class="admin-table ev-table">
<thead><tr><th>时间</th><th>类型</th><th>目标</th><th>详情</th></tr></thead>
<tbody id="event-list"></tbody>
</table>
</div>
</div>
<div class="admin-card">
<h2>安全</h2>
<p class="admin-muted" id="twofa-summary">–</p>
<p class="admin-muted">会话最长 2 小时,30 分钟无操作自动失效;进程重启会清空所有会话。</p>
</div>
</section>
</main>
<script type="module" src="/assets/admin.js"></script>
</body>
</html>