368 lines
14 KiB
TypeScript
368 lines
14 KiB
TypeScript
import {WebSocketServer, WebSocket, type RawData} from 'ws';
|
|
import {Client, type ClientChannel, type PseudoTtyOptions} from 'ssh2';
|
|
import {db} from '../database/db/index.js';
|
|
import {sshData, sshCredentials} from '../database/db/schema.js';
|
|
import {eq, and} from 'drizzle-orm';
|
|
import { sshLogger } from '../utils/logger.js';
|
|
|
|
const wss = new WebSocketServer({port: 8082});
|
|
|
|
sshLogger.success('SSH Terminal WebSocket server started', { operation: 'server_start', port: 8082 });
|
|
|
|
|
|
|
|
|
|
wss.on('connection', (ws: WebSocket) => {
|
|
let sshConn: Client | null = null;
|
|
let sshStream: ClientChannel | null = null;
|
|
let pingInterval: NodeJS.Timeout | null = null;
|
|
|
|
sshLogger.info('New WebSocket connection established', { operation: 'websocket_connect' });
|
|
|
|
|
|
ws.on('close', () => {
|
|
cleanupSSH();
|
|
});
|
|
|
|
ws.on('message', (msg: RawData) => {
|
|
|
|
|
|
let parsed: any;
|
|
try {
|
|
parsed = JSON.parse(msg.toString());
|
|
} catch (e) {
|
|
sshLogger.error('Invalid JSON received', e, { operation: 'websocket_message', messageLength: msg.toString().length });
|
|
ws.send(JSON.stringify({type: 'error', message: 'Invalid JSON'}));
|
|
return;
|
|
}
|
|
|
|
const {type, data} = parsed;
|
|
|
|
switch (type) {
|
|
case 'connectToHost':
|
|
sshLogger.info('SSH connection request received', { operation: 'ssh_connect', hostId: data.hostConfig?.id, ip: data.hostConfig?.ip, port: data.hostConfig?.port });
|
|
handleConnectToHost(data).catch(error => {
|
|
sshLogger.error('Failed to connect to host', error, { operation: 'ssh_connect', hostId: data.hostConfig?.id, ip: data.hostConfig?.ip });
|
|
ws.send(JSON.stringify({type: 'error', message: 'Failed to connect to host: ' + (error instanceof Error ? error.message : 'Unknown error')}));
|
|
});
|
|
break;
|
|
|
|
case 'resize':
|
|
handleResize(data);
|
|
break;
|
|
|
|
case 'disconnect':
|
|
cleanupSSH();
|
|
break;
|
|
|
|
case 'input':
|
|
if (sshStream) {
|
|
if (data === '\t') {
|
|
sshStream.write(data);
|
|
} else if (data.startsWith('\x1b')) {
|
|
sshStream.write(data);
|
|
} else {
|
|
sshStream.write(Buffer.from(data, 'utf8'));
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'ping':
|
|
ws.send(JSON.stringify({type: 'pong'}));
|
|
break;
|
|
|
|
default:
|
|
sshLogger.warn('Unknown message type received', { operation: 'websocket_message', messageType: type });
|
|
}
|
|
});
|
|
|
|
async function handleConnectToHost(data: {
|
|
cols: number;
|
|
rows: number;
|
|
hostConfig: {
|
|
id: number;
|
|
ip: string;
|
|
port: number;
|
|
username: string;
|
|
password?: string;
|
|
key?: string;
|
|
keyPassword?: string;
|
|
keyType?: string;
|
|
authType?: string;
|
|
credentialId?: number;
|
|
userId?: string;
|
|
};
|
|
}) {
|
|
const {cols, rows, hostConfig} = data;
|
|
const {id, ip, port, username, password, key, keyPassword, keyType, authType, credentialId} = hostConfig;
|
|
|
|
if (!username || typeof username !== 'string' || username.trim() === '') {
|
|
sshLogger.error('Invalid username provided', undefined, { operation: 'ssh_connect', hostId: id, ip });
|
|
ws.send(JSON.stringify({type: 'error', message: 'Invalid username provided'}));
|
|
return;
|
|
}
|
|
|
|
if (!ip || typeof ip !== 'string' || ip.trim() === '') {
|
|
sshLogger.error('Invalid IP provided', undefined, { operation: 'ssh_connect', hostId: id, username });
|
|
ws.send(JSON.stringify({type: 'error', message: 'Invalid IP provided'}));
|
|
return;
|
|
}
|
|
|
|
if (!port || typeof port !== 'number' || port <= 0) {
|
|
sshLogger.error('Invalid port provided', undefined, { operation: 'ssh_connect', hostId: id, ip, username, port });
|
|
ws.send(JSON.stringify({type: 'error', message: 'Invalid port provided'}));
|
|
return;
|
|
}
|
|
|
|
sshConn = new Client();
|
|
|
|
const connectionTimeout = setTimeout(() => {
|
|
if (sshConn) {
|
|
sshLogger.error('SSH connection timeout', undefined, { operation: 'ssh_connect', hostId: id, ip, port, username });
|
|
ws.send(JSON.stringify({type: 'error', message: 'SSH connection timeout'}));
|
|
cleanupSSH(connectionTimeout);
|
|
}
|
|
}, 60000);
|
|
|
|
let resolvedCredentials = {password, key, keyPassword, keyType, authType};
|
|
if (credentialId && id && hostConfig.userId) {
|
|
try {
|
|
const credentials = await db
|
|
.select()
|
|
.from(sshCredentials)
|
|
.where(and(
|
|
eq(sshCredentials.id, credentialId),
|
|
eq(sshCredentials.userId, hostConfig.userId)
|
|
));
|
|
|
|
if (credentials.length > 0) {
|
|
const credential = credentials[0];
|
|
resolvedCredentials = {
|
|
password: credential.password,
|
|
key: credential.key,
|
|
keyPassword: credential.keyPassword,
|
|
keyType: credential.keyType,
|
|
authType: credential.authType
|
|
};
|
|
} else {
|
|
sshLogger.warn(`No credentials found for host ${id}`, { operation: 'ssh_credentials', hostId: id, credentialId, userId: hostConfig.userId });
|
|
}
|
|
} catch (error) {
|
|
sshLogger.warn(`Failed to resolve credentials for host ${id}`, { operation: 'ssh_credentials', hostId: id, credentialId, error: error instanceof Error ? error.message : 'Unknown error' });
|
|
}
|
|
} else if (credentialId && id) {
|
|
sshLogger.warn('Missing userId for credential resolution in terminal', { operation: 'ssh_credentials', hostId: id, credentialId, hasUserId: !!hostConfig.userId });
|
|
}
|
|
|
|
sshConn.on('ready', () => {
|
|
clearTimeout(connectionTimeout);
|
|
|
|
|
|
sshConn!.shell({
|
|
rows: data.rows,
|
|
cols: data.cols,
|
|
term: 'xterm-256color'
|
|
} as PseudoTtyOptions, (err, stream) => {
|
|
if (err) {
|
|
sshLogger.error('Shell error', err, { operation: 'ssh_shell', hostId: id, ip, port, username });
|
|
ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message}));
|
|
return;
|
|
}
|
|
|
|
sshStream = stream;
|
|
|
|
stream.on('data', (data: Buffer) => {
|
|
ws.send(JSON.stringify({type: 'data', data: data.toString()}));
|
|
});
|
|
|
|
stream.on('close', () => {
|
|
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
|
|
});
|
|
|
|
stream.on('error', (err: Error) => {
|
|
sshLogger.error('SSH stream error', err, { operation: 'ssh_stream', hostId: id, ip, port, username });
|
|
ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message}));
|
|
});
|
|
|
|
setupPingInterval();
|
|
|
|
ws.send(JSON.stringify({type: 'connected', message: 'SSH connected'}));
|
|
});
|
|
});
|
|
|
|
sshConn.on('error', (err: Error) => {
|
|
clearTimeout(connectionTimeout);
|
|
sshLogger.error('SSH connection error', err, { operation: 'ssh_connect', hostId: id, ip, port, username, authType: resolvedCredentials.authType });
|
|
|
|
let errorMessage = 'SSH error: ' + err.message;
|
|
if (err.message.includes('No matching key exchange algorithm')) {
|
|
errorMessage = 'SSH error: No compatible key exchange algorithm found. This may be due to an older SSH server or network device.';
|
|
} else if (err.message.includes('No matching cipher')) {
|
|
errorMessage = 'SSH error: No compatible cipher found. This may be due to an older SSH server or network device.';
|
|
} else if (err.message.includes('No matching MAC')) {
|
|
errorMessage = 'SSH error: No compatible MAC algorithm found. This may be due to an older SSH server or network device.';
|
|
} else if (err.message.includes('ENOTFOUND') || err.message.includes('ENOENT')) {
|
|
errorMessage = 'SSH error: Could not resolve hostname or connect to server.';
|
|
} else if (err.message.includes('ECONNREFUSED')) {
|
|
errorMessage = 'SSH error: Connection refused. The server may not be running or the port may be incorrect.';
|
|
} else if (err.message.includes('ETIMEDOUT')) {
|
|
errorMessage = 'SSH error: Connection timed out. Check your network connection and server availability.';
|
|
} else if (err.message.includes('ECONNRESET') || err.message.includes('EPIPE')) {
|
|
errorMessage = 'SSH error: Connection was reset. This may be due to network issues or server timeout.';
|
|
} else if (err.message.includes('authentication failed') || err.message.includes('Permission denied')) {
|
|
errorMessage = 'SSH error: Authentication failed. Please check your username and password/key.';
|
|
}
|
|
|
|
ws.send(JSON.stringify({type: 'error', message: errorMessage}));
|
|
cleanupSSH(connectionTimeout);
|
|
});
|
|
|
|
sshConn.on('close', () => {
|
|
clearTimeout(connectionTimeout);
|
|
cleanupSSH(connectionTimeout);
|
|
});
|
|
|
|
|
|
const connectConfig: any = {
|
|
host: ip,
|
|
port,
|
|
username,
|
|
keepaliveInterval: 30000,
|
|
keepaliveCountMax: 3,
|
|
readyTimeout: 60000,
|
|
tcpKeepAlive: true,
|
|
tcpKeepAliveInitialDelay: 30000,
|
|
|
|
env: {
|
|
TERM: 'xterm-256color',
|
|
LANG: 'en_US.UTF-8',
|
|
LC_ALL: 'en_US.UTF-8',
|
|
LC_CTYPE: 'en_US.UTF-8',
|
|
LC_MESSAGES: 'en_US.UTF-8',
|
|
LC_MONETARY: 'en_US.UTF-8',
|
|
LC_NUMERIC: 'en_US.UTF-8',
|
|
LC_TIME: 'en_US.UTF-8',
|
|
LC_COLLATE: 'en_US.UTF-8',
|
|
COLORTERM: 'truecolor',
|
|
},
|
|
|
|
algorithms: {
|
|
kex: [
|
|
'diffie-hellman-group14-sha256',
|
|
'diffie-hellman-group14-sha1',
|
|
'diffie-hellman-group1-sha1',
|
|
'diffie-hellman-group-exchange-sha256',
|
|
'diffie-hellman-group-exchange-sha1',
|
|
'ecdh-sha2-nistp256',
|
|
'ecdh-sha2-nistp384',
|
|
'ecdh-sha2-nistp521'
|
|
],
|
|
cipher: [
|
|
'aes128-ctr',
|
|
'aes192-ctr',
|
|
'aes256-ctr',
|
|
'aes128-gcm@openssh.com',
|
|
'aes256-gcm@openssh.com',
|
|
'aes128-cbc',
|
|
'aes192-cbc',
|
|
'aes256-cbc',
|
|
'3des-cbc'
|
|
],
|
|
hmac: [
|
|
'hmac-sha2-256',
|
|
'hmac-sha2-512',
|
|
'hmac-sha1',
|
|
'hmac-md5'
|
|
],
|
|
compress: [
|
|
'none',
|
|
'zlib@openssh.com',
|
|
'zlib'
|
|
]
|
|
}
|
|
};
|
|
if (resolvedCredentials.authType === 'key' && resolvedCredentials.key) {
|
|
try {
|
|
if (!resolvedCredentials.key.includes('-----BEGIN') || !resolvedCredentials.key.includes('-----END')) {
|
|
throw new Error('Invalid private key format');
|
|
}
|
|
|
|
const cleanKey = resolvedCredentials.key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
|
|
connectConfig.privateKey = Buffer.from(cleanKey, 'utf8');
|
|
|
|
if (resolvedCredentials.keyPassword) {
|
|
connectConfig.passphrase = resolvedCredentials.keyPassword;
|
|
}
|
|
|
|
if (resolvedCredentials.keyType && resolvedCredentials.keyType !== 'auto') {
|
|
connectConfig.privateKeyType = resolvedCredentials.keyType;
|
|
}
|
|
} catch (keyError) {
|
|
sshLogger.error('SSH key format error: ' + keyError.message);
|
|
ws.send(JSON.stringify({type: 'error', message: 'SSH key format error: Invalid private key format'}));
|
|
return;
|
|
}
|
|
} else if (resolvedCredentials.authType === 'key') {
|
|
sshLogger.error('SSH key authentication requested but no key provided');
|
|
ws.send(JSON.stringify({type: 'error', message: 'SSH key authentication requested but no key provided'}));
|
|
return;
|
|
} else {
|
|
connectConfig.password = resolvedCredentials.password;
|
|
}
|
|
|
|
sshConn.connect(connectConfig);
|
|
}
|
|
|
|
function handleResize(data: { cols: number; rows: number }) {
|
|
if (sshStream && sshStream.setWindow) {
|
|
sshStream.setWindow(data.rows, data.cols, data.rows, data.cols);
|
|
ws.send(JSON.stringify({type: 'resized', cols: data.cols, rows: data.rows}));
|
|
}
|
|
}
|
|
|
|
function cleanupSSH(timeoutId?: NodeJS.Timeout) {
|
|
if (timeoutId) {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
|
|
if (pingInterval) {
|
|
clearInterval(pingInterval);
|
|
pingInterval = null;
|
|
}
|
|
|
|
if (sshStream) {
|
|
try {
|
|
sshStream.end();
|
|
} catch (e: any) {
|
|
sshLogger.error('Error closing stream: ' + e.message);
|
|
}
|
|
sshStream = null;
|
|
}
|
|
|
|
if (sshConn) {
|
|
try {
|
|
sshConn.end();
|
|
} catch (e: any) {
|
|
sshLogger.error('Error closing connection: ' + e.message);
|
|
}
|
|
sshConn = null;
|
|
}
|
|
}
|
|
|
|
function setupPingInterval() {
|
|
pingInterval = setInterval(() => {
|
|
if (sshConn && sshStream) {
|
|
try {
|
|
sshStream.write('\x00');
|
|
} catch (e: any) {
|
|
sshLogger.error('SSH keepalive failed: ' + e.message);
|
|
cleanupSSH();
|
|
}
|
|
}
|
|
}, 60000);
|
|
}
|
|
|
|
|
|
});
|