UNPKG

webssh2-server

Version:

A Websocket to SSH2 gateway using xterm.js, socket.io, ssh2

392 lines (348 loc) 10.8 kB
import ssh2 from 'ssh2' import crypto from 'crypto' import { test, describe, beforeEach, afterEach } from 'node:test' import { strict as assert } from 'assert' const { Server } = ssh2 import SSHConnection from '../app/ssh.js' describe('SSHConnection', () => { let sshServer let sshConnection const TEST_PORT = 2222 const TEST_CREDENTIALS = { username: 'testuser', password: 'testpass', } const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem', }, privateKeyEncoding: { type: 'pkcs1', format: 'pem', }, }) const mockConfig = { ssh: { algorithms: {}, readyTimeout: 2000, keepaliveInterval: 1000, keepaliveCountMax: 3, term: 'xterm', }, user: { privateKey: null, }, } beforeEach(() => { sshServer = new Server( { hostKeys: [privateKey], }, (client) => { client.on('authentication', (ctx) => { if ( ctx.method === 'password' && ctx.username === TEST_CREDENTIALS.username && ctx.password === TEST_CREDENTIALS.password ) { ctx.accept() } else { ctx.reject() } }) client.on('ready', () => { client.on('session', (accept) => { const session = accept() session.once('pty', (accept) => { accept() }) session.once('shell', (accept) => { const stream = accept() stream.write('Connected to test server\r\n') }) }) }) } ) // Bind explicitly to loopback to avoid sandbox restrictions on 0.0.0.0 sshServer.listen(TEST_PORT, '127.0.0.1') sshConnection = new SSHConnection(mockConfig) }) afterEach(() => { if (sshConnection) { sshConnection.end() } return new Promise((resolve) => { sshServer.close(resolve) }) }) test('should connect with valid credentials', async () => { const credentials = { host: 'localhost', port: TEST_PORT, username: TEST_CREDENTIALS.username, password: TEST_CREDENTIALS.password, } const connection = await sshConnection.connect(credentials) assert.ok(connection, 'Connection should be established') }) test('should reject connection with invalid credentials', async () => { const invalidCredentials = { host: 'localhost', port: TEST_PORT, username: 'wronguser', password: 'wrongpass', } try { await sshConnection.connect(invalidCredentials) assert.fail('Connection should have been rejected') } catch (error) { assert.equal(error.name, 'SSHConnectionError') assert.equal(error.message, 'All authentication methods failed') } }) test('should connect using private key authentication', async () => { const credentials = { host: 'localhost', port: TEST_PORT, username: TEST_CREDENTIALS.username, privateKey: privateKey, } // Update server auth handler to accept private key sshServer.removeAllListeners('connection') sshServer.on('connection', (client) => { client.on('authentication', (ctx) => { if (ctx.method === 'publickey' && ctx.username === TEST_CREDENTIALS.username) { ctx.accept() } else { ctx.reject() } }) client.on('ready', () => { client.on('session', (accept) => { accept() }) }) }) const connection = await sshConnection.connect(credentials) assert.ok(connection, 'Connection should be established using private key') }) test('should reject invalid private key format', async () => { const invalidPrivateKey = 'not-a-valid-private-key-format' const credentials = { host: 'localhost', port: TEST_PORT, username: TEST_CREDENTIALS.username, privateKey: invalidPrivateKey, } try { await sshConnection.connect(credentials) assert.fail('Connection should have been rejected') } catch (error) { assert.equal(error.name, 'SSHConnectionError') assert.equal(error.message, 'Invalid private key format') } }) test('should resize terminal when stream exists', async () => { const credentials = { host: 'localhost', port: TEST_PORT, username: TEST_CREDENTIALS.username, password: TEST_CREDENTIALS.password, } // Connect and create shell await sshConnection.connect(credentials) await sshConnection.shell({ term: 'xterm' }) // Mock the setWindow method on stream let windowResized = false sshConnection.stream.setWindow = (rows, cols) => { windowResized = true assert.equal(rows, 24) assert.equal(cols, 80) } // Test resize sshConnection.resizeTerminal(24, 80) assert.ok(windowResized, 'Terminal should be resized') }) test('should try private key first when both password and key are provided', async () => { const authAttemptOrder = [] sshServer.removeAllListeners('connection') sshServer.on('connection', (client) => { client.on('authentication', (ctx) => { authAttemptOrder.push(ctx.method) if (ctx.method === 'publickey' && ctx.username === TEST_CREDENTIALS.username) { return ctx.accept() } if ( ctx.method === 'password' && ctx.username === TEST_CREDENTIALS.username && ctx.password === TEST_CREDENTIALS.password ) { return ctx.accept() } ctx.reject(['publickey', 'password']) }) client.on('ready', () => { client.on('session', (accept) => { const session = accept() session.once('pty', (accept) => accept()) session.once('shell', (accept) => accept()) }) }) }) const credentials = { host: 'localhost', port: TEST_PORT, username: TEST_CREDENTIALS.username, privateKey: privateKey, password: TEST_CREDENTIALS.password, } const connection = await sshConnection.connect(credentials) assert.ok(connection, 'Connection should be established') assert.ok( authAttemptOrder.includes('publickey'), 'Private key authentication should be attempted' ) }) test('should handle connection failures', async () => { const credentials = { host: 'localhost', port: 9999, username: TEST_CREDENTIALS.username, password: TEST_CREDENTIALS.password, } try { await sshConnection.connect(credentials) assert.fail('Connection should have failed') } catch (error) { assert.equal(error.name, 'SSHConnectionError') assert.match(error.message, /Connection failed|All authentication methods failed/) } }) test('should handle connection timeout', async () => { const credentials = { host: '240.0.0.0', port: TEST_PORT, username: TEST_CREDENTIALS.username, password: TEST_CREDENTIALS.password, } try { await sshConnection.connect(credentials) assert.fail('Connection should have timed out') } catch (error) { assert.equal(error.name, 'SSHConnectionError') assert.equal(error.message, 'All authentication methods failed') } }) test('should exec command and receive stdout and exit code', async () => { // Reconfigure server to support exec sshServer.removeAllListeners('connection') sshServer.on('connection', (client) => { client.on('authentication', (ctx) => { if ( ctx.method === 'password' && ctx.username === TEST_CREDENTIALS.username && ctx.password === TEST_CREDENTIALS.password ) { ctx.accept() } else { ctx.reject() } }) client.on('ready', () => { client.on('session', (accept) => { const session = accept() // Accept PTY if requested prior to exec (optional) session.on('pty', (accept) => accept && accept()) session.on('exec', (accept, _reject, info) => { const stream = accept() // Simulate command behavior const out = `ran: ${info.command}\n` stream.write(out) // exit code 0 stream.exit(0) stream.close() }) }) }) }) const credentials = { host: 'localhost', port: TEST_PORT, username: TEST_CREDENTIALS.username, password: TEST_CREDENTIALS.password, } await sshConnection.connect(credentials) const stream = await sshConnection.exec('echo hello') let stdout = '' let code = null await new Promise((resolve, reject) => { stream.on('data', (data) => { stdout += data.toString('utf-8') }) stream.on('close', (c) => { code = c resolve() }) stream.on('error', reject) }) assert.match(stdout, /ran: echo hello/) assert.equal(code, 0) }) test('should exec with PTY when requested', async () => { // Reconfigure server to capture PTY request, then exec sshServer.removeAllListeners('connection') let ptyRequested = false sshServer.on('connection', (client) => { client.on('authentication', (ctx) => { if ( ctx.method === 'password' && ctx.username === TEST_CREDENTIALS.username && ctx.password === TEST_CREDENTIALS.password ) { ctx.accept() } else { ctx.reject() } }) client.on('ready', () => { client.on('session', (accept) => { const session = accept() session.on('pty', (accept) => { ptyRequested = true accept && accept() }) session.on('exec', (accept, _reject) => { const stream = accept() stream.write('pty-exec\n') stream.exit(0) stream.close() }) }) }) }) const credentials = { host: 'localhost', port: TEST_PORT, username: TEST_CREDENTIALS.username, password: TEST_CREDENTIALS.password, } await sshConnection.connect(credentials) const stream = await sshConnection.exec('uptime', { pty: true, term: 'xterm', cols: 80, rows: 24, }) let stdout = '' await new Promise((resolve, reject) => { stream.on('data', (data) => (stdout += data.toString('utf-8'))) stream.on('close', () => resolve()) stream.on('error', reject) }) assert.equal(ptyRequested, true, 'PTY should be requested for exec') assert.match(stdout, /pty-exec/) }) })