Files
shiroko-monitor/prober/node/lib/admin-routes.js
T
tamakyi 752fad412b 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 项全部通过
2026-10-14 12:37:07 +08:00

334 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 管理端 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 });
}
}