518 lines
15 KiB
TypeScript
518 lines
15 KiB
TypeScript
import { Client, type ClientChannel, type ConnectConfig } from "ssh2";
|
||
import { StringDecoder } from "node:string_decoder";
|
||
import { config } from "../config";
|
||
|
||
interface SshHopOptions {
|
||
host: string;
|
||
port: number;
|
||
username: string;
|
||
credential:
|
||
| { type: "password"; password: string }
|
||
| { type: "privateKey"; privateKey: string; passphrase?: string }
|
||
| { type: "certificate"; privateKey: string; passphrase?: string; certificate: string };
|
||
knownHostFingerprint?: string;
|
||
}
|
||
|
||
export interface SshConnectOptions {
|
||
host: string;
|
||
port: number;
|
||
username: string;
|
||
credential:
|
||
| { type: "password"; password: string }
|
||
| { type: "privateKey"; privateKey: string; passphrase?: string }
|
||
| { type: "certificate"; privateKey: string; passphrase?: string; certificate: string };
|
||
jumpHost?: SshHopOptions;
|
||
pty: { cols: number; rows: number };
|
||
knownHostFingerprint?: string;
|
||
onHostFingerprint?: (payload: { fingerprint: string; hostPort: string; role: "target" | "jump" }) => void;
|
||
onStdout: (data: string) => void;
|
||
onStderr: (data: string) => void;
|
||
onClose: (reason: string) => void;
|
||
}
|
||
|
||
export interface ActiveSshSession {
|
||
write(data: string, traceId?: number): void;
|
||
resize(cols: number, rows: number): void;
|
||
close(reason?: string): void;
|
||
}
|
||
|
||
/**
|
||
* 初始化哨兵:用于标记“静默初始化命令”的开始与结束。
|
||
*/
|
||
const INIT_BEGIN = "__RCSBEGIN_7f3a__";
|
||
const INIT_DONE = "__RCSDONE_7f3a__";
|
||
|
||
/**
|
||
* 初始化命令分三段写入:
|
||
* 1) 关闭回显 + BEGIN 哨兵
|
||
* 2) shell 兼容初始化
|
||
* 3) 打开回显 + DONE 哨兵
|
||
*/
|
||
const SHELL_INIT_W1 = "stty -echo; echo '__RCSBEGIN_7f3a__'\r";
|
||
const SHELL_INIT_W2 = [
|
||
"stty iutf8 2>/dev/null",
|
||
'; [ -z "$LANG" ] && export LANG=zh_CN.UTF-8',
|
||
'; [ -z "$LC_CTYPE" ] && export LC_CTYPE=zh_CN.UTF-8',
|
||
'; [ -z "$LC_ALL" ] && export LC_ALL=zh_CN.UTF-8',
|
||
"; setopt MULTIBYTE PRINT_EIGHT_BIT 2>/dev/null",
|
||
"; unsetopt PROMPT_SP 2>/dev/null",
|
||
"; PROMPT_EOL_MARK='' 2>/dev/null"
|
||
].join("") + "\r";
|
||
const SHELL_INIT_W3 = "stty echo; echo '__RCSDONE_7f3a__'\r";
|
||
|
||
/**
|
||
* 这些特征行一旦出现在 stdout,说明是网关内部初始化泄漏,必须过滤。
|
||
*/
|
||
const INIT_LEAK_PATTERNS = [
|
||
"stty -echo; echo '__RCSBEGIN_7f3a__'",
|
||
"stty iutf8 2>/dev/null",
|
||
"setopt MULTIBYTE PRINT_EIGHT_BIT",
|
||
"unsetopt PROMPT_SP",
|
||
"PROMPT_EOL_MARK=''",
|
||
"stty echo; echo '__RCSDONE_7f3a__'",
|
||
INIT_BEGIN,
|
||
INIT_DONE
|
||
];
|
||
|
||
/** @internal 仅供测试调用。 */
|
||
export const INIT_BEGIN_FOR_TEST = INIT_BEGIN;
|
||
/** @internal 仅供测试调用。 */
|
||
export const INIT_DONE_FOR_TEST = INIT_DONE;
|
||
|
||
interface SentinelMatch {
|
||
index: number;
|
||
end: number;
|
||
}
|
||
|
||
/**
|
||
* 输入去噪:去除 C1 控制字符(0x80-0x9F)。
|
||
*/
|
||
function stripC1Controls(data: string): string {
|
||
return data.replace(/[\u0080-\u009F]/g, "");
|
||
}
|
||
|
||
/**
|
||
* 判断字节串是否可无损按 UTF-8 解释。
|
||
*/
|
||
function isLosslessUtf8Bytes(bytes: Buffer): boolean {
|
||
const decoded = bytes.toString("utf8");
|
||
if (decoded.includes("\uFFFD")) {
|
||
return false;
|
||
}
|
||
return Buffer.from(decoded, "utf8").equals(bytes);
|
||
}
|
||
|
||
function decodeByteRun(bytes: number[]): string {
|
||
if (bytes.length === 0) {
|
||
return "";
|
||
}
|
||
const buf = Buffer.from(bytes);
|
||
if (isLosslessUtf8Bytes(buf)) {
|
||
return buf.toString("utf8");
|
||
}
|
||
return String.fromCharCode(...bytes.filter((b) => (b >= 0x20 && b <= 0x7e) || b === 0x09));
|
||
}
|
||
|
||
/**
|
||
* 混合输入归一:宽字符原样保留,单字节段按 UTF-8 尝试解码。
|
||
*/
|
||
function normalizeMixedInput(data: string): string {
|
||
let output = "";
|
||
let byteRun: number[] = [];
|
||
|
||
const flushByteRun = (): void => {
|
||
output += decodeByteRun(byteRun);
|
||
byteRun = [];
|
||
};
|
||
|
||
for (const ch of data) {
|
||
const codePoint = ch.codePointAt(0) ?? 0;
|
||
if (codePoint <= 0xff) {
|
||
byteRun.push(codePoint);
|
||
continue;
|
||
}
|
||
flushByteRun();
|
||
output += ch;
|
||
}
|
||
flushByteRun();
|
||
return output;
|
||
}
|
||
|
||
/**
|
||
* 将前端输入转换为 SSH 可写内容。
|
||
*/
|
||
export function encodeInputForSsh(data: string): string {
|
||
let hasHighByte = false;
|
||
let hasWideChar = false;
|
||
|
||
for (let i = 0; i < data.length; i += 1) {
|
||
const code = data.charCodeAt(i);
|
||
if (code > 0xff) {
|
||
hasWideChar = true;
|
||
continue;
|
||
}
|
||
if (code >= 0x80) {
|
||
hasHighByte = true;
|
||
}
|
||
}
|
||
|
||
if (hasWideChar) {
|
||
return normalizeMixedInput(data).replace(/\uFFFD/g, "");
|
||
}
|
||
|
||
if (!hasHighByte) {
|
||
return data;
|
||
}
|
||
|
||
const bytes = Buffer.from(data, "latin1");
|
||
if (isLosslessUtf8Bytes(bytes)) {
|
||
return bytes.toString("utf8");
|
||
}
|
||
|
||
return stripC1Controls(data);
|
||
}
|
||
|
||
/**
|
||
* 匹配哨兵真实输出行(SENTINEL + \n 或 \r\n)。
|
||
* 注意:不会误匹配命令回显里的 `echo '__RCS...__'`(后面是单引号)。
|
||
*/
|
||
function findSentinelLine(buffer: string, sentinel: string): SentinelMatch | null {
|
||
let from = 0;
|
||
while (from < buffer.length) {
|
||
const index = buffer.indexOf(sentinel, from);
|
||
if (index < 0) {
|
||
return null;
|
||
}
|
||
|
||
const next = buffer[index + sentinel.length];
|
||
const nextNext = buffer[index + sentinel.length + 1];
|
||
|
||
if (next === "\n") {
|
||
return { index, end: index + sentinel.length + 1 };
|
||
}
|
||
if (next === "\r" && nextNext === "\n") {
|
||
return { index, end: index + sentinel.length + 2 };
|
||
}
|
||
|
||
// 落在 chunk 边界,等下个 chunk 再判定。
|
||
if (next === undefined || (next === "\r" && nextNext === undefined)) {
|
||
return null;
|
||
}
|
||
|
||
from = index + 1;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 兜底清理初始化泄漏内容,保留 banner 与提示符。
|
||
*/
|
||
function sanitizeInitLeakOutput(text: string): string {
|
||
return text
|
||
.split(/(\r\n|\n|\r)/)
|
||
.reduce((acc, current, index, parts) => {
|
||
if (index % 2 === 1) {
|
||
return acc;
|
||
}
|
||
const shouldDrop = INIT_LEAK_PATTERNS.some((pattern) => current.includes(pattern));
|
||
if (shouldDrop) {
|
||
return acc;
|
||
}
|
||
return acc + current + (parts[index + 1] ?? "");
|
||
}, "");
|
||
}
|
||
|
||
/**
|
||
* 统一可读错误信息,便于前端提示用户。
|
||
*/
|
||
function normalizeSshError(error: Error): Error {
|
||
const raw = String(error.message ?? "");
|
||
if (raw.includes("All configured authentication methods failed")) {
|
||
return new Error(
|
||
`SSH 认证失败:用户名/密码不正确,或目标服务器要求额外认证(如 publickey + keyboard-interactive 多因子)。原始错误: ${raw}`
|
||
);
|
||
}
|
||
if (raw.includes("Timed out while waiting for handshake")) {
|
||
return new Error(`SSH 握手超时,请检查目标主机连通性与端口(原始错误: ${raw})`);
|
||
}
|
||
return error;
|
||
}
|
||
|
||
function toPrivateKeyPayload(
|
||
credential: Extract<SshHopOptions["credential"], { type: "privateKey" | "certificate" }>
|
||
): string {
|
||
if (credential.type === "certificate") {
|
||
return `${credential.privateKey}\n${credential.certificate}`;
|
||
}
|
||
return credential.privateKey;
|
||
}
|
||
|
||
/**
|
||
* 统一构造 ssh2 connect 配置:
|
||
* - 密码模式优先 keyboard-interactive,再回退 password;
|
||
* - 私钥/证书模式直接走 publickey;
|
||
* - hostVerifier 在每个 hop 独立上报指纹,便于前端分别确认。
|
||
*/
|
||
function buildSshConnectConfig(
|
||
hop: SshHopOptions,
|
||
onHostFingerprint: SshConnectOptions["onHostFingerprint"],
|
||
role: "target" | "jump",
|
||
sock?: ConnectConfig["sock"]
|
||
): ConnectConfig {
|
||
const hostPort = `${hop.host}:${hop.port}`;
|
||
const baseConfig: ConnectConfig = {
|
||
host: hop.host,
|
||
port: hop.port,
|
||
username: hop.username,
|
||
readyTimeout: config.sshReadyTimeoutMs,
|
||
keepaliveInterval: config.sshKeepaliveIntervalMs,
|
||
keepaliveCountMax: config.sshKeepaliveCountMax,
|
||
hostHash: "sha256",
|
||
...(sock ? { sock } : {}),
|
||
hostVerifier: (keyHash: string) => {
|
||
onHostFingerprint?.({ fingerprint: keyHash, hostPort, role });
|
||
return !hop.knownHostFingerprint || keyHash === hop.knownHostFingerprint;
|
||
}
|
||
};
|
||
|
||
if (hop.credential.type === "password") {
|
||
const authPassword = hop.credential.password;
|
||
const authHandler = [
|
||
{
|
||
type: "keyboard-interactive",
|
||
username: hop.username,
|
||
prompt(
|
||
_name: string,
|
||
_instructions: string,
|
||
_lang: string,
|
||
prompts: Array<{ prompt: string; echo?: boolean }>,
|
||
finish: (responses: string[]) => void
|
||
) {
|
||
finish(prompts.map(() => authPassword));
|
||
}
|
||
},
|
||
{ type: "password", username: hop.username, password: authPassword }
|
||
] as unknown as ConnectConfig["authHandler"];
|
||
|
||
return {
|
||
...baseConfig,
|
||
password: authPassword,
|
||
tryKeyboard: true,
|
||
authHandler
|
||
};
|
||
}
|
||
|
||
return {
|
||
...baseConfig,
|
||
privateKey: toPrivateKeyPayload(hop.credential),
|
||
passphrase: hop.credential.passphrase
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 建立 SSH 会话并返回可操作句柄。
|
||
* 仅保留已验证有效的链路:双哨兵过滤 + 超时兜底净化。
|
||
*/
|
||
export async function createSshSession(options: SshConnectOptions): Promise<ActiveSshSession> {
|
||
const targetConn = new Client();
|
||
const jumpConn = options.jumpHost ? new Client() : null;
|
||
|
||
return await new Promise<ActiveSshSession>((resolve, reject) => {
|
||
let streamRef: {
|
||
write: (data: string | Buffer) => void;
|
||
setWindow: (rows: number, cols: number, height: number, width: number) => void;
|
||
close: () => void;
|
||
on: (event: string, listener: (...args: unknown[]) => void) => void;
|
||
stderr: { on: (event: string, listener: (chunk: Buffer) => void) => void };
|
||
} | null = null;
|
||
let closeReported = false;
|
||
/**
|
||
* 关闭原因只允许上报一次,避免 `close()` / `stream.close` / `conn.close`
|
||
* 多路并发触发导致日志重复。
|
||
*/
|
||
const reportCloseOnce = (reason: string): void => {
|
||
if (closeReported) {
|
||
return;
|
||
}
|
||
closeReported = true;
|
||
options.onClose(reason);
|
||
};
|
||
|
||
const onError = (error: Error): void => {
|
||
reject(normalizeSshError(error));
|
||
targetConn.end();
|
||
jumpConn?.end();
|
||
};
|
||
|
||
const openShell = (): void => {
|
||
targetConn.shell(
|
||
{ cols: options.pty.cols, rows: options.pty.rows, term: "xterm-256color", modes: { ECHO: 0 } },
|
||
(shellError: Error | undefined, stream: ClientChannel) => {
|
||
if (shellError) {
|
||
onError(shellError);
|
||
return;
|
||
}
|
||
|
||
streamRef = stream as typeof streamRef;
|
||
const stdoutDecoder = new StringDecoder("utf8");
|
||
const stderrDecoder = new StringDecoder("utf8");
|
||
|
||
let initState: "waiting_begin" | "waiting_done" | "done" = "waiting_begin";
|
||
let initBuffer = "";
|
||
const initTimeoutHandle = setTimeout(() => {
|
||
if (initState !== "done") {
|
||
initState = "done";
|
||
const sanitized = sanitizeInitLeakOutput(initBuffer);
|
||
if (sanitized) {
|
||
options.onStdout(sanitized);
|
||
}
|
||
initBuffer = "";
|
||
}
|
||
}, 3000);
|
||
|
||
stream.on("data", (chunk: Buffer) => {
|
||
const text = stdoutDecoder.write(chunk);
|
||
if (!text) {
|
||
return;
|
||
}
|
||
|
||
if (initState === "done") {
|
||
options.onStdout(text);
|
||
return;
|
||
}
|
||
|
||
initBuffer += text;
|
||
|
||
if (initState === "waiting_begin") {
|
||
const beginMatch = findSentinelLine(initBuffer, INIT_BEGIN);
|
||
if (!beginMatch) {
|
||
return;
|
||
}
|
||
|
||
const rawBefore = initBuffer.slice(0, beginMatch.index);
|
||
const echoIndex = rawBefore.lastIndexOf("stty -echo");
|
||
const newlineBeforeEcho = echoIndex >= 0 ? rawBefore.lastIndexOf("\n", echoIndex) : -1;
|
||
const before =
|
||
echoIndex >= 0 ? (newlineBeforeEcho >= 0 ? rawBefore.slice(0, newlineBeforeEcho + 1) : "") : rawBefore;
|
||
|
||
initBuffer = initBuffer.slice(beginMatch.end);
|
||
initState = "waiting_done";
|
||
|
||
const sanitizedBefore = sanitizeInitLeakOutput(before);
|
||
if (sanitizedBefore) {
|
||
options.onStdout(sanitizedBefore);
|
||
}
|
||
}
|
||
|
||
if (initState === "waiting_done") {
|
||
const doneMatch = findSentinelLine(initBuffer, INIT_DONE);
|
||
if (!doneMatch) {
|
||
const keepTail = INIT_DONE.length + 2;
|
||
if (initBuffer.length > keepTail) {
|
||
initBuffer = initBuffer.slice(-keepTail);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const after = sanitizeInitLeakOutput(initBuffer.slice(doneMatch.end));
|
||
initBuffer = "";
|
||
initState = "done";
|
||
clearTimeout(initTimeoutHandle);
|
||
|
||
if (after) {
|
||
options.onStdout(after);
|
||
}
|
||
}
|
||
});
|
||
|
||
stream.stderr.on("data", (chunk: Buffer) => {
|
||
const text = stderrDecoder.write(chunk);
|
||
if (text) {
|
||
options.onStderr(text);
|
||
}
|
||
});
|
||
|
||
try {
|
||
stream.write(Buffer.from(SHELL_INIT_W1, "utf8"));
|
||
stream.write(Buffer.from(SHELL_INIT_W2, "utf8"));
|
||
stream.write(Buffer.from(SHELL_INIT_W3, "utf8"));
|
||
} catch {
|
||
clearTimeout(initTimeoutHandle);
|
||
initState = "done";
|
||
}
|
||
|
||
stream.on("close", () => {
|
||
clearTimeout(initTimeoutHandle);
|
||
const remainOut = stdoutDecoder.end();
|
||
if (remainOut) {
|
||
options.onStdout(remainOut);
|
||
}
|
||
const remainErr = stderrDecoder.end();
|
||
if (remainErr) {
|
||
options.onStderr(remainErr);
|
||
}
|
||
reportCloseOnce("shell_closed");
|
||
targetConn.end();
|
||
jumpConn?.end();
|
||
});
|
||
|
||
resolve({
|
||
write(data: string, _traceId?: number) {
|
||
const payload = encodeInputForSsh(data).replace(/\uFFFD/g, "");
|
||
if (payload) {
|
||
streamRef?.write(Buffer.from(payload, "utf8"));
|
||
}
|
||
},
|
||
resize(cols: number, rows: number) {
|
||
streamRef?.setWindow(rows, cols, 0, 0);
|
||
},
|
||
close(reason = "manual") {
|
||
reportCloseOnce(reason);
|
||
streamRef?.close();
|
||
targetConn.end();
|
||
jumpConn?.end();
|
||
}
|
||
});
|
||
}
|
||
);
|
||
};
|
||
|
||
targetConn.on("ready", openShell);
|
||
targetConn.on("error", onError);
|
||
targetConn.on("close", () => {
|
||
reportCloseOnce("connection_closed");
|
||
});
|
||
|
||
const targetHop: SshHopOptions = {
|
||
host: options.host,
|
||
port: options.port,
|
||
username: options.username,
|
||
credential: options.credential,
|
||
knownHostFingerprint: options.knownHostFingerprint
|
||
};
|
||
|
||
if (!options.jumpHost) {
|
||
targetConn.connect(buildSshConnectConfig(targetHop, options.onHostFingerprint, "target"));
|
||
return;
|
||
}
|
||
|
||
jumpConn?.on("error", onError);
|
||
jumpConn?.on("close", () => {
|
||
reportCloseOnce("jump_connection_closed");
|
||
});
|
||
|
||
jumpConn?.on("ready", () => {
|
||
jumpConn.forwardOut("127.0.0.1", 0, options.host, options.port, (error, stream) => {
|
||
if (error || !stream) {
|
||
onError(error ?? new Error("jump forward stream missing"));
|
||
return;
|
||
}
|
||
targetConn.connect(buildSshConnectConfig(targetHop, options.onHostFingerprint, "target", stream));
|
||
});
|
||
});
|
||
|
||
jumpConn?.connect(buildSshConnectConfig(options.jumpHost, options.onHostFingerprint, "jump"));
|
||
});
|
||
}
|