UNPKG

webssh2-server

Version:

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

277 lines (276 loc) 9.28 kB
// app/ssh/connection-adapter.ts // SSH connection I/O adapter import { randomUUID } from 'node:crypto'; import { createNamespacedDebug } from '../logger.js'; import { validateConnectionWithDns } from './hostname-resolver.js'; const debug = createNamespacedDebug('ssh:adapter'); /** * Creates SSH configuration from credentials * @param credentials - Authentication credentials * @param config - Server configuration * @returns SSH configuration * @pure */ export function createSSHConfig(credentials, config) { const sshConfig = { host: credentials.host, port: credentials.port, username: credentials.username, }; // Add authentication method if (credentials.password != null) { sshConfig.password = credentials.password; } if (credentials.privateKey != null) { sshConfig.privateKey = credentials.privateKey; if (credentials.passphrase != null) { sshConfig.passphrase = credentials.passphrase; } } // Add server default private key if needed if (config.user.privateKey != null && config.user.privateKey !== '' && sshConfig.privateKey == null) { sshConfig.privateKey = config.user.privateKey; } // Add algorithm configuration from ssh.algorithms // Note: algorithms is always defined in the config type sshConfig.algorithms = config.ssh.algorithms; // Add timeout configuration // Note: these are always defined in the config type if (config.ssh.readyTimeout > 0) { sshConfig.readyTimeout = config.ssh.readyTimeout; } if (config.ssh.keepaliveInterval > 0) { sshConfig.keepaliveInterval = config.ssh.keepaliveInterval; } return sshConfig; } /** * Parses SSH error for user-friendly message * @param error - SSH error * @returns User-friendly error message * @pure */ export function parseSSHError(error) { if (error == null) { return 'SSH connection failed'; } if (typeof error === 'string') { return error; } const err = error; // Handle specific error codes if (err.code != null) { switch (err.code) { case 'ECONNREFUSED': return 'Connection refused - check host and port'; case 'ENOTFOUND': return 'Host not found - check hostname'; case 'ETIMEDOUT': return 'Connection timeout - host may be unreachable'; case 'EHOSTUNREACH': return 'Host unreachable - check network connection'; case 'ENETUNREACH': return 'Network unreachable'; case 'ECONNRESET': return 'Connection reset by peer'; case 'ERR_SOCKET_CLOSED': return 'Socket closed unexpectedly'; } } // Handle authentication errors // Cast to check for level property const sshErr = err; if (sshErr.level === 'client-authentication') { return 'Authentication failed - check credentials'; } // Use error message if available // Note: Error.message is always defined as a string return err.message === '' ? 'SSH connection failed' : err.message; } /** * Generates unique connection ID * @returns Connection ID * @pure */ export function generateConnectionId() { return `conn_${randomUUID()}`; } /** * SSH connection adapter for managing SSH connections * Handles all SSH I/O operations */ export class SSHConnectionAdapter { sshClient = null; connection = null; config; SSHConnectionClass; constructor(config, SSHConnectionClass) { this.config = config; this.SSHConnectionClass = SSHConnectionClass; } /** * Connect to SSH server */ async connect(credentials) { try { // Create SSH configuration const sshConfig = createSSHConfig(credentials, this.config); // Validate connection against allowed subnets if configured if (this.config.ssh.allowedSubnets != null && this.config.ssh.allowedSubnets.length > 0) { debug(`Validating connection to ${sshConfig.host} against subnet restrictions`); const validationResult = await validateConnectionWithDns(sshConfig.host, this.config.ssh.allowedSubnets); if (validationResult.ok) { // DNS resolution succeeded, check if host is in allowed subnets if (validationResult.value) { // Host is in allowed subnets, continue } else { // Host not in allowed subnets debug(`Host ${sshConfig.host} is not in allowed subnets: ${this.config.ssh.allowedSubnets.join(', ')}`); const errorMessage = `Connection to host ${sshConfig.host} is not permitted`; return { success: false, error: errorMessage, }; } } else { // DNS resolution failed const errorMessage = validationResult.error.message; debug(`Host validation failed: ${errorMessage}`); return { success: false, error: errorMessage, }; } } // Create new SSH client this.sshClient = new this.SSHConnectionClass(this.config); // Create connection record this.connection = { id: generateConnectionId(), status: 'connecting', }; debug(`Connecting to ${sshConfig.host}:${sshConfig.port}`); // Attempt connection await this.sshClient.connect(sshConfig); // Update connection status this.connection.status = 'connected'; this.connection.connectedAt = new Date(); debug(`Connected successfully: ${this.connection.id}`); return { success: true, connection: { ...this.connection }, }; } catch (error) { const errorMessage = parseSSHError(error); debug(`Connection failed: ${errorMessage}`); if (this.connection != null) { this.connection.status = 'error'; this.connection.error = errorMessage; } return { success: false, error: errorMessage, }; } } /** * Create SSH shell */ async shell(options, env) { if (this.sshClient == null) { return { success: false, error: 'SSH client not connected', }; } try { debug('Creating shell with options:', options); const stream = await this.sshClient.shell(options, env); debug('Shell created successfully'); return { success: true, stream, }; } catch (error) { const errorMessage = parseSSHError(error); debug(`Shell creation failed: ${errorMessage}`); return { success: false, error: errorMessage, }; } } /** * Execute command over SSH */ async exec(command, options, env) { if (this.sshClient == null) { return { success: false, error: 'SSH client not connected', }; } try { debug(`Executing command: ${command}`); const stream = await this.sshClient.exec(command, options, env); debug('Command execution started'); return { success: true, stream, }; } catch (error) { const errorMessage = parseSSHError(error); debug(`Command execution failed: ${errorMessage}`); return { success: false, error: errorMessage, }; } } /** * Resize terminal */ resizeTerminal(rows, cols) { if (this.sshClient?.resizeTerminal != null) { debug(`Resizing terminal to ${cols}x${rows}`); this.sshClient.resizeTerminal(rows, cols); } } /** * End SSH connection */ end() { if (this.sshClient?.end != null) { try { debug('Ending SSH connection'); this.sshClient.end(); } catch (error) { debug('Error ending SSH connection:', error); } } if (this.connection != null) { this.connection.status = 'disconnected'; } this.sshClient = null; } /** * Get connection status */ getConnection() { return this.connection == null ? null : { ...this.connection }; } /** * Check if connected */ isConnected() { return this.connection != null && this.connection.status === 'connected'; } }