chore: v0.6.0 baseline — 主题美化前存档
已实现:HTTP/TCP/Banner 探测、90 天可用率、表格/卡片视图、 数据源过滤、UptimeRobot 双向兼容、服务端驱动的刷新节奏、 刷新接口滥用防护与信息泄露控制。
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* shiroko-monitor 边缘探针(Cloudflare Workers)
|
||||
*
|
||||
* - scheduled:cron 每分钟触发一轮探测(HTTP 用 fetch,TCP 用 cloudflare:sockets)
|
||||
* - 状态与历史心率整体存于 KV 的 state 键(小规模单键足够,上限 25MB)
|
||||
* - /api/status、/api/monitors、/api/refresh 由本 Worker 提供,其余请求交给静态资源
|
||||
*
|
||||
* 注意:cloudflare:sockets 的 TCP 出站连接需要 Workers 付费计划;
|
||||
* 免费计划下 http 类型探测与静态面板均可正常使用,tcp 类型会返回 down。
|
||||
*/
|
||||
|
||||
import { connect } from 'cloudflare:sockets';
|
||||
|
||||
const VERSION = '0.6.0';
|
||||
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));
|
||||
}
|
||||
return env.ASSETS.fetch(request);
|
||||
},
|
||||
};
|
||||
|
||||
// 未配置 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;
|
||||
let up = 0;
|
||||
let down = 0;
|
||||
let pending = 0;
|
||||
let latencySum = 0;
|
||||
let latencyCount = 0;
|
||||
|
||||
await Promise.all(
|
||||
(config.monitors ?? []).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 {
|
||||
down++;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const next = {
|
||||
prober: `shiroko-monitor-worker/${VERSION}`,
|
||||
demo: false,
|
||||
generatedAt: new Date().toISOString(),
|
||||
stats: {
|
||||
total: (config.monitors ?? []).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;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
name = "shiroko-monitor"
|
||||
main = "worker.js"
|
||||
compatibility_date = "2025-09-01"
|
||||
|
||||
[triggers]
|
||||
crons = ["* * * * *"]
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "MONITOR_KV"
|
||||
id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID"
|
||||
|
||||
[assets]
|
||||
directory = "../../public"
|
||||
binding = "ASSETS"
|
||||
Reference in New Issue
Block a user