Files
shiroko-monitor/edge/cloudflare/worker.js
T
tamakyi bdaa31f10c feat: P0 通知(邮件 + MeoW)与 TLS 证书监控,事件时间线与维护窗口
通知(P0-1):
- 邮件:自写 SMTP 客户端,零依赖。465 隐式 TLS / 587·25 STARTTLS、
  AUTH LOGIN+PLAIN、RFC2047 中文主题、base64 正文、多收件人;
  服务器不支持 STARTTLS 时默认拒绝明文(需显式 allowInsecure)
- MeoW:按官方文档实现,昵称寻址、msgType/htmlHeight 走查询参数、
  以响应体 status 判定成功(HTTP 恒 200)、429/404/403 可读化、
  本地限流器遵守 3s/15min/60h
- 告警:连续 N 次失败去抖 + 恢复通知 + 同轮合并 + 去抖状态持久化
  (data/notify-state.json,gitignore)+ 静默时段(跨零点)
- --test-notify 自检命令、--data-dir / SHIROKO_DATA_DIR

证书(P0-2):
- 独立 TLS 握手取 notAfter(不动既有 fetch 探测路径),默认 6 小时一次
- 面板徽标 ≤14 天黄 / ≤7 天红,阈值 14/7/3 各告一次、已过期只报最严重一档
- 单个目标可用 tls: false 关闭

事件与维护(P1-1 的后端部分):
- data/events.json 追加式事件流(最近 500 条)+ GET /api/events +
  快照内附最近 20 条;redactDetails 时脱敏
- 维护窗口:窗口内不计入可用率、不发告警、显示「维护中」徽标

测试抓到并修复的三个真 bug:
1. SMTP 空闲超时无人处理 → 服务器不响应会把探针循环挂死
2. MeoW 限流器把空历史当 0 → 第一条通知永远被自己拦掉
3. 去抖状态在无事轮次不落盘 → 连续失败计数丢失、永远达不到阈值

验证:新增 verify-notify.cjs 39 项(假 SMTP / 假 MeoW / 三轮集成 / 维护窗口 /
真实证书握手),六套合计 154 项全通过。
2026-10-13 23:54:46 +08:00

238 lines
8.5 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)
* - 状态与历史心率整体存于 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.10.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;
}
}
// 与本地探针保持同一份快照口径: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 ?? []) {
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,
stats: {
total: (config.monitors ?? []).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));
return next;
}