/** * shiroko-monitor 边缘探针(Cloudflare Workers) * * - scheduled:cron 每分钟触发一轮探测(HTTP 用 fetch,TCP 用 cloudflare:sockets) * - 状态与历史心率整体存于 KV 的 state 键(小规模单键足够,上限 25MB) * - /api/status、/api/monitors、/api/refresh 由本 Worker 提供,其余请求交给静态资源 * * 注意:cloudflare:sockets 的 TCP 出站连接需要 Workers 付费计划; * 免费计划下 http 类型探测与静态面板均可正常使用,tcp 类型会返回 down。 */ import { connect } from 'cloudflare:sockets'; const VERSION = '0.9.1'; 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(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)); } return env.ASSETS.fetch(request); }, }; // 未配置 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: [] }; } 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; let up = 0; let down = 0; let pending = 0; let latencySum = 0; let latencyCount = 0; await Promise.all( (config.monitors ?? []).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 { down++; } }) ); 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 filter = ['both', 'config', 'uptimerobot'].includes(config.settings?.dataFilter) ? config.settings.dataFilter : 'both'; 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, stats: { total: (config.monitors ?? []).length, up, down, pending, avgLatency: latencyCount ? Math.round(latencySum / latencyCount) : null, avgUptime90: uptimeTotal ? Math.round((uptimeUp / uptimeTotal) * 1000) / 10 : null, }, sources: {}, monitors, }; await env.MONITOR_KV.put('state', JSON.stringify(next)); return next; }