import * as plugins from './smartssh.plugins.js'; import * as helpers from './smartssh.classes.helpers.js'; import { SshKey } from './smartssh.classes.sshkey.js'; import type * as ssh2 from 'ssh2'; export type TSshHostVerifier = (fingerprintArg: string, keyArg: Buffer) => boolean | Promise; export interface ISshProfile { host: string; port?: number; username?: string; password?: string; privateKey?: string | Buffer | SshKey; passphrase?: string | Buffer; agent?: ssh2.ConnectConfig['agent']; agentForward?: boolean; tryKeyboard?: boolean; readyTimeout?: number; keepaliveInterval?: number; keepaliveCountMax?: number; strictVendor?: boolean; strictHostKeyChecking?: boolean; trustedHostFingerprints?: string[]; hostVerifier?: TSshHostVerifier; algorithms?: ssh2.Algorithms; authHandler?: ssh2.ConnectConfig['authHandler']; sock?: ssh2.ConnectConfig['sock']; forceIPv4?: boolean; forceIPv6?: boolean; localAddress?: string; localPort?: number; timeout?: number; ident?: Buffer | string; debug?: ssh2.DebugFunction; } export interface ISshExecOptions extends ssh2.ExecOptions { input?: string | Buffer; encoding?: BufferEncoding; timeout?: number; signal?: AbortSignal; } export interface ISshExecResult { command: string; code: number | null; signal: string | null; stdout: string; stderr: string; stdoutBuffer: Buffer; stderrBuffer: Buffer; } export interface ISshShellOptions { window?: ssh2.PseudoTtyOptions | false; options?: ssh2.ShellOptions; } export interface ISshForwardOutOptions { sourceHost?: string; sourcePort?: number; destinationHost: string; destinationPort: number; } export interface ISshForwardInOptions { remoteHost?: string; remotePort: number; } export interface ISshForwardInHandle { remoteHost: string; remotePort: number; close: () => Promise; } export interface ISshUploadOptions { localPath: string; remotePath: string; transferOptions?: ssh2.TransferOptions; } export interface ISshDownloadOptions { remotePath: string; localPath: string; transferOptions?: ssh2.TransferOptions; } export class SshSftpClient { constructor(private sftp: ssh2.SFTPWrapper) {} async upload(optionsArg: ISshUploadOptions) { await this.runVoid((done) => { if (optionsArg.transferOptions) { this.sftp.fastPut(optionsArg.localPath, optionsArg.remotePath, optionsArg.transferOptions, done); } else { this.sftp.fastPut(optionsArg.localPath, optionsArg.remotePath, done); } }); } async download(optionsArg: ISshDownloadOptions) { await this.runVoid((done) => { if (optionsArg.transferOptions) { this.sftp.fastGet(optionsArg.remotePath, optionsArg.localPath, optionsArg.transferOptions, done); } else { this.sftp.fastGet(optionsArg.remotePath, optionsArg.localPath, done); } }); } async readFile(remotePathArg: string): Promise; async readFile(remotePathArg: string, encodingArg: BufferEncoding): Promise; async readFile(remotePathArg: string, encodingArg?: BufferEncoding) { const fileBuffer = await this.run((done) => { this.sftp.readFile(remotePathArg, (error, fileContent) => done(error, fileContent)); }); return encodingArg ? fileBuffer.toString(encodingArg) : fileBuffer; } async writeFile(remotePathArg: string, dataArg: string | Buffer) { await this.runVoid((done) => this.sftp.writeFile(remotePathArg, dataArg, done)); } async readdir(remotePathArg: string) { return this.run((done) => { this.sftp.readdir(remotePathArg, (error, list) => done(error, list)); }); } async stat(remotePathArg: string) { return this.run((done) => { this.sftp.stat(remotePathArg, (error, stats) => done(error, stats)); }); } async lstat(remotePathArg: string) { return this.run((done) => { this.sftp.lstat(remotePathArg, (error, stats) => done(error, stats)); }); } async mkdir(remotePathArg: string, attributesArg?: ssh2.InputAttributes) { await this.runVoid((done) => { if (attributesArg) { this.sftp.mkdir(remotePathArg, attributesArg, done); } else { this.sftp.mkdir(remotePathArg, done); } }); } async chmod(remotePathArg: string, modeArg: number | string) { await this.runVoid((done) => this.sftp.chmod(remotePathArg, modeArg, done)); } async remove(remotePathArg: string) { await this.runVoid((done) => this.sftp.unlink(remotePathArg, done)); } async rmdir(remotePathArg: string) { await this.runVoid((done) => this.sftp.rmdir(remotePathArg, done)); } async rename(sourcePathArg: string, destinationPathArg: string) { await this.runVoid((done) => this.sftp.rename(sourcePathArg, destinationPathArg, done)); } private async run(runnerArg: (doneArg: (errorArg?: Error | null, valueArg?: T) => void) => void) { return new Promise((resolve, reject) => { runnerArg((error, value) => { if (error) { reject(error); return; } resolve(value as T); }); }); } private async runVoid(runnerArg: (doneArg: (errorArg?: Error | null) => void) => void) { await this.run((done) => runnerArg(done)); } } export class SshClient { private connection?: ssh2.Client; private connected = false; private lastError?: Error; constructor(private profile: ISshProfile) {} static async connect(profileArg: ISshProfile) { const sshClient = new SshClient(profileArg); await sshClient.connect(); return sshClient; } static fingerprintSha256(keyArg: Buffer | string) { return helpers.fingerprintSha256(keyArg); } static fingerprintMd5(keyArg: Buffer | string) { return helpers.fingerprintMd5(keyArg); } async connect() { if (this.connected) { return; } const connectConfig = this.createConnectConfig(); const connection = new plugins.ssh2.Client(); this.connection = connection; await new Promise((resolve, reject) => { let settled = false; const settle = (errorArg?: Error) => { if (settled) { if (errorArg) { this.lastError = errorArg; } return; } settled = true; connection.removeListener('ready', handleReady); connection.removeListener('close', handleClose); if (errorArg) { reject(errorArg); } else { resolve(); } }; const handleReady = () => { this.connected = true; settle(); }; const handleClose = () => { this.connected = false; settle(new Error('SSH connection closed before it became ready.')); }; const handleError = (errorArg: Error) => { this.lastError = errorArg; settle(errorArg); }; connection.on('error', handleError); connection.once('ready', handleReady); connection.once('close', handleClose); connection.connect(connectConfig); }); } async exec(commandArg: string, optionsArg: ISshExecOptions = {}): Promise { const connection = this.ensureConnected(); const { input, encoding = 'utf8', timeout, signal, ...execOptions } = optionsArg; return new Promise((resolve, reject) => { let settled = false; let timeoutHandle: NodeJS.Timeout | undefined; let channel: ssh2.ClientChannel | undefined; let code: number | null = null; let exitSignal: string | null = null; const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; const settle = (errorArg?: Error) => { if (settled) { return; } settled = true; if (timeoutHandle) { clearTimeout(timeoutHandle); } signal?.removeEventListener('abort', handleAbort); if (errorArg) { reject(errorArg); return; } const stdoutBuffer = Buffer.concat(stdoutChunks); const stderrBuffer = Buffer.concat(stderrChunks); resolve({ command: commandArg, code, signal: exitSignal, stdout: stdoutBuffer.toString(encoding), stderr: stderrBuffer.toString(encoding), stdoutBuffer, stderrBuffer, }); }; const handleAbort = () => { channel?.close(); settle(new Error(`SSH exec aborted: ${commandArg}`)); }; if (signal?.aborted) { handleAbort(); return; } if (timeout) { timeoutHandle = setTimeout(() => { channel?.close(); settle(new Error(`SSH exec timed out after ${timeout}ms: ${commandArg}`)); }, timeout); } signal?.addEventListener('abort', handleAbort); connection.exec(commandArg, execOptions, (error, stream) => { if (error) { settle(error); return; } channel = stream; stream.on('data', (chunk: Buffer | string) => stdoutChunks.push(Buffer.from(chunk))); stream.stderr.on('data', (chunk: Buffer | string) => stderrChunks.push(Buffer.from(chunk))); stream.on('exit', (exitCode: number | null, signalName?: string) => { code = exitCode; exitSignal = signalName ?? null; }); stream.on('error', (streamError: Error) => settle(streamError)); stream.on('close', () => settle()); stream.end(input); }); }); } async stream(commandArg: string, optionsArg: ssh2.ExecOptions = {}) { const connection = this.ensureConnected(); return new Promise((resolve, reject) => { connection.exec(commandArg, optionsArg, (error, channel) => { if (error) { reject(error); return; } resolve(channel); }); }); } async shell(optionsArg: ISshShellOptions = {}) { const connection = this.ensureConnected(); return new Promise((resolve, reject) => { const done = (error: Error | undefined, channel: ssh2.ClientChannel) => { if (error) { reject(error); return; } resolve(channel); }; if (optionsArg.window !== undefined) { connection.shell(optionsArg.window, optionsArg.options ?? {}, done); } else { connection.shell(optionsArg.options ?? {}, done); } }); } async sftp() { const connection = this.ensureConnected(); return new Promise((resolve, reject) => { connection.sftp((error, sftp) => { if (error) { reject(error); return; } resolve(new SshSftpClient(sftp)); }); }); } async forwardOut(optionsArg: ISshForwardOutOptions) { const connection = this.ensureConnected(); return new Promise((resolve, reject) => { connection.forwardOut( optionsArg.sourceHost ?? '127.0.0.1', optionsArg.sourcePort ?? 0, optionsArg.destinationHost, optionsArg.destinationPort, (error, channel) => { if (error) { reject(error); return; } resolve(channel); } ); }); } async forwardIn(optionsArg: ISshForwardInOptions): Promise { const connection = this.ensureConnected(); const remoteHost = optionsArg.remoteHost ?? '127.0.0.1'; const remotePort = await new Promise((resolve, reject) => { connection.forwardIn(remoteHost, optionsArg.remotePort, (error, port) => { if (error) { reject(error); return; } resolve(port); }); }); return { remoteHost, remotePort, close: async () => { await new Promise((resolve, reject) => { connection.unforwardIn(remoteHost, remotePort, (error) => { if (error) { reject(error); return; } resolve(); }); }); }, }; } async close() { const connection = this.connection; if (!connection) { return; } await new Promise((resolve) => { const timeoutHandle = setTimeout(resolve, 1000); connection.once('close', () => { clearTimeout(timeoutHandle); resolve(); }); connection.end(); }); this.connected = false; this.connection = undefined; } get isConnected() { return this.connected; } get error() { return this.lastError; } private createConnectConfig(): ssh2.ConnectConfig { const strictHostKeyChecking = this.profile.strictHostKeyChecking ?? true; const hasHostValidation = Boolean(this.profile.hostVerifier) || Boolean(this.profile.trustedHostFingerprints?.length); if (strictHostKeyChecking && !hasHostValidation) { throw new Error( 'strictHostKeyChecking is enabled. Provide trustedHostFingerprints, hostVerifier, or set strictHostKeyChecking to false.' ); } const privateKey = this.profile.privateKey instanceof SshKey ? this.profile.privateKey.privKey : this.profile.privateKey; const connectConfig: ssh2.ConnectConfig = { host: this.profile.host, port: this.profile.port, username: this.profile.username, password: this.profile.password, privateKey, passphrase: this.profile.passphrase, agent: this.profile.agent, agentForward: this.profile.agentForward, tryKeyboard: this.profile.tryKeyboard, readyTimeout: this.profile.readyTimeout, keepaliveInterval: this.profile.keepaliveInterval, keepaliveCountMax: this.profile.keepaliveCountMax, strictVendor: this.profile.strictVendor, algorithms: this.profile.algorithms, authHandler: this.profile.authHandler, sock: this.profile.sock, forceIPv4: this.profile.forceIPv4, forceIPv6: this.profile.forceIPv6, localAddress: this.profile.localAddress, localPort: this.profile.localPort, timeout: this.profile.timeout, ident: this.profile.ident, debug: this.profile.debug, }; connectConfig.hostVerifier = ((key: Buffer, verify: ssh2.VerifyCallback) => { const verifyHost = async () => { if (!strictHostKeyChecking && !hasHostValidation) { return true; } const sha256Fingerprint = helpers.fingerprintSha256(key); const md5Fingerprint = helpers.fingerprintMd5(key); const trustedFingerprints = this.profile.trustedHostFingerprints ?? []; if ( trustedFingerprints.includes(sha256Fingerprint) || trustedFingerprints.includes(md5Fingerprint) || trustedFingerprints.includes(`MD5:${md5Fingerprint}`) ) { return true; } if (this.profile.hostVerifier) { return this.profile.hostVerifier(sha256Fingerprint, key); } return false; }; verifyHost().then( (result) => verify(Boolean(result)), () => verify(false) ); }) as ssh2.HostVerifier; return connectConfig; } private ensureConnected() { if (!this.connection || !this.connected) { throw new Error('SSH client is not connected.'); } return this.connection; } }