feat(sshclient): add a promise-first SSH client with secure host verification and improve SSH key/config handling
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartssh',
|
||||
version: '2.0.3',
|
||||
description: 'A library for setting up SSH configuration quickly and painlessly.'
|
||||
version: '2.1.0',
|
||||
description: 'Secure SSH configuration, key management, and remote machine control for TypeScript.'
|
||||
}
|
||||
|
||||
+18
@@ -4,3 +4,21 @@ export { SshInstance } from './smartssh.classes.sshinstance.js';
|
||||
export { SshKey } from './smartssh.classes.sshkey.js';
|
||||
export { SshDir } from './smartssh.classes.sshdir.js';
|
||||
export { SshConfig } from './smartssh.classes.sshconfig.js';
|
||||
export { SshClient, SshSftpClient } from './smartssh.classes.sshclient.js';
|
||||
export type {
|
||||
ISshDownloadOptions,
|
||||
ISshExecOptions,
|
||||
ISshExecResult,
|
||||
ISshForwardInHandle,
|
||||
ISshForwardInOptions,
|
||||
ISshForwardOutOptions,
|
||||
ISshProfile,
|
||||
ISshShellOptions,
|
||||
ISshUploadOptions,
|
||||
TSshHostVerifier,
|
||||
} from './smartssh.classes.sshclient.js';
|
||||
export type {
|
||||
ISshConfigHostBlock,
|
||||
ISshConfigOptions,
|
||||
TSshStrictHostKeyChecking,
|
||||
} from './smartssh.classes.sshconfig.js';
|
||||
|
||||
@@ -1,7 +1,107 @@
|
||||
import * as plugins from './smartssh.plugins.js';
|
||||
import { SshKey } from './smartssh.classes.sshkey.js';
|
||||
|
||||
export let sshKeyArrayFromDir = function (dirArg: string): SshKey[] {
|
||||
let sshKeyArray: SshKey[] = []; // TODO
|
||||
return sshKeyArray;
|
||||
const unsafeHostMessage =
|
||||
'SSH host aliases must be single, filename-safe values without whitespace or path separators.';
|
||||
|
||||
export const assertSafeHost = (hostArg: string) => {
|
||||
if (!hostArg || hostArg === '.' || hostArg === '..') {
|
||||
throw new Error(unsafeHostMessage);
|
||||
}
|
||||
|
||||
if (!/^[A-Za-z0-9._@:%+-]+$/.test(hostArg)) {
|
||||
throw new Error(unsafeHostMessage);
|
||||
}
|
||||
};
|
||||
|
||||
export const isSafeHost = (hostArg: string) => {
|
||||
try {
|
||||
assertSafeHost(hostArg);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveSshDirPath = (dirPathArg?: string) => {
|
||||
return plugins.path.resolve(
|
||||
dirPathArg ?? plugins.path.join(plugins.smartpath.get.home(), '.ssh')
|
||||
);
|
||||
};
|
||||
|
||||
export const ensureSshDirSync = (dirPathArg: string) => {
|
||||
plugins.fs.ensureDirSync(dirPathArg);
|
||||
plugins.fs.chmodSync(dirPathArg, 0o700);
|
||||
};
|
||||
|
||||
export const quoteSshConfigValue = (valueArg: string) => {
|
||||
if (/^[A-Za-z0-9._~/:@%+-]+$/.test(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
|
||||
return `"${valueArg.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
||||
};
|
||||
|
||||
export const fingerprintSha256 = (keyArg: Buffer | string) => {
|
||||
const hash = plugins.crypto
|
||||
.createHash('sha256')
|
||||
.update(keyArg)
|
||||
.digest('base64')
|
||||
.replace(/=+$/, '');
|
||||
return `SHA256:${hash}`;
|
||||
};
|
||||
|
||||
export const fingerprintMd5 = (keyArg: Buffer | string) => {
|
||||
const hash = plugins.crypto.createHash('md5').update(keyArg).digest('hex');
|
||||
return hash.match(/.{2}/g)?.join(':') ?? hash;
|
||||
};
|
||||
|
||||
export const sshKeyArrayFromDir = (dirArg: string): SshKey[] => {
|
||||
const resolvedDir = resolveSshDirPath(dirArg);
|
||||
if (!plugins.fs.pathExistsSync(resolvedDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = plugins.fs.readdirSync(resolvedDir, { withFileTypes: true });
|
||||
const keyMap = new Map<string, { private?: string; public?: string }>();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileName = entry.name;
|
||||
if (
|
||||
fileName === 'config' ||
|
||||
fileName === 'authorized_keys' ||
|
||||
fileName === 'known_hosts' ||
|
||||
fileName.startsWith('.')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isPublicKey = fileName.endsWith('.pub');
|
||||
const host = isPublicKey ? fileName.slice(0, -4) : fileName;
|
||||
if (!isSafeHost(host)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const keyRecord = keyMap.get(host) ?? {};
|
||||
const filePath = plugins.path.join(resolvedDir, fileName);
|
||||
const keyContent = plugins.fs.readFileSync(filePath, 'utf8');
|
||||
if (isPublicKey) {
|
||||
keyRecord.public = keyContent;
|
||||
} else {
|
||||
keyRecord.private = keyContent;
|
||||
}
|
||||
keyMap.set(host, keyRecord);
|
||||
}
|
||||
|
||||
return Array.from(keyMap.entries()).map(([host, keyRecord]) => {
|
||||
return new SshKey({
|
||||
host,
|
||||
private: keyRecord.private,
|
||||
public: keyRecord.public,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -2,21 +2,40 @@ import * as plugins from './smartssh.plugins.js';
|
||||
import * as helpers from './smartssh.classes.helpers.js';
|
||||
import { SshKey } from './smartssh.classes.sshkey.js';
|
||||
|
||||
export type TSshStrictHostKeyChecking = boolean | 'accept-new';
|
||||
|
||||
export interface ISshConfigOptions {
|
||||
strictHostKeyChecking?: TSshStrictHostKeyChecking;
|
||||
}
|
||||
|
||||
export interface ISshConfigHostBlock {
|
||||
host: string;
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
export class SshConfig {
|
||||
private _sshKeyArray: SshKey[];
|
||||
constructor(sshKeyArrayArg: SshKey[]) {
|
||||
private _options: ISshConfigOptions;
|
||||
constructor(sshKeyArrayArg: SshKey[], optionsArg: ISshConfigOptions = {}) {
|
||||
this._sshKeyArray = sshKeyArrayArg;
|
||||
this._options = {
|
||||
...optionsArg,
|
||||
strictHostKeyChecking: optionsArg.strictHostKeyChecking ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* stores a config file
|
||||
*/
|
||||
store(dirPathArg: string) {
|
||||
plugins.fs.ensureDirSync(dirPathArg);
|
||||
let configArray: configObject[] = [];
|
||||
const resolvedDir = helpers.resolveSshDirPath(dirPathArg);
|
||||
helpers.ensureSshDirSync(resolvedDir);
|
||||
const configArray: configObject[] = [];
|
||||
for (const sshKey of this._sshKeyArray) {
|
||||
let configString = '';
|
||||
if (sshKey.host) {
|
||||
helpers.assertSafeHost(sshKey.host);
|
||||
const identityFilePath = plugins.path.join(resolvedDir, sshKey.host);
|
||||
configString =
|
||||
'Host ' +
|
||||
sshKey.host +
|
||||
@@ -24,11 +43,20 @@ export class SshConfig {
|
||||
' HostName ' +
|
||||
sshKey.host +
|
||||
'\n' +
|
||||
' IdentityFile ~/.ssh/' +
|
||||
sshKey.host +
|
||||
'\n' +
|
||||
' StrictHostKeyChecking no' +
|
||||
' IdentityFile ' +
|
||||
helpers.quoteSshConfigValue(identityFilePath) +
|
||||
'\n';
|
||||
if (this._options.strictHostKeyChecking !== undefined) {
|
||||
const strictHostKeyChecking = this._options.strictHostKeyChecking;
|
||||
configString +=
|
||||
' StrictHostKeyChecking ' +
|
||||
(strictHostKeyChecking === 'accept-new'
|
||||
? 'accept-new'
|
||||
: strictHostKeyChecking
|
||||
? 'yes'
|
||||
: 'no') +
|
||||
'\n';
|
||||
}
|
||||
}
|
||||
configArray.push({
|
||||
configString: configString,
|
||||
@@ -40,10 +68,52 @@ export class SshConfig {
|
||||
for (const config of configArray) {
|
||||
configFile = configFile + config.configString + '\n';
|
||||
}
|
||||
plugins.fs.writeFileSync(plugins.path.join(dirPathArg, 'config'), configFile);
|
||||
plugins.fs.writeFileSync(plugins.path.join(resolvedDir, 'config'), configFile);
|
||||
plugins.fs.chmodSync(plugins.path.join(resolvedDir, 'config'), 0o600);
|
||||
}
|
||||
read(dirPathArg: string) {
|
||||
return plugins.fs.readFileSync(plugins.path.join(dirPathArg, 'config'), 'utf8');
|
||||
const configPath = plugins.path.join(helpers.resolveSshDirPath(dirPathArg), 'config');
|
||||
return plugins.fs.readFileSync(configPath, 'utf8');
|
||||
}
|
||||
|
||||
parse(dirPathArg: string) {
|
||||
return SshConfig.parse(this.read(dirPathArg));
|
||||
}
|
||||
|
||||
static parse(configStringArg: string): ISshConfigHostBlock[] {
|
||||
const blocks: ISshConfigHostBlock[] = [];
|
||||
let currentBlock: ISshConfigHostBlock | undefined;
|
||||
|
||||
for (const rawLine of configStringArg.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [keyword, ...valueParts] = line.split(/\s+/);
|
||||
const value = valueParts.join(' ');
|
||||
if (!keyword || !value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (keyword.toLowerCase() === 'host') {
|
||||
if (!helpers.isSafeHost(value)) {
|
||||
continue;
|
||||
}
|
||||
currentBlock = {
|
||||
host: value,
|
||||
values: {},
|
||||
};
|
||||
blocks.push(currentBlock);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentBlock) {
|
||||
currentBlock.values[keyword.toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as plugins from './smartssh.plugins.js';
|
||||
import * as helpers from './smartssh.classes.helpers.js';
|
||||
import { SshInstance } from './smartssh.classes.sshinstance.js';
|
||||
import { SshKey } from './smartssh.classes.sshkey.js';
|
||||
import { SshConfig } from './smartssh.classes.sshconfig.js';
|
||||
|
||||
@@ -13,32 +12,31 @@ export class SshDir {
|
||||
this._sshKeyArray = sshKeyArray;
|
||||
this._sshConfig = sshConfig;
|
||||
if (sshDirPathArg) {
|
||||
this._path = sshDirPathArg;
|
||||
this._path = helpers.resolveSshDirPath(sshDirPathArg);
|
||||
} else {
|
||||
this._path = plugins.path.join(plugins.smartpath.get.home(), '.ssh/');
|
||||
this._path = helpers.resolveSshDirPath();
|
||||
}
|
||||
}
|
||||
|
||||
writeToDir(dirPathArg?: string) {
|
||||
// syncs sshInstance to directory
|
||||
let path = this._path;
|
||||
if (dirPathArg) path = dirPathArg;
|
||||
if (dirPathArg) path = helpers.resolveSshDirPath(dirPathArg);
|
||||
this._sshKeyArray.forEach((sshKeyArg) => {
|
||||
sshKeyArg.store(path);
|
||||
sshKeyArg.storeSync(path);
|
||||
});
|
||||
this._sshConfig.store(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: implement reading of directories
|
||||
*/
|
||||
readFromDir(dirPathArg?: string) {
|
||||
// syncs sshInstance from directory
|
||||
let path = this._path;
|
||||
if (dirPathArg) path = dirPathArg;
|
||||
if (dirPathArg) path = helpers.resolveSshDirPath(dirPathArg);
|
||||
const sshKeys = helpers.sshKeyArrayFromDir(path);
|
||||
this._sshKeyArray.splice(0, this._sshKeyArray.length, ...sshKeys);
|
||||
}
|
||||
updateDirPath(dirPathArg: string) {
|
||||
this._path = dirPathArg;
|
||||
this._path = helpers.resolveSshDirPath(dirPathArg);
|
||||
}
|
||||
|
||||
getKeys() {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import * as plugins from './smartssh.plugins.js';
|
||||
import * as helpers from './smartssh.classes.helpers.js';
|
||||
|
||||
import { SshDir } from './smartssh.classes.sshdir.js';
|
||||
import { SshConfig } from './smartssh.classes.sshconfig.js';
|
||||
import { SshConfig, type ISshConfigOptions } from './smartssh.classes.sshconfig.js';
|
||||
import { SshKey } from './smartssh.classes.sshkey.js';
|
||||
|
||||
/**
|
||||
@@ -13,10 +12,12 @@ export class SshInstance {
|
||||
private _sshConfig: SshConfig; // sshConfig (e.g. represents ~/.ssh/config)
|
||||
private _sshDir: SshDir; // points to sshDir class instance.
|
||||
private _sshSync: boolean; // if set to true, the ssh dir will be kept in sync automatically
|
||||
constructor(optionsArg: { sshDirPath?: string; sshSync?: boolean } = {}) {
|
||||
constructor(optionsArg: { sshDirPath?: string; sshSync?: boolean } & ISshConfigOptions = {}) {
|
||||
optionsArg ? void 0 : (optionsArg = {});
|
||||
this._sshKeyArray = [];
|
||||
this._sshConfig = new SshConfig(this._sshKeyArray);
|
||||
this._sshConfig = new SshConfig(this._sshKeyArray, {
|
||||
strictHostKeyChecking: optionsArg.strictHostKeyChecking,
|
||||
});
|
||||
this._sshDir = new SshDir(this._sshKeyArray, this._sshConfig, optionsArg.sshDirPath);
|
||||
this._sshSync = optionsArg.sshSync ?? false;
|
||||
}
|
||||
@@ -29,10 +30,10 @@ export class SshInstance {
|
||||
}
|
||||
removeKey(sshKeyArg: SshKey) {
|
||||
this._syncAuto('from');
|
||||
let filteredArray = this._sshKeyArray.filter((sshKeyArg2: SshKey) => {
|
||||
return sshKeyArg != sshKeyArg2;
|
||||
});
|
||||
this._sshKeyArray = filteredArray;
|
||||
const sshKeyIndex = this._sshKeyArray.indexOf(sshKeyArg);
|
||||
if (sshKeyIndex >= 0) {
|
||||
this._sshKeyArray.splice(sshKeyIndex, 1);
|
||||
}
|
||||
this._syncAuto('to');
|
||||
}
|
||||
replaceKey(sshKeyOldArg: SshKey, sshKeyNewArg: SshKey) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as plugins from './smartssh.plugins.js';
|
||||
import * as helpers from './smartssh.classes.helpers.js';
|
||||
|
||||
export type TSshKeyType = 'duplex' | 'private' | 'public';
|
||||
|
||||
export class SshKey {
|
||||
private _privKey: string;
|
||||
private _pubKey: string;
|
||||
@@ -15,6 +17,9 @@ export class SshKey {
|
||||
) {
|
||||
this._privKey = optionsArg.private ?? '';
|
||||
this._pubKey = optionsArg.public ?? '';
|
||||
if (optionsArg.host) {
|
||||
helpers.assertSafeHost(optionsArg.host);
|
||||
}
|
||||
this._hostVar = optionsArg.host ?? '';
|
||||
this._authorized = optionsArg.authorized ?? false;
|
||||
}
|
||||
@@ -24,6 +29,9 @@ export class SshKey {
|
||||
return this._hostVar;
|
||||
}
|
||||
set host(hostArg: string) {
|
||||
if (hostArg) {
|
||||
helpers.assertSafeHost(hostArg);
|
||||
}
|
||||
this._hostVar = hostArg;
|
||||
}
|
||||
|
||||
@@ -69,7 +77,7 @@ export class SshKey {
|
||||
/**
|
||||
* returns wether there is a private, a public or both keys
|
||||
*/
|
||||
get type() {
|
||||
get type(): TSshKeyType | undefined {
|
||||
if (this._privKey && this._pubKey) {
|
||||
return 'duplex';
|
||||
} else if (this._privKey) {
|
||||
@@ -78,25 +86,68 @@ export class SshKey {
|
||||
return 'public';
|
||||
}
|
||||
}
|
||||
set type(someVlueArg: any) {
|
||||
console.log('the type of an SshKey connot be set. This value is autocomputed.');
|
||||
}
|
||||
|
||||
// methods
|
||||
read(filePathArg: string) {}
|
||||
read(filePathArg: string) {
|
||||
const resolvedPath = plugins.path.resolve(filePathArg);
|
||||
const fileName = plugins.path.basename(resolvedPath);
|
||||
const isPublicKey = fileName.endsWith('.pub');
|
||||
const host = isPublicKey ? fileName.slice(0, -4) : fileName;
|
||||
helpers.assertSafeHost(host);
|
||||
|
||||
this._hostVar = host;
|
||||
if (isPublicKey) {
|
||||
this._pubKey = plugins.fs.readFileSync(resolvedPath, 'utf8');
|
||||
} else {
|
||||
this._privKey = plugins.fs.readFileSync(resolvedPath, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
static fromFile(filePathArg: string) {
|
||||
const sshKey = new SshKey();
|
||||
sshKey.read(filePathArg);
|
||||
return sshKey;
|
||||
}
|
||||
|
||||
static fromFiles(optionsArg: { privateKeyPath?: string; publicKeyPath?: string; host?: string }) {
|
||||
const sshKey = new SshKey({ host: optionsArg.host });
|
||||
if (optionsArg.privateKeyPath) {
|
||||
sshKey.privKey = plugins.fs.readFileSync(plugins.path.resolve(optionsArg.privateKeyPath), 'utf8');
|
||||
if (!sshKey.host) {
|
||||
const fileName = plugins.path.basename(optionsArg.privateKeyPath);
|
||||
helpers.assertSafeHost(fileName);
|
||||
sshKey.host = fileName;
|
||||
}
|
||||
}
|
||||
if (optionsArg.publicKeyPath) {
|
||||
sshKey.pubKey = plugins.fs.readFileSync(plugins.path.resolve(optionsArg.publicKeyPath), 'utf8');
|
||||
if (!sshKey.host) {
|
||||
const fileName = plugins.path.basename(optionsArg.publicKeyPath).replace(/\.pub$/, '');
|
||||
helpers.assertSafeHost(fileName);
|
||||
sshKey.host = fileName;
|
||||
}
|
||||
}
|
||||
return sshKey;
|
||||
}
|
||||
|
||||
async store(dirPathArg: string) {
|
||||
plugins.fs.ensureDirSync(dirPathArg);
|
||||
let fileNameBase = this.host;
|
||||
this.storeSync(dirPathArg);
|
||||
}
|
||||
|
||||
storeSync(dirPathArg: string) {
|
||||
helpers.assertSafeHost(this.host);
|
||||
const resolvedDir = helpers.resolveSshDirPath(dirPathArg);
|
||||
helpers.ensureSshDirSync(resolvedDir);
|
||||
const fileNameBase = this.host;
|
||||
if (this._privKey) {
|
||||
let filePath = plugins.path.join(dirPathArg, fileNameBase);
|
||||
const filePath = plugins.path.join(resolvedDir, fileNameBase);
|
||||
plugins.fs.writeFileSync(filePath, this._privKey);
|
||||
plugins.fs.chmodSync(filePath, 0o600);
|
||||
}
|
||||
if (this._pubKey) {
|
||||
let filePath = plugins.path.join(dirPathArg, fileNameBase + '.pub');
|
||||
const filePath = plugins.path.join(resolvedDir, fileNameBase + '.pub');
|
||||
plugins.fs.writeFileSync(filePath, this._pubKey);
|
||||
plugins.fs.chmodSync(filePath, 0o600);
|
||||
plugins.fs.chmodSync(filePath, 0o644);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// node native
|
||||
import * as crypto from 'crypto';
|
||||
import fs from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
|
||||
export { fs, path };
|
||||
export { crypto, fs, path };
|
||||
|
||||
// @push.rocks scope
|
||||
import * as smartjson from '@push.rocks/smartjson';
|
||||
@@ -19,6 +20,6 @@ export {
|
||||
|
||||
// third party scope
|
||||
import * as minimatch from 'minimatch';
|
||||
import * as nodeSsh from 'node-ssh';
|
||||
import ssh2 from 'ssh2';
|
||||
|
||||
export { minimatch, nodeSsh };
|
||||
export { minimatch, ssh2 };
|
||||
|
||||
Reference in New Issue
Block a user