Files
shiroko-monitor/edge/cloudflare/worker.js
T
tamakyi d2aa0a1163 feat: v0.12.1 —— Worker 支持 UptimeRobot + 管理端远程访问 + 前后端分离
起因:核实「管理端在 Cloudflare 上是否有效」时发现一个更要紧的问题 ——
Worker 完全没有 UptimeRobot 同步能力(runChecks 的探测目标只有 config.monitors)。
而实际配置是 dataFilter=uptimerobot,也就是说直接部署到 Worker 面板会是空的:
它探测 8 个本地条目,而真正在监控的 6 个 UR 目标它根本不认识。

① Worker 支持 UptimeRobot(Step 1)
- edge/cloudflare/uptimerobot.js(新增):Workers 版 UR 只读数据源,
  与 prober/node/lib/uptimerobot.js 同一套语义(状态映射/ratios 解析/故障原因)
- runChecks 的探测集合扩展为「自建目标 + UR 镜像目标」;UR 目标只拉取不探测,
  采用其状态并补齐本地 daily 分桶
- probeScope 与 dataFilter 解耦(与本地探针同语义):config 范围不拉 UR,
  uptimerobot 范围不探测本地
- 同步失败时标记 sources.uptimerobot.ok=false 并保留上次数据,
  绝不把目标判为离线或从面板消失;UR 侧删除的监控自动清理
- 快照新增 probeScope;UR 目标带 meta(面板区分自建/外部)与 uptimeRatios

② 管理端远程访问(Step 2)
- settings.admin.allowRemote:探针默认只监听 127.0.0.1,显式打开才监听全网卡,
  启动日志就此告警。这是 Cloudflare Tunnel 的安全前提(已实测两种行为)
- edge/cloudflare/tunnel.yml(新增):命名隧道配置,只放行
  ^/(admin|api/admin/.*|assets/.*)$,其余 404 —— 不把公开面板一起挂到公网

③ 前后端分离(Step 3)
- Worker 侧 /admin 与 /api/admin/* 显式返回 403 + 指向文档的提示,
  而非默默落到静态资源变成 404,确保边缘侧没有任何可写面

测试:新增 scripts/verify-worker.cjs(36 项)—— 把 Worker 以 data: URL 动态载入以
绕开 cloudflare:sockets,注入假 KV/静态资源/UptimeRobot 服务器,
不需要 wrangler 也不需要联网。全项目 8 套 310 项全部通过。

过程中抓到两个真 bug:
1. UR 同步失败的降级路径引用了不含 meta 的旧对象,导致 name/url 为 undefined、
   UR 目标从面板上消失
2. stats.total 按 config.monitors.length 计数,在 probeScope=uptimerobot 时
   与实际参与的目标数不符

另有一条测试夹具教训:内存版 KV 必须实现 get(key, 'json') 的类型参数
(真实 Workers KV 会自动反序列化),否则 state.monitors 恒为 undefined。
2026-10-14 14:51:28 +08:00

347 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.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));
}
// 管理端只存在于本地 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: [] };
}
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;
}