From 4b53139c200d0426e8b80d0573958d4f87c44314 Mon Sep 17 00:00:00 2001 From: Karmaa <88517757+LukeGus@users.noreply.github.com> Date: Thu, 31 Jul 2025 14:55:40 -0500 Subject: [PATCH 1/6] Update ssh.ts to fix 60 second disconnect --- src/backend/ssh/ssh.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/backend/ssh/ssh.ts b/src/backend/ssh/ssh.ts index b94dcb8c..5a5dfca2 100644 --- a/src/backend/ssh/ssh.ts +++ b/src/backend/ssh/ssh.ts @@ -130,6 +130,12 @@ wss.on('connection', (ws: WebSocket) => { sshStream = stream; + const keepaliveTimer = setInterval(() => { + if (sshStream && sshStream.writable) { + sshStream.write('\x00'); + } + }, 30000); + stream.on('data', (chunk: Buffer) => { ws.send(JSON.stringify({ type: 'data', data: chunk.toString() })); }); @@ -177,8 +183,8 @@ wss.on('connection', (ws: WebSocket) => { host: ip, port, username, - keepaliveInterval: 5000, - keepaliveCountMax: 10, + keepaliveInterval: 10000, + keepaliveCountMax: 60, readyTimeout: 10000, algorithms: { @@ -261,4 +267,4 @@ wss.on('connection', (ws: WebSocket) => { sshConn = null; } } -}); \ No newline at end of file +}); From 38f836b94fc2bfddd624abfbb3d7ce00b28983bd Mon Sep 17 00:00:00 2001 From: Karmaa <88517757+LukeGus@users.noreply.github.com> Date: Thu, 31 Jul 2025 15:20:27 -0500 Subject: [PATCH 2/6] Update ssh.ts --- src/backend/ssh/ssh.ts | 94 ++++++++++++++---------------------------- 1 file changed, 30 insertions(+), 64 deletions(-) diff --git a/src/backend/ssh/ssh.ts b/src/backend/ssh/ssh.ts index 5a5dfca2..d739ad75 100644 --- a/src/backend/ssh/ssh.ts +++ b/src/backend/ssh/ssh.ts @@ -10,19 +10,13 @@ const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): st return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${sshIconSymbol}]`)} ${message}`; }; const logger = { - info: (msg: string): void => { - console.log(formatMessage('info', chalk.cyan, msg)); - }, - warn: (msg: string): void => { - console.warn(formatMessage('warn', chalk.yellow, msg)); - }, + info: (msg: string): void => console.log(formatMessage('info', chalk.cyan, msg)), + warn: (msg: string): void => console.warn(formatMessage('warn', chalk.yellow, msg)), error: (msg: string, err?: unknown): void => { console.error(formatMessage('error', chalk.redBright, msg)); if (err) console.error(err); }, - success: (msg: string): void => { - console.log(formatMessage('success', chalk.greenBright, msg)); - }, + success: (msg: string): void => console.log(formatMessage('success', chalk.greenBright, msg)), debug: (msg: string): void => { if (process.env.NODE_ENV !== 'production') { console.debug(formatMessage('debug', chalk.magenta, msg)); @@ -33,6 +27,7 @@ const logger = { wss.on('connection', (ws: WebSocket) => { let sshConn: Client | null = null; let sshStream: ClientChannel | null = null; + let keepAliveTimer: NodeJS.Timeout | null = null; ws.on('close', () => { cleanupSSH(); @@ -54,38 +49,21 @@ wss.on('connection', (ws: WebSocket) => { case 'connectToHost': handleConnectToHost(data); break; - case 'resize': handleResize(data); break; - case 'disconnect': cleanupSSH(); break; - case 'input': if (sshStream) sshStream.write(data); break; - default: logger.warn('Unknown message type: ' + type); } }); - function handleConnectToHost(data: { - cols: number; - rows: number; - hostConfig: { - ip: string; - port: number; - username: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - authType?: string; - }; - }) { + function handleConnectToHost(data: any) { const { cols, rows, hostConfig } = data; const { ip, port, username, password, key, keyPassword, keyType, authType } = hostConfig; @@ -94,13 +72,11 @@ wss.on('connection', (ws: WebSocket) => { 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' })); return; } - if (!port || typeof port !== 'number' || port <= 0) { logger.error('Invalid port provided'); ws.send(JSON.stringify({ type: 'error', message: 'Invalid port provided' })); @@ -130,17 +106,12 @@ wss.on('connection', (ws: WebSocket) => { sshStream = stream; - const keepaliveTimer = setInterval(() => { - if (sshStream && sshStream.writable) { - sshStream.write('\x00'); - } - }, 30000); - stream.on('data', (chunk: Buffer) => { ws.send(JSON.stringify({ type: 'data', data: chunk.toString() })); }); stream.on('close', () => { + ws.send(JSON.stringify({ type: 'disconnected', message: 'SSH session closed' })); cleanupSSH(); }); @@ -150,32 +121,37 @@ wss.on('connection', (ws: WebSocket) => { }); ws.send(JSON.stringify({ type: 'connected', message: 'SSH connected' })); + + keepAliveTimer = setInterval(() => { + if (sshStream && sshStream.writable) { + sshStream.write(''); // keepalive + } + }, 30000); }); }); sshConn.on('error', (err: Error) => { logger.error('SSH connection error: ' + err.message); - 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.'; + errorMessage = 'SSH error: No compatible key exchange algorithm found.'; } 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.'; + errorMessage = 'SSH error: No compatible cipher found.'; } 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.'; + errorMessage = 'SSH error: Connection refused.'; } else if (err.message.includes('ETIMEDOUT')) { - errorMessage = 'SSH error: Connection timed out. Check your network connection and server availability.'; + errorMessage = 'SSH error: Connection timed out.'; } - + ws.send(JSON.stringify({ type: 'error', message: errorMessage })); cleanupSSH(); }); sshConn.on('close', () => { + ws.send(JSON.stringify({ type: 'disconnected', message: 'SSH connection closed' })); cleanupSSH(); }); @@ -186,7 +162,6 @@ wss.on('connection', (ws: WebSocket) => { keepaliveInterval: 10000, keepaliveCountMax: 60, readyTimeout: 10000, - algorithms: { kex: [ 'diffie-hellman-group14-sha256', @@ -209,30 +184,18 @@ wss.on('connection', (ws: WebSocket) => { 'aes256-cbc', '3des-cbc' ], - hmac: [ - 'hmac-sha2-256', - 'hmac-sha2-512', - 'hmac-sha1', - 'hmac-md5' - ], - compress: [ - 'none', - 'zlib@openssh.com', - 'zlib' - ] + hmac: ['hmac-sha2-256', 'hmac-sha2-512', 'hmac-sha1', 'hmac-md5'], + compress: ['none', 'zlib@openssh.com', 'zlib'] } }; + if (authType === 'key' && key) { connectConfig.privateKey = key; - if (keyPassword) { - connectConfig.passphrase = keyPassword; - } - if (keyType && keyType !== 'auto') { - connectConfig.privateKeyType = keyType; - } + if (keyPassword) connectConfig.passphrase = keyPassword; + if (keyType && keyType !== 'auto') connectConfig.privateKeyType = keyType; } 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' })); + logger.error('SSH key auth requested but no key provided'); + ws.send(JSON.stringify({ type: 'error', message: 'SSH key auth requested but no key provided' })); return; } else { connectConfig.password = password; @@ -249,6 +212,10 @@ wss.on('connection', (ws: WebSocket) => { } function cleanupSSH() { + if (keepAliveTimer) { + clearInterval(keepAliveTimer); + keepAliveTimer = null; + } if (sshStream) { try { sshStream.end(); @@ -257,7 +224,6 @@ wss.on('connection', (ws: WebSocket) => { } sshStream = null; } - if (sshConn) { try { sshConn.end(); From 38560b81787a76f72576afcef4c8b6e933ac17f6 Mon Sep 17 00:00:00 2001 From: Karmaa <88517757+LukeGus@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:18:44 -0500 Subject: [PATCH 3/6] Update ssh.ts --- src/backend/ssh/ssh.ts | 92 +++++++++++++++++++++++++++--------------- 1 file changed, 60 insertions(+), 32 deletions(-) diff --git a/src/backend/ssh/ssh.ts b/src/backend/ssh/ssh.ts index d739ad75..3d0a4671 100644 --- a/src/backend/ssh/ssh.ts +++ b/src/backend/ssh/ssh.ts @@ -10,13 +10,19 @@ const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): st return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${sshIconSymbol}]`)} ${message}`; }; const logger = { - info: (msg: string): void => console.log(formatMessage('info', chalk.cyan, msg)), - warn: (msg: string): void => console.warn(formatMessage('warn', chalk.yellow, msg)), + info: (msg: string): void => { + console.log(formatMessage('info', chalk.cyan, msg)); + }, + warn: (msg: string): void => { + console.warn(formatMessage('warn', chalk.yellow, msg)); + }, error: (msg: string, err?: unknown): void => { console.error(formatMessage('error', chalk.redBright, msg)); if (err) console.error(err); }, - success: (msg: string): void => console.log(formatMessage('success', chalk.greenBright, msg)), + success: (msg: string): void => { + console.log(formatMessage('success', chalk.greenBright, msg)); + }, debug: (msg: string): void => { if (process.env.NODE_ENV !== 'production') { console.debug(formatMessage('debug', chalk.magenta, msg)); @@ -27,7 +33,6 @@ const logger = { wss.on('connection', (ws: WebSocket) => { let sshConn: Client | null = null; let sshStream: ClientChannel | null = null; - let keepAliveTimer: NodeJS.Timeout | null = null; ws.on('close', () => { cleanupSSH(); @@ -49,21 +54,38 @@ wss.on('connection', (ws: WebSocket) => { case 'connectToHost': handleConnectToHost(data); break; + case 'resize': handleResize(data); break; + case 'disconnect': cleanupSSH(); break; + case 'input': if (sshStream) sshStream.write(data); break; + default: logger.warn('Unknown message type: ' + type); } }); - function handleConnectToHost(data: any) { + function handleConnectToHost(data: { + cols: number; + rows: number; + hostConfig: { + ip: string; + port: number; + username: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + authType?: string; + }; + }) { const { cols, rows, hostConfig } = data; const { ip, port, username, password, key, keyPassword, keyType, authType } = hostConfig; @@ -72,11 +94,13 @@ wss.on('connection', (ws: WebSocket) => { 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' })); return; } + if (!port || typeof port !== 'number' || port <= 0) { logger.error('Invalid port provided'); ws.send(JSON.stringify({ type: 'error', message: 'Invalid port provided' })); @@ -111,7 +135,6 @@ wss.on('connection', (ws: WebSocket) => { }); stream.on('close', () => { - ws.send(JSON.stringify({ type: 'disconnected', message: 'SSH session closed' })); cleanupSSH(); }); @@ -121,37 +144,32 @@ wss.on('connection', (ws: WebSocket) => { }); ws.send(JSON.stringify({ type: 'connected', message: 'SSH connected' })); - - keepAliveTimer = setInterval(() => { - if (sshStream && sshStream.writable) { - sshStream.write(''); // keepalive - } - }, 30000); }); }); sshConn.on('error', (err: Error) => { logger.error('SSH connection error: ' + err.message); - let errorMessage = 'SSH error: ' + err.message; + let errorMessage = 'SSH error: ' + err.message; if (err.message.includes('No matching key exchange algorithm')) { - errorMessage = 'SSH error: No compatible key exchange algorithm found.'; + 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.'; + 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.'; + 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.'; + errorMessage = 'SSH error: Connection timed out. Check your network connection and server availability.'; } - + ws.send(JSON.stringify({ type: 'error', message: errorMessage })); cleanupSSH(); }); sshConn.on('close', () => { - ws.send(JSON.stringify({ type: 'disconnected', message: 'SSH connection closed' })); cleanupSSH(); }); @@ -159,9 +177,10 @@ wss.on('connection', (ws: WebSocket) => { host: ip, port, username, - keepaliveInterval: 10000, - keepaliveCountMax: 60, + keepaliveInterval: 5000, + keepaliveCountMax: 10, readyTimeout: 10000, + algorithms: { kex: [ 'diffie-hellman-group14-sha256', @@ -184,18 +203,30 @@ wss.on('connection', (ws: WebSocket) => { 'aes256-cbc', '3des-cbc' ], - hmac: ['hmac-sha2-256', 'hmac-sha2-512', 'hmac-sha1', 'hmac-md5'], - compress: ['none', 'zlib@openssh.com', 'zlib'] + hmac: [ + 'hmac-sha2-256', + 'hmac-sha2-512', + 'hmac-sha1', + 'hmac-md5' + ], + compress: [ + 'none', + 'zlib@openssh.com', + 'zlib' + ] } }; - if (authType === 'key' && key) { connectConfig.privateKey = key; - if (keyPassword) connectConfig.passphrase = keyPassword; - if (keyType && keyType !== 'auto') connectConfig.privateKeyType = keyType; + if (keyPassword) { + connectConfig.passphrase = keyPassword; + } + if (keyType && keyType !== 'auto') { + connectConfig.privateKeyType = keyType; + } } else if (authType === 'key') { - logger.error('SSH key auth requested but no key provided'); - ws.send(JSON.stringify({ type: 'error', message: 'SSH key auth requested but no key provided' })); + 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' })); return; } else { connectConfig.password = password; @@ -212,10 +243,6 @@ wss.on('connection', (ws: WebSocket) => { } function cleanupSSH() { - if (keepAliveTimer) { - clearInterval(keepAliveTimer); - keepAliveTimer = null; - } if (sshStream) { try { sshStream.end(); @@ -224,6 +251,7 @@ wss.on('connection', (ws: WebSocket) => { } sshStream = null; } + if (sshConn) { try { sshConn.end(); From 43b833c4aea79aaff23c6764b15aab673256641a Mon Sep 17 00:00:00 2001 From: Karmaa <88517757+LukeGus@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:19:18 -0500 Subject: [PATCH 4/6] Update SSHTerminal.tsx --- src/apps/SSH/Terminal/SSHTerminal.tsx | 92 ++++++++++----------------- 1 file changed, 33 insertions(+), 59 deletions(-) diff --git a/src/apps/SSH/Terminal/SSHTerminal.tsx b/src/apps/SSH/Terminal/SSHTerminal.tsx index 7ca20724..9d5f226f 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,42 +12,39 @@ 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 [visible, setVisible] = useState(false); useImperativeHandle(ref, () => ({ disconnect: () => { - if (webSocketRef.current) { - webSocketRef.current.close(); - } + webSocketRef.current?.close(); }, fit: () => { - if (fitAddonRef.current) { - fitAddonRef.current.fit(); - } + fitAddonRef.current?.fit(); }, sendInput: (data: string) => { - if (webSocketRef.current && webSocketRef.current.readyState === 1) { - webSocketRef.current.send(JSON.stringify({type: 'input', data})); + if (webSocketRef.current?.readyState === 1) { + webSocketRef.current.send(JSON.stringify({ type: 'input', data })); } } }), []); useEffect(() => { - function handleWindowResize() { - fitAddonRef.current?.fit(); - } - window.addEventListener('resize', handleWindowResize); return () => window.removeEventListener('resize', handleWindowResize); }, []); + function handleWindowResize() { + fitAddonRef.current?.fit(); + } + useEffect(() => { if (!terminal || !xtermRef.current || !hostConfig) return; @@ -70,78 +67,55 @@ export const SSHTerminal = forwardRef(function SSHTermina }, }; - const onResize = () => { - if (!xtermRef.current) return; - const {width, height} = xtermRef.current.getBoundingClientRect(); - - if (width < 100 || height < 50) return; - + const resizeObserver = new ResizeObserver(() => { if (resizeTimeout.current) clearTimeout(resizeTimeout.current); resizeTimeout.current = setTimeout(() => { 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); - }; + }); - const resizeObserver = new ResizeObserver(onResize); resizeObserver.observe(xtermRef.current); - setTimeout(() => { fitAddon.fit(); setVisible(true); const cols = terminal.cols + 1; const rows = terminal.rows; - const wsUrl = window.location.hostname === 'localhost' ? 'ws://localhost:8082' : `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`; const ws = new WebSocket(wsUrl); - webSocketRef.current = ws; + wasDisconnectedBySSH.current = false; ws.addEventListener('open', () => { - ws.send(JSON.stringify({ - type: 'connectToHost', - data: { - cols, - rows, - hostConfig: 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 })); }); }); ws.addEventListener('message', (event) => { try { 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') { + 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') { + wasDisconnectedBySSH.current = true; + terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`); } - } catch (err) { - } + } catch (_) {} }); ws.addEventListener('close', () => { - terminal.writeln('\r\n[Connection closed]'); + if (!wasDisconnectedBySSH.current) { + terminal.writeln('\r\n[Connection closed]'); + } }); ws.addEventListener('error', () => { @@ -167,7 +141,7 @@ export const SSHTerminal = forwardRef(function SSHTermina ref={xtermRef} style={{ position: 'absolute', - top: splitScreen ? 0 : 0, + top: 0, left: 0, right: 0, bottom: 0, @@ -197,4 +171,4 @@ style.innerHTML = ` scrollbar-color: rgba(180,180,180,0.7) transparent; } `; -document.head.appendChild(style); \ No newline at end of file +document.head.appendChild(style); From b70fa4cd5236f20377140e0b051bff278d7b08cf Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 31 Jul 2025 18:54:10 -0500 Subject: [PATCH 5/6] Fix terminal and tunnel timeout connection errors --- src/apps/SSH/Terminal/SSHTerminal.tsx | 42 ++++++---- src/backend/ssh/ssh.ts | 108 +++++++++++++++++++------- src/backend/ssh_tunnel/ssh_tunnel.ts | 25 +++--- 3 files changed, 126 insertions(+), 49 deletions(-) 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 3d0a4671..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; } } + + 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) => { From 3559a6fd879b6af6a4371b1b08f42721f644655d Mon Sep 17 00:00:00 2001 From: Karmaa <88517757+LukeGus@users.noreply.github.com> Date: Thu, 31 Jul 2025 19:13:02 -0500 Subject: [PATCH 6/6] Update docker-image.yml --- .github/workflows/docker-image.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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