Merge pull request #57 from LukeGus/dev-1.01

Dev 1.01
This commit was merged in pull request #57.
This commit is contained in:
Karmaa
2025-07-31 22:38:27 -05:00
committed by GitHub
4 changed files with 129 additions and 52 deletions

View File

@@ -16,7 +16,7 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: [self-hosted, linux]
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -113,4 +113,4 @@ jobs:
if: always() if: always()
run: | run: |
docker image prune -af docker image prune -af
docker system prune -af --volumes docker system prune -af --volumes

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react'; import {useEffect, useRef, useState, useImperativeHandle, forwardRef} from 'react';
import { useXTerm } from 'react-xtermjs'; import {useXTerm} from 'react-xtermjs';
import { FitAddon } from '@xterm/addon-fit'; import {FitAddon} from '@xterm/addon-fit';
import { ClipboardAddon } from '@xterm/addon-clipboard'; import {ClipboardAddon} from '@xterm/addon-clipboard';
interface SSHTerminalProps { interface SSHTerminalProps {
hostConfig: any; hostConfig: any;
@@ -12,18 +12,23 @@ interface SSHTerminalProps {
} }
export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal( export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
{ hostConfig, isVisible, splitScreen = false }, {hostConfig, isVisible, splitScreen = false},
ref ref
) { ) {
const { instance: terminal, ref: xtermRef } = useXTerm(); const {instance: terminal, ref: xtermRef} = useXTerm();
const fitAddonRef = useRef<FitAddon | null>(null); const fitAddonRef = useRef<FitAddon | null>(null);
const webSocketRef = useRef<WebSocket | null>(null); const webSocketRef = useRef<WebSocket | null>(null);
const resizeTimeout = useRef<NodeJS.Timeout | null>(null); const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
const wasDisconnectedBySSH = useRef(false); const wasDisconnectedBySSH = useRef(false);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
disconnect: () => { disconnect: () => {
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
webSocketRef.current?.close(); webSocketRef.current?.close();
}, },
fit: () => { fit: () => {
@@ -31,7 +36,7 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
}, },
sendInput: (data: string) => { sendInput: (data: string) => {
if (webSocketRef.current?.readyState === 1) { 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<any, SSHTerminalProps>(function SSHTermina
fitAddonRef.current?.fit(); fitAddonRef.current?.fit();
const cols = terminal.cols + 1; const cols = terminal.cols + 1;
const rows = terminal.rows; 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); }, 100);
}); });
@@ -93,10 +98,16 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
wasDisconnectedBySSH.current = false; wasDisconnectedBySSH.current = false;
ws.addEventListener('open', () => { 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) => { 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) => { ws.addEventListener('message', (event) => {
@@ -104,12 +115,13 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
if (msg.type === 'data') terminal.write(msg.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 === 'error') terminal.writeln(`\r\n[ERROR] ${msg.message}`);
else if (msg.type === 'connected') {} else if (msg.type === 'connected') {
else if (msg.type === 'disconnected') { } else if (msg.type === 'disconnected') {
wasDisconnectedBySSH.current = true; wasDisconnectedBySSH.current = true;
terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`); terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`);
} }
} catch (_) {} } catch (_) {
}
}); });
ws.addEventListener('close', () => { ws.addEventListener('close', () => {
@@ -126,6 +138,10 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
return () => { return () => {
resizeObserver.disconnect(); resizeObserver.disconnect();
if (resizeTimeout.current) clearTimeout(resizeTimeout.current); if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
webSocketRef.current?.close(); webSocketRef.current?.close();
}; };
}, [xtermRef, terminal, hostConfig]); }, [xtermRef, terminal, hostConfig]);

View File

@@ -1,8 +1,8 @@
import { WebSocketServer, WebSocket, type RawData } from 'ws'; import {WebSocketServer, WebSocket, type RawData} from 'ws';
import { Client, type ClientChannel, type PseudoTtyOptions } from 'ssh2'; import {Client, type ClientChannel, type PseudoTtyOptions} from 'ssh2';
import chalk from 'chalk'; import chalk from 'chalk';
const wss = new WebSocketServer({ port: 8082 }); const wss = new WebSocketServer({port: 8082});
const sshIconSymbol = '🖥️'; const sshIconSymbol = '🖥️';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
@@ -33,6 +33,7 @@ const logger = {
wss.on('connection', (ws: WebSocket) => { wss.on('connection', (ws: WebSocket) => {
let sshConn: Client | null = null; let sshConn: Client | null = null;
let sshStream: ClientChannel | null = null; let sshStream: ClientChannel | null = null;
let pingInterval: NodeJS.Timeout | null = null;
ws.on('close', () => { ws.on('close', () => {
cleanupSSH(); cleanupSSH();
@@ -44,11 +45,11 @@ wss.on('connection', (ws: WebSocket) => {
parsed = JSON.parse(msg.toString()); parsed = JSON.parse(msg.toString());
} catch (e) { } catch (e) {
logger.error('Invalid JSON received: ' + msg.toString()); 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; return;
} }
const { type, data } = parsed; const {type, data} = parsed;
switch (type) { switch (type) {
case 'connectToHost': case 'connectToHost':
@@ -67,6 +68,10 @@ wss.on('connection', (ws: WebSocket) => {
if (sshStream) sshStream.write(data); if (sshStream) sshStream.write(data);
break; break;
case 'ping':
ws.send(JSON.stringify({type: 'pong'}));
break;
default: default:
logger.warn('Unknown message type: ' + type); logger.warn('Unknown message type: ' + type);
} }
@@ -86,30 +91,39 @@ wss.on('connection', (ws: WebSocket) => {
authType?: string; authType?: string;
}; };
}) { }) {
const { cols, rows, hostConfig } = data; const {cols, rows, hostConfig} = data;
const { ip, port, username, password, key, keyPassword, keyType, authType } = hostConfig; const {ip, port, username, password, key, keyPassword, keyType, authType} = hostConfig;
if (!username || typeof username !== 'string' || username.trim() === '') { if (!username || typeof username !== 'string' || username.trim() === '') {
logger.error('Invalid username provided'); 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; return;
} }
if (!ip || typeof ip !== 'string' || ip.trim() === '') { if (!ip || typeof ip !== 'string' || ip.trim() === '') {
logger.error('Invalid IP provided'); 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; return;
} }
if (!port || typeof port !== 'number' || port <= 0) { if (!port || typeof port !== 'number' || port <= 0) {
logger.error('Invalid port provided'); 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; return;
} }
sshConn = new Client(); 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', () => { sshConn.on('ready', () => {
clearTimeout(connectionTimeout);
const pseudoTtyOpts: PseudoTtyOptions = { const pseudoTtyOpts: PseudoTtyOptions = {
term: 'xterm-256color', term: 'xterm-256color',
cols, cols,
@@ -124,30 +138,43 @@ wss.on('connection', (ws: WebSocket) => {
sshConn!.shell(pseudoTtyOpts, (err, stream) => { sshConn!.shell(pseudoTtyOpts, (err, stream) => {
if (err) { if (err) {
logger.error('Shell error: ' + err.message); 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; return;
} }
sshStream = stream; sshStream = stream;
stream.on('data', (chunk: Buffer) => { 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', () => { stream.on('close', () => {
cleanupSSH(); cleanupSSH(connectionTimeout);
}); });
stream.on('error', (err: Error) => { stream.on('error', (err: Error) => {
logger.error('SSH stream error: ' + err.message); 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) => { sshConn.on('error', (err: Error) => {
clearTimeout(connectionTimeout);
logger.error('SSH connection error: ' + err.message); logger.error('SSH connection error: ' + err.message);
let errorMessage = 'SSH 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.'; errorMessage = 'SSH error: Connection refused. The server may not be running or the port may be incorrect.';
} else if (err.message.includes('ETIMEDOUT')) { } 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. 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 })); ws.send(JSON.stringify({type: 'error', message: errorMessage}));
cleanupSSH(); cleanupSSH(connectionTimeout);
}); });
sshConn.on('close', () => { sshConn.on('close', () => {
cleanupSSH(); clearTimeout(connectionTimeout);
cleanupSSH(connectionTimeout);
}); });
const connectConfig: any = { const connectConfig: any = {
host: ip, host: ip,
port, port,
username, username,
keepaliveInterval: 5000, keepaliveInterval: 30000,
keepaliveCountMax: 10, keepaliveCountMax: 3,
readyTimeout: 10000, readyTimeout: 10000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
algorithms: { algorithms: {
kex: [ kex: [
@@ -226,7 +260,7 @@ wss.on('connection', (ws: WebSocket) => {
} }
} else if (authType === 'key') { } else if (authType === 'key') {
logger.error('SSH key authentication 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' })); ws.send(JSON.stringify({type: 'error', message: 'SSH key authentication requested but no key provided'}));
return; return;
} else { } else {
connectConfig.password = password; connectConfig.password = password;
@@ -238,11 +272,20 @@ wss.on('connection', (ws: WebSocket) => {
function handleResize(data: { cols: number; rows: number }) { function handleResize(data: { cols: number; rows: number }) {
if (sshStream && sshStream.setWindow) { if (sshStream && sshStream.setWindow) {
sshStream.setWindow(data.rows, data.cols, data.rows, data.cols); 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) { if (sshStream) {
try { try {
sshStream.end(); sshStream.end();
@@ -261,4 +304,17 @@ wss.on('connection', (ws: WebSocket) => {
sshConn = null; 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);
}
});

View File

@@ -336,7 +336,6 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
} }
if (retryExhaustedTunnels.has(tunnelName)) { if (retryExhaustedTunnels.has(tunnelName)) {
broadcastTunnelStatus(tunnelName, { broadcastTunnelStatus(tunnelName, {
connected: false, connected: false,
@@ -571,6 +570,10 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
port: tunnelConfig.sourceSSHPort, port: tunnelConfig.sourceSSHPort,
username: tunnelConfig.sourceUsername, username: tunnelConfig.sourceUsername,
readyTimeout: 10000, readyTimeout: 10000,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
algorithms: { algorithms: {
kex: [ kex: [
'diffie-hellman-group14-sha256', 'diffie-hellman-group14-sha256',
@@ -692,7 +695,7 @@ function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName)); handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
}); });
}); });
}, 30000); }, 60000);
} }
function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
@@ -891,11 +894,9 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
}); });
stream.stdout?.on("data", (data: Buffer) => { stream.stdout?.on("data", (data: Buffer) => {
// Ignore stdout data
}); });
stream.on("error", (err: Error) => { stream.on("error", (err: Error) => {
// Ignore stream errors
}); });
stream.stderr.on("data", (data) => { stream.stderr.on("data", (data) => {
@@ -909,10 +910,11 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
host: tunnelConfig.sourceIP, host: tunnelConfig.sourceIP,
port: tunnelConfig.sourceSSHPort, port: tunnelConfig.sourceSSHPort,
username: tunnelConfig.sourceUsername, username: tunnelConfig.sourceUsername,
keepaliveInterval: 5000, keepaliveInterval: 30000,
keepaliveCountMax: 10, keepaliveCountMax: 3,
readyTimeout: 10000, readyTimeout: 10000,
tcpKeepAlive: true, tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
algorithms: { algorithms: {
kex: [ kex: [
'diffie-hellman-group14-sha256', 'diffie-hellman-group14-sha256',
@@ -1025,10 +1027,11 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
host: tunnelConfig.sourceIP, host: tunnelConfig.sourceIP,
port: tunnelConfig.sourceSSHPort, port: tunnelConfig.sourceSSHPort,
username: tunnelConfig.sourceUsername, username: tunnelConfig.sourceUsername,
keepaliveInterval: 5000, keepaliveInterval: 30000,
keepaliveCountMax: 10, keepaliveCountMax: 3,
readyTimeout: 10000, readyTimeout: 10000,
tcpKeepAlive: true, tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
algorithms: { algorithms: {
kex: [ kex: [
'diffie-hellman-group14-sha256', 'diffie-hellman-group14-sha256',
@@ -1087,8 +1090,10 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
conn.end(); conn.end();
callback(); callback();
}); });
stream.on('data', () => {}); stream.on('data', () => {
stream.stderr.on('data', () => {}); });
stream.stderr.on('data', () => {
});
}); });
}); });
conn.on('error', (err) => { conn.on('error', (err) => {