Files
shiroko-monitor/edge/cloudflare/worker.js
T
tamakyi f345626840 fix: 管理入口改为「填链接」而非绑定特定隧道方案,并修掉配置接口的密钥泄露
用户反馈:「你不需要帮我处理 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 项通过。
2026-10-14 15:23:22 +08:00

355 lines
14 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(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;
}