diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 324a8587..b5d4f731 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -16,7 +16,7 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: [self-hosted, linux] steps: - name: Checkout repository uses: actions/checkout@v4 @@ -113,4 +113,4 @@ jobs: if: always() run: | docker image prune -af - docker system prune -af --volumes \ No newline at end of file + docker system prune -af --volumes diff --git a/src/apps/SSH/Terminal/SSHTerminal.tsx b/src/apps/SSH/Terminal/SSHTerminal.tsx index 9d5f226f..e102f981 100644 --- a/src/apps/SSH/Terminal/SSHTerminal.tsx +++ b/src/apps/SSH/Terminal/SSHTerminal.tsx @@ -1,7 +1,7 @@ -import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react'; -import { useXTerm } from 'react-xtermjs'; -import { FitAddon } from '@xterm/addon-fit'; -import { ClipboardAddon } from '@xterm/addon-clipboard'; +import {useEffect, useRef, useState, useImperativeHandle, forwardRef} from 'react'; +import {useXTerm} from 'react-xtermjs'; +import {FitAddon} from '@xterm/addon-fit'; +import {ClipboardAddon} from '@xterm/addon-clipboard'; interface SSHTerminalProps { hostConfig: any; @@ -12,18 +12,23 @@ interface SSHTerminalProps { } export const SSHTerminal = forwardRef(function SSHTerminal( - { hostConfig, isVisible, splitScreen = false }, + {hostConfig, isVisible, splitScreen = false}, ref ) { - const { instance: terminal, ref: xtermRef } = useXTerm(); + const {instance: terminal, ref: xtermRef} = useXTerm(); const fitAddonRef = useRef(null); const webSocketRef = useRef(null); const resizeTimeout = useRef(null); const wasDisconnectedBySSH = useRef(false); + const pingIntervalRef = useRef(null); const [visible, setVisible] = useState(false); useImperativeHandle(ref, () => ({ disconnect: () => { + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; + } webSocketRef.current?.close(); }, fit: () => { @@ -31,7 +36,7 @@ export const SSHTerminal = forwardRef(function SSHTermina }, sendInput: (data: string) => { if (webSocketRef.current?.readyState === 1) { - webSocketRef.current.send(JSON.stringify({ type: 'input', data })); + webSocketRef.current.send(JSON.stringify({type: 'input', data})); } } }), []); @@ -73,7 +78,7 @@ export const SSHTerminal = forwardRef(function SSHTermina fitAddonRef.current?.fit(); const cols = terminal.cols + 1; const rows = terminal.rows; - webSocketRef.current?.send(JSON.stringify({ type: 'resize', data: { cols, rows } })); + webSocketRef.current?.send(JSON.stringify({type: 'resize', data: {cols, rows}})); }, 100); }); @@ -93,10 +98,16 @@ export const SSHTerminal = forwardRef(function SSHTermina wasDisconnectedBySSH.current = false; ws.addEventListener('open', () => { - ws.send(JSON.stringify({ type: 'connectToHost', data: { cols, rows, hostConfig } })); + ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}})); terminal.onData((data) => { - ws.send(JSON.stringify({ type: 'input', data })); + ws.send(JSON.stringify({type: 'input', data})); }); + + pingIntervalRef.current = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({type: 'ping'})); + } + }, 30000); }); ws.addEventListener('message', (event) => { @@ -104,12 +115,13 @@ export const SSHTerminal = forwardRef(function SSHTermina const msg = JSON.parse(event.data); if (msg.type === 'data') terminal.write(msg.data); else if (msg.type === 'error') terminal.writeln(`\r\n[ERROR] ${msg.message}`); - else if (msg.type === 'connected') {} - else if (msg.type === 'disconnected') { + else if (msg.type === 'connected') { + } else if (msg.type === 'disconnected') { wasDisconnectedBySSH.current = true; terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`); } - } catch (_) {} + } catch (_) { + } }); ws.addEventListener('close', () => { @@ -126,6 +138,10 @@ export const SSHTerminal = forwardRef(function SSHTermina return () => { resizeObserver.disconnect(); if (resizeTimeout.current) clearTimeout(resizeTimeout.current); + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; + } webSocketRef.current?.close(); }; }, [xtermRef, terminal, hostConfig]); diff --git a/src/backend/ssh/ssh.ts b/src/backend/ssh/ssh.ts index b94dcb8c..5c0cdd1b 100644 --- a/src/backend/ssh/ssh.ts +++ b/src/backend/ssh/ssh.ts @@ -1,8 +1,8 @@ -import { WebSocketServer, WebSocket, type RawData } from 'ws'; -import { Client, type ClientChannel, type PseudoTtyOptions } from 'ssh2'; +import {WebSocketServer, WebSocket, type RawData} from 'ws'; +import {Client, type ClientChannel, type PseudoTtyOptions} from 'ssh2'; import chalk from 'chalk'; -const wss = new WebSocketServer({ port: 8082 }); +const wss = new WebSocketServer({port: 8082}); const sshIconSymbol = '🖥️'; const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); @@ -33,6 +33,7 @@ const logger = { wss.on('connection', (ws: WebSocket) => { let sshConn: Client | null = null; let sshStream: ClientChannel | null = null; + let pingInterval: NodeJS.Timeout | null = null; ws.on('close', () => { cleanupSSH(); @@ -44,11 +45,11 @@ wss.on('connection', (ws: WebSocket) => { parsed = JSON.parse(msg.toString()); } catch (e) { logger.error('Invalid JSON received: ' + msg.toString()); - ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' })); + ws.send(JSON.stringify({type: 'error', message: 'Invalid JSON'})); return; } - const { type, data } = parsed; + const {type, data} = parsed; switch (type) { case 'connectToHost': @@ -67,6 +68,10 @@ wss.on('connection', (ws: WebSocket) => { if (sshStream) sshStream.write(data); break; + case 'ping': + ws.send(JSON.stringify({type: 'pong'})); + break; + default: logger.warn('Unknown message type: ' + type); } @@ -86,30 +91,39 @@ wss.on('connection', (ws: WebSocket) => { authType?: string; }; }) { - const { cols, rows, hostConfig } = data; - const { ip, port, username, password, key, keyPassword, keyType, authType } = hostConfig; + const {cols, rows, hostConfig} = data; + const {ip, port, username, password, key, keyPassword, keyType, authType} = hostConfig; if (!username || typeof username !== 'string' || username.trim() === '') { logger.error('Invalid username provided'); - ws.send(JSON.stringify({ type: 'error', message: 'Invalid username provided' })); + ws.send(JSON.stringify({type: 'error', message: 'Invalid username provided'})); return; } - + if (!ip || typeof ip !== 'string' || ip.trim() === '') { logger.error('Invalid IP provided'); - ws.send(JSON.stringify({ type: 'error', message: 'Invalid IP provided' })); + ws.send(JSON.stringify({type: 'error', message: 'Invalid IP provided'})); return; } - + if (!port || typeof port !== 'number' || port <= 0) { logger.error('Invalid port provided'); - ws.send(JSON.stringify({ type: 'error', message: 'Invalid port provided' })); + ws.send(JSON.stringify({type: 'error', message: 'Invalid port provided'})); return; } sshConn = new Client(); + const connectionTimeout = setTimeout(() => { + if (sshConn) { + logger.error('SSH connection timeout'); + ws.send(JSON.stringify({type: 'error', message: 'SSH connection timeout'})); + cleanupSSH(connectionTimeout); + } + }, 15000); + sshConn.on('ready', () => { + clearTimeout(connectionTimeout); const pseudoTtyOpts: PseudoTtyOptions = { term: 'xterm-256color', cols, @@ -124,30 +138,43 @@ wss.on('connection', (ws: WebSocket) => { sshConn!.shell(pseudoTtyOpts, (err, stream) => { if (err) { logger.error('Shell error: ' + err.message); - ws.send(JSON.stringify({ type: 'error', message: 'Shell error: ' + err.message })); + ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message})); return; } sshStream = stream; stream.on('data', (chunk: Buffer) => { - ws.send(JSON.stringify({ type: 'data', data: chunk.toString() })); + ws.send(JSON.stringify({type: 'data', data: chunk.toString()})); }); stream.on('close', () => { - cleanupSSH(); + cleanupSSH(connectionTimeout); }); stream.on('error', (err: Error) => { logger.error('SSH stream error: ' + err.message); - ws.send(JSON.stringify({ type: 'error', message: 'SSH stream error: ' + err.message })); + + const isConnectionError = err.message.includes('ECONNRESET') || + err.message.includes('EPIPE') || + err.message.includes('ENOTCONN') || + err.message.includes('ETIMEDOUT'); + + if (isConnectionError) { + ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'})); + } else { + ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message})); + } }); - ws.send(JSON.stringify({ type: 'connected', message: 'SSH connected' })); + setupPingInterval(); + + ws.send(JSON.stringify({type: 'connected', message: 'SSH connected'})); }); }); sshConn.on('error', (err: Error) => { + clearTimeout(connectionTimeout); logger.error('SSH connection error: ' + err.message); let errorMessage = 'SSH error: ' + err.message; @@ -163,23 +190,30 @@ wss.on('connection', (ws: WebSocket) => { 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(); + + ws.send(JSON.stringify({type: 'error', message: errorMessage})); + cleanupSSH(connectionTimeout); }); sshConn.on('close', () => { - cleanupSSH(); + clearTimeout(connectionTimeout); + cleanupSSH(connectionTimeout); }); const connectConfig: any = { host: ip, port, username, - keepaliveInterval: 5000, - keepaliveCountMax: 10, + keepaliveInterval: 30000, + keepaliveCountMax: 3, readyTimeout: 10000, + tcpKeepAlive: true, + tcpKeepAliveInitialDelay: 30000, algorithms: { kex: [ @@ -226,7 +260,7 @@ wss.on('connection', (ws: WebSocket) => { } } else if (authType === 'key') { logger.error('SSH key authentication requested but no key provided'); - ws.send(JSON.stringify({ type: 'error', message: '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 = password; @@ -238,11 +272,20 @@ wss.on('connection', (ws: WebSocket) => { 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 })); + ws.send(JSON.stringify({type: 'resized', cols: data.cols, rows: data.rows})); } } - function cleanupSSH() { + function cleanupSSH(timeoutId?: NodeJS.Timeout) { + if (timeoutId) { + clearTimeout(timeoutId); + } + + if (pingInterval) { + clearInterval(pingInterval); + pingInterval = null; + } + if (sshStream) { try { sshStream.end(); @@ -261,4 +304,17 @@ wss.on('connection', (ws: WebSocket) => { sshConn = null; } } -}); \ No newline at end of file + + function setupPingInterval() { + pingInterval = setInterval(() => { + if (sshConn && sshStream) { + try { + sshStream.write('\x00'); + } catch (e: any) { + logger.error('SSH keepalive failed: ' + e.message); + cleanupSSH(); + } + } + }, 60000); + } +}); diff --git a/src/backend/ssh_tunnel/ssh_tunnel.ts b/src/backend/ssh_tunnel/ssh_tunnel.ts index 760c906b..82881605 100644 --- a/src/backend/ssh_tunnel/ssh_tunnel.ts +++ b/src/backend/ssh_tunnel/ssh_tunnel.ts @@ -336,7 +336,6 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null, } - if (retryExhaustedTunnels.has(tunnelName)) { broadcastTunnelStatus(tunnelName, { connected: false, @@ -571,6 +570,10 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, port: tunnelConfig.sourceSSHPort, username: tunnelConfig.sourceUsername, readyTimeout: 10000, + keepaliveInterval: 30000, + keepaliveCountMax: 3, + tcpKeepAlive: true, + tcpKeepAliveInitialDelay: 30000, algorithms: { kex: [ 'diffie-hellman-group14-sha256', @@ -692,7 +695,7 @@ function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName)); }); }); - }, 30000); + }, 60000); } function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { @@ -891,11 +894,9 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { }); stream.stdout?.on("data", (data: Buffer) => { - // Ignore stdout data }); stream.on("error", (err: Error) => { - // Ignore stream errors }); stream.stderr.on("data", (data) => { @@ -909,10 +910,11 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { host: tunnelConfig.sourceIP, port: tunnelConfig.sourceSSHPort, username: tunnelConfig.sourceUsername, - keepaliveInterval: 5000, - keepaliveCountMax: 10, + keepaliveInterval: 30000, + keepaliveCountMax: 3, readyTimeout: 10000, tcpKeepAlive: true, + tcpKeepAliveInitialDelay: 30000, algorithms: { kex: [ 'diffie-hellman-group14-sha256', @@ -1025,10 +1027,11 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string host: tunnelConfig.sourceIP, port: tunnelConfig.sourceSSHPort, username: tunnelConfig.sourceUsername, - keepaliveInterval: 5000, - keepaliveCountMax: 10, + keepaliveInterval: 30000, + keepaliveCountMax: 3, readyTimeout: 10000, tcpKeepAlive: true, + tcpKeepAliveInitialDelay: 30000, algorithms: { kex: [ 'diffie-hellman-group14-sha256', @@ -1087,8 +1090,10 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string conn.end(); callback(); }); - stream.on('data', () => {}); - stream.stderr.on('data', () => {}); + stream.on('data', () => { + }); + stream.stderr.on('data', () => { + }); }); }); conn.on('error', (err) => {