mirror of
https://github.com/coollabsio/coolify.git
synced 2026-06-14 03:19:51 +00:00
fix(terminal): keep sessions alive without hard timeouts
This commit is contained in:
@@ -69,7 +69,7 @@ class SshMultiplexingHelper
|
|||||||
return $scpCommand.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest);
|
return $scpCommand.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false): string
|
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false, ?int $commandTimeout = null): string
|
||||||
{
|
{
|
||||||
if ($server->settings->force_disabled) {
|
if ($server->settings->force_disabled) {
|
||||||
throw new \RuntimeException('Server is disabled.');
|
throw new \RuntimeException('Server is disabled.');
|
||||||
@@ -80,7 +80,8 @@ class SshMultiplexingHelper
|
|||||||
|
|
||||||
self::validateSshKey($server->privateKey);
|
self::validateSshKey($server->privateKey);
|
||||||
|
|
||||||
$sshCommand = 'timeout '.config('constants.ssh.command_timeout').' ssh ';
|
$commandTimeout = $commandTimeout ?? (int) config('constants.ssh.command_timeout');
|
||||||
|
$sshCommand = $commandTimeout > 0 ? "timeout {$commandTimeout} ssh " : 'ssh ';
|
||||||
|
|
||||||
if (! $disableMultiplexing && self::isMultiplexingEnabled()) {
|
if (! $disableMultiplexing && self::isMultiplexingEnabled()) {
|
||||||
$sshCommand .= self::multiplexingOptions($server);
|
$sshCommand .= self::multiplexingOptions($server);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class Terminal extends Component
|
|||||||
{
|
{
|
||||||
public bool $hasShell = true;
|
public bool $hasShell = true;
|
||||||
|
|
||||||
|
public bool $isTerminalConnected = false;
|
||||||
|
|
||||||
private function checkShellAvailability(Server $server, string $container): bool
|
private function checkShellAvailability(Server $server, string $container): bool
|
||||||
{
|
{
|
||||||
$escapedContainer = escapeshellarg($container);
|
$escapedContainer = escapeshellarg($container);
|
||||||
@@ -65,12 +67,20 @@ class Terminal extends Component
|
|||||||
$dockerCommand = "sudo {$dockerCommand}";
|
$dockerCommand = "sudo {$dockerCommand}";
|
||||||
}
|
}
|
||||||
|
|
||||||
$command = SshMultiplexingHelper::generateSshCommand($server, $dockerCommand);
|
$command = SshMultiplexingHelper::generateSshCommand(
|
||||||
|
$server,
|
||||||
|
$dockerCommand,
|
||||||
|
commandTimeout: (int) config('constants.terminal.command_timeout')
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
|
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
|
||||||
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
|
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
|
||||||
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
|
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
|
||||||
$command = SshMultiplexingHelper::generateSshCommand($server, $shellCommand);
|
$command = SshMultiplexingHelper::generateSshCommand(
|
||||||
|
$server,
|
||||||
|
$shellCommand,
|
||||||
|
commandTimeout: (int) config('constants.terminal.command_timeout')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// ssh command is sent back to frontend then to websocket
|
// ssh command is sent back to frontend then to websocket
|
||||||
// this is done because the websocket connection is not available here
|
// this is done because the websocket connection is not available here
|
||||||
@@ -84,6 +94,23 @@ class Terminal extends Component
|
|||||||
$this->dispatch('send-back-command', $command);
|
$this->dispatch('send-back-command', $command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[On('terminalConnected')]
|
||||||
|
public function markTerminalConnected(): void
|
||||||
|
{
|
||||||
|
$this->isTerminalConnected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[On('terminalDisconnected')]
|
||||||
|
public function markTerminalDisconnected(): void
|
||||||
|
{
|
||||||
|
$this->isTerminalConnected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function keepTerminalPageAlive(): void
|
||||||
|
{
|
||||||
|
$this->isTerminalConnected = true;
|
||||||
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.project.shared.terminal');
|
return view('livewire.project.shared.terminal');
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ return [
|
|||||||
'protocol' => env('TERMINAL_PROTOCOL'),
|
'protocol' => env('TERMINAL_PROTOCOL'),
|
||||||
'host' => env('TERMINAL_HOST'),
|
'host' => env('TERMINAL_HOST'),
|
||||||
'port' => env('TERMINAL_PORT'),
|
'port' => env('TERMINAL_PORT'),
|
||||||
|
'command_timeout' => 0,
|
||||||
],
|
],
|
||||||
|
|
||||||
'pusher' => [
|
'pusher' => [
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ function createHttpError(response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userSessions = new Map();
|
const userSessions = new Map();
|
||||||
const terminalDebugEnabled = ['1', 'true', 'yes'].includes(
|
const terminalDebugEnabled = ['local', 'development'].includes(
|
||||||
String(process.env.TERMINAL_DEBUG || '').toLowerCase()
|
String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase()
|
||||||
);
|
);
|
||||||
|
|
||||||
function logTerminal(level, message, context = {}) {
|
function logTerminal(level, message, context = {}) {
|
||||||
@@ -154,7 +154,6 @@ const verifyClient = async (info, callback) => {
|
|||||||
const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient });
|
const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient });
|
||||||
|
|
||||||
const HEARTBEAT_INTERVAL_MS = 30000;
|
const HEARTBEAT_INTERVAL_MS = 30000;
|
||||||
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
wss.on('connection', async (ws, req) => {
|
wss.on('connection', async (ws, req) => {
|
||||||
ws.isAlive = true;
|
ws.isAlive = true;
|
||||||
@@ -168,7 +167,6 @@ wss.on('connection', async (ws, req) => {
|
|||||||
ptyProcess: null,
|
ptyProcess: null,
|
||||||
isActive: false,
|
isActive: false,
|
||||||
authorizedIPs: [],
|
authorizedIPs: [],
|
||||||
lastActivityAt: Date.now(),
|
|
||||||
authReady: false,
|
authReady: false,
|
||||||
pendingMessages: [],
|
pendingMessages: [],
|
||||||
};
|
};
|
||||||
@@ -260,29 +258,6 @@ const heartbeat = setInterval(() => {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
// ignore — close handler will follow
|
// ignore — close handler will follow
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = ws.userId ? userSessions.get(ws.userId) : null;
|
|
||||||
if (session?.isActive && session.lastActivityAt && (Date.now() - session.lastActivityAt > IDLE_TIMEOUT_MS)) {
|
|
||||||
const idleMs = Date.now() - session.lastActivityAt;
|
|
||||||
logTerminal('warn', 'Closing terminal session due to idle timeout.', {
|
|
||||||
userId: ws.userId,
|
|
||||||
idleMs,
|
|
||||||
idleTimeoutMs: IDLE_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
ws.send('idle-timeout');
|
|
||||||
} catch (_) {
|
|
||||||
// ignore — close still attempted below
|
|
||||||
}
|
|
||||||
killPtyProcess(ws.userId);
|
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
ws.close(1000, 'Idle timeout');
|
|
||||||
} catch (_) {
|
|
||||||
// ignore — already closed
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}, HEARTBEAT_INTERVAL_MS);
|
}, HEARTBEAT_INTERVAL_MS);
|
||||||
|
|
||||||
@@ -290,11 +265,9 @@ wss.on('close', () => clearInterval(heartbeat));
|
|||||||
|
|
||||||
const messageHandlers = {
|
const messageHandlers = {
|
||||||
message: (session, data) => {
|
message: (session, data) => {
|
||||||
session.lastActivityAt = Date.now();
|
|
||||||
session.ptyProcess.write(data);
|
session.ptyProcess.write(data);
|
||||||
},
|
},
|
||||||
resize: (session, { cols, rows }) => {
|
resize: (session, { cols, rows }) => {
|
||||||
session.lastActivityAt = Date.now();
|
|
||||||
cols = cols > 0 ? cols : 80;
|
cols = cols > 0 ? cols : 80;
|
||||||
rows = rows > 0 ? rows : 30;
|
rows = rows > 0 ? rows : 30;
|
||||||
session.ptyProcess.resize(cols, rows)
|
session.ptyProcess.resize(cols, rows)
|
||||||
@@ -420,7 +393,6 @@ async function handleCommand(ws, command, userId) {
|
|||||||
|
|
||||||
userSession.ptyProcess = ptyProcess;
|
userSession.ptyProcess = ptyProcess;
|
||||||
userSession.isActive = true;
|
userSession.isActive = true;
|
||||||
userSession.lastActivityAt = Date.now();
|
|
||||||
|
|
||||||
ws.send('pty-ready');
|
ws.send('pty-ready');
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-6
@@ -1261,8 +1261,7 @@
|
|||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
@@ -1752,7 +1751,6 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -1946,8 +1944,7 @@
|
|||||||
"version": "4.1.18",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||||
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
@@ -1992,7 +1989,6 @@
|
|||||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function initializeTerminalComponent() {
|
|||||||
pendingCommand: null,
|
pendingCommand: null,
|
||||||
// Last successfully sent SSH command — replayed after a transient reconnect
|
// Last successfully sent SSH command — replayed after a transient reconnect
|
||||||
// so the PTY respawns automatically. Cleared on intentional terminations
|
// so the PTY respawns automatically. Cleared on intentional terminations
|
||||||
// (pty-exited, idle-timeout, unprocessable).
|
// (pty-exited, unprocessable).
|
||||||
lastSentCommand: null,
|
lastSentCommand: null,
|
||||||
// Resize handling
|
// Resize handling
|
||||||
resizeObserver: null,
|
resizeObserver: null,
|
||||||
@@ -462,15 +462,6 @@ export function initializeTerminalComponent() {
|
|||||||
|
|
||||||
// Notify parent component that terminal disconnected
|
// Notify parent component that terminal disconnected
|
||||||
this.$wire.dispatch('terminalDisconnected');
|
this.$wire.dispatch('terminalDisconnected');
|
||||||
} else if (event.data === 'idle-timeout') {
|
|
||||||
this.$wire.dispatch('error', 'Terminal closed after 30 minutes of inactivity.');
|
|
||||||
this.terminalActive = false;
|
|
||||||
if (this.term) {
|
|
||||||
this.term.reset();
|
|
||||||
}
|
|
||||||
this.commandBuffer = '';
|
|
||||||
this.lastSentCommand = null;
|
|
||||||
this.$wire.dispatch('terminalDisconnected');
|
|
||||||
} else if (
|
} else if (
|
||||||
typeof event.data === 'string' &&
|
typeof event.data === 'string' &&
|
||||||
(event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:'))
|
(event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:'))
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<div id="terminal-container" x-data="terminalData()">
|
<div id="terminal-container" x-data="terminalData()">
|
||||||
|
@if ($isTerminalConnected)
|
||||||
|
<div class="hidden" aria-hidden="true" wire:poll.keep-alive.30s="keepTerminalPageAlive"></div>
|
||||||
|
@endif
|
||||||
@if (!$hasShell)
|
@if (!$hasShell)
|
||||||
<div class="flex pt-4 items-center justify-center w-full py-4 mx-auto">
|
<div class="flex pt-4 items-center justify-center w-full py-4 mx-auto">
|
||||||
<div class="p-4 w-full rounded-sm border dark:bg-coolgray-100 dark:border-coolgray-300">
|
<div class="p-4 w-full rounded-sm border dark:bg-coolgray-100 dark:border-coolgray-300">
|
||||||
|
|||||||
@@ -58,22 +58,35 @@ it('uses a fast probe timeout when the tab regains visibility', function () {
|
|||||||
->toContain("'Visibility-resume timeout'");
|
->toContain("'Visibility-resume timeout'");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes idle terminal sessions after 30 minutes on the server', function () {
|
it('does not hard close terminal sessions after 30 minutes on the server', function () {
|
||||||
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
|
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
|
||||||
|
|
||||||
expect($terminalServer)
|
expect($terminalServer)
|
||||||
->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000')
|
->not->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000')
|
||||||
->toContain('lastActivityAt')
|
->not->toContain("ws.send('idle-timeout');")
|
||||||
->toContain("ws.send('idle-timeout');")
|
->not->toContain("ws.close(1000, 'Idle timeout');");
|
||||||
->toContain("ws.close(1000, 'Idle timeout');");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reacts to idle-timeout sentinel on the client and shows a user-facing error', function () {
|
it('does not close the client terminal from an idle-timeout sentinel', function () {
|
||||||
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
||||||
|
|
||||||
expect($terminalClient)
|
expect($terminalClient)
|
||||||
->toContain("event.data === 'idle-timeout'")
|
->not->toContain("event.data === 'idle-timeout'")
|
||||||
->toContain('Terminal closed after 30 minutes of inactivity.');
|
->not->toContain('Terminal closed after 30 minutes of inactivity.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps Livewire alive in background tabs while a terminal is connected', function () {
|
||||||
|
$terminalComponent = file_get_contents(base_path('app/Livewire/Project/Shared/Terminal.php'));
|
||||||
|
$terminalView = file_get_contents(base_path('resources/views/livewire/project/shared/terminal.blade.php'));
|
||||||
|
|
||||||
|
expect($terminalComponent)
|
||||||
|
->toContain('public bool $isTerminalConnected = false;')
|
||||||
|
->toContain("#[On('terminalConnected')]")
|
||||||
|
->toContain('public function markTerminalConnected(): void')
|
||||||
|
->toContain('public function keepTerminalPageAlive(): void')
|
||||||
|
->and($terminalView)
|
||||||
|
->toContain('@if ($isTerminalConnected)')
|
||||||
|
->toContain('wire:poll.keep-alive.30s="keepTerminalPageAlive"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('replays the last command on reconnect so the PTY respawns automatically', function () {
|
it('replays the last command on reconnect so the PTY respawns automatically', function () {
|
||||||
|
|||||||
@@ -73,6 +73,19 @@ it('adds native openssh multiplexing options to ssh commands', function () {
|
|||||||
Process::assertNothingRan();
|
Process::assertNothingRan();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can generate terminal ssh commands without a hard command timeout', function () {
|
||||||
|
config(['constants.ssh.mux_enabled' => true]);
|
||||||
|
$server = makeMuxServer();
|
||||||
|
Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key);
|
||||||
|
|
||||||
|
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', commandTimeout: 0);
|
||||||
|
|
||||||
|
expect($command)
|
||||||
|
->toStartWith('ssh ')
|
||||||
|
->not->toStartWith('timeout ')
|
||||||
|
->not->toContain('timeout 3600 ssh');
|
||||||
|
});
|
||||||
|
|
||||||
it('omits native multiplexing options when ssh multiplexing is disabled for a command', function () {
|
it('omits native multiplexing options when ssh multiplexing is disabled for a command', function () {
|
||||||
config(['constants.ssh.mux_enabled' => true]);
|
config(['constants.ssh.mux_enabled' => true]);
|
||||||
$server = makeMuxServer();
|
$server = makeMuxServer();
|
||||||
|
|||||||
Reference in New Issue
Block a user