first commit

This commit is contained in:
douboer
2026-03-21 18:57:10 +08:00
commit c49aa1a5e9
570 changed files with 107167 additions and 0 deletions

View File

@@ -0,0 +1,517 @@
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"));
});
}