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。
This commit is contained in:
tamakyi
2026-10-14 14:51:28 +08:00
parent 752fad412b
commit d2aa0a1163
11 changed files with 1013 additions and 331 deletions
+115 -6
View File
@@ -2,16 +2,21 @@
* 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.11.2';
const VERSION = '0.12.1';
const HISTORY_LIMIT = 48;
const UPTIME_DAYS = 90;
const USER_AGENT = 'shiroko-monitor/0.3 (lightweight uptime panel)';
@@ -42,10 +47,21 @@ export default {
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) {
@@ -154,14 +170,98 @@ async function runChecks(env) {
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(
(config.monitors ?? []).map(async (m) => {
localTargets.map(async (m) => {
const prev = monitors[m.id];
const r = await probeMonitor(m);
r.checkedAt = new Date().toISOString();
@@ -187,12 +287,21 @@ async function runChecks(env) {
up++;
latencySum += r.latency ?? 0;
latencyCount++;
} else {
} 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)) {
@@ -204,7 +313,6 @@ async function runChecks(env) {
// 与本地探针保持同一份快照口径: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 ?? []) {
@@ -221,15 +329,16 @@ async function runChecks(env) {
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: (config.monitors ?? []).length,
total: localTargets.length + urSource.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));