Improve SSH stability and reconnection
This commit is contained in:
@@ -71,6 +71,16 @@ http {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
proxy_connect_timeout 75s;
|
||||||
|
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ 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()}]`);
|
||||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||||
@@ -30,16 +33,22 @@ 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;
|
let pingInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
cleanupSSH();
|
cleanupSSH();
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('message', (msg: RawData) => {
|
ws.on('message', (msg: RawData) => {
|
||||||
|
|
||||||
|
|
||||||
let parsed: any;
|
let parsed: any;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(msg.toString());
|
parsed = JSON.parse(msg.toString());
|
||||||
@@ -132,34 +141,13 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
|
|
||||||
sshConn.on('ready', () => {
|
sshConn.on('ready', () => {
|
||||||
clearTimeout(connectionTimeout);
|
clearTimeout(connectionTimeout);
|
||||||
const pseudoTtyOpts: PseudoTtyOptions = {
|
|
||||||
term: 'xterm-256color',
|
|
||||||
cols,
|
|
||||||
rows,
|
|
||||||
modes: {
|
|
||||||
ECHO: 1,
|
|
||||||
ECHOCTL: 0,
|
|
||||||
ICANON: 1,
|
|
||||||
ISIG: 1,
|
|
||||||
ICRNL: 1,
|
|
||||||
IXON: 1,
|
|
||||||
IXOFF: 0,
|
|
||||||
ISTRIP: 0,
|
|
||||||
OPOST: 1,
|
|
||||||
ONLCR: 1,
|
|
||||||
OCRNL: 0,
|
|
||||||
ONOCR: 0,
|
|
||||||
ONLRET: 0,
|
|
||||||
CS7: 0,
|
|
||||||
CS8: 1,
|
|
||||||
PARENB: 0,
|
|
||||||
PARODD: 0,
|
|
||||||
TTY_OP_ISPEED: 38400,
|
|
||||||
TTY_OP_OSPEED: 38400,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
sshConn!.shell(pseudoTtyOpts, (err, stream) => {
|
|
||||||
|
sshConn!.shell({
|
||||||
|
rows: data.rows,
|
||||||
|
cols: data.cols,
|
||||||
|
term: 'xterm-256color'
|
||||||
|
} as PseudoTtyOptions, (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}));
|
||||||
@@ -168,34 +156,18 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
|
|
||||||
sshStream = stream;
|
sshStream = stream;
|
||||||
|
|
||||||
stream.on('data', (chunk: Buffer) => {
|
stream.on('data', (data: Buffer) => {
|
||||||
let data: string;
|
ws.send(JSON.stringify({type: 'data', data: data.toString()}));
|
||||||
try {
|
|
||||||
data = chunk.toString('utf8');
|
|
||||||
} catch (e) {
|
|
||||||
data = chunk.toString('binary');
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.send(JSON.stringify({type: 'data', data}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.on('close', () => {
|
stream.on('close', () => {
|
||||||
cleanupSSH(connectionTimeout);
|
|
||||||
|
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
|
||||||
});
|
});
|
||||||
|
|
||||||
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}));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setupPingInterval();
|
setupPingInterval();
|
||||||
@@ -233,9 +205,12 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
|
|
||||||
sshConn.on('close', () => {
|
sshConn.on('close', () => {
|
||||||
clearTimeout(connectionTimeout);
|
clearTimeout(connectionTimeout);
|
||||||
|
|
||||||
cleanupSSH(connectionTimeout);
|
cleanupSSH(connectionTimeout);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const connectConfig: any = {
|
const connectConfig: any = {
|
||||||
host: ip,
|
host: ip,
|
||||||
port,
|
port,
|
||||||
@@ -245,6 +220,7 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
readyTimeout: 10000,
|
readyTimeout: 10000,
|
||||||
tcpKeepAlive: true,
|
tcpKeepAlive: true,
|
||||||
tcpKeepAliveInitialDelay: 30000,
|
tcpKeepAliveInitialDelay: 30000,
|
||||||
|
|
||||||
env: {
|
env: {
|
||||||
TERM: 'xterm-256color',
|
TERM: 'xterm-256color',
|
||||||
LANG: 'en_US.UTF-8',
|
LANG: 'en_US.UTF-8',
|
||||||
@@ -374,4 +350,6 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
}
|
}
|
||||||
}, 60000);
|
}, 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const isVisibleRef = useRef<boolean>(false);
|
const isVisibleRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
|
||||||
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||||
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||||
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@@ -115,6 +116,50 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
return getCookie("rightClickCopyPaste") === "true"
|
return getCookie("rightClickCopyPaste") === "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) {
|
||||||
|
ws.addEventListener('open', () => {
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
|
||||||
|
terminal.onData((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) => {
|
||||||
|
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') {
|
||||||
|
} else if (msg.type === 'disconnected') {
|
||||||
|
wasDisconnectedBySSH.current = true;
|
||||||
|
terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('close', () => {
|
||||||
|
if (!wasDisconnectedBySSH.current) {
|
||||||
|
terminal.writeln('\r\n[Connection closed]');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('error', () => {
|
||||||
|
terminal.writeln('\r\n[Connection error]');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function writeTextToClipboard(text: string): Promise<void> {
|
async function writeTextToClipboard(text: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
@@ -222,6 +267,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||||
hardRefresh();
|
hardRefresh();
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
|
if (terminal && !splitScreen) {
|
||||||
|
terminal.focus();
|
||||||
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
const cols = terminal.cols;
|
const cols = terminal.cols;
|
||||||
@@ -234,38 +282,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
webSocketRef.current = ws;
|
webSocketRef.current = ws;
|
||||||
wasDisconnectedBySSH.current = false;
|
wasDisconnectedBySSH.current = false;
|
||||||
|
|
||||||
ws.addEventListener('open', () => {
|
setupWebSocketListeners(ws, cols, rows);
|
||||||
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
|
|
||||||
terminal.onData((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) => {
|
|
||||||
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') {
|
|
||||||
} else if (msg.type === 'disconnected') {
|
|
||||||
wasDisconnectedBySSH.current = true;
|
|
||||||
terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.addEventListener('close', () => {
|
|
||||||
if (!wasDisconnectedBySSH.current) terminal.writeln('\r\n[Connection closed]');
|
|
||||||
});
|
|
||||||
ws.addEventListener('error', () => {
|
|
||||||
terminal.writeln('\r\n[Connection error]');
|
|
||||||
});
|
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -288,9 +305,18 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
fitAddonRef.current?.fit();
|
fitAddonRef.current?.fit();
|
||||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||||
hardRefresh();
|
hardRefresh();
|
||||||
|
if (terminal && !splitScreen) {
|
||||||
|
terminal.focus();
|
||||||
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
if (terminal && !splitScreen) {
|
||||||
|
setTimeout(() => {
|
||||||
|
terminal.focus();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [isVisible]);
|
}, [isVisible, splitScreen, terminal]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!fitAddonRef.current) return;
|
if (!fitAddonRef.current) return;
|
||||||
@@ -298,12 +324,23 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
fitAddonRef.current?.fit();
|
fitAddonRef.current?.fit();
|
||||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||||
hardRefresh();
|
hardRefresh();
|
||||||
|
if (terminal && !splitScreen && isVisible) {
|
||||||
|
terminal.focus();
|
||||||
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
}, [splitScreen]);
|
}, [splitScreen, isVisible, terminal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={xtermRef} className="h-full w-full m-1"
|
<div
|
||||||
style={{opacity: visible && isVisible ? 1 : 0, overflow: 'hidden'}}/>
|
ref={xtermRef}
|
||||||
|
className="h-full w-full m-1"
|
||||||
|
style={{opacity: visible && isVisible ? 1 : 0, overflow: 'hidden'}}
|
||||||
|
onClick={() => {
|
||||||
|
if (terminal && !splitScreen) {
|
||||||
|
terminal.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user