用户反馈:「你不需要帮我处理 tunnel 这部分,也许我不是通过 tunnel 开放内网呢? 你只需要给我一个填写开放的管理端链接的地方。」 之前把「Cloudflare Tunnel」当成既定方案写进了配置与文档,这是替用户做了假设。 现在改为:项目只提供一个填地址的地方,怎么暴露完全由使用者决定。 - 删除 edge/cloudflare/tunnel.yml 与全部 tunnel 相关文档 - 新增 settings.adminUrl:管理界面里填一个地址,公开面板页脚出现「管理」按钮 指向它;留空则完全不显示(默认,避免在公网面板暴露管理面位置) - 只接受 http/https,javascript: 等协议被服务端拒绝(会被渲染成 <a href>) - 设置落在 local.monitors.json(私有层),只有自己的面板会出现该按钮 - 面板启动时额外拉 /api/monitors 合并 settings:原先只读静态 monitors.json (公开层),导致私有层的设置面板看不到;纯静态部署无该接口时沿用公开配置 顺带修掉一个既存漏洞(与本轮改动无关,是引入私有层叠加机制时埋下的): /api/monitors 无鉴权却回吐合并后的完整配置,包含私有层的 UptimeRobot API Key —— 只要配了 key,任何访客 curl /api/monitors 就能拿到。现在两侧(Node 与 Worker) 都对对外响应剥离 uptimeRobot.apiKey,保留 enabled 等非敏感字段供面板读取; 探针内部同步仍用完整配置,剥离只作用于 HTTP 出口。 测试:verify-worker 36→40 项(新增 [E] 配置接口密钥泄露 4 条)、 verify-security 20→25 项(新增 [D2] 密钥泄露 5 条)。全项目 8 套 314 项通过。
359 lines
16 KiB
JavaScript
359 lines
16 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,
|
||
adminUrl: s.adminUrl ?? '',
|
||
},
|
||
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/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 });
|
||
}
|
||
} |