diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index 021ac3608..d0bd8d3a3 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -69,7 +69,7 @@ class SshMultiplexingHelper 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) { throw new \RuntimeException('Server is disabled.'); @@ -80,7 +80,8 @@ class SshMultiplexingHelper 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()) { $sshCommand .= self::multiplexingOptions($server); diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php index bbc2b3e66..db65cdaad 100644 --- a/app/Livewire/Project/Shared/Terminal.php +++ b/app/Livewire/Project/Shared/Terminal.php @@ -12,6 +12,8 @@ class Terminal extends Component { public bool $hasShell = true; + public bool $isTerminalConnected = false; + private function checkShellAvailability(Server $server, string $container): bool { $escapedContainer = escapeshellarg($container); @@ -65,12 +67,20 @@ class Terminal extends Component $dockerCommand = "sudo {$dockerCommand}"; } - $command = SshMultiplexingHelper::generateSshCommand($server, $dockerCommand); + $command = SshMultiplexingHelper::generateSshCommand( + $server, + $dockerCommand, + commandTimeout: (int) config('constants.terminal.command_timeout') + ); } else { $shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '. 'if [ -f ~/.profile ]; then . ~/.profile; 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 // 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); } + #[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() { return view('livewire.project.shared.terminal'); diff --git a/config/constants.php b/config/constants.php index 89b633650..fd66a682a 100644 --- a/config/constants.php +++ b/config/constants.php @@ -35,6 +35,7 @@ return [ 'protocol' => env('TERMINAL_PROTOCOL'), 'host' => env('TERMINAL_HOST'), 'port' => env('TERMINAL_PORT'), + 'command_timeout' => 0, ], 'pusher' => [ diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 42ca7c81d..c76383b9f 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -63,8 +63,8 @@ function createHttpError(response) { } const userSessions = new Map(); -const terminalDebugEnabled = ['1', 'true', 'yes'].includes( - String(process.env.TERMINAL_DEBUG || '').toLowerCase() +const terminalDebugEnabled = ['local', 'development'].includes( + String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase() ); 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 HEARTBEAT_INTERVAL_MS = 30000; -const IDLE_TIMEOUT_MS = 30 * 60 * 1000; wss.on('connection', async (ws, req) => { ws.isAlive = true; @@ -168,7 +167,6 @@ wss.on('connection', async (ws, req) => { ptyProcess: null, isActive: false, authorizedIPs: [], - lastActivityAt: Date.now(), authReady: false, pendingMessages: [], }; @@ -260,29 +258,6 @@ const heartbeat = setInterval(() => { } catch (_) { // 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); @@ -290,11 +265,9 @@ wss.on('close', () => clearInterval(heartbeat)); const messageHandlers = { message: (session, data) => { - session.lastActivityAt = Date.now(); session.ptyProcess.write(data); }, resize: (session, { cols, rows }) => { - session.lastActivityAt = Date.now(); cols = cols > 0 ? cols : 80; rows = rows > 0 ? rows : 30; session.ptyProcess.resize(cols, rows) @@ -420,7 +393,6 @@ async function handleCommand(ws, command, userId) { userSession.ptyProcess = ptyProcess; userSession.isActive = true; - userSession.lastActivityAt = Date.now(); ws.send('pty-ready'); diff --git a/package-lock.json b/package-lock.json index bcacecc8b..9d495c412 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1261,8 +1261,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/clsx": { "version": "2.1.1", @@ -1752,7 +1751,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1946,8 +1944,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -1992,7 +1989,6 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/resources/js/terminal.js b/resources/js/terminal.js index 7a7fc8536..cb3a26b3a 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -44,7 +44,7 @@ export function initializeTerminalComponent() { pendingCommand: null, // Last successfully sent SSH command — replayed after a transient reconnect // so the PTY respawns automatically. Cleared on intentional terminations - // (pty-exited, idle-timeout, unprocessable). + // (pty-exited, unprocessable). lastSentCommand: null, // Resize handling resizeObserver: null, @@ -462,15 +462,6 @@ export function initializeTerminalComponent() { // Notify parent component that terminal disconnected 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 ( typeof event.data === 'string' && (event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:')) diff --git a/resources/views/livewire/project/shared/terminal.blade.php b/resources/views/livewire/project/shared/terminal.blade.php index c46c5f316..abcb366f5 100644 --- a/resources/views/livewire/project/shared/terminal.blade.php +++ b/resources/views/livewire/project/shared/terminal.blade.php @@ -1,4 +1,7 @@