UNPKG

webssh2-server

Version:

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

409 lines (353 loc) 13.1 kB
// server // app/ssh.js import { Client as SSH } from 'ssh2' import { EventEmitter } from 'events' import { createNamespacedDebug } from './logger.js' import { SSHConnectionError, handleError } from './errors.js' import { maskSensitiveData } from './utils.js' const debug = createNamespacedDebug('ssh') /** * SSHConnection class handles SSH connections and operations. * @extends EventEmitter */ class SSHConnection extends EventEmitter { constructor(config) { super() this.config = config this.conn = null this.stream = null this.creds = null } /** * Validates the format of a private key, supporting modern SSH key formats * @param {string} key - The private key string to validate * @returns {boolean} - Whether the key appears to be valid */ validatePrivateKey(key) { if (!key || typeof key !== 'string') { return false } // Trim whitespace for consistent validation const trimmedKey = key.trim() // Patterns for various private key formats const keyPatterns = [ // OpenSSH format (modern default for all key types) /^-----BEGIN OPENSSH PRIVATE KEY-----[\s\S]*-----END OPENSSH PRIVATE KEY-----$/, // Traditional RSA format /^-----BEGIN (?:RSA )?PRIVATE KEY-----[\s\S]*-----END (?:RSA )?PRIVATE KEY-----$/, // EC (ECDSA) private key format /^-----BEGIN EC PRIVATE KEY-----[\s\S]*-----END EC PRIVATE KEY-----$/, // DSA private key format /^-----BEGIN DSA PRIVATE KEY-----[\s\S]*-----END DSA PRIVATE KEY-----$/, // PKCS#8 format (can contain any key type) /^-----BEGIN PRIVATE KEY-----[\s\S]*-----END PRIVATE KEY-----$/, // Encrypted PKCS#8 format /^-----BEGIN ENCRYPTED PRIVATE KEY-----[\s\S]*-----END ENCRYPTED PRIVATE KEY-----$/, ] // Test against all supported formats return keyPatterns.some((pattern) => pattern.test(trimmedKey)) } /** * Checks if a private key is encrypted * @param {string} key - The private key to check * @returns {boolean} - Whether the key is encrypted */ isEncryptedKey(key) { if (!key || typeof key !== 'string') { return false } // Check for various encryption indicators return ( // Traditional encrypted RSA format key.includes('Proc-Type: 4,ENCRYPTED') || // Encrypted PKCS#8 format key.includes('-----BEGIN ENCRYPTED PRIVATE KEY-----') || // OpenSSH encrypted format - contains encryption headers (key.includes('-----BEGIN OPENSSH PRIVATE KEY-----') && (key.includes('bcrypt') || key.includes('aes') || key.includes('3des'))) ) } /** * Attempts to connect using the provided credentials * @param {Object} creds - The credentials object * @returns {Promise<Object>} - A promise that resolves with the SSH connection */ connect(creds) { debug('connect: %O', maskSensitiveData(creds)) this.creds = creds if (this.conn) { this.conn.end() } this.conn = new SSH() // Build a single connection config with preferred auth order const sshConfig = this.getSSHConfig(creds, true) debug('Initial connection config: %O', maskSensitiveData(sshConfig)) return new Promise((resolve, reject) => { this.setupConnectionHandlers(resolve, reject) try { this.conn.connect(sshConfig) } catch (err) { reject(new SSHConnectionError(`Connection failed: ${err.message}`)) } }) } /** * Sets up SSH connection event handlers * @param {Function} resolve - Promise resolve function * @param {Function} reject - Promise reject function */ setupConnectionHandlers(resolve, reject) { this.conn.on('ready', () => { debug(`connect: ready: ${this.creds.host}`) resolve(this.conn) }) this.conn.on('error', (err) => { // Sometimes err.message is empty, use err.code or err.toString() as fallback const errorMessage = err.message || err.code || err.toString() || 'Unknown error' debug(`connect: error: ${errorMessage}`) // Check if this is a connection error (DNS, network, etc) vs authentication error const isConnectionError = errorMessage.includes('ENOTFOUND') || errorMessage.includes('ECONNREFUSED') || errorMessage.includes('ETIMEDOUT') || errorMessage.includes('EHOSTUNREACH') || errorMessage.includes('ENETUNREACH') || errorMessage.includes('getaddrinfo') || // Also check err.level if it's a system-level error (err.level === 'client-socket' && !errorMessage.includes('authentication')) if (isConnectionError) { // This is a connection error, not an auth error const displayMessage = errorMessage === 'Error' ? `Connection failed: Unable to connect to ${this.creds.host}:${this.creds.port || 22}` : `Connection failed: ${errorMessage}` const error = new SSHConnectionError(displayMessage) handleError(error) reject(error) return } // Authentication failures are handled within a single connection via authHandler const error = new SSHConnectionError('All authentication methods failed') handleError(error) reject(error) }) this.conn.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => { this.handleKeyboardInteractive(name, instructions, lang, prompts, finish) }) } /** * Handles keyboard-interactive authentication prompts. * @param {string} name - The name of the authentication request. * @param {string} instructions - The instructions for the keyboard-interactive prompt. * @param {string} lang - The language of the prompt. * @param {Array<Object>} prompts - The list of prompts provided by the server. * @param {Function} finish - The callback to complete the keyboard-interactive authentication. */ handleKeyboardInteractive(name, instructions, lang, prompts, finish) { debug('handleKeyboardInteractive: Keyboard-interactive auth %O', prompts) // Check if we should always send prompts to the client if (this.config.ssh.alwaysSendKeyboardInteractivePrompts) { this.sendPromptsToClient(name, instructions, prompts, finish) return } const responses = [] let shouldSendToClient = false for (let i = 0; i < prompts.length; i += 1) { if (prompts[i].prompt.toLowerCase().includes('password') && this.creds.password) { responses.push(this.creds.password) } else { shouldSendToClient = true break } } if (shouldSendToClient) { this.sendPromptsToClient(name, instructions, prompts, finish) } else { finish(responses) } } /** * Sends prompts to the client for keyboard-interactive authentication. * * @param {string} name - The name of the authentication method. * @param {string} instructions - The instructions for the authentication. * @param {Array<{ prompt: string, echo: boolean }>} prompts - The prompts to be sent to the client. * @param {Function} finish - The callback function to be called when the client responds. */ sendPromptsToClient(name, instructions, prompts, finish) { this.emit('keyboard-interactive', { name: name, instructions: instructions, prompts: prompts.map((p) => ({ prompt: p.prompt, echo: p.echo })), }) this.once('keyboard-interactive-response', (responses) => { finish(responses) }) } /** * Generates the SSH configuration object based on credentials. * @param {Object} creds - The credentials object * @param {boolean} useKey - Whether to attempt key authentication * @returns {Object} - The SSH configuration object */ getSSHConfig(creds, useKey) { const config = { host: creds.host, port: creds.port, username: creds.username, // Keep keyboard-interactive available; authHandler controls order tryKeyboard: true, algorithms: this.config.ssh.algorithms, readyTimeout: this.config.ssh.readyTimeout, keepaliveInterval: this.config.ssh.keepaliveInterval, keepaliveCountMax: this.config.ssh.keepaliveCountMax, debug: createNamespacedDebug('ssh2'), } // Populate available credentials const authOrder = [] // Prefer private key first (if provided and allowed) if (useKey && (creds.privateKey || this.config.user.privateKey)) { const privateKey = creds.privateKey || this.config.user.privateKey if (!this.validatePrivateKey(privateKey)) { throw new SSHConnectionError('Invalid private key format') } config.privateKey = privateKey if (this.isEncryptedKey(privateKey)) { const passphrase = creds.passphrase || this.config.user.passphrase if (!passphrase) { throw new SSHConnectionError('Encrypted private key requires a passphrase') } config.passphrase = passphrase } authOrder.push('publickey') } // Then try password if present if (creds.password) { config.password = creds.password authOrder.push('password') } // Finally, allow keyboard-interactive as a fallback authOrder.push('keyboard-interactive') // Use a single connection to iterate through methods in order config.authHandler = authOrder return config } /** * Opens an interactive shell session over the SSH connection. * @param {Object} options - Options for the shell * @param {Object} [envVars] - Environment variables to set * @returns {Promise<Object>} - A promise that resolves with the SSH shell stream */ shell(options, envVars) { // Separate PTY options from environment options // SSH2 expects them as separate parameters const ptyOptions = { term: options.term, rows: options.rows, cols: options.cols, width: options.width, height: options.height, } // Only include environment options if we have envVars const envOptions = envVars ? { env: this.getEnvironment(envVars), } : undefined debug(`shell: Creating shell with PTY options:`, ptyOptions, 'and env options:', envOptions) return new Promise((resolve, reject) => { // Pass PTY options as first param, env options as second this.conn.shell(ptyOptions, envOptions, (err, stream) => { if (err) { reject(err) } else { this.stream = stream resolve(stream) } }) }) } /** * Executes a single non-interactive command over the SSH connection. * Optionally requests a PTY when options.pty is true to emulate TTY behavior. * * @param {string} command - The command to execute * @param {Object} [options] - Execution options * @param {boolean} [options.pty] - Request a PTY for the exec channel * @param {string} [options.term] - Terminal type * @param {number} [options.rows] - Rows for PTY * @param {number} [options.cols] - Columns for PTY * @param {number} [options.width] - Pixel width for PTY * @param {number} [options.height] - Pixel height for PTY * @param {Object} [envVars] - Environment variables to set for the command * @returns {Promise<Object>} - Resolves with the SSH exec stream */ exec(command, options = {}, envVars) { const execOptions = {} // Include environment vars if provided (same behavior as shell()) if (envVars) { execOptions.env = this.getEnvironment(envVars) } // PTY request if needed if (options.pty) { execOptions.pty = { term: options.term, rows: options.rows, cols: options.cols, width: options.width, height: options.height, } } debug('exec: Executing command with options:', command, execOptions) return new Promise((resolve, reject) => { this.conn.exec(command, execOptions, (err, stream) => { if (err) { reject(err) } else { this.stream = stream resolve(stream) } }) }) } /** * Resizes the terminal window for the current SSH session. * @param {number} rows - The number of rows for the terminal. * @param {number} cols - The number of columns for the terminal. */ resizeTerminal(rows, cols) { if (this.stream && typeof this.stream.setWindow === 'function') { this.stream.setWindow(rows, cols) } } /** * Ends the SSH connection and stream. */ end() { if (this.stream) { this.stream.end() this.stream = null } if (this.conn) { this.conn.end() this.conn = null } } /** * Gets the environment variables for the SSH session * @param {Object} envVars - Environment variables from URL * @returns {Object} - Combined environment variables */ getEnvironment(envVars) { const env = { TERM: this.config.ssh.term, } if (envVars) { Object.keys(envVars).forEach((key) => { env[key] = envVars[key] }) } return env } } export default SSHConnection