① 每行绝对等高 - 行高与内容长度彻底解耦:新增 --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 项全部通过
334 lines
14 KiB
JavaScript
334 lines
14 KiB
JavaScript
/**
|
||
* 管理端 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 });
|
||
}
|
||
} |