feat(sshclient): add a promise-first SSH client with secure host verification and improve SSH key/config handling

This commit is contained in:
2026-05-02 09:43:21 +00:00
parent 4a97d63c04
commit 3b20db79d0
17 changed files with 1332 additions and 170 deletions
+79 -9
View File
@@ -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;
}
}