已实现:HTTP/TCP/Banner 探测、90 天可用率、表格/卡片视图、 数据源过滤、UptimeRobot 双向兼容、服务端驱动的刷新节奏、 刷新接口滥用防护与信息泄露控制。
65 lines
2.8 KiB
JavaScript
65 lines
2.8 KiB
JavaScript
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();
|
||
});
|
||
});
|
||
}
|