diff --git a/README.md b/README.md index 20ef2f4..078ae34 100644 --- a/README.md +++ b/README.md @@ -522,6 +522,27 @@ node prober/node/index.js > ⚠️ 注意:打开后同一端口上的公开面板(`/` 与 `/api/status`)也会跟着对外可达。它们本身是只读的,但目标名称、地址与可用率会一并暴露。如果不希望这样,请在你自己那层(反向代理/隧道)按路径收窄。 +### 面板部署在别处(跨域只读) + +面板与探针可以放在两台机器上:面板放 GitHub Pages / Cloudflare(静态托管),探针放你家里或内网的机器上持续探测。两项配置: + +| 配置 | 作用 | +| --- | --- | +| `settings.dataUrl` | 面板去哪里取数据,例 `https://monitor.example.com`。留空表示与探针同源 | +| `settings.corsOrigins` | 允许跨域读取的**页面地址**,逗号分隔的完整 origin,如 `https://you.github.io` | + +两者都在管理界面里填(改完立即生效)。 + +**硬限制(务必先看)**:GitHub Pages 强制 HTTPS,而浏览器的**混合内容**策略禁止 HTTPS 页面 `fetch` HTTP 资源 —— 这与 CORS 无关,改设置也没用。**因此 `dataUrl` 必须与面板页面同为 HTTPS**(你自己搭隧道给本地探针套 HTTPS 即可)。 + +安全设计: + +- **绝不使用 `Access-Control-Allow-Origin: *`** —— 那等于让任意网站都能读走你的监控数据。默认只放行本机回环地址(`localhost` / `127.0.0.1`,**忽略端口**,因为本地前端常跑在 5173 这类端口) +- 白名单里的 origin 必须**精确匹配**,`https://you.github.io.evil.com` 这种前缀伪造会被拒 +- 回显具体 origin 并带 `Vary: Origin`,避免中间缓存串味 +- **管理端永不放行跨域**:`/admin` 与 `/api/admin/*` 不带任何 `Access-Control-*` 头,外部页面无法读取也无法写入 +- 跨域读到的配置同样经过密钥剥离 + ### 部署形态与可写能力 | 部署形态 | 面板可读 | 探测 | 通知/维护窗口/事件 | 管理端(2FA) | @@ -814,7 +835,7 @@ NODE_PATH= node scripts/verify-views.cjs # 视 NODE_PATH= node scripts/verify-layout.cjs # 13 视口 × 2 视图零横向溢出 + 行高一致(26 项) NODE_PATH= node scripts/verify-admin.cjs # 管理端 2FA 全流程(102 项:RFC 向量 / 绑定 / 会话 / 限流 / 接口 / 页面) node scripts/verify-worker.cjs # Cloudflare Worker(40 项:UR 映射 / 探测范围 / 失败降级 / 管理端拒绝 / 密钥泄露) -node scripts/verify-security.cjs # banner 探测 / 脱敏 / 私有分层 / 刷新限流 / 配置接口密钥泄露(25 项) +node scripts/verify-security.cjs # banner 探测 / 脱敏 / 私有分层 / 刷新限流 / 密钥泄露 / CORS 白名单(40 项) node scripts/verify-notify.cjs # 通知与证书(44 项:假 SMTP / 假 MeoW / 去抖 / 恢复 / 维护窗口 / 真实证书) UPTIMEROBOT_API_KEY=ur... node scripts/verify-uptimerobot.cjs # UR 映射/兼容接口/失败路径/冒烟(18 项) ``` diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 1074b2b..a2d5af7 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -180,6 +180,23 @@ v0.12.0 起,所有会改配置的操作都放在 `/admin`,**必须绑定并 `/api/monitors` 与 Worker 上的同名接口都**无鉴权**,因此对外响应一律剥离 `uptimeRobot.apiKey`(保留 `enabled` 等非敏感字段供面板读取)。探针内部同步 UptimeRobot 仍使用完整配置,剥离只作用于 HTTP 出口。`/api/status` 与静态 `monitors.json` 同样不含私有层内容。 +### 跨域只读白名单(CORS) + +面板部署在别处(GitHub Pages / Cloudflare)、探针在另一台机器时,需要让指定页面跨域读取状态与配置。配置项:`settings.dataUrl`(面板去哪里取数据)与 `settings.corsOrigins`(允许哪些页面读取)。 + +核心风险是「任意网站都能读走监控数据」,措施: + +| 风险 | 措施 | +| --- | --- | +| 开放成 `*` 导致任意网站可读 | **代码里不存在 `*` 分支**:白名单里写 `*` 不会放行任何来源(已加断言) | +| 本机回环被任意端口冒用 | 默认只放行 `localhost` / `127.0.0.1` 且**忽略端口**(本地前端常跑 5173);其余必须精确匹配完整 origin | +| 前缀伪造域名(`https://you.github.io.evil.com`) | 精确字符串匹配,不做子串判断(已加断言) | +| 协议降级(`https://localhost` 冒用 http 的默认项) | 协议必须一致才走回环豁免(已加断言) | +| 中间缓存串味(把 A 的响应给 B) | 回显具体 origin 并带 `Vary: Origin` | +| **外部页面读取或改写管理端** | `/admin` 与 `/api/admin/*` **一律不带 `Access-Control-*` 头**,预检直接 403(已加断言) | +| 外部页面借 CORS 触发有副作用的调用 | `/api/admin/*` 完全不放行;`/api/refresh` 另有 `refreshDecision` 的限流与 `refreshLocalOnly` 兜底 | +| 跨域读到密钥 | 跨域响应与同源响应走同一套剥离逻辑,`uptimeRobot.apiKey` 不会回吐(已加断言) | + ### 威胁模型与对应措施 | 威胁 | 措施 | diff --git a/docs/screenshot-desktop.png b/docs/screenshot-desktop.png index 86be7b3..fa341b1 100644 Binary files a/docs/screenshot-desktop.png and b/docs/screenshot-desktop.png differ diff --git a/docs/screenshot-mobile.png b/docs/screenshot-mobile.png index db98130..8138a33 100644 Binary files a/docs/screenshot-mobile.png and b/docs/screenshot-mobile.png differ diff --git a/docs/screenshot-view-cards.png b/docs/screenshot-view-cards.png index b4a808d..17ba3bb 100644 Binary files a/docs/screenshot-view-cards.png and b/docs/screenshot-view-cards.png differ diff --git a/docs/screenshot-view-table.png b/docs/screenshot-view-table.png index d18269c..b968171 100644 Binary files a/docs/screenshot-view-table.png and b/docs/screenshot-view-table.png differ diff --git a/edge/cloudflare/worker.js b/edge/cloudflare/worker.js index 5ed7ccd..8525dbb 100644 --- a/edge/cloudflare/worker.js +++ b/edge/cloudflare/worker.js @@ -16,7 +16,7 @@ import { connect } from 'cloudflare:sockets'; import { fetchUptimeRobotMonitors } from './uptimerobot.js'; -const VERSION = '0.12.1'; +const VERSION = '0.12.2'; const HISTORY_LIMIT = 48; const UPTIME_DAYS = 90; const USER_AGENT = 'shiroko-monitor/0.3 (lightweight uptime panel)'; diff --git a/prober/node/index.js b/prober/node/index.js index 6e59017..14e39a2 100644 --- a/prober/node/index.js +++ b/prober/node/index.js @@ -39,7 +39,7 @@ const CONCURRENCY = 8; const WRITE_DEBOUNCE_MS = 300; const DEFAULT_INTERVAL_SEC = 60; const PORT = Number(process.env.PORT || 8787); -const VERSION = '0.12.1'; +const VERSION = '0.12.2'; const UR_DEFAULT_POLL_SEC = 300; const UR_MIN_REFRESH_GAP_MS = 60000; @@ -875,6 +875,56 @@ function sendJson(res, data, { status = 200, headers = {} } = {}) { res.end(JSON.stringify(data)); } +/* ---------- 跨域只读(前端可部署在别处,数据源指向本机) ---------- */ + +// 允许跨域读取的前端来源。默认放行本机:本地开发时前端页面与探针常常跑在不同端口上 +// (例如 Vite 的 5173),而 origin 是「协议 + 主机 + 端口」的完整组合, +// 因此默认项按「忽略端口」匹配,主机相同即放行。 +// 部署在 GitHub Pages / Cloudflare 等其它域名时,在 settings.corsOrigins 里显式列出 +// 完整 origin(含协议与端口,如 https://you.github.io)。绝不使用 '*': +// 那等于让任意网站都能读走监控数据。 +function corsOrigins(config) { + const extra = Array.isArray(config.settings?.corsOrigins) + ? config.settings.corsOrigins.map((s) => String(s).trim()).filter(Boolean) + : []; + return { exact: new Set(extra), loopbackHosts: new Set(['127.0.0.1', 'localhost', '[::1]']) }; +} + +// 请求的 origin 是否在白名单内。主机是本机回环地址时忽略端口(本地开发常见形态), +// 其余情况要求完整 origin 精确匹配。 +function originAllowed(reqOrigin, config) { + let u; + try { u = new URL(reqOrigin); } catch { return false; } + const { exact, loopbackHosts } = corsOrigins(config); + if (exact.has(reqOrigin)) return true; + return u.protocol === 'http:' && loopbackHosts.has(u.hostname); +} + +// 返回跨域响应头;origin 不在白名单时返回空对象(请求本身照常处理,只是浏览器读不到响应) +function corsHeaders(req, config) { + const origin = req.headers.origin; + if (!origin || !originAllowed(origin, config)) return {}; + return { + 'access-control-allow-origin': origin, // 回显具体 origin,由白名单保证安全 + 'access-control-allow-methods': 'GET, POST, OPTIONS', + 'access-control-allow-headers': 'content-type', + 'access-control-max-age': '86400', + vary: 'Origin', // 缓存必须区分 Origin,否则会串味 + }; +} + +// 只读接口的预检响应。写接口(/api/refresh、/api/admin/*)不放行 OPTIONS, +// 因此外部页面无法用简单请求之外的方式触发有副作用的调用。 +function handlePreflight(req, res, config) { + const headers = corsHeaders(req, config); + if (!headers['access-control-allow-origin']) { + res.writeHead(403); + return res.end('origin not allowed'); + } + res.writeHead(204, headers); + res.end(); +} + /* ---------- /api/refresh 滥用防护 ---------- */ // 仅信任 socket 的对端地址,不解析 X-Forwarded-For(可被伪造);若前方挂了反向代理,请改用 allowRefresh:false @@ -985,15 +1035,22 @@ function createServer(runtime) { res.writeHead(200, { 'content-type': MIME['.html'], 'cache-control': 'no-store' }); return void fs.createReadStream(file).pipe(res); } - if (url.pathname === '/api/status') return sendJson(res, snapshot(runtime)); - if (url.pathname === '/api/monitors') return sendJson(res, publicConfig(config)); + // 预检:只对只读接口与 /api/refresh 放行(/api/admin/* 一律不放行,管理端必须同源访问)。 +// /api/refresh 虽然有副作用,但已有 refreshDecision 的限流与 refreshLocalOnly 兜底, +// 且它不带自定义头时属于「简单请求」——浏览器无论如何都会把它发出去, +// 加跨域头只影响响应能否被 JS 读到(前端据此退回只读),不影响安全性。 + if (req.method === 'OPTIONS' && (url.pathname === '/api/status' || url.pathname === '/api/monitors' || url.pathname === '/api/events' || url.pathname === '/api/refresh' || url.pathname === '/api/v2/getMonitors' || url.pathname === '/v2/getMonitors')) { + return handlePreflight(req, res, config); + } + if (url.pathname === '/api/status') return sendJson(res, snapshot(runtime), { headers: corsHeaders(req, config) }); + if (url.pathname === '/api/monitors') return sendJson(res, publicConfig(config), { headers: corsHeaders(req, config) }); if (url.pathname === '/api/refresh') { const decide = refreshDecision(config, req.socket.remoteAddress); if (!decide.ok) { // 只回错误码,不回显目标地址与内部细节 return sendJson(res, { error: decide.error }, { status: decide.status, - headers: decide.retryAfter ? { 'retry-after': String(decide.retryAfter) } : {}, + headers: { ...(decide.retryAfter ? { 'retry-after': String(decide.retryAfter) } : {}), ...corsHeaders(req, config) }, }); } // scope=external:只同步 UptimeRobot,不跑本地探测轮次(面板在「只看 UR」模式下用) @@ -1010,19 +1067,19 @@ function createServer(runtime) { ]); // 浏览器中断请求(切页/关标签)是常态:等待期间连接可能已断开,此时不能再写响应 if (res.writableEnded || res.destroyed) return; - return sendJson(res, snapshot(runtime)); + return sendJson(res, snapshot(runtime), { headers: corsHeaders(req, config) }); } // UptimeRobot 兼容的只读接口(GET/POST 均可):已有脚本/看板可直接消费本服务数据。 // 不做鉴权,因为其暴露内容与公开面板一致;如需鉴权请在其前方加代理。 if (url.pathname === '/api/v2/getMonitors' || url.pathname === '/v2/getMonitors') { - return sendJson(res, toUptimeRobotResponse(runtime)); + return sendJson(res, toUptimeRobotResponse(runtime), { headers: corsHeaders(req, config) }); } // 事件时间线:面板「事件」视图取这里(含通知去抖前的全部状态翻转记录) if (url.pathname === '/api/events') { const list = [...(runtime.events ?? [])].reverse(); - return sendJson(res, { total: list.length, events: list.slice(0, 200) }); + return sendJson(res, { total: list.length, events: list.slice(0, 200) }, { headers: corsHeaders(req, config) }); } let rel = decodeURIComponent(url.pathname); diff --git a/prober/node/lib/admin-routes.js b/prober/node/lib/admin-routes.js index 6d8496c..1b736e9 100644 --- a/prober/node/lib/admin-routes.js +++ b/prober/node/lib/admin-routes.js @@ -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; diff --git a/public/admin.html b/public/admin.html index 96de905..ff71424 100644 --- a/public/admin.html +++ b/public/admin.html @@ -134,6 +134,23 @@ 面板不做任何假设。

+
+ + +

+ 仅当面板部署在别处(GitHub Pages / Cloudflare)而探针在另一台机器时才需要填。 + 填了之后面板会去这个地址取数据,因此该地址必须与页面同为 HTTPS —— + HTTPS 页面读 HTTP 会被浏览器以「混合内容」为由直接拦截,与跨域设置无关。 +

+
+
+ + +

+ 逗号分隔,必须是完整来源(含协议与端口)。只有这里列出的页面才能跨域读走监控数据; + 默认只放行 http://127.0.0.1 与 http://localhost。管理端本身永远不同源开放。 +

+
diff --git a/public/assets/admin.js b/public/assets/admin.js index 5fdcbdf..ee10ffb 100644 --- a/public/assets/admin.js +++ b/public/assets/admin.js @@ -382,6 +382,8 @@ function renderConsole(d) { $('data-filter').value = d.settings?.dataFilter ?? 'both'; $('admin-url').value = d.settings?.adminUrl ?? ''; $('admin-url').dataset.saved = $('admin-url').value; + $('data-url').value = d.settings?.dataUrl ?? ''; + $('cors-origins').value = (d.settings?.corsOrigins ?? []).join(', '); // 维护窗口目标下拉 const sel = $('mw-id'); @@ -559,6 +561,21 @@ $('admin-url').addEventListener('change', async (e) => { } }); +async function saveRemoteSettings() { + const dataUrl = $('data-url').value.trim(); + const corsOrigins = $('cors-origins').value.split(',').map((s) => s.trim()).filter(Boolean); + const r = await api('/remote', { method: 'POST', body: { dataUrl, corsOrigins } }); + if (r.ok) { + msg($('action-msg'), dataUrl ? `跨域数据源:${r.data.dataUrl};已放行 ${r.data.corsOrigins.length} 个来源` : '已清空跨域数据源', 'ok'); + refresh(); + } else { + msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err'); + } +} + +$('data-url').addEventListener('change', saveRemoteSettings); +$('cors-origins').addEventListener('change', saveRemoteSettings); + $('probe-now').addEventListener('click', async () => { const btn = $('probe-now'); btn.disabled = true; diff --git a/public/assets/app.js b/public/assets/app.js index 098e24d..dc55f75 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": '' 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'); diff --git a/public/index.html b/public/index.html index 42a1311..b166006 100644 --- a/public/index.html +++ b/public/index.html @@ -76,7 +76,7 @@ – – - shiroko-monitor v0.12.1 + shiroko-monitor v0.12.2 diff --git a/public/monitors.json b/public/monitors.json index fc3040e..e9d34fc 100644 --- a/public/monitors.json +++ b/public/monitors.json @@ -10,6 +10,8 @@ "refreshInterval": 300, "dataSource": "auto", "adminUrl": "", + "dataUrl": "", + "corsOrigins": [], "uptimeRobot": { "enabled": false, "apiKey": "", diff --git a/public/status.json b/public/status.json index 2c302d2..5c25649 100644 --- a/public/status.json +++ b/public/status.json @@ -1,8 +1,8 @@ { "prober": "shiroko-monitor-node/0.12.1", "demo": false, - "generatedAt": "2026-10-14T07:22:36.648Z", - "dataUpdatedAt": "2026-10-14T07:22:28.178Z", + "generatedAt": "2026-10-14T08:19:10.335Z", + "dataUpdatedAt": "2026-10-14T08:19:01.199Z", "updateIntervalSec": 300, "dataFilter": "uptimerobot", "probeScope": "all", @@ -11,12 +11,12 @@ "up": 5, "down": 1, "pending": 0, - "avgLatency": 805, + "avgLatency": 865, "avgUptime90": 83.3, "memory": { - "rssMB": 75.5, - "heapUsedMB": 13.7, - "heapTotalMB": 14.7 + "rssMB": 73.7, + "heapUsedMB": 13.2, + "heapTotalMB": 14.5 } }, "sources": { @@ -24,10 +24,10 @@ "enabled": true, "ok": true, "error": null, - "lastSyncAt": "2026-10-14T07:20:45.501Z", + "lastSyncAt": "2026-10-14T08:19:01.199Z", "count": 6, "syncing": false, - "lastSyncMs": 1791962444262 + "lastSyncMs": 1791965938931 } }, "notify": { @@ -55,189 +55,189 @@ "monitors": { "ur-792059439": { "status": "up", - "latency": 433, + "latency": 325, "code": null, "detail": "UptimeRobot:运行正常", - "lastCheck": "2026-10-14T07:15:00.000Z", + "lastCheck": "2026-10-14T08:16:00.000Z", "history": [ { - "t": "2026-10-13T08:35:00.000Z", + "t": "2026-10-13T09:30:00.000Z", "ok": true, - "ms": 434 + "ms": 263 }, { - "t": "2026-10-13T09:15:00.000Z", - "ok": true, - "ms": 333 - }, - { - "t": "2026-10-13T09:55:00.000Z", - "ok": true, - "ms": 427 - }, - { - "t": "2026-10-13T10:35:00.000Z", - "ok": true, - "ms": 233 - }, - { - "t": "2026-10-13T11:15:00.000Z", - "ok": true, - "ms": 388 - }, - { - "t": "2026-10-13T11:55:00.000Z", - "ok": true, - "ms": 329 - }, - { - "t": "2026-10-13T12:35:00.000Z", - "ok": true, - "ms": 422 - }, - { - "t": "2026-10-13T13:15:00.000Z", - "ok": true, - "ms": 271 - }, - { - "t": "2026-10-13T13:55:00.000Z", + "t": "2026-10-13T10:15:00.000Z", "ok": true, "ms": 307 }, { - "t": "2026-10-13T14:35:00.000Z", + "t": "2026-10-13T10:55:00.000Z", "ok": true, - "ms": 417 + "ms": 379 }, { - "t": "2026-10-13T15:15:00.000Z", + "t": "2026-10-13T11:35:00.000Z", "ok": true, - "ms": 283 + "ms": 244 }, { - "t": "2026-10-13T15:55:00.000Z", + "t": "2026-10-13T12:15:00.000Z", "ok": true, - "ms": 308 + "ms": 337 }, { - "t": "2026-10-13T16:35:00.000Z", + "t": "2026-10-13T12:55:00.000Z", "ok": true, - "ms": 279 + "ms": 392 }, { - "t": "2026-10-13T17:15:00.000Z", + "t": "2026-10-13T13:35:00.000Z", "ok": true, - "ms": 239 + "ms": 249 }, { - "t": "2026-10-13T17:55:00.000Z", + "t": "2026-10-13T14:15:00.000Z", "ok": true, - "ms": 299 + "ms": 549 }, { - "t": "2026-10-13T18:35:00.000Z", + "t": "2026-10-13T14:55:00.000Z", "ok": true, - "ms": 240 + "ms": 231 }, { - "t": "2026-10-13T19:15:00.000Z", + "t": "2026-10-13T15:40:00.000Z", "ok": true, - "ms": 526 + "ms": 251 }, { - "t": "2026-10-13T19:55:00.000Z", + "t": "2026-10-13T16:20:00.000Z", "ok": true, - "ms": 367 + "ms": 482 }, { - "t": "2026-10-13T20:35:00.000Z", + "t": "2026-10-13T17:00:00.000Z", "ok": true, - "ms": 369 + "ms": 266 }, { - "t": "2026-10-13T21:15:00.000Z", + "t": "2026-10-13T17:40:00.000Z", "ok": true, - "ms": 281 + "ms": 249 }, { - "t": "2026-10-13T21:55:00.000Z", - "ok": true, - "ms": 321 - }, - { - "t": "2026-10-13T22:35:00.000Z", - "ok": true, - "ms": 150 - }, - { - "t": "2026-10-13T23:15:00.000Z", - "ok": true, - "ms": 298 - }, - { - "t": "2026-10-13T23:55:00.000Z", + "t": "2026-10-13T18:20:00.000Z", "ok": true, "ms": 305 }, { - "t": "2026-10-14T00:35:00.000Z", + "t": "2026-10-13T19:00:00.000Z", "ok": true, - "ms": 208 + "ms": 479 }, { - "t": "2026-10-14T01:15:00.000Z", + "t": "2026-10-13T19:40:00.000Z", + "ok": true, + "ms": 378 + }, + { + "t": "2026-10-13T20:20:00.000Z", + "ok": true, + "ms": 372 + }, + { + "t": "2026-10-13T21:00:00.000Z", + "ok": true, + "ms": 276 + }, + { + "t": "2026-10-13T21:40:00.000Z", + "ok": true, + "ms": 304 + }, + { + "t": "2026-10-13T22:20:00.000Z", + "ok": true, + "ms": 202 + }, + { + "t": "2026-10-13T23:00:00.000Z", + "ok": true, + "ms": 228 + }, + { + "t": "2026-10-13T23:40:00.000Z", + "ok": true, + "ms": 316 + }, + { + "t": "2026-10-14T00:20:00.000Z", + "ok": true, + "ms": 356 + }, + { + "t": "2026-10-14T01:00:00.000Z", + "ok": true, + "ms": 264 + }, + { + "t": "2026-10-14T01:40:00.000Z", + "ok": true, + "ms": 260 + }, + { + "t": "2026-10-14T02:20:00.000Z", + "ok": true, + "ms": 286 + }, + { + "t": "2026-10-14T03:00:00.000Z", + "ok": true, + "ms": 204 + }, + { + "t": "2026-10-14T03:40:00.000Z", + "ok": true, + "ms": 256 + }, + { + "t": "2026-10-14T04:20:00.000Z", + "ok": true, + "ms": 197 + }, + { + "t": "2026-10-14T05:00:00.000Z", + "ok": true, + "ms": 261 + }, + { + "t": "2026-10-14T05:40:00.000Z", + "ok": true, + "ms": 374 + }, + { + "t": "2026-10-14T06:20:00.000Z", + "ok": true, + "ms": 268 + }, + { + "t": "2026-10-14T07:35:00.000Z", "ok": true, "ms": 421 }, { - "t": "2026-10-14T02:00:00.000Z", + "t": "2026-10-14T08:16:00.000Z", "ok": true, - "ms": 191 - }, - { - "t": "2026-10-14T02:40:00.000Z", - "ok": true, - "ms": 187 - }, - { - "t": "2026-10-14T03:20:00.000Z", - "ok": true, - "ms": 282 - }, - { - "t": "2026-10-14T04:00:00.000Z", - "ok": true, - "ms": 289 - }, - { - "t": "2026-10-14T04:40:00.000Z", - "ok": true, - "ms": 171 - }, - { - "t": "2026-10-14T05:20:00.000Z", - "ok": true, - "ms": 311 - }, - { - "t": "2026-10-14T06:34:00.000Z", - "ok": true, - "ms": 177 - }, - { - "t": "2026-10-14T07:15:00.000Z", - "ok": true, - "ms": 433 + "ms": 325 } ], "daily": { "2026-10-14": { "total": 4, "up": 4, - "latSum": 1744, + "latSum": 1903, "latCount": 4, - "latMax": 439 + "latMax": 546 } }, "meta": { @@ -262,211 +262,211 @@ "90": 0 }, "lat": { - "avg": 436, - "max": 439, + "avg": 476, + "max": 546, "days": [ { "key": "2026-10-14", - "avg": 436 + "avg": 476 } ] } }, "ur-795497022": { "status": "up", - "latency": 672, + "latency": 719, "code": null, "detail": "UptimeRobot:运行正常", - "lastCheck": "2026-10-14T07:18:00.000Z", + "lastCheck": "2026-10-14T08:16:00.000Z", "history": [ { - "t": "2026-10-13T08:00:00.000Z", + "t": "2026-10-13T08:55:00.000Z", "ok": true, - "ms": 758 + "ms": 939 }, { - "t": "2026-10-13T08:40:00.000Z", + "t": "2026-10-13T09:35:00.000Z", "ok": true, - "ms": 826 + "ms": 663 }, { - "t": "2026-10-13T09:20:00.000Z", + "t": "2026-10-13T10:15:00.000Z", "ok": true, - "ms": 749 + "ms": 837 }, { - "t": "2026-10-13T10:00:00.000Z", + "t": "2026-10-13T10:55:00.000Z", "ok": true, - "ms": 791 + "ms": 723 }, { - "t": "2026-10-13T10:40:00.000Z", + "t": "2026-10-13T11:35:00.000Z", "ok": true, - "ms": 678 + "ms": 688 }, { - "t": "2026-10-13T11:20:00.000Z", + "t": "2026-10-13T12:15:00.000Z", "ok": true, - "ms": 740 + "ms": 824 }, { - "t": "2026-10-13T12:00:00.000Z", + "t": "2026-10-13T12:55:00.000Z", "ok": true, - "ms": 828 + "ms": 799 }, { - "t": "2026-10-13T12:40:00.000Z", + "t": "2026-10-13T13:35:00.000Z", + "ok": true, + "ms": 943 + }, + { + "t": "2026-10-13T14:20:00.000Z", + "ok": true, + "ms": 808 + }, + { + "t": "2026-10-13T15:00:00.000Z", + "ok": true, + "ms": 842 + }, + { + "t": "2026-10-13T15:40:00.000Z", + "ok": true, + "ms": 918 + }, + { + "t": "2026-10-13T16:20:00.000Z", + "ok": true, + "ms": 646 + }, + { + "t": "2026-10-13T17:00:00.000Z", "ok": true, "ms": 722 }, { - "t": "2026-10-13T13:20:00.000Z", - "ok": true, - "ms": 849 - }, - { - "t": "2026-10-13T14:00:00.000Z", - "ok": true, - "ms": 1071 - }, - { - "t": "2026-10-13T14:40:00.000Z", - "ok": true, - "ms": 863 - }, - { - "t": "2026-10-13T15:20:00.000Z", - "ok": true, - "ms": 893 - }, - { - "t": "2026-10-13T16:00:00.000Z", - "ok": true, - "ms": 742 - }, - { - "t": "2026-10-13T16:40:00.000Z", - "ok": true, - "ms": 769 - }, - { - "t": "2026-10-13T17:20:00.000Z", - "ok": true, - "ms": 769 - }, - { - "t": "2026-10-13T18:00:00.000Z", - "ok": true, - "ms": 738 - }, - { - "t": "2026-10-13T18:40:00.000Z", - "ok": true, - "ms": 888 - }, - { - "t": "2026-10-13T19:20:00.000Z", - "ok": true, - "ms": 810 - }, - { - "t": "2026-10-13T20:00:00.000Z", - "ok": true, - "ms": 754 - }, - { - "t": "2026-10-13T20:40:00.000Z", - "ok": true, - "ms": 1721 - }, - { - "t": "2026-10-13T21:20:00.000Z", + "t": "2026-10-13T17:40:00.000Z", "ok": true, "ms": 749 }, { - "t": "2026-10-13T22:00:00.000Z", + "t": "2026-10-13T18:20:00.000Z", "ok": true, - "ms": 1288 + "ms": 750 }, { - "t": "2026-10-13T22:40:00.000Z", + "t": "2026-10-13T19:00:00.000Z", "ok": true, - "ms": 694 + "ms": 920 }, { - "t": "2026-10-13T23:20:00.000Z", + "t": "2026-10-13T19:40:00.000Z", "ok": true, - "ms": 728 + "ms": 715 }, { - "t": "2026-10-14T00:00:00.000Z", + "t": "2026-10-13T20:20:00.000Z", "ok": true, - "ms": 611 + "ms": 835 }, { - "t": "2026-10-14T00:40:00.000Z", + "t": "2026-10-13T21:00:00.000Z", "ok": true, - "ms": 752 + "ms": 1038 }, { - "t": "2026-10-14T01:20:00.000Z", + "t": "2026-10-13T21:40:00.000Z", "ok": true, - "ms": 734 + "ms": 1235 }, { - "t": "2026-10-14T02:00:00.000Z", + "t": "2026-10-13T22:20:00.000Z", "ok": true, - "ms": 635 + "ms": 731 }, { - "t": "2026-10-14T02:40:00.000Z", + "t": "2026-10-13T23:00:00.000Z", "ok": true, - "ms": 781 + "ms": 685 }, { - "t": "2026-10-14T03:20:00.000Z", + "t": "2026-10-13T23:40:00.000Z", + "ok": true, + "ms": 597 + }, + { + "t": "2026-10-14T00:20:00.000Z", + "ok": true, + "ms": 676 + }, + { + "t": "2026-10-14T01:00:00.000Z", + "ok": true, + "ms": 755 + }, + { + "t": "2026-10-14T01:40:00.000Z", + "ok": true, + "ms": 658 + }, + { + "t": "2026-10-14T02:20:00.000Z", + "ok": true, + "ms": 740 + }, + { + "t": "2026-10-14T03:00:00.000Z", + "ok": true, + "ms": 718 + }, + { + "t": "2026-10-14T03:40:00.000Z", + "ok": true, + "ms": 593 + }, + { + "t": "2026-10-14T04:20:00.000Z", + "ok": true, + "ms": 627 + }, + { + "t": "2026-10-14T05:00:00.000Z", + "ok": true, + "ms": 674 + }, + { + "t": "2026-10-14T05:40:00.000Z", + "ok": true, + "ms": 700 + }, + { + "t": "2026-10-14T06:20:00.000Z", + "ok": true, + "ms": 707 + }, + { + "t": "2026-10-14T07:02:00.000Z", "ok": true, "ms": 710 }, { - "t": "2026-10-14T04:00:00.000Z", + "t": "2026-10-14T07:39:00.000Z", "ok": true, - "ms": 645 + "ms": 718 }, { - "t": "2026-10-14T04:40:00.000Z", + "t": "2026-10-14T08:16:00.000Z", "ok": true, - "ms": 668 - }, - { - "t": "2026-10-14T05:20:00.000Z", - "ok": true, - "ms": 683 - }, - { - "t": "2026-10-14T06:13:00.000Z", - "ok": true, - "ms": 711 - }, - { - "t": "2026-10-14T06:45:00.000Z", - "ok": true, - "ms": 692 - }, - { - "t": "2026-10-14T07:18:00.000Z", - "ok": true, - "ms": 672 + "ms": 719 } ], "daily": { "2026-10-14": { "total": 4, "up": 4, - "latSum": 2889, + "latSum": 2884, "latCount": 4, - "latMax": 751 + "latMax": 745 } }, "meta": { @@ -491,201 +491,206 @@ "90": 5432 }, "lat": { - "avg": 722, - "max": 751, + "avg": 721, + "max": 745, "days": [ { "key": "2026-10-14", - "avg": 722 + "avg": 721 } ] } }, "ur-795556828": { "status": "up", - "latency": 435, + "latency": 369, "code": null, "detail": "UptimeRobot:运行正常", - "lastCheck": "2026-10-14T07:15:00.000Z", + "lastCheck": "2026-10-14T08:16:00.000Z", "history": [ { - "t": "2026-10-13T08:35:00.000Z", + "t": "2026-10-13T09:00:00.000Z", "ok": true, - "ms": 224 + "ms": 264 }, { - "t": "2026-10-13T09:15:00.000Z", + "t": "2026-10-13T09:40:00.000Z", "ok": true, - "ms": 247 + "ms": 305 }, { - "t": "2026-10-13T10:00:00.000Z", + "t": "2026-10-13T10:20:00.000Z", "ok": true, - "ms": 455 + "ms": 420 }, { - "t": "2026-10-13T10:40:00.000Z", + "t": "2026-10-13T11:00:00.000Z", "ok": true, - "ms": 384 + "ms": 227 }, { - "t": "2026-10-13T11:20:00.000Z", + "t": "2026-10-13T11:40:00.000Z", "ok": true, - "ms": 334 + "ms": 389 }, { - "t": "2026-10-13T12:00:00.000Z", + "t": "2026-10-13T12:20:00.000Z", "ok": true, - "ms": 234 + "ms": 302 }, { - "t": "2026-10-13T12:40:00.000Z", + "t": "2026-10-13T13:00:00.000Z", "ok": true, - "ms": 335 + "ms": 375 }, { - "t": "2026-10-13T13:20:00.000Z", + "t": "2026-10-13T13:40:00.000Z", "ok": true, - "ms": 379 + "ms": 287 }, { - "t": "2026-10-13T14:00:00.000Z", + "t": "2026-10-13T14:20:00.000Z", "ok": true, - "ms": 304 + "ms": 352 }, { - "t": "2026-10-13T14:40:00.000Z", - "ok": true, - "ms": 341 - }, - { - "t": "2026-10-13T15:25:00.000Z", - "ok": true, - "ms": 417 - }, - { - "t": "2026-10-13T16:05:00.000Z", - "ok": true, - "ms": 241 - }, - { - "t": "2026-10-13T16:45:00.000Z", - "ok": true, - "ms": 366 - }, - { - "t": "2026-10-13T17:25:00.000Z", - "ok": true, - "ms": 244 - }, - { - "t": "2026-10-13T18:05:00.000Z", - "ok": true, - "ms": 378 - }, - { - "t": "2026-10-13T18:45:00.000Z", - "ok": true, - "ms": 310 - }, - { - "t": "2026-10-13T19:25:00.000Z", - "ok": true, - "ms": 335 - }, - { - "t": "2026-10-13T20:05:00.000Z", - "ok": true, - "ms": 351 - }, - { - "t": "2026-10-13T20:45:00.000Z", - "ok": true, - "ms": 523 - }, - { - "t": "2026-10-13T21:25:00.000Z", - "ok": true, - "ms": 400 - }, - { - "t": "2026-10-13T22:05:00.000Z", - "ok": true, - "ms": 222 - }, - { - "t": "2026-10-13T22:45:00.000Z", - "ok": true, - "ms": 169 - }, - { - "t": "2026-10-13T23:25:00.000Z", - "ok": true, - "ms": 225 - }, - { - "t": "2026-10-14T00:05:00.000Z", - "ok": true, - "ms": 209 - }, - { - "t": "2026-10-14T00:45:00.000Z", - "ok": true, - "ms": 272 - }, - { - "t": "2026-10-14T01:25:00.000Z", - "ok": true, - "ms": 431 - }, - { - "t": "2026-10-14T02:05:00.000Z", - "ok": true, - "ms": 241 - }, - { - "t": "2026-10-14T02:45:00.000Z", - "ok": true, - "ms": 444 - }, - { - "t": "2026-10-14T03:25:00.000Z", - "ok": true, - "ms": 359 - }, - { - "t": "2026-10-14T04:05:00.000Z", - "ok": true, - "ms": 400 - }, - { - "t": "2026-10-14T04:45:00.000Z", - "ok": true, - "ms": 214 - }, - { - "t": "2026-10-14T05:25:00.000Z", + "t": "2026-10-13T15:00:00.000Z", "ok": true, "ms": 404 }, { - "t": "2026-10-14T06:35:00.000Z", + "t": "2026-10-13T15:40:00.000Z", + "ok": true, + "ms": 323 + }, + { + "t": "2026-10-13T16:20:00.000Z", + "ok": true, + "ms": 311 + }, + { + "t": "2026-10-13T17:00:00.000Z", + "ok": true, + "ms": 376 + }, + { + "t": "2026-10-13T17:40:00.000Z", + "ok": true, + "ms": 413 + }, + { + "t": "2026-10-13T18:20:00.000Z", + "ok": true, + "ms": 386 + }, + { + "t": "2026-10-13T19:00:00.000Z", + "ok": true, + "ms": 302 + }, + { + "t": "2026-10-13T19:40:00.000Z", + "ok": true, + "ms": 376 + }, + { + "t": "2026-10-13T20:20:00.000Z", + "ok": true, + "ms": 266 + }, + { + "t": "2026-10-13T21:00:00.000Z", + "ok": true, + "ms": 475 + }, + { + "t": "2026-10-13T21:40:00.000Z", + "ok": true, + "ms": 354 + }, + { + "t": "2026-10-13T22:20:00.000Z", + "ok": true, + "ms": 239 + }, + { + "t": "2026-10-13T23:00:00.000Z", + "ok": true, + "ms": 225 + }, + { + "t": "2026-10-13T23:40:00.000Z", + "ok": true, + "ms": 243 + }, + { + "t": "2026-10-14T00:20:00.000Z", + "ok": true, + "ms": 319 + }, + { + "t": "2026-10-14T01:00:00.000Z", + "ok": true, + "ms": 396 + }, + { + "t": "2026-10-14T01:45:00.000Z", + "ok": true, + "ms": 475 + }, + { + "t": "2026-10-14T02:25:00.000Z", + "ok": true, + "ms": 128 + }, + { + "t": "2026-10-14T03:05:00.000Z", + "ok": true, + "ms": 351 + }, + { + "t": "2026-10-14T03:45:00.000Z", + "ok": true, + "ms": 429 + }, + { + "t": "2026-10-14T04:25:00.000Z", + "ok": true, + "ms": 303 + }, + { + "t": "2026-10-14T05:05:00.000Z", + "ok": true, + "ms": 276 + }, + { + "t": "2026-10-14T05:45:00.000Z", + "ok": true, + "ms": 555 + }, + { + "t": "2026-10-14T06:25:00.000Z", "ok": true, "ms": 278 }, { - "t": "2026-10-14T07:15:00.000Z", + "t": "2026-10-14T07:36:00.000Z", "ok": true, - "ms": 435 + "ms": 204 + }, + { + "t": "2026-10-14T08:16:00.000Z", + "ok": true, + "ms": 369 } ], "daily": { "2026-10-14": { "total": 4, "up": 4, - "latSum": 1749, + "latSum": 1530, "latCount": 4, - "latMax": 438 + "latMax": 387 } }, "meta": { @@ -710,28 +715,23 @@ "90": 0 }, "lat": { - "avg": 437, - "max": 438, + "avg": 383, + "max": 387, "days": [ { "key": "2026-10-14", - "avg": 437 + "avg": 383 } ] } }, "ur-792059414": { "status": "up", - "latency": 2007, + "latency": 2428, "code": null, "detail": "UptimeRobot:运行正常", - "lastCheck": "2026-10-14T07:16:00.000Z", + "lastCheck": "2026-10-14T08:17:00.000Z", "history": [ - { - "t": "2026-10-13T08:25:00.000Z", - "ok": true, - "ms": 1861 - }, { "t": "2026-10-13T09:10:00.000Z", "ok": true, @@ -783,118 +783,123 @@ "ms": 2097 }, { - "t": "2026-10-13T16:30:00.000Z", + "t": "2026-10-13T16:35:00.000Z", "ok": true, - "ms": 2060 + "ms": 2206 }, { - "t": "2026-10-13T17:15:00.000Z", + "t": "2026-10-13T17:20:00.000Z", "ok": true, - "ms": 2264 + "ms": 2293 }, { - "t": "2026-10-13T17:55:00.000Z", + "t": "2026-10-13T18:05:00.000Z", "ok": true, - "ms": 1246 + "ms": 1347 }, { - "t": "2026-10-13T18:35:00.000Z", + "t": "2026-10-13T18:50:00.000Z", "ok": true, - "ms": 1451 + "ms": 1656 }, { - "t": "2026-10-13T19:15:00.000Z", + "t": "2026-10-13T19:35:00.000Z", "ok": true, - "ms": 3225 + "ms": 2883 }, { - "t": "2026-10-13T19:55:00.000Z", + "t": "2026-10-13T20:20:00.000Z", "ok": true, - "ms": 2138 + "ms": 2649 }, { - "t": "2026-10-13T20:35:00.000Z", + "t": "2026-10-13T21:05:00.000Z", "ok": true, - "ms": 2633 + "ms": 2923 }, { - "t": "2026-10-13T21:15:00.000Z", + "t": "2026-10-13T21:50:00.000Z", "ok": true, - "ms": 3605 + "ms": 1964 }, { - "t": "2026-10-13T21:55:00.000Z", + "t": "2026-10-13T22:35:00.000Z", "ok": true, - "ms": 1785 + "ms": 2335 }, { - "t": "2026-10-13T22:40:00.000Z", + "t": "2026-10-13T23:20:00.000Z", "ok": true, - "ms": 1904 + "ms": 2418 }, { - "t": "2026-10-13T23:25:00.000Z", + "t": "2026-10-14T00:05:00.000Z", "ok": true, - "ms": 2527 + "ms": 2098 }, { - "t": "2026-10-14T00:10:00.000Z", + "t": "2026-10-14T00:50:00.000Z", "ok": true, - "ms": 2372 + "ms": 1921 }, { - "t": "2026-10-14T00:55:00.000Z", + "t": "2026-10-14T01:35:00.000Z", "ok": true, - "ms": 1934 + "ms": 3110 }, { - "t": "2026-10-14T01:40:00.000Z", + "t": "2026-10-14T02:20:00.000Z", "ok": true, - "ms": 3003 + "ms": 1719 }, { - "t": "2026-10-14T02:25:00.000Z", + "t": "2026-10-14T03:05:00.000Z", "ok": true, - "ms": 1655 + "ms": 1623 }, { - "t": "2026-10-14T03:10:00.000Z", + "t": "2026-10-14T03:45:00.000Z", "ok": true, - "ms": 1873 + "ms": 3219 }, { - "t": "2026-10-14T03:55:00.000Z", + "t": "2026-10-14T04:25:00.000Z", "ok": true, - "ms": 3017 + "ms": 2408 }, { - "t": "2026-10-14T04:40:00.000Z", + "t": "2026-10-14T05:05:00.000Z", "ok": true, - "ms": 1955 + "ms": 2619 }, { - "t": "2026-10-14T05:25:00.000Z", + "t": "2026-10-14T05:45:00.000Z", "ok": true, - "ms": 3612 + "ms": 2976 }, { - "t": "2026-10-14T06:36:00.000Z", + "t": "2026-10-14T06:25:00.000Z", "ok": true, - "ms": 1548 + "ms": 1855 }, { - "t": "2026-10-14T07:16:00.000Z", + "t": "2026-10-14T07:38:00.000Z", "ok": true, - "ms": 2007 + "ms": 2010 + }, + { + "t": "2026-10-14T08:17:00.000Z", + "ok": true, + "ms": 2428 } ], "daily": { "2026-10-14": { "total": 4, "up": 4, - "latSum": 7923, + "latSum": 8517, "latCount": 4, - "latMax": 2007 + "latMax": 2428 } }, "meta": { @@ -919,206 +924,201 @@ "90": 7593 }, "lat": { - "avg": 1981, - "max": 2007, + "avg": 2129, + "max": 2428, "days": [ { "key": "2026-10-14", - "avg": 1981 + "avg": 2129 } ] } }, "ur-798788113": { "status": "up", - "latency": 480, + "latency": 486, "code": null, "detail": "UptimeRobot:运行正常", - "lastCheck": "2026-10-14T07:16:00.000Z", + "lastCheck": "2026-10-14T08:17:00.000Z", "history": [ { - "t": "2026-10-13T08:00:00.000Z", + "t": "2026-10-13T09:30:00.000Z", "ok": true, - "ms": 279 + "ms": 545 }, { - "t": "2026-10-13T08:40:00.000Z", + "t": "2026-10-13T10:10:00.000Z", "ok": true, - "ms": 407 + "ms": 456 }, { - "t": "2026-10-13T09:20:00.000Z", + "t": "2026-10-13T10:50:00.000Z", "ok": true, - "ms": 317 + "ms": 394 }, { - "t": "2026-10-13T10:00:00.000Z", + "t": "2026-10-13T11:30:00.000Z", "ok": true, - "ms": 627 + "ms": 398 }, { - "t": "2026-10-13T10:40:00.000Z", + "t": "2026-10-13T12:10:00.000Z", "ok": true, - "ms": 505 + "ms": 588 }, { - "t": "2026-10-13T11:20:00.000Z", + "t": "2026-10-13T12:50:00.000Z", "ok": true, - "ms": 424 + "ms": 430 }, { - "t": "2026-10-13T12:00:00.000Z", + "t": "2026-10-13T13:30:00.000Z", "ok": true, - "ms": 1135 + "ms": 502 }, { - "t": "2026-10-13T12:40:00.000Z", + "t": "2026-10-13T14:15:00.000Z", "ok": true, - "ms": 462 + "ms": 355 }, { - "t": "2026-10-13T13:20:00.000Z", + "t": "2026-10-13T14:55:00.000Z", "ok": true, - "ms": 436 + "ms": 358 }, { - "t": "2026-10-13T14:00:00.000Z", + "t": "2026-10-13T15:35:00.000Z", "ok": true, - "ms": 521 + "ms": 385 }, { - "t": "2026-10-13T14:40:00.000Z", + "t": "2026-10-13T16:15:00.000Z", "ok": true, - "ms": 480 + "ms": 342 }, { - "t": "2026-10-13T15:20:00.000Z", + "t": "2026-10-13T16:55:00.000Z", "ok": true, - "ms": 462 + "ms": 428 }, { - "t": "2026-10-13T16:00:00.000Z", + "t": "2026-10-13T17:35:00.000Z", "ok": true, - "ms": 331 + "ms": 508 }, { - "t": "2026-10-13T16:40:00.000Z", + "t": "2026-10-13T18:15:00.000Z", "ok": true, - "ms": 373 + "ms": 602 }, { - "t": "2026-10-13T17:20:00.000Z", - "ok": true, - "ms": 429 - }, - { - "t": "2026-10-13T18:00:00.000Z", - "ok": true, - "ms": 558 - }, - { - "t": "2026-10-13T18:40:00.000Z", - "ok": true, - "ms": 370 - }, - { - "t": "2026-10-13T19:20:00.000Z", - "ok": true, - "ms": 1519 - }, - { - "t": "2026-10-13T20:00:00.000Z", - "ok": true, - "ms": 483 - }, - { - "t": "2026-10-13T20:40:00.000Z", - "ok": true, - "ms": 388 - }, - { - "t": "2026-10-13T21:20:00.000Z", - "ok": true, - "ms": 367 - }, - { - "t": "2026-10-13T22:00:00.000Z", - "ok": true, - "ms": 273 - }, - { - "t": "2026-10-13T22:40:00.000Z", - "ok": true, - "ms": 261 - }, - { - "t": "2026-10-13T23:20:00.000Z", - "ok": true, - "ms": 263 - }, - { - "t": "2026-10-14T00:00:00.000Z", - "ok": true, - "ms": 229 - }, - { - "t": "2026-10-14T00:40:00.000Z", + "t": "2026-10-13T18:55:00.000Z", "ok": true, "ms": 363 }, { - "t": "2026-10-14T01:20:00.000Z", + "t": "2026-10-13T19:40:00.000Z", "ok": true, - "ms": 329 + "ms": 1380 }, { - "t": "2026-10-14T02:00:00.000Z", + "t": "2026-10-13T20:20:00.000Z", "ok": true, - "ms": 302 + "ms": 512 }, { - "t": "2026-10-14T02:40:00.000Z", + "t": "2026-10-13T21:00:00.000Z", "ok": true, - "ms": 425 + "ms": 394 }, { - "t": "2026-10-14T03:20:00.000Z", + "t": "2026-10-13T21:40:00.000Z", "ok": true, - "ms": 328 + "ms": 238 }, { - "t": "2026-10-14T04:00:00.000Z", + "t": "2026-10-13T22:20:00.000Z", "ok": true, - "ms": 319 + "ms": 323 }, { - "t": "2026-10-14T04:40:00.000Z", + "t": "2026-10-13T23:00:00.000Z", "ok": true, - "ms": 272 + "ms": 353 }, { - "t": "2026-10-14T05:20:00.000Z", + "t": "2026-10-13T23:40:00.000Z", "ok": true, - "ms": 347 + "ms": 179 }, { - "t": "2026-10-14T06:36:00.000Z", + "t": "2026-10-14T00:20:00.000Z", "ok": true, - "ms": 484 + "ms": 263 }, { - "t": "2026-10-14T07:16:00.000Z", + "t": "2026-10-14T01:00:00.000Z", "ok": true, - "ms": 480 + "ms": 381 + }, + { + "t": "2026-10-14T01:40:00.000Z", + "ok": true, + "ms": 378 + }, + { + "t": "2026-10-14T02:20:00.000Z", + "ok": true, + "ms": 278 + }, + { + "t": "2026-10-14T03:00:00.000Z", + "ok": true, + "ms": 280 + }, + { + "t": "2026-10-14T03:40:00.000Z", + "ok": true, + "ms": 322 + }, + { + "t": "2026-10-14T04:20:00.000Z", + "ok": true, + "ms": 323 + }, + { + "t": "2026-10-14T05:00:00.000Z", + "ok": true, + "ms": 321 + }, + { + "t": "2026-10-14T05:40:00.000Z", + "ok": true, + "ms": 642 + }, + { + "t": "2026-10-14T06:20:00.000Z", + "ok": true, + "ms": 315 + }, + { + "t": "2026-10-14T07:36:00.000Z", + "ok": true, + "ms": 363 + }, + { + "t": "2026-10-14T08:17:00.000Z", + "ok": true, + "ms": 486 } ], "daily": { "2026-10-14": { "total": 4, "up": 4, - "latSum": 1875, + "latSum": 1756, "latCount": 4, - "latMax": 480 + "latMax": 486 } }, "meta": { @@ -1143,12 +1143,12 @@ "90": 0 }, "lat": { - "avg": 469, - "max": 480, + "avg": 439, + "max": 486, "days": [ { "key": "2026-10-14", - "avg": 469 + "avg": 439 } ] } diff --git a/scripts/verify-security.cjs b/scripts/verify-security.cjs index cb3d1d9..64a0b8c 100644 --- a/scripts/verify-security.cjs +++ b/scripts/verify-security.cjs @@ -172,6 +172,77 @@ async function main() { check('静态 monitors.json 不含私有层内容', !pubText.includes(SECRET), ''); srvC.kill(); + // ---------- D3:跨域只读(CORS 白名单) ---------- + // 场景:前端部署在 GitHub Pages / Cloudflare,探针在另一台机器, + // 需要让指定页面能跨域读状态与配置。核心风险是「任意网站都能读走监控数据」。 + console.log('\n[D3] 跨域只读白名单'); + const corsCfg = path.join(TMP, 'cors.json'); + fs.writeFileSync(corsCfg, JSON.stringify({ + settings: { + title: 't', + corsOrigins: ['https://you.github.io', 'https://monitor.example.com'], + uptimeRobot: { enabled: true, apiKey: SECRET }, + }, + monitors: [], + }, null, 2)); + const srvD = startProber([`--config=${corsCfg}`, `--local-config=${corsCfg}`, `--out=${path.join(TMP, 'd.json')}`], { PORT: '8895' }); + await waitForHttp('http://127.0.0.1:8895/api/status'); + + const acaoOf = async (pathname, origin, method = 'GET') => { + const res = await fetch(`http://127.0.0.1:8895${pathname}`, { method, headers: { Origin: origin } }); + return res.headers.get('access-control-allow-origin'); + }; + + check('白名单内的 GitHub Pages 来源被放行', await acaoOf('/api/status', 'https://you.github.io') === 'https://you.github.io'); + check('白名单内的自定义域名被放行', await acaoOf('/api/status', 'https://monitor.example.com') === 'https://monitor.example.com'); + check('白名单外的站点被拒(无跨域头)', await acaoOf('/api/status', 'https://evil.example') === null); + check('前缀伪造的域名被拒(如 https://you.github.io.evil.com)', await acaoOf('/api/status', 'https://you.github.io.evil.com') === null); + check('协议不符的同域被拒(https 的 localhost 不匹配 http 默认项)', await acaoOf('/api/status', 'https://localhost') === null); + + // 本机回环:默认放行且忽略端口(本地前端与探针常跑在不同端口) + check('本机 localhost 放行(任意端口)', await acaoOf('/api/status', 'http://localhost:5173') === 'http://localhost:5173'); + check('本机 127.0.0.1 放行(任意端口)', await acaoOf('/api/status', 'http://127.0.0.1:3000') === 'http://127.0.0.1:3000'); + check('局域网 IP 未列入白名单时被拒', await acaoOf('/api/status', 'http://192.168.1.5:5173') === null); + + // 预检 + const pre = await fetch('http://127.0.0.1:8895/api/status', { method: 'OPTIONS', headers: { Origin: 'https://you.github.io', 'Access-Control-Request-Method': 'GET' } }); + check('合法来源的预检返回 204 且带跨域头', pre.status === 204 && pre.headers.get('access-control-allow-origin') === 'https://you.github.io', `HTTP ${pre.status}`); + const preBad = await fetch('http://127.0.0.1:8895/api/status', { method: 'OPTIONS', headers: { Origin: 'https://evil.example', 'Access-Control-Request-Method': 'GET' } }); + check('非法来源的预检被拒 403', preBad.status === 403, `HTTP ${preBad.status}`); + + // 各只读接口都放行 + const readOnly = ['/api/status', '/api/monitors', '/api/events', '/api/v2/getMonitors']; + const allowed = []; + for (const p of readOnly) allowed.push((await acaoOf(p, 'https://you.github.io')) === 'https://you.github.io'); + check('四个只读接口都支持跨域', allowed.every(Boolean), readOnly.filter((_, i) => !allowed[i]).join(',') || '全部放行'); + + // 关键:管理端绝不能跨域 + const adminAcao = []; + for (const p of ['/api/admin/status', '/api/admin/overview', '/api/admin/login']) { + const r = await fetch(`http://127.0.0.1:8895${p}`, { method: 'GET', headers: { Origin: 'https://you.github.io' } }); + adminAcao.push(r.headers.get('access-control-allow-origin')); + } + check('管理端接口一律不带跨域头(必须同源访问)', adminAcao.every((v) => v === null), JSON.stringify(adminAcao)); + const adminPre = await fetch('http://127.0.0.1:8895/api/admin/overview', { method: 'OPTIONS', headers: { Origin: 'https://you.github.io', 'Access-Control-Request-Method': 'POST' } }); + check('管理端的预检被拒 403(外部页面无法发写操作)', adminPre.status === 403, `HTTP ${adminPre.status}`); + + // 跨域读到的配置同样不得含密钥 + const corsMonitors = await (await fetch('http://127.0.0.1:8895/api/monitors', { headers: { Origin: 'https://you.github.io' } })).text(); + check('跨域读取配置时不泄露 API Key', !corsMonitors.includes(SECRET), corsMonitors.includes(SECRET) ? '泄露了!' : '未泄露'); + srvD.kill(); + + // corsOrigins 里不允许 '*' + console.log('\n[D4] corsOrigins 的写法校验'); + const starCfg = path.join(TMP, 'star.json'); + fs.writeFileSync(starCfg, JSON.stringify({ settings: { title: 't', corsOrigins: ['*'] }, monitors: [] }, null, 2)); + const { corsOrigins: corsFn } = await import(`file://${PROBER.replace(/\\/g, '/')}`).catch(() => ({})); + // corsOrigins 未导出时以 HTTP 层验证:配置 '*' 后不应变成任意来源可读 + const srvE = startProber([`--config=${starCfg}`, `--local-config=${starCfg}`, `--out=${path.join(TMP, 'e.json')}`], { PORT: '8894' }); + await waitForHttp('http://127.0.0.1:8894/api/status'); + const starRes = await fetch('http://127.0.0.1:8894/api/status', { headers: { Origin: 'https://anything.example' } }); + check("corsOrigins 配成 '*' 不会放行任意来源(必须写完整 origin)", starRes.headers.get('access-control-allow-origin') === null, starRes.headers.get('access-control-allow-origin') ?? '无跨域头'); + srvE.kill(); + // ---------- E:来源判定 ---------- console.log('\n[E] 来源判定'); const { isPrivateAddress } = await import(`file://${PROBER.replace(/\\/g, '/')}`);