import type { Client as SSHClient } from "ssh2"; // Serializes SSH channel open requests so only one channel negotiation is // in-flight at a time per session. Once the channel is established the slot // is released immediately so the next open can proceed; the channels // themselves remain open concurrently. export class ChannelOpenSerializer { private tail: Promise = Promise.resolve(); run(action: () => Promise): Promise { const next = this.tail.then( () => action(), () => action(), ); this.tail = next.then( () => {}, () => {}, ); return next; } } export interface SSHSession { client: SSHClient; isConnected: boolean; lastActive: number; timeout?: NodeJS.Timeout; activeOperations: number; sudoPassword?: string; sftp?: import("ssh2").SFTPWrapper; sftpPending?: Promise; channelOpener: ChannelOpenSerializer; poolKey?: string; userId?: string; hostId?: number; ip?: string; port?: number; username?: string; transferDedicated?: boolean; transferId?: string; browseSessionId?: string; scpLegacy?: boolean; } export interface PendingTOTPSession { client: SSHClient; finish: (responses: string[]) => void; config: import("ssh2").ConnectConfig; createdAt: number; sessionId: string; hostId?: number; ip?: string; port?: number; username?: string; userId?: string; prompts?: Array<{ prompt: string; echo: boolean }>; totpPromptIndex?: number; resolvedPassword?: string; totpAttempts: number; isWarpgate?: boolean; } export function execWithSudo( session: SSHSession, command: string, sudoPassword: string, ): Promise<{ stdout: string; stderr: string; code: number }> { return execWithSudoBuffer(session, command, sudoPassword).then((result) => ({ stdout: result.stdout.toString("utf8"), stderr: result.stderr, code: result.code, })); } export function execWithSudoBuffer( session: SSHSession, command: string, sudoPassword: string, maxStdoutBytes?: number, ): Promise<{ stdout: Buffer; stderr: string; code: number; exceededLimit?: boolean; }> { return new Promise((resolve) => { const sudoCommand = `sudo -S -p '' ${command} 2>&1`; execChannel(session, sudoCommand, (err, stream) => { if (err) { resolve({ stdout: Buffer.alloc(0), stderr: err.message, code: 1 }); return; } const stdoutChunks: Buffer[] = []; let stdoutBytes = 0; let stderr = ""; let settled = false; const finish = ( code: number, extra: { exceededLimit?: boolean } = {}, ) => { if (settled) return; settled = true; resolve({ stdout: Buffer.concat(stdoutChunks), stderr, code, ...extra, }); }; stream.on("data", (chunk: Buffer) => { if (settled) return; stdoutBytes += chunk.length; if (maxStdoutBytes !== undefined && stdoutBytes > maxStdoutBytes) { stderr = `Command output exceeds ${maxStdoutBytes} bytes`; finish(1, { exceededLimit: true }); stream.close(); return; } stdoutChunks.push(chunk); }); stream.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString(); }); stream.on("close", (code: number) => { if (settled) return; let stdout = Buffer.concat(stdoutChunks); const sudoPromptMatch = stdout .toString("utf8", 0, Math.min(stdout.length, 256)) .match(/^\[sudo\] password for .+?:\s*/); if (sudoPromptMatch) { stdout = stdout.subarray(Buffer.byteLength(sudoPromptMatch[0])); } settled = true; resolve({ stdout, stderr, code: code || 0 }); }); stream.on("error", (streamErr: Error) => { stderr = streamErr.message; finish(1); }); stream.write(`${sudoPassword}\n`); }); }); } export function execBuffer( session: SSHSession, command: string, maxStdoutBytes?: number, ): Promise<{ stdout: Buffer; stderr: string; code: number; exceededLimit?: boolean; }> { return new Promise((resolve) => { execChannel(session, command, (err, stream) => { if (err) { resolve({ stdout: Buffer.alloc(0), stderr: err.message, code: 1 }); return; } const stdoutChunks: Buffer[] = []; let stdoutBytes = 0; let stderr = ""; let settled = false; const finish = ( code: number, extra: { exceededLimit?: boolean } = {}, ) => { if (settled) return; settled = true; resolve({ stdout: Buffer.concat(stdoutChunks), stderr, code, ...extra, }); }; stream.on("data", (chunk: Buffer) => { if (settled) return; stdoutBytes += chunk.length; if (maxStdoutBytes !== undefined && stdoutBytes > maxStdoutBytes) { stderr = `Command output exceeds ${maxStdoutBytes} bytes`; finish(1, { exceededLimit: true }); stream.close(); return; } stdoutChunks.push(chunk); }); stream.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString(); }); stream.on("close", (code: number) => finish(code || 0)); stream.on("error", (streamErr: Error) => { stderr = stderr || streamErr.message; finish(1); }); }); }); } export function getSessionSftp( session: SSHSession, ): Promise { if (session.sftp) { return Promise.resolve(session.sftp); } if (session.sftpPending) { return session.sftpPending; } const openOnce = (): Promise => session.channelOpener.run( () => new Promise((resolve, reject) => { session.client.sftp((err, sftp) => { if (err) return reject(err); session.sftp = sftp; sftp.on("error", () => { session.sftp = undefined; }); sftp.on("close", () => { session.sftp = undefined; }); resolve(sftp); }); }), ); session.sftpPending = openOnce() .catch((err: Error) => { const isChannelFailure = err.message.toLowerCase().includes("channel open failure") || err.message.toLowerCase().includes("open failed"); if (isChannelFailure) { return new Promise((resolve, reject) => setTimeout(() => openOnce().then(resolve, reject), 500), ); } return Promise.reject(err); }) .finally(() => { session.sftpPending = undefined; }); return session.sftpPending; } export function execChannel( session: SSHSession, command: string, callback: ( err: Error | undefined, stream: import("ssh2").ClientChannel, ) => void, ): void { session.channelOpener .run( () => new Promise((resolve, reject) => { session.client.exec(command, (err, stream) => { if (err) return reject(err); resolve(stream); }); }), ) .then( (stream) => callback(undefined, stream), (err: Error) => callback(err, undefined as never), ); }