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(); }); }); }