/** * shiroko-monitor 边缘探针(Cloudflare Workers) * * - scheduled:cron 每分钟触发一轮探测(HTTP 用 fetch,TCP 用 cloudflare:sockets) * 探测目标 = 配置里的自建目标 + UptimeRobot 只读镜像目标(与本地 Node 探针同口径) * - 状态与历史心率整体存于 KV 的 state 键(小规模单键足够,上限 25MB) * - /api/status、/api/monitors、/api/refresh 由本 Worker 提供,其余请求交给静态资源 * * 管理端说明:本 Worker 不提供任何写接口,/admin 与 /api/admin/* 一律拒绝 * (管理操作在本地 Node 探针侧完成,见 docs/DEPLOYMENT.md)。 * * 注意:cloudflare:sockets 的 TCP 出站连接需要 Workers 付费计划; * 免费计划下 http 类型探测与静态面板均可正常使用,tcp 类型会返回 down。 */ import { connect } from 'cloudflare:sockets'; import { fetchUptimeRobotMonitors } from './uptimerobot.js'; const VERSION = '0.12.2'; const HISTORY_LIMIT = 48; const UPTIME_DAYS = 90; const USER_AGENT = 'shiroko-monitor/0.3 (lightweight uptime panel)'; // 边缘端按 UTC 日期分桶(Worker 无本地时区概念) function dayKey(d = new Date()) { return d.toISOString().slice(0, 10); } // 只保留最近 UPTIME_DAYS 天;YYYY-MM-DD 的字典序即时间序 function pruneDaily(daily) { const keys = Object.keys(daily).sort(); while (keys.length > UPTIME_DAYS) delete daily[keys.shift()]; } export default { async scheduled(event, env, ctx) { ctx.waitUntil(runChecksDedup(env)); }, async fetch(request, env, ctx) { const url = new URL(request.url); if (url.pathname === '/api/status') return json(await getStatus(env, ctx)); if (url.pathname === '/api/monitors') return json(stripSecrets(await getMonitors(env, request.url))); if (url.pathname === '/api/refresh') { // 公网上的强制探测必须显式开启,避免被任意访客当作「探测放大器」使用 const gate = refreshGate(env, request); if (!gate.ok) return json({ error: gate.error }, { status: gate.status }); return json(await runChecksDedup(env)); } // 管理端只存在于本地 Node 探针:这里显式拒绝而非落到静态资源, // 避免访客把 404 误读成「路径写错」,也确保 Worker 侧没有任何可写面 if (url.pathname === '/admin' || url.pathname.startsWith('/api/admin')) { return json({ error: 'admin-not-here', hint: '管理端(含 2FA)只在本地 Node 探针上运行,不在 Cloudflare Worker 上。部署方式见 docs/DEPLOYMENT.md', }, { status: 403 }); } return env.ASSETS.fetch(request); }, }; // 供 scripts/verify-worker.cjs 离线单测 runChecks(生产路径不依赖它) export const __test = { runChecks, mapStatus: null }; // 未配置 REFRESH_TOKEN 时一律拒绝(wrangler secret put REFRESH_TOKEN 开启); // 比较采用等长逐字节异或,避免按字符提前返回带来的时序差异 function refreshGate(env, request) { const token = env.REFRESH_TOKEN; if (!token) return { ok: false, status: 403, error: 'refresh-disabled' }; const url = new URL(request.url); const provided = request.headers.get('x-refresh-token') || url.searchParams.get('token') || ''; if (provided.length !== token.length) return { ok: false, status: 403, error: 'refresh-forbidden' }; let diff = 0; for (let i = 0; i < token.length; i++) diff |= provided.charCodeAt(i) ^ token.charCodeAt(i); return diff === 0 ? { ok: true } : { ok: false, status: 403, error: 'refresh-forbidden' }; } async function probeMonitor(m) { if (m.type === 'tcp') return probeTcp(m); // 边缘端无原始 socket 读取能力(sockets API 需付费版且未覆盖测试),banner 类型请使用本地探针 if (m.type === 'banner') return { status: 'down', latency: 0, code: null, detail: '边缘端暂不支持 banner 探测(请用本地探针)' }; return probeHttp(m); } // 同隔离体内共享同一轮探测,避免并发刷新对目标重复施压(跨隔离体为 best-effort) let inFlightRound = null; function runChecksDedup(env) { if (!inFlightRound) { inFlightRound = runChecks(env).finally(() => { inFlightRound = null; }); } return inFlightRound; } function json(data, init = {}) { return new Response(JSON.stringify(data), { ...init, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...(init.headers ?? {}) }, }); } async function getMonitors(env, baseUrl) { const raw = await env.MONITOR_KV.get('monitors'); if (raw) return JSON.parse(raw); // 未手动写入 KV 时,直接从静态资产读取仓库根目录的 monitors.json,实现零配置部署 const res = await env.ASSETS.fetch(new URL('/monitors.json', baseUrl)); if (res.ok) return res.json(); return { settings: {}, monitors: [] }; } // 对外提供配置时必须剥掉密钥:/api/monitors 无鉴权,而配置里可能含 UptimeRobot API Key // (该 Key 以 secret 形式传入 env,不写进 monitors.json,但手动写进 KV 的配置可能带) function stripSecrets(config) { const ur = config?.settings?.uptimeRobot; if (!ur) return config; return { ...config, settings: { ...config.settings, uptimeRobot: { ...ur, apiKey: undefined } } }; } async function getStatus(env, ctx) { const state = (await env.MONITOR_KV.get('state', 'json')) ?? { monitors: {}, generatedAt: null }; if (!state.generatedAt) ctx.waitUntil(runChecks(env)); return state; } async function probeHttp(m) { const started = Date.now(); const timeoutMs = (m.timeout ?? 10) * 1000; try { // redirect: manual —— 保留 3xx 原始状态码交给 expectStatus 判定 const res = await fetch(m.url, { method: m.method || 'GET', redirect: 'manual', signal: AbortSignal.timeout(timeoutMs), headers: { 'user-agent': USER_AGENT, accept: '*/*' }, }); const latency = Date.now() - started; const expect = m.expectStatus ?? [200]; const ok = expect.includes(res.status); return { status: ok ? 'up' : 'down', latency, code: res.status, detail: ok ? `HTTP ${res.status}` : `HTTP ${res.status}(期望 ${expect.join('/')})`, }; } catch (err) { const latency = Date.now() - started; return { status: 'down', latency, code: 0, detail: err.name === 'TimeoutError' ? `请求超时(>${timeoutMs / 1000}s)` : String(err.cause?.code || err.message), }; } } async function probeTcp(m) { const started = Date.now(); const timeoutMs = (m.timeout ?? 10) * 1000; try { const socket = connect({ hostname: m.host, port: m.port }); const timer = setTimeout(() => socket.close(), timeoutMs); await Promise.race([ socket.opened, new Promise((_, reject) => setTimeout(() => reject(new Error('TCP 连接超时')), timeoutMs)), ]); clearTimeout(timer); socket.close(); return { status: 'up', latency: Date.now() - started, code: null, detail: `TCP ${m.host}:${m.port} 可连接` }; } catch (err) { return { status: 'down', latency: Date.now() - started, code: null, detail: String(err?.message || err) }; } } async function runChecks(env) { const config = await getMonitors(env, new URL('http://placeholder/')); const state = (await env.MONITOR_KV.get('state', 'json')) ?? { monitors: {} }; const monitors = state.monitors ?? {}; const redact = config.settings?.redactDetails === true; // 探测范围与展示口径解耦:dataFilter 只管展示,probeScope 决定真正发请求的范围 const filter = ['both', 'config', 'uptimerobot'].includes(config.settings?.dataFilter) ? config.settings.dataFilter : 'both'; const scope = ['all', 'config', 'uptimerobot'].includes(config.settings?.probeScope) ? config.settings.probeScope : (filter === 'both' ? 'all' : filter); // UptimeRobot 目标:只拉取不探测。同步失败时保留上次数据,绝不因此把目标判为离线 const urCfg = config.settings?.uptimeRobot ?? {}; const urKey = String(urCfg.apiKey || '').trim(); const urEnabled = urCfg.enabled === true && Boolean(urKey); const urStatus = state.sources?.uptimerobot ?? { enabled: false, ok: null, count: 0 }; let urList = []; if (scope !== 'config' && urEnabled) { try { const ratios = Array.isArray(urCfg.ratios) && urCfg.ratios.length ? urCfg.ratios.map(Number).filter(Number.isFinite) : [1, 7, 30, 90]; urList = await fetchUptimeRobotMonitors({ apiKey: urKey, ratios }); urStatus.enabled = true; urStatus.ok = true; urStatus.error = null; urStatus.count = urList.length; urStatus.lastSyncAt = new Date().toISOString(); } catch (err) { urStatus.enabled = true; urStatus.ok = false; urStatus.error = String(err?.message ?? err); } } // 本轮要处理的全部目标:自建(scope 含 config)+ UR 镜像(scope 含 uptimerobot) const localTargets = scope === 'uptimerobot' ? [] : (config.monitors ?? []); const urTargets = scope === 'config' ? [] : urList; const urById = new Map(urTargets.map((m) => [m.id, m])); let up = 0; let down = 0; let pending = 0; let latencySum = 0; let latencyCount = 0; // 本次同步没拿到数据时(失败或被限流跳过),沿用上一轮已写入快照的 UR 目标: // 直接保留其 status / meta / uptimeRatios,只更新 daily 分桶,绝不把目标改成离线或删掉。 // 注意不能直接复用 urTargets 里的旧对象——那需要 meta 里的名称/地址,缺了就渲染不出行。 const urSource = urTargets.length ? urTargets.map((m) => ({ ...m, _fresh: true })) : Object.entries(monitors) .filter(([, s]) => s.meta?.source === 'uptimerobot') .map(([id, s]) => ({ id, name: s.meta.name, group: s.meta.group, url: s.meta.url, sourceLabel: s.meta.sourceLabel, interval: s.meta.interval, status: s.status, latency: s.latency, code: s.code, detail: s.detail, lastCheckIso: s.lastCheck, history: s.history ?? [], uptimeRatios: s.uptimeRatios, _fresh: false, })); for (const m of urSource) { const prev = monitors[m.id]; const daily = { ...(prev?.daily ?? {}) }; const key = dayKey(); const bucket = daily[key] ?? (daily[key] = { total: 0, up: 0 }); bucket.total++; if (m.status === 'up') bucket.up++; pruneDaily(daily); monitors[m.id] = { status: m.status, latency: m.latency ?? null, code: m.code ?? null, detail: redact ? (m.status === 'up' ? '正常' : '异常') : m.detail, lastCheck: m.lastCheckIso ?? new Date().toISOString(), // meta 让面板认出这是外部数据源目标(名称/分组/协议都来自 UR 侧) meta: { name: m.name, group: m.group ?? 'UptimeRobot', url: m.url ?? '', source: 'uptimerobot', sourceLabel: m.sourceLabel ?? 'HTTP', interval: m.interval ?? 300, type: 'ur' }, uptimeRatios: m.uptimeRatios, history: m.history ?? [], daily, }; if (m.status === 'up') { up++; if (m.latency != null) { latencySum += m.latency; latencyCount++; } } else if (m.status === 'down') down++; else pending++; } await Promise.all( localTargets.map(async (m) => { const prev = monitors[m.id]; const r = await probeMonitor(m); r.checkedAt = new Date().toISOString(); const history = [...(prev?.history ?? []), { t: r.checkedAt, ok: r.status === 'up', ms: r.latency ?? 0 }].slice(-HISTORY_LIMIT); const daily = { ...(prev?.daily ?? {}) }; const key = dayKey(); const bucket = daily[key] ?? (daily[key] = { total: 0, up: 0 }); bucket.total++; if (r.status === 'up') bucket.up++; pruneDaily(daily); monitors[m.id] = { status: r.status, latency: r.latency, code: r.code, detail: redact ? (r.status === 'up' ? '正常' : '异常') : r.detail, lastCheck: r.checkedAt, history, daily, }; if (r.status === 'up') { up++; latencySum += r.latency ?? 0; latencyCount++; } else if (r.status === 'down') { down++; } else { pending++; } }) ); // UR 侧已删除的监控要从面板移除,避免残留僵尸条目 if (scope !== 'config' && urEnabled && urStatus.ok && urById.size) { for (const [id, s] of Object.entries(monitors)) { if (s.meta?.source === 'uptimerobot' && !urById.has(id)) delete monitors[id]; } } let uptimeUp = 0; let uptimeTotal = 0; for (const m of Object.values(monitors)) { for (const bucket of Object.values(m.daily ?? {})) { uptimeTotal += bucket.total ?? 0; uptimeUp += bucket.up ?? 0; } } // 与本地探针保持同一份快照口径:dataUpdatedAt / updateIntervalSec / dataFilter // 面板据此把倒计时对齐服务端节奏,并按同一过滤口径展示 const intervals = []; if (filter !== 'uptimerobot') { for (const m of config.monitors ?? []) { const n = Number(m.interval); if (Number.isFinite(n) && n > 0) intervals.push(n); } } const next = { prober: `shiroko-monitor-worker/${VERSION}`, demo: false, generatedAt: new Date().toISOString(), // state.generatedAt 是上一轮探测完成的时间,即「数据最后变更时刻」 dataUpdatedAt: state.generatedAt ?? new Date().toISOString(), updateIntervalSec: Math.max(5, Math.round(intervals.length ? Math.min(...intervals) : 60)), dataFilter: filter, probeScope: scope, sources: { uptimerobot: { ...urStatus, syncing: false } }, stats: { total: localTargets.length + urSource.length, up, down, pending, avgLatency: latencyCount ? Math.round(latencySum / latencyCount) : null, avgUptime90: uptimeTotal ? Math.round((uptimeUp / uptimeTotal) * 1000) / 10 : null, }, monitors, }; await env.MONITOR_KV.put('state', JSON.stringify(next)); return next; }