535 lines
15 KiB
TypeScript
535 lines
15 KiB
TypeScript
|
|
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;
|
||
|
|
}
|
||
|
|
}
|