Files
smartssh/ts/smartssh.classes.sshclient.ts
T

535 lines
15 KiB
TypeScript
Raw Normal View History

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<boolean>;
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<void>;
}
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<Buffer>;
async readFile(remotePathArg: string, encodingArg: BufferEncoding): Promise<string>;
async readFile(remotePathArg: string, encodingArg?: BufferEncoding) {
const fileBuffer = await this.run<Buffer>((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<ssh2.FileEntryWithStats[]>((done) => {
this.sftp.readdir(remotePathArg, (error, list) => done(error, list));
});
}
async stat(remotePathArg: string) {
return this.run<ssh2.Stats>((done) => {
this.sftp.stat(remotePathArg, (error, stats) => done(error, stats));
});
}
async lstat(remotePathArg: string) {
return this.run<ssh2.Stats>((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<T>(runnerArg: (doneArg: (errorArg?: Error | null, valueArg?: T) => void) => void) {
return new Promise<T>((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<void>((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<void>((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<ISshExecResult> {
const connection = this.ensureConnected();
const { input, encoding = 'utf8', timeout, signal, ...execOptions } = optionsArg;
return new Promise<ISshExecResult>((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<ssh2.ClientChannel>((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<ssh2.ClientChannel>((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<SshSftpClient>((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<ssh2.ClientChannel>((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<ISshForwardInHandle> {
const connection = this.ensureConnected();
const remoteHost = optionsArg.remoteHost ?? '127.0.0.1';
const remotePort = await new Promise<number>((resolve, reject) => {
connection.forwardIn(remoteHost, optionsArg.remotePort, (error, port) => {
if (error) {
reject(error);
return;
}
resolve(port);
});
});
return {
remoteHost,
remotePort,
close: async () => {
await new Promise<void>((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<void>((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;
}
}