Files
tamakyi 6b0a23c38e feat: v0.12.2 —— 跨域只读支持(面板可部署在别处)
用户诉求:管理端与前端完全分离,本地探针只跑 /admin,前端部署到 GitHub Pages
并硬编码管理地址,探测仍在本地常驻。用户自己搭隧道给本地套 HTTPS。

硬限制(务必先看):GitHub Pages 强制 HTTPS,而浏览器「混合内容」策略禁止
HTTPS 页面 fetch HTTP 资源 —— 这与 CORS 无关,改设置也没用。
因此 settings.dataUrl 必须与面板页面同为 HTTPS。

服务端
- 只读接口(/api/status、/api/monitors、/api/events、/api/v2/getMonitors、
  /api/refresh)按 settings.corsOrigins 白名单回显 Access-Control-Allow-Origin
- /api/admin/* 一律不放行跨域,预检直接 403 —— 外部页面既读不到也改不了
- 绝不使用 '*':白名单里写 '*' 不放行任何来源

前端
- settings.dataUrl 存在时,所有 api/ 相对路径自动改写到该地址
- 数据源标记为「远程探针(跨域)」,页脚可区分
- 管理界面新增两项配置:跨域数据源地址、允许跨域的页面地址(改完立即生效)

关键设计:origin 匹配必须考虑端口
origin 是「协议 + 主机 + 端口」的完整组合。最初白名单存 http://localhost(无端口),
导致本地前端跑在 localhost:5173 时被拒 —— 浏览器实测 Failed to fetch 而 curl 能过。
修法:回环主机(localhost / 127.0.0.1)忽略端口放行,其余要求完整 origin 精确匹配。

测试:verify-security 25→40 项,新增 [D3] CORS 白名单 14 条 + [D4] '*' 写法 1 条。
全项目 8 套 334 项通过。

附记一个测试上的教训:跨域 fetch 时 JS 读不到 Access-Control-Allow-Origin
(CORS 规范规定只有同源能读),所以断言「是否放行」要看 fetch 有没有拿到数据,
不能看响应头 —— 我一开始用 headers.get() 判断,拿到 null 误以为失败。
2026-10-14 16:19:31 +08:00

396 lines
17 KiB
JavaScript
Raw Permalink 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,
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:面板会把它渲染成 <a href>,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 });
}
}