Files

418 lines
15 KiB
JavaScript
Raw Permalink Normal View History

"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<string>} 命令输出
*/
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 commandSent = 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);
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 lastLogTime = 0;
stream.on("data", (data) => {
const chunk = data.toString("utf-8");
outputBuffer += chunk;
// 检测是否出现命令提示符
if (!commandSent) {
// 交换机提示符格式: <hostname> 或 hostname>
if (chunk.includes(">") || chunk.includes("<")) {
commandSent = true;
stream.write(command + "\n");
if (DEBUG) console.log("[InspectExec] 已发送命令:", command);
}
}
// 命令发送后,收集输出
if (commandSent) {
// 检测分页提示,发送 '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");
}
}
// 检测输出完成:出现新提示符
const lines = outputBuffer.split("\n");
if (lines.length > 5) {
// 检查倒数第二行是否是提示符(因为最后一行为空)
const lastContentLine = lines[lines.length - 2]?.trim() || "";
if (lastContentLine.includes(">") || lastContentLine.includes("#")) {
stdout = outputBuffer.replace(/__sent_q__/g, "");
clearTimeout(execTimer);
if (DEBUG) console.log("[InspectExec] 命令完成,行数:", lines.length);
done(null, stdout);
}
}
}
// 防止缓冲区过大
if (outputBuffer.length > maxOutputBytes * 2) {
stdout = outputBuffer.replace(/__sent_q__/g, "");
clearTimeout(execTimer);
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<object>} { 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> 或 hostname>/hostname# 格式
function hasPrompt(text) {
const lines = (text || '').split('\n');
// 从后往前找非空行
let lastLine = '';
for (let i = lines.length - 1; i >= 0; i--) {
const trimmed = lines[i].trimEnd();
if (trimmed.length > 0) {
lastLine = trimmed;
break;
}
}
if (DEBUG) {
console.log("[InspectExec] hasPrompt check:", JSON.stringify(lastLine.slice(-100)));
}
// <hostname> 格式(H3C/华为 VRP)
if (/<[\w\-./]+>\s*$/.test(lastLine)) return true;
// hostname> 或 hostname# 格式
if (/[\w\-./]+[>#]\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 → disable-paging → idle → waiting-output → done
let disableRetries = 0;
stream.on("data", (data) => {
const chunk = data.toString("utf-8");
outputBuffer += chunk;
if (DEBUG && outputBuffer.length % 1000 < 100) {
// 每隔约 1KB 打印一次状态(避免刷屏)
const lines = outputBuffer.split('\n');
console.log(`[InspectExec] phase=${phase} bufLen=${outputBuffer.length} lastLines=${JSON.stringify(lines.slice(-3))}`);
}
if (phase === "wait-login") {
if (hasPrompt(outputBuffer)) {
phase = "disable-paging";
outputBuffer = "";
if (DEBUG) console.log("[InspectExec] 登录成功,发送 screen-length 0 关闭分页");
stream.write("screen-length 0 temporary\n");
}
} else if (phase === "disable-paging") {
if (hasPrompt(outputBuffer)) {
phase = "idle";
outputBuffer = "";
if (DEBUG) console.log("[InspectExec] 分页已关闭");
sendNextCommand();
} else if (outputBuffer.length > 500 && disableRetries < 1) {
disableRetries++;
outputBuffer = "";
stream.write("screen-length 0 temporary\n");
if (DEBUG) console.log("[InspectExec] 重试关闭分页");
}
} else if (phase === "waiting-output") {
// 处理分页提示符:H3C/华为交换机可能在输出中途显示 "-- More --"
// 注意:如果 switch 不认识 screen-length 0,分页仍然会发生
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, "");
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("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 || "")
.replace(/<[^>]+>/g, "")
.replace(/--\s*More\s*--/gi, "")
.replace(/----\s*More\s*----/gi, "")
.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,
};