"use strict"; /** * 巡检执行器 - 用 ssh2 临时建立 SSH 连接执行命令 * * 用于设备巡检的命令执行:每次巡检临时建立连接,执行完即释放, * 不占用 terminalWorker 的连接池(巡检是低频后台任务)。 */ const { Client: SSHClient } = require("ssh2"); const { buildAlgorithms } = require("../sshAlgorithms.cjs"); const DEFAULT_CONNECT_TIMEOUT_MS = 10_000; const DEFAULT_EXEC_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 100 * 1024; // 100KB /** * 执行单条 SSH 命令(使用 pty shell 方式,兼容交换机等需要交互式 shell 的设备) * @param {object} connConfig - ssh2 连接配置(host/port/username/password/privateKey 等) * @param {string} command - 要执行的命令 * @param {object} [options] * @param {number} [options.connectTimeoutMs] * @param {number} [options.execTimeoutMs] * @param {number} [options.maxOutputBytes] * @returns {Promise} 命令输出 */ function execSshCommand(connConfig, command, options = {}) { const connectTimeoutMs = options.connectTimeoutMs || DEFAULT_CONNECT_TIMEOUT_MS; const execTimeoutMs = options.execTimeoutMs || DEFAULT_EXEC_TIMEOUT_MS; const maxOutputBytes = options.maxOutputBytes || DEFAULT_MAX_OUTPUT_BYTES; const DEBUG = process.env.INSPECTION_DEBUG === "1"; if (DEBUG) { console.log("[InspectExec] SSH host:", connConfig.host, "| cmd:", command.slice(0, 100)); } return new Promise((resolve, reject) => { const client = new SSHClient(); let settled = false; let stdout = ""; let execTimer = null; let connectTimer = null; let sendCommandTimer = null; let outputDoneTimer = null; let commandSent = false; // 匹配行尾是否是 shell prompt(同时兼容交换机和 Linux) function hasPromptTail(line) { // 交换机: 结尾 或 hostname> / hostname# 结尾 if (/<[\w\-./]+>\s*$/.test(line)) return true; if (/^[\w\-./]+[>#]\s*$/.test(line)) return true; // Linux bash/zsh: user@host...$ / user@host...# 结尾 if (/[\w.-]+@[\w.-]+.*[$#]\s*$/.test(line)) return true; // Linux 路径 prompt: ~$ / /tmp# 等 if (/[:~\/]\s*[$#]\s*$/.test(line)) return true; return false; } if (DEBUG) { client.on("banner", (msg) => console.log("[InspectExec] banner:", msg.slice(0, 100))); client.on("handshake", () => console.log("[InspectExec] handshake OK")); client.on("error", (err) => console.error("[InspectExec] client error:", err.message)); client.on("close", () => console.log("[InspectExec] client closed")); } const cleanup = () => { if (execTimer) clearTimeout(execTimer); if (connectTimer) clearTimeout(connectTimer); if (sendCommandTimer) clearTimeout(sendCommandTimer); if (outputDoneTimer) clearTimeout(outputDoneTimer); try { client.end(); } catch (e) { /* ignore */ } }; const done = (err, result) => { if (settled) return; settled = true; cleanup(); if (err) reject(err); else resolve(result); }; // 连接超时 connectTimer = setTimeout(() => { console.error("[InspectExec] 连接超时,host:", connConfig.host); done(new Error("SSH 连接超时(" + connectTimeoutMs / 1000 + "s)")); }, connectTimeoutMs); client.on("ready", () => { if (DEBUG) console.log("[InspectExec] SSH ready,打开 pty shell..."); clearTimeout(connectTimer); // 使用 pty shell(交互式)执行命令 client.shell({ term: "vt100", cols: 80, rows: 24 }, (err, stream) => { if (err) { console.error("[InspectExec] shell 失败:", err.message); done(new Error("打开 shell 失败: " + err.message)); return; } // 执行超时 execTimer = setTimeout(() => { console.error("[InspectExec] 命令执行超时(" + execTimeoutMs / 1000 + "s)"); done(new Error("命令执行超时")); }, execTimeoutMs); let outputBuffer = ""; let sendCommandTimer = null; let outputDoneTimer = null; // 命令发送后静默期兜底(输出完成检测) let promptStableCount = 0; // shell 稳定化:需要看到 prompt 连续出现 2 次才发命令 // shell 稳定化:先发空回车让交换机 VTY/Linux bash 进入稳定态 // shell ready 静默期 500ms(比之前的 200ms 更宽松,给 banner 时间) const scheduleSendCommand = () => { if (commandSent || sendCommandTimer) return; clearTimeout(sendCommandTimer); sendCommandTimer = setTimeout(() => { commandSent = true; sendCommandTimer = null; stream.write(command + "\n"); if (DEBUG) console.log("[InspectExec] shell 稳定,发送命令:", command); }, 500); // 500ms 给 shell 稳定时间 }; // 命令发送后,2500ms 静默期 → 兜底认为输出完成(兼容 Linux bash $ 结尾 prompt) // 注意:top/df 等命令输出可能持续 1-2s,不能太短 const scheduleOutputDone = () => { if (outputDoneTimer) clearTimeout(outputDoneTimer); outputDoneTimer = setTimeout(() => { outputDoneTimer = null; if (stdout) return; // 已完成 // 命令回显可能被 prompt 包含了 —— 做基本判断后返回 const buf = outputBuffer.replace(/__sent_q__/g, ""); if (buf.trim().length > 0) { stdout = buf; clearTimeout(execTimer); if (DEBUG) console.log("[InspectExec] 输出静默期到,兜底完成,len:", stdout.length); done(null, stdout); } else { // 什么都没收到,让 execTimer 继续等 if (DEBUG) console.log("[InspectExec] 输出静默期到但缓冲区为空,继续等待"); } }, 2500); }; stream.on("data", (data) => { const chunk = data.toString("utf-8"); outputBuffer += chunk; // 未发送命令时:等待 shell prompt 出现后再给稳定时间 if (!commandSent) { const lastLine = outputBuffer.split("\n").pop()?.trimEnd() || ""; // 精确匹配交换机/路由器 prompt const swPrompt = /^<[\w\-./]+>\s*$/.test(lastLine) || /^[\w\-./]+[>#]\s*$/.test(lastLine); if (swPrompt) { // 看到 prompt 了,但还要给 shell 稳定时间(可能需要 enable / system-view) // 不立即发命令 → 走 scheduleSendCommand 的 500ms 静默期逻辑 scheduleSendCommand(); } else { scheduleSendCommand(); } } // 命令发送后,收集输出 if (commandSent) { // 重置输出静默期计时器(持续有数据就不触发兜底) scheduleOutputDone(); // 检测分页提示,发送 'q' 退出 if (outputBuffer.includes("-- More --") || outputBuffer.includes("---- More ----")) { if (!outputBuffer.includes("__sent_q__")) { stream.write("q"); outputBuffer += "__sent_q__"; // 避免重复发送 if (DEBUG) console.log("[InspectExec] 检测到分页,发送 q"); } } // 检测输出完成:匹配末尾 prompt(同时兼容交换机和 Linux) const lines = outputBuffer.split("\n"); // 至少收到了命令本身的回显 + 一些数据才检查 if (lines.length >= 2) { const lastContentLine = lines[lines.length - 1]?.trimEnd() || ""; const prevContentLine = lines.length >= 2 ? lines[lines.length - 2]?.trimEnd() || "" : ""; // 匹配最后一行或倒数第二行的 prompt const promptEnd = lastContentLine.length > 0 && hasPromptTail(lastContentLine); const prevPromptEnd = prevContentLine.length > 0 && hasPromptTail(prevContentLine); if (promptEnd || prevPromptEnd) { stdout = outputBuffer.replace(/__sent_q__/g, ""); clearTimeout(execTimer); clearTimeout(outputDoneTimer); if (DEBUG) console.log("[InspectExec] 命令完成,行数:", lines.length); done(null, stdout); } } } // 防止缓冲区过大 if (outputBuffer.length > maxOutputBytes * 2) { stdout = outputBuffer.replace(/__sent_q__/g, ""); clearTimeout(execTimer); clearTimeout(outputDoneTimer); if (DEBUG) console.log("[InspectExec] 缓冲区满,强制返回"); done(null, stdout); } }); stream.stderr.on("data", (data) => { // stderr 通常为空 }); stream.on("close", () => { clearTimeout(execTimer); if (!commandSent) { // 没等到提示符就关闭了,再试一次发送命令 commandSent = true; stream.write(command + "\n"); } else if (stdout) { // 已有输出,正常结束 done(null, stdout); } else { done(new Error("连接意外关闭")); } }); stream.on("error", (e) => { console.error("[InspectExec] stream error:", e.message); done(new Error("流错误: " + e.message)); }); }); }); client.on("error", (err) => { clearTimeout(connectTimer); console.error("[InspectExec] SSH 连接失败:", err.message); done(new Error("SSH 连接失败: " + err.message)); }); try { client.connect({ host: connConfig.host, port: connConfig.port || 22, username: connConfig.username, password: connConfig.password || undefined, privateKey: connConfig.privateKey || undefined, passphrase: connConfig.passphrase || undefined, readyTimeout: connectTimeoutMs, // 跳过主机密钥验证 hostVerifier: () => true, // 使用 NetMesh 的算法构建器,启用旧算法兼容,跳过 ECDSA 主机密钥 algorithms: buildAlgorithms(true, { skipEcdsaHostKey: true }), }); } catch (e) { clearTimeout(connectTimer); done(e); } }); } /** * 批量执行多条 SSH 命令(一次连接,执行多条命令) * @param {object} connConfig - ssh2 连接配置 * @param {string[]} commands - 命令数组 * @param {object} [options] * @returns {Promise} { outputs: { command, stdout }[] } */ function execSshCommands(connConfig, commands, options = {}) { const connectTimeoutMs = options.connectTimeoutMs || DEFAULT_CONNECT_TIMEOUT_MS; const totalTimeoutMs = options.totalTimeoutMs || 180000; // 总超时 3 分钟 const maxOutputBytes = options.maxOutputBytes || DEFAULT_MAX_OUTPUT_BYTES; const DEBUG = process.env.INSPECTION_DEBUG === "1"; if (DEBUG) { console.log("[InspectExec] 批量执行:", connConfig.host, "| 命令数:", commands.length); } return new Promise((resolve, reject) => { const client = new SSHClient(); let settled = false; let connectTimer = null; let totalTimer = null; let cmdTimeout = null; let outputBuffer = ""; let commandIndex = 0; let phase = "wait-login"; // wait-login → disable-paging → idle → waiting-output → done let disableRetries = 0; const results = []; const cleanup = () => { if (connectTimer) clearTimeout(connectTimer); if (totalTimer) clearTimeout(totalTimer); if (cmdTimeout) clearTimeout(cmdTimeout); try { client.end(); } catch (e) { /* ignore */ } }; const done = (err, result) => { if (settled) return; settled = true; cleanup(); if (err) reject(err); else resolve(result); }; // 总超时 totalTimer = setTimeout(() => { done(new Error("批量命令执行超时(" + totalTimeoutMs / 1000 + "s)")); }, totalTimeoutMs); // 匹配提示符(同时兼容交换机 / hostname> 和 Linux bash/zsh) function hasPrompt(text) { const lines = (text || '').split('\n'); // 从后往前找非空行(最后一行通常是空的,我们看倒数第一和倒数第二) const findLastLine = () => { for (let i = lines.length - 1; i >= 0; i--) { const trimmed = lines[i].trimEnd(); if (trimmed.length > 0) return trimmed; } return ''; }; const lastLine = findLastLine(); // 如果只有一行内容,再看前一行(有些 shell 会在输出末尾多一个空行) const prevLine = lines.length >= 2 ? lines[lines.length - 2].trimEnd() : ''; if (DEBUG) { console.log("[InspectExec] hasPrompt check lastLine:", JSON.stringify(lastLine.slice(-100)), "| prevLine:", JSON.stringify(prevLine.slice(-100))); } // 华为 H3C VRP prompt(尖括号包裹,可能带 # root 标记、-D slot 标记): // — 用户视图 // — 系统视图 / root prompt // — 指定 slot 的 root prompt // — SSH 登录提示等 if (/<[\w\-./]+(?:[#~]\w*)*(?:-\w+)?>\s*$/.test(lastLine)) return true; if (/<[\w\-./]+(?:[#~]\w*)*(?:-\w+)?>\s*$/.test(prevLine)) return true; // Cisco IOS / 纯 hostname prompt(无尖括号): hostname> 或 hostname# if (/^[\w\-./]+[>#]\s*$/.test(lastLine)) return true; if (/^[\w\-./]+[>#]\s*$/.test(prevLine)) return true; // Linux bash: user@host:path$ 或 root@host:path#(必须有 @ 符号区分 "user@host" 和命令输出里的路径) if (/[\w.-]+@[\w.-]+[:\s].*[$#]\s*$/.test(lastLine)) return true; // 简单 Linux prompt 兜底: 行尾是 # 或 $ 且前面有路径特征(~/ / path) if (/[:~\/]\s*[$#]\s*$/.test(lastLine)) return true; return false; } client.on("ready", () => { if (DEBUG) console.log("[InspectExec] SSH ready,打开 shell..."); clearTimeout(connectTimer); client.shell({ term: "vt100", cols: 200, rows: 24 }, (err, stream) => { if (err) { done(new Error("打开 shell 失败: " + err.message)); return; } let phase = "wait-login"; // wait-login → idle → waiting-output → done let promptCheckTimer = null; // debounced prompt check // 延迟 150ms 再检查 prompt —— 给 vt100 pty 足够时间把一整行数据吐出来 // 避免逐字节 stream 'data' 事件导致永远 split 不到 \n const schedulePromptCheck = () => { if (promptCheckTimer) clearTimeout(promptCheckTimer); promptCheckTimer = setTimeout(() => { promptCheckTimer = null; _runPromptCheck(); }, 150); }; const _runPromptCheck = () => { if (phase === "wait-login") { if (hasPrompt(outputBuffer)) { phase = "idle"; outputBuffer = ""; if (DEBUG) console.log("[InspectExec] 登录成功,进入 idle,准备发送第一条命令(由 reportCommands 提供)"); sendNextCommand(); } } else if (phase === "waiting-output") { const moreIdx = outputBuffer.indexOf("-- More --"); if (moreIdx !== -1 || outputBuffer.indexOf("---- More ----") !== -1) { if (DEBUG) console.log("[InspectExec] 检测到分页,发送空格继续"); stream.write(" "); outputBuffer = outputBuffer.replace(/--\s*More\s*--/gi, "").replace(/----\s*More\s*----/gi, ""); schedulePromptCheck(); // 分页处理完继续等 return; } if (hasPrompt(outputBuffer)) { const cmdOutput = cleanOutput(outputBuffer); results[results.length - 1].output = cmdOutput; outputBuffer = ""; phase = "idle"; if (DEBUG) console.log("[InspectExec] 命令输出完成,进入 idle"); sendNextCommand(); } } }; stream.on("data", (data) => { const chunk = data.toString("utf-8"); outputBuffer += chunk; if (DEBUG && outputBuffer.length % 1000 < 100) { const lines = outputBuffer.split('\n'); console.log(`[InspectExec] phase=${phase} bufLen=${outputBuffer.length} lastLines=${JSON.stringify(lines.slice(-3))}`); } // 每次收到数据都重置 debounce timer —— 持续有数据就不检查, // 等 vt100 pty 静默 150ms 后再统一跑 hasPrompt schedulePromptCheck(); }); stream.on("close", () => { if (phase === "done") { // 主动 quit 关闭的,正常 resolve if (!settled) done(null, { results }); return; } if (!settled) { done(new Error("连接意外关闭")); } }); stream.on("error", (e) => { done(new Error("流错误: " + e.message)); }); function sendNextCommand() { if (commandIndex >= commands.length) { if (DEBUG) console.log("[InspectExec] 所有命令执行完毕"); phase = "done"; stream.write("quit\n"); setTimeout(() => done(null, { results }), 500); return; } const cmd = commands[commandIndex]; if (DEBUG) console.log("[InspectExec] 发送命令 " + (commandIndex + 1) + "/" + commands.length + ":", cmd.slice(0, 50)); stream.write(cmd + "\n"); results.push({ command: cmd, output: "" }); commandIndex++; phase = "waiting-output"; // 每个命令 15 秒超时,防止卡住 if (cmdTimeout) clearTimeout(cmdTimeout); cmdTimeout = setTimeout(() => { if (phase === "waiting-output") { if (DEBUG) console.log("[InspectExec] 命令超时,强制完成"); const cmdOutput = cleanOutput(outputBuffer); results[results.length - 1].output = cmdOutput; outputBuffer = ""; phase = "idle"; sendNextCommand(); } }, 15000); } function cleanOutput(text) { return (text || "") // ANSI escape sequences(\x1B[... 系列 — 光标移动、颜色、进度条等) .replace(/\x1B\[[0-9;]*[A-Za-z]/g, "") .replace(/\x1B\][^\x07\x1B]*(\x07|\x1B\\)/g, "") .replace(/\x1B[PX][^\x1B]*\x1B\\/g, "") // 其他常见控制序列 .replace(/\x1B[@-_][0-9;]*[ -\/]*[@-~]/g, "") // 华为 CLI 的 More 提示 .replace(/--\s*More\s*--/gi, "") .replace(/----\s*More\s*----/gi, "") // 清理剩余可能的单控制字符 .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "") .replace(/\r/g, "") .trim(); } }); }); client.on("error", (err) => { clearTimeout(connectTimer); done(new Error("SSH 连接失败: " + err.message)); }); try { client.connect({ host: connConfig.host, port: connConfig.port || 22, username: connConfig.username, password: connConfig.password || undefined, privateKey: connConfig.privateKey || undefined, passphrase: connConfig.passphrase || undefined, readyTimeout: connectTimeoutMs, hostVerifier: () => true, algorithms: buildAlgorithms(true, { skipEcdsaHostKey: true }), // 保活配置,防止交换机 VTY 超时断开 keepaliveInterval: 30000, // 每 30 秒发送一次心跳 keepaliveCountMax: 3, // 最多重试 3 次 }); } catch (e) { done(e); } }); } module.exports = { execSshCommand, execSshCommands, DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_EXEC_TIMEOUT_MS, };