/** * 管理端 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, adminUrl: s.adminUrl ?? '', dataUrl: s.dataUrl ?? '', corsOrigins: Array.isArray(s.corsOrigins) ? s.corsOrigins : [], }, 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/admin-url' && req.method === 'POST') { const parsed = await readJsonBody(req, res); if (!parsed.ok) return; const body = parsed.value; const raw = String(body.adminUrl ?? '').trim(); if (raw) { // 只收 http/https:面板会把它渲染成 ,javascript: 之类必须挡住 let u; try { u = new URL(raw); } catch { return sendJson(res, { error: 'invalid-url', hint: '不是合法的绝对 URL' }, { status: 400 }); } if (u.protocol !== 'http:' && u.protocol !== 'https:') { return sendJson(res, { error: 'invalid-url', hint: '只支持 http/https' }, { status: 400 }); } } savePrivateConfig(localConfigPath, (local) => { local.settings = { ...(local.settings ?? {}), adminUrl: raw }; return local; }); reloadConfig(); scheduleWrite(runtime); console.log(`[admin] 面板管理入口链接${raw ? `已设置为 ${raw}` : '已清空'}(来自 ${ip})`); return sendJson(res, { ok: true, adminUrl: raw }); } if (p === '/api/admin/remote' && req.method === 'POST') { const parsed = await readJsonBody(req, res); if (!parsed.ok) return; const body = parsed.value; // 跨域数据源:允许省略协议(默认 https),省得使用者每次都写全 const rawUrl = String(body.dataUrl ?? '').trim(); let dataUrl = ''; if (rawUrl) { let u; try { u = new URL(/^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`); } catch { return sendJson(res, { error: 'invalid-url', hint: '不是合法的地址' }, { status: 400 }); } if (u.protocol !== 'http:' && u.protocol !== 'https:') { return sendJson(res, { error: 'invalid-url', hint: '只支持 http/https' }, { status: 400 }); } dataUrl = u.origin + u.pathname.replace(/\/+$/, ''); } // CORS 白名单:只收完整的 origin,且拒绝 '*' const origins = Array.isArray(body.corsOrigins) ? body.corsOrigins.map((s) => String(s).trim()).filter(Boolean) : String(body.corsOrigins ?? '').split(',').map((s) => s.trim()).filter(Boolean); const bad = origins.filter((o) => o === '*' || !/^https?:\/\/[^\s/]+(:\d+)?$/.test(o)); if (bad.length) { return sendJson(res, { error: 'invalid-origin', hint: `这些写法不合法(必须是完整来源,且不允许 *):${bad.join(', ')}` }, { status: 400 }); } savePrivateConfig(localConfigPath, (local) => { local.settings = { ...(local.settings ?? {}), dataUrl, corsOrigins: origins }; return local; }); reloadConfig(); scheduleWrite(runtime); console.log(`[admin] 跨域设置已保存:dataUrl=${dataUrl || '(空)'},corsOrigins=${origins.length ? origins.join(' ') : '(默认)'}`); return sendJson(res, { ok: true, dataUrl, corsOrigins: origins }); } 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 }); } }