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:
tamakyi
2026-10-14 16:19:31 +08:00
parent f345626840
commit 6b0a23c38e
16 changed files with 835 additions and 575 deletions
+29 -8
View File
@@ -1,4 +1,4 @@
const VERSION = '0.12.1';
const VERSION = '0.12.2';
const HB_LIMIT = 48;
const UPTIME_DAYS = 90;
const BROWSER_CONCURRENCY = 6;
@@ -28,6 +28,7 @@ const $ = (id) => document.getElementById(id);
const SOURCE_LABEL = {
api: '边缘 API(实时)',
remote: '远程探针(跨域)',
static: '静态快照',
browser: '浏览器直连',
};
@@ -36,7 +37,10 @@ const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;
const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
async function fetchJson(url, timeoutMs = 8000) {
const res = await fetch(url, { cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) });
// 相对路径 + 存在远程数据源时,自动改写到该地址(跨域读取由对方开放 CORS)
const base = remoteBase();
const full = base && url.startsWith('api/') ? `${base}/${url}` : url;
const res = await fetch(full, { cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) });
if (!res.ok) {
const err = new Error(`HTTP ${res.status}`);
err.status = res.status;
@@ -83,9 +87,26 @@ function setSource(s) {
$('source-badge').textContent = `数据源:${SOURCE_LABEL[s] ?? s}`;
}
// 跨域数据源:settings.dataUrl 指向另一台机器上的探针(通常是本机,但页面部署在别处)。
// 例:"https://monitor.example.com" —— 前端会从 https://monitor.example.com/api/status 取数据。
// 该地址必须与本页同为 HTTPS,否则浏览器会以「混合内容」为由拦截(与 CORS 无关)。
function remoteBase() {
const raw = String(state.config?.settings?.dataUrl ?? '').trim();
if (!raw) return '';
try {
const u = new URL(raw);
return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin + u.pathname.replace(/\/+$/, '') : '';
} catch {
return '';
}
}
async function resolveSource() {
const forced = new URLSearchParams(location.search).get('source');
const mode = forced || state.config.settings?.dataSource || 'auto';
// 配置了远程数据源时,「api」与「auto」都优先尝试远程地址
const remote = remoteBase();
const apiUrl = remote ? `${remote}/api/status` : 'api/status';
if (mode === 'browser') return setSource('browser');
if (mode === 'static') {
try {
@@ -97,17 +118,17 @@ async function resolveSource() {
}
if (mode === 'api') {
try {
await fetchJson('api/status', 5000);
return setSource('api');
await fetchJson(apiUrl, 6000);
return setSource(remote ? 'remote' : 'api');
} catch {
return setSource('browser');
}
}
// auto:优先边缘 API,其次静态快照,最后浏览器直连
// auto:优先远程/边缘 API,其次静态快照,最后浏览器直连
try {
await fetchJson('api/status', 3000);
return setSource('api');
} catch { /* 无边缘 API */ }
await fetchJson(apiUrl, 4000);
return setSource(remote ? 'remote' : 'api');
} catch { /* 无边缘/远程 API */ }
try {
await fetchJson('status.json', 3000);
return setSource('static');