Files
shiroko-monitor/prober/node/lib/banner-probe.js
T
tamakyi 3db3ce570a chore: v0.6.0 baseline — 主题美化前存档
已实现:HTTP/TCP/Banner 探测、90 天可用率、表格/卡片视图、
数据源过滤、UptimeRobot 双向兼容、服务端驱动的刷新节奏、
刷新接口滥用防护与信息泄露控制。
2026-10-13 18:31:58 +08:00

65 lines
2.8 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.
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();
});
});
}