Files
remoteconn-gitea/apps/gateway/src/ssh/sshSession.ts
2026-03-21 18:57:10 +08:00

518 lines
15 KiB
TypeScript
Raw 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.

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