用户反馈:「你不需要帮我处理 tunnel 这部分,也许我不是通过 tunnel 开放内网呢? 你只需要给我一个填写开放的管理端链接的地方。」 之前把「Cloudflare Tunnel」当成既定方案写进了配置与文档,这是替用户做了假设。 现在改为:项目只提供一个填地址的地方,怎么暴露完全由使用者决定。 - 删除 edge/cloudflare/tunnel.yml 与全部 tunnel 相关文档 - 新增 settings.adminUrl:管理界面里填一个地址,公开面板页脚出现「管理」按钮 指向它;留空则完全不显示(默认,避免在公网面板暴露管理面位置) - 只接受 http/https,javascript: 等协议被服务端拒绝(会被渲染成 <a href>) - 设置落在 local.monitors.json(私有层),只有自己的面板会出现该按钮 - 面板启动时额外拉 /api/monitors 合并 settings:原先只读静态 monitors.json (公开层),导致私有层的设置面板看不到;纯静态部署无该接口时沿用公开配置 顺带修掉一个既存漏洞(与本轮改动无关,是引入私有层叠加机制时埋下的): /api/monitors 无鉴权却回吐合并后的完整配置,包含私有层的 UptimeRobot API Key —— 只要配了 key,任何访客 curl /api/monitors 就能拿到。现在两侧(Node 与 Worker) 都对对外响应剥离 uptimeRobot.apiKey,保留 enabled 等非敏感字段供面板读取; 探针内部同步仍用完整配置,剥离只作用于 HTTP 出口。 测试:verify-worker 36→40 项(新增 [E] 配置接口密钥泄露 4 条)、 verify-security 20→25 项(新增 [D2] 密钥泄露 5 条)。全项目 8 套 314 项通过。
355 lines
14 KiB
JavaScript
355 lines
14 KiB
JavaScript
/**
|
||
* 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(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;
|
||
}
|