webssh2-server
Version:
A Websocket to SSH2 gateway using xterm.js, socket.io, ssh2
277 lines (276 loc) • 9.28 kB
JavaScript
// 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';
}
}