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:
+38
-1
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user