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 误以为失败。
This commit is contained in:
@@ -139,6 +139,8 @@ export function createAdminRouter({ auth, localConfigPath, reloadConfig, runtime
|
||||
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) => ({
|
||||
@@ -325,6 +327,41 @@ export function createAdminRouter({ auth, localConfigPath, reloadConfig, runtime
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user