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:
@@ -0,0 +1,42 @@
|
||||
# Cloudflare Tunnel:让管理端从外网访问
|
||||
#
|
||||
# 场景:探针跑在内网/家用小主机上(没有公网入口),但你需要在手机或外网笔记本上
|
||||
# 打开 /admin 做管理操作。
|
||||
#
|
||||
# 原理:cloudflared 在你的机器上**主动向外**建立到 Cloudflare 的隧道(出站连接,
|
||||
# 不需要公网 IP、不需要开放任何入站端口),Cloudflare 再把指定子域名回源到它。
|
||||
# 因此内网无需任何端口映射。
|
||||
#
|
||||
# 用法(命名隧道,推荐):
|
||||
# 1. 安装 cloudflared
|
||||
# 2. 在 Cloudflare Zero Trust 后台创建命名隧道,取它的 token
|
||||
# 3. 打开探针的远程管理开关(默认关闭):
|
||||
# 在 local.monitors.json 里加 "settings": { "admin": { "allowRemote": true } }
|
||||
# 4. 跑:cloudflared tunnel --no-autoupdate run --token <你的 token>
|
||||
#
|
||||
# 一次性(快速隧道)命令,仅适合临时验证:
|
||||
# cloudflared tunnel --url http://localhost:8787
|
||||
# 它会打印一个随机 trycloudflare.com 地址;不要用于正式环境(无鉴权、无 SLA)。
|
||||
#
|
||||
# 前提:探针必须处于「已绑定 2FA」的状态。Tunnel 只是把端口暴露出去,
|
||||
# 真正拦住未授权访问的是管理端的 TOTP + 会话 + 失败限流。
|
||||
|
||||
tunnel: <你的隧道 ID 或名字>
|
||||
credentials-file: /etc/cloudflared/<TUNNEL_ID>.json
|
||||
|
||||
# Tunnel 只回源管理端这一条路径,面板与公开 API 不通过隧道暴露。
|
||||
# ingress 的第一条是 catch-all,必须放在最后。
|
||||
ingress:
|
||||
# 管理后台页面与接口:都指向同一个本地端口(管理端自身会校验 2FA 与会话)
|
||||
- hostname: admin.example.com
|
||||
path: ^/(admin|api/admin/.*|assets/.*)$
|
||||
service: http://localhost:8787
|
||||
|
||||
# 其余一律拒绝:不要把公开面板与 /api/status 一起暴露出去
|
||||
- service: http_status:404
|
||||
|
||||
# 探针侧的探测与通知日志仍打在 stdout,不受隧道影响。
|
||||
# 若用 systemd 常驻,建议:
|
||||
# ExecStart=/usr/local/bin/cloudflared tunnel run --no-autoupdate --token-file /etc/cloudflared/token
|
||||
# Restart=on-failure
|
||||
# RestartSec=5s
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* UptimeRobot 只读数据源(API v2)—— Cloudflare Workers 版
|
||||
*
|
||||
* 与 prober/node/lib/uptimerobot.js 共用同一份语义(术语对齐见该文件顶部注释),
|
||||
* 但按 Workers 环境改了两处:
|
||||
* 1. 没有 node:crypto / node:fs,API Key 从 env(wrangler secret)读取
|
||||
* 2. 限流改为「按 KV 里记录的上次同步时间判断」,KV 最终一致对分钟级同步无影响
|
||||
*
|
||||
* 只读:仅调用 getMonitors,不做任何写操作,因此用 ur* 开头的只读 Key 即可。
|
||||
*/
|
||||
|
||||
const API_URL = 'https://api.uptimerobot.com/v2/getMonitors';
|
||||
const TYPE_LABEL = { 1: 'HTTP', 2: 'KEYWORD', 3: 'PING', 4: 'PORT' };
|
||||
|
||||
// 暂停/未检测都归入 pending:否则会被误报成「离线」
|
||||
const STATUS_MAP = { 0: 'pending', 1: 'pending', 2: 'up', 8: 'down', 9: 'down' };
|
||||
|
||||
function splitNums(value) {
|
||||
return String(value ?? '').split('-').map(Number).filter(Number.isFinite);
|
||||
}
|
||||
|
||||
function ratiosToObject(value, ratios) {
|
||||
const nums = splitNums(value);
|
||||
const out = {};
|
||||
ratios.forEach((r, i) => { if (Number.isFinite(nums[i])) out[r] = nums[i]; });
|
||||
return out;
|
||||
}
|
||||
|
||||
function targetOf(m) {
|
||||
if (m.type === 4 && m.port) return `${m.url ? `${m.url}:` : ''}${m.port}`;
|
||||
return m.url || '';
|
||||
}
|
||||
|
||||
function latestDownReason(logs) {
|
||||
const down = (logs ?? []).find((l) => l.type === 1);
|
||||
const reason = down?.reason;
|
||||
if (!reason) return null;
|
||||
const code = String(reason.code ?? '').trim();
|
||||
const detail = String(reason.detail ?? '').trim();
|
||||
return { code: code || null, text: [code, detail].filter(Boolean).join(' ') || null };
|
||||
}
|
||||
|
||||
/** 把 UptimeRobot 的 getMonitors 响应映射成内部结构(纯函数,可离线单测) */
|
||||
export function mapUptimeRobotMonitors(payload, options = {}) {
|
||||
const group = options.group || 'UptimeRobot';
|
||||
const ratios = options.ratios ?? [1, 7, 30, 90];
|
||||
const now = options.now ?? Date.now();
|
||||
const historyLimit = options.historyLimit ?? 48;
|
||||
const monitors = [];
|
||||
|
||||
for (const m of payload?.monitors ?? []) {
|
||||
const status = STATUS_MAP[m.status] ?? 'pending';
|
||||
const samples = Array.isArray(m.response_times) ? m.response_times : [];
|
||||
const logs = Array.isArray(m.logs) ? m.logs : [];
|
||||
// UR 侧的真实检测时刻:正常取最新响应样本,故障取最近一次故障日志
|
||||
const lastCheckSec = status === 'down' ? logs.find((l) => l.type === 1)?.datetime : samples[0]?.datetime;
|
||||
const lastCheckIso = new Date((Number(lastCheckSec) || Math.floor(now / 1000)) * 1000).toISOString();
|
||||
|
||||
const history = samples
|
||||
.slice(0, historyLimit)
|
||||
.map((s) => ({ t: new Date(s.datetime * 1000).toISOString(), ok: Number(s.value) > 0, ms: Math.max(0, Number(s.value) || 0) }))
|
||||
.reverse();
|
||||
if (status === 'down') history.push({ t: lastCheckIso, ok: false, ms: 0 });
|
||||
|
||||
const last = samples[0]?.value;
|
||||
const avg = m.average_response_time;
|
||||
const latency = Number.isFinite(Number(last)) && Number(last) > 0
|
||||
? Math.round(Number(last))
|
||||
: Number.isFinite(Number(avg)) ? Math.round(Number(avg)) : null;
|
||||
|
||||
const reason = latestDownReason(logs);
|
||||
let detail;
|
||||
if (status === 'up') detail = 'UptimeRobot:运行正常';
|
||||
else if (status === 'down') detail = `UptimeRobot:${reason?.text ?? '连接失败'}`;
|
||||
else detail = m.status === 0 ? 'UptimeRobot:监控已暂停' : 'UptimeRobot:尚未检测';
|
||||
|
||||
monitors.push({
|
||||
id: `ur-${m.id}`,
|
||||
name: m.friendly_name || `UptimeRobot #${m.id}`,
|
||||
group,
|
||||
type: 'ur',
|
||||
url: targetOf(m),
|
||||
source: 'uptimerobot',
|
||||
sourceId: m.id,
|
||||
sourceType: m.type,
|
||||
sourceLabel: TYPE_LABEL[m.type] ?? `TYPE${m.type}`,
|
||||
interval: Number(m.interval) || 300,
|
||||
status,
|
||||
latency,
|
||||
code: status === 'down' && reason?.code ? Number(reason.code) || null : null,
|
||||
detail,
|
||||
lastCheckIso,
|
||||
history,
|
||||
uptimeRatios: ratiosToObject(m.custom_uptime_ratio, ratios),
|
||||
downSeconds: ratiosToObject(m.custom_down_durations, ratios),
|
||||
});
|
||||
}
|
||||
return monitors;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取 UptimeRobot 监控列表。
|
||||
* @param {string} apiKey
|
||||
* @param {{ratios?: number[], timeoutMs?: number}} [options]
|
||||
*/
|
||||
export async function fetchUptimeRobotMonitors({ apiKey, ratios = [1, 7, 30, 90], timeoutMs = 15000 }) {
|
||||
if (!apiKey) throw new Error('缺少 UptimeRobot API Key');
|
||||
const body = new URLSearchParams({
|
||||
api_key: apiKey,
|
||||
format: 'json',
|
||||
response_times: '1',
|
||||
response_times_average: '30',
|
||||
custom_uptime_ratios: ratios.join('-'),
|
||||
logs: '1',
|
||||
log_types: '1-2',
|
||||
// 注意:custom_down_durations 不能作为请求参数传入(v2 会返回 invalid_parameter)
|
||||
});
|
||||
const res = await fetch(API_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
||||
body,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!res.ok) throw new Error(`UptimeRobot HTTP ${res.status}`);
|
||||
const payload = await res.json();
|
||||
if (payload.stat !== 'ok') throw new Error(`UptimeRobot 返回错误:${payload.error?.message || payload.error?.type || 'unknown'}`);
|
||||
return mapUptimeRobotMonitors(payload, { ratios });
|
||||
}
|
||||
+115
-6
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user