chore: v0.6.0 baseline — 主题美化前存档

已实现:HTTP/TCP/Banner 探测、90 天可用率、表格/卡片视图、
数据源过滤、UptimeRobot 双向兼容、服务端驱动的刷新节奏、
刷新接口滥用防护与信息泄露控制。
This commit is contained in:
tamakyi
2026-10-13 18:31:58 +08:00
commit 3db3ce570a
31 changed files with 5136 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
import net from 'node:net';
// 协议族识别只用于生成「SSH 响应正常」这类摘要;版本号等细节默认不回显,避免写入公开的 status.json
const FAMILIES = [
{ re: /^SSH-\d/i, name: 'SSH' },
{ re: /^220[\s\S]*ESMTP/i, name: 'SMTP' },
{ re: /^220[\s\S]*FTP/i, name: 'FTP' },
{ re: /^\+OK/i, name: 'POP3' },
{ re: /^\* OK/i, name: 'IMAP' },
{ re: /^HTTP\/\d/i, name: 'HTTP' },
{ re: /^220\b/i, name: 'SMTP/TCP' },
];
/**
* 被动 banner 探测:连接后仅读取服务端主动发送的标识串(RFC 4253 的 SSH banner 即此类),
* 不做认证、不发送任何数据、不执行任何命令——因此不会触发目标侧的登录失败计数或封禁。
* 需要客户端先发字节的协议(Redis/MySQL)不适用,属于路线图内容。
*/
export function probeBanner(monitor) {
const started = Date.now();
const timeoutMs = (monitor.timeout ?? 10) * 1000;
const maxBytes = Math.max(32, Number(monitor.maxBannerBytes) || 512);
return new Promise((resolve) => {
const socket = net.createConnection({ host: monitor.host, port: monitor.port });
let raw = '';
let settled = false;
const done = (status, detail) => {
if (settled) return;
settled = true;
socket.destroy();
resolve({ status, latency: Date.now() - started, code: null, detail });
};
const evaluate = () => {
const line = (raw.split(/\r?\n/)[0] ?? '').trim();
const family = FAMILIES.find((f) => f.re.test(line))?.name ?? null;
if (monitor.expectBanner) {
let ok = false;
try {
ok = new RegExp(monitor.expectBanner, monitor.bannerFlags || 'i').test(line);
} catch {
return done('down', 'banner 规则无法编译(expectBanner 不是合法正则)');
}
// 不匹配时只报告策略结果,不回显实际收到的标识串
if (!ok) return done('down', '标识串与期望规则不符');
}
const label = family ? `${family} 响应正常` : '收到服务标识串';
return done('up', monitor.revealBanner ? `${label}:${line.slice(0, 80)}` : label);
};
socket.setTimeout(timeoutMs, () => done('down', '等待标识串超时'));
socket.once('error', (err) =>
done('down', err.code === 'ECONNREFUSED' ? '连接被拒绝(端口关闭)' : (err.code || err.message))
);
// 对端发完就断开(无换行结尾)也算有效响应
socket.once('end', () => (raw ? evaluate() : done('down', '连接被对端关闭且未发送标识串')));
// 只在收到换行或达到字节上限时判定,避免把分包到达的标识串截断
socket.on('data', (chunk) => {
raw += chunk.toString('latin1');
if (/[\r\n]/.test(raw) || raw.length >= maxBytes) evaluate();
});
});
}
+31
View File
@@ -0,0 +1,31 @@
const USER_AGENT = 'shiroko-monitor/0.2 (lightweight uptime panel)';
export async function probeHttp(monitor) {
const started = Date.now();
const timeoutMs = (monitor.timeout ?? 10) * 1000;
try {
// redirect: manual —— 保留 3xx 原始状态码交给 expectStatus 判定,避免跟随重定向掩盖异常
const res = await fetch(monitor.url, {
method: monitor.method || 'GET',
redirect: 'manual',
signal: AbortSignal.timeout(timeoutMs),
headers: { 'user-agent': USER_AGENT, accept: '*/*' },
});
const latency = Date.now() - started;
const expect = monitor.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;
let detail;
if (err.name === 'TimeoutError') detail = `请求超时(>${timeoutMs / 1000}s)`;
else if (err.cause?.code) detail = err.cause.code;
else detail = err.message;
return { status: 'down', latency, code: 0, detail };
}
}
+18
View File
@@ -0,0 +1,18 @@
import net from 'node:net';
export function probeTcp(monitor) {
const started = Date.now();
const timeoutMs = (monitor.timeout ?? 10) * 1000;
return new Promise((resolve) => {
const socket = net.createConnection({ host: monitor.host, port: monitor.port });
const finish = (status, detail) => {
socket.destroy();
resolve({ status, latency: Date.now() - started, code: null, detail });
};
socket.setTimeout(timeoutMs, () => finish('down', 'TCP 连接超时'));
socket.once('error', (err) =>
finish('down', err.code === 'ECONNREFUSED' ? '连接被拒绝(端口关闭)' : (err.code || err.message))
);
socket.once('connect', () => finish('up', '端口可连接'));
});
}
+140
View File
@@ -0,0 +1,140 @@
/**
* UptimeRobot 只读数据源(API v2)
*
* 作用:把 UptimeRobot 已有的监控结果接入本面板,作为「异地探测视角」与自建探针并列展示。
* 只读:仅调用 getMonitors,不做任何写操作(暂停/编辑/删除),因此用 ur* 开头的只读 Key 即可。
*
* 术语对齐(UptimeRobot v2):
* monitor.type 1=HTTP(s) 2=Keyword 3=Ping 4=Port
* monitor.status 0=暂停 1=未检测 2=正常 8/9=异常
* logs[].type 1=故障 2=恢复 98=开始监控 99=暂停
* logs[].reason { code: "403", detail: "Forbidden" }
* custom_uptime_ratio / custom_down_durations 按请求顺序以 '-' 分隔
*/
const API_URL = 'https://api.uptimerobot.com/v2/getMonitors';
const HISTORY_LIMIT = 48;
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((v) => Number(v))
.filter((v) => Number.isFinite(v));
}
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 响应映射成本探针的内部结构(可离线单测)
* @param {object} payload getMonitors 原始响应
* @param {{group?: string, ratios?: number[], now?: number}} [options]
*/
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 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, HISTORY_LIMIT)
.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),
// 与 UR 侧一致:编号、协议、检测间隔
source: 'uptimerobot',
sourceId: m.id,
sourceType: m.type,
sourceLabel: TYPE_LABEL[m.type] ?? `TYPE${m.type}`,
interval: Number(m.interval) || 300,
status,
latency,
// UR 不直接给「当前 HTTP 状态码」,故障时取最近一次故障日志里的 code
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),
createDatetime: Number(m.create_datetime) || null,
});
}
return monitors;
}
export async function fetchUptimeRobotMonitors({ apiKey, timeoutMs = 15000, ratios = [1, 7, 30, 90] }) {
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),
// 但响应中默认会带该字段,故 downSeconds 仍可解析
});
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 });
}