Files
SenMeshNet/electron/bridges/inspection/inspectionExec.cjs
zhaolei 2f9f8da48e
Some checks failed
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
Initial commit: NetMesh smart ops platform with 3D topology, inspection reports, RCA and AI ops assistant
2026-10-15 13:53:04 +08:00

507 lines
20 KiB
JavaScript
Raw Permalink 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.
"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 sendCommandTimer = null;
let outputDoneTimer = null;
let commandSent = false;
// 匹配行尾是否是 shell prompt(同时兼容交换机和 Linux)
function hasPromptTail(line) {
// 交换机: <hostname> 结尾 或 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<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> 和 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 标记):
// <hostname> — 用户视图
// <hostname#> — 系统视图 / root prompt
// <hostname#-D> — 指定 slot 的 root prompt
// <hostname~> — 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,
};