feat(sshclient): add a promise-first SSH client with secure host verification and improve SSH key/config handling
This commit is contained in:
@@ -1,11 +1,65 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartssh from '../ts/index.js';
|
||||
import fs from 'fs-extra';
|
||||
import type { AddressInfo } from 'net';
|
||||
import * as path from 'path';
|
||||
import ssh2 from 'ssh2';
|
||||
import type { Server as TSsh2Server } from 'ssh2';
|
||||
|
||||
const { Server, utils } = ssh2;
|
||||
|
||||
const testDir = path.join(process.cwd(), '.nogit/test/temp');
|
||||
const configPath = path.join(testDir, 'config');
|
||||
|
||||
let testSshInstance: smartssh.SshInstance;
|
||||
let testSshKey: smartssh.SshKey;
|
||||
let testSshServer: TSsh2Server;
|
||||
let testSshServerPort: number;
|
||||
|
||||
const createTestSshServer = async () => {
|
||||
const hostKeys = [utils.generateKeyPairSync('ed25519').private];
|
||||
const server = new Server({ hostKeys }, (client) => {
|
||||
client.on('authentication', (ctx) => {
|
||||
if (ctx.method === 'password' && ctx.username === 'tester' && ctx.password === 'secret') {
|
||||
ctx.accept();
|
||||
return;
|
||||
}
|
||||
ctx.reject();
|
||||
});
|
||||
|
||||
client.on('ready', () => {
|
||||
client.on('session', (accept) => {
|
||||
const session = accept();
|
||||
session.on('exec', (acceptExec, _rejectExec, info) => {
|
||||
const stream = acceptExec();
|
||||
if (info.command === 'printf smartssh') {
|
||||
stream.write('smartssh\n');
|
||||
stream.stderr.write('warn\n');
|
||||
stream.exit(0);
|
||||
stream.end();
|
||||
return;
|
||||
}
|
||||
|
||||
stream.stderr.write(`unknown command: ${info.command}\n`);
|
||||
stream.exit(127);
|
||||
stream.end();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
|
||||
const address = server.address() as AddressInfo;
|
||||
return {
|
||||
server,
|
||||
port: address.port,
|
||||
};
|
||||
};
|
||||
|
||||
tap.test('should prepare a clean test directory', async () => {
|
||||
await fs.emptyDir(testDir);
|
||||
});
|
||||
|
||||
tap.test('should create a valid SshKey object', async () => {
|
||||
testSshKey = new smartssh.SshKey({
|
||||
host: 'example.com',
|
||||
@@ -29,6 +83,18 @@ tap.test('.publicKeyBase64 should be public key base 64 encoded', async () => {
|
||||
});
|
||||
tap.test('.store() should store the file to disk', async () => {
|
||||
await testSshKey.store(testDir);
|
||||
const privateKeyMode = (await fs.stat(path.join(testDir, 'example.com'))).mode & 0o777;
|
||||
const publicKeyMode = (await fs.stat(path.join(testDir, 'example.com.pub'))).mode & 0o777;
|
||||
expect(privateKeyMode).toEqual(0o600);
|
||||
expect(publicKeyMode).toEqual(0o644);
|
||||
});
|
||||
tap.test('.store() should reject unsafe host aliases', async () => {
|
||||
expect(() => {
|
||||
new smartssh.SshKey({
|
||||
host: '../evil',
|
||||
private: 'somePrivateKey',
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
// SSH INstance
|
||||
@@ -85,6 +151,64 @@ tap.test('.removeKey() should remove a key', async () => {
|
||||
|
||||
tap.test('it should store to disk', async () => {
|
||||
testSshInstance.writeToDisk();
|
||||
const config = await fs.readFile(configPath, 'utf8');
|
||||
expect(config).toInclude(`IdentityFile ${path.join(testDir, 'github.com')}`);
|
||||
expect(config).toInclude('StrictHostKeyChecking yes');
|
||||
expect(config).not.toInclude('StrictHostKeyChecking no');
|
||||
});
|
||||
|
||||
tap.test('it should read keys back from disk', async () => {
|
||||
const readBackInstance = new smartssh.SshInstance({ sshDirPath: testDir });
|
||||
readBackInstance.readFromDisk();
|
||||
const githubKey = readBackInstance.getKey('github.com');
|
||||
expect(githubKey).toBeInstanceOf(smartssh.SshKey);
|
||||
expect(githubKey?.privKey).toEqual('someGitHubPrivateKey');
|
||||
expect(githubKey?.pubKey).toEqual('someGitHubPublicKey');
|
||||
});
|
||||
|
||||
tap.test('SshClient should require host validation by default', async () => {
|
||||
let strictHostError: Error | undefined;
|
||||
try {
|
||||
await smartssh.SshClient.connect({
|
||||
host: '127.0.0.1',
|
||||
username: 'tester',
|
||||
});
|
||||
} catch (error) {
|
||||
strictHostError = error as Error;
|
||||
}
|
||||
expect(strictHostError?.message).toInclude('strictHostKeyChecking');
|
||||
});
|
||||
|
||||
tap.test('SshClient should execute a command over a real SSH server', async () => {
|
||||
const testServer = await createTestSshServer();
|
||||
testSshServer = testServer.server;
|
||||
testSshServerPort = testServer.port;
|
||||
|
||||
const sshClient = await smartssh.SshClient.connect({
|
||||
host: '127.0.0.1',
|
||||
port: testSshServerPort,
|
||||
username: 'tester',
|
||||
password: 'secret',
|
||||
strictHostKeyChecking: false,
|
||||
});
|
||||
const result = await sshClient.exec('printf smartssh');
|
||||
await sshClient.close();
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
expect(result.stdout).toEqual('smartssh\n');
|
||||
expect(result.stderr).toEqual('warn\n');
|
||||
});
|
||||
|
||||
tap.test('should close the real SSH test server', async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
testSshServer.close((error?: Error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
Reference in New Issue
Block a user