diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 22973d88c..519792716 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -8,6 +8,7 @@ import { extractSshArgs, extractTargetHost, extractTimeout, + getTerminalSessionTimeout, isAuthorizedTargetHost, } from './terminal-utils.js'; @@ -171,6 +172,7 @@ wss.on('connection', async (ws, req) => { authorizedIPs: [], authReady: false, pendingMessages: [], + terminalSessionTimer: null, }; const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req); const connectionContext = { @@ -340,8 +342,14 @@ async function handleCommand(ws, command, userId) { } } + if (userSession.terminalSessionTimer) { + clearTimeout(userSession.terminalSessionTimer); + userSession.terminalSessionTimer = null; + } + const commandString = command[0].split('\n').join(' '); - const timeout = extractTimeout(commandString); + const commandTimeout = extractTimeout(commandString); + const terminalSessionTimeout = getTerminalSessionTimeout(); const sshArgs = extractSshArgs(commandString); const hereDocContent = extractHereDocContent(commandString); @@ -350,7 +358,8 @@ async function handleCommand(ws, command, userId) { logTerminal('log', 'Parsed terminal command metadata.', { userId, targetHost, - timeout, + commandTimeout, + terminalSessionTimeout, sshArgs, authorizedIPs: userSession?.authorizedIPs ?? [], }); @@ -389,7 +398,8 @@ async function handleCommand(ws, command, userId) { logTerminal('log', 'Spawning PTY process for terminal session.', { userId, targetHost, - timeout, + commandTimeout, + terminalSessionTimeout, }); const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options); @@ -411,13 +421,16 @@ async function handleCommand(ws, command, userId) { }); ws.send('pty-exited'); userSession.isActive = false; + + if (userSession.terminalSessionTimer) { + clearTimeout(userSession.terminalSessionTimer); + userSession.terminalSessionTimer = null; + } }); - if (timeout) { - setTimeout(async () => { - await killPtyProcess(userId); - }, timeout * 1000); - } + userSession.terminalSessionTimer = setTimeout(async () => { + await killPtyProcess(userId); + }, terminalSessionTimeout * 1000); } async function handleError(err, userId) { @@ -459,6 +472,11 @@ async function killPtyProcess(userId) { setTimeout(() => { if (!session.isActive || !session.ptyProcess) { + if (session.terminalSessionTimer) { + clearTimeout(session.terminalSessionTimer); + session.terminalSessionTimer = null; + } + logTerminal('log', 'PTY process terminated successfully.', { userId, killAttempts, diff --git a/docker/coolify-realtime/terminal-utils.js b/docker/coolify-realtime/terminal-utils.js index 7456b282c..8769d62d9 100644 --- a/docker/coolify-realtime/terminal-utils.js +++ b/docker/coolify-realtime/terminal-utils.js @@ -1,3 +1,9 @@ +export const MAX_TERMINAL_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60; + +export function getTerminalSessionTimeout() { + return MAX_TERMINAL_SESSION_TIMEOUT_SECONDS; +} + export function extractTimeout(commandString) { const timeoutMatch = commandString.match(/timeout (\d+)/); return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null; diff --git a/docker/coolify-realtime/terminal-utils.test.js b/docker/coolify-realtime/terminal-utils.test.js index 3da444155..bf863099b 100644 --- a/docker/coolify-realtime/terminal-utils.test.js +++ b/docker/coolify-realtime/terminal-utils.test.js @@ -1,8 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + MAX_TERMINAL_SESSION_TIMEOUT_SECONDS, extractSshArgs, extractTargetHost, + getTerminalSessionTimeout, isAuthorizedTargetHost, normalizeHostForAuthorization, } from './terminal-utils.js'; @@ -45,3 +47,10 @@ test('normalizeHostForAuthorization unwraps bracketed IPv6 hosts', () => { test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => { assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false); }); + + +test('getTerminalSessionTimeout always enforces the maximum terminal session lifetime', () => { + assert.equal(getTerminalSessionTimeout(null), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS); + assert.equal(getTerminalSessionTimeout(60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS); + assert.equal(getTerminalSessionTimeout(MAX_TERMINAL_SESSION_TIMEOUT_SECONDS + 60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS); +}); diff --git a/resources/js/terminal-session-timer.js b/resources/js/terminal-session-timer.js new file mode 100644 index 000000000..60c7f7311 --- /dev/null +++ b/resources/js/terminal-session-timer.js @@ -0,0 +1,22 @@ +export const MAX_TERMINAL_SESSION_SECONDS = 8 * 60 * 60; +export const TERMINAL_SESSION_WARNING_SECONDS = 30 * 60; +export const TERMINAL_SESSION_DANGER_SECONDS = 5 * 60; + +export function formatTerminalSessionRemainingTime(seconds) { + const remainingSeconds = Math.max(0, Math.ceil(seconds)); + + if (remainingSeconds === 0) { + return 'expired'; + } + + const totalMinutes = Math.floor(remainingSeconds / 60); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + const secondsPart = remainingSeconds % 60; + + if (hours === 0) { + return `${minutes}m ${String(secondsPart).padStart(2, '0')}s`; + } + + return `${hours}h ${String(minutes).padStart(2, '0')}m ${String(secondsPart).padStart(2, '0')}s`; +} diff --git a/resources/js/terminal.js b/resources/js/terminal.js index cb3a26b3a..6c1d3057a 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -1,5 +1,11 @@ import { Terminal } from '@xterm/xterm'; import '@xterm/xterm/css/xterm.css'; +import { + MAX_TERMINAL_SESSION_SECONDS, + TERMINAL_SESSION_DANGER_SECONDS, + TERMINAL_SESSION_WARNING_SECONDS, + formatTerminalSessionRemainingTime, +} from './terminal-session-timer.js'; import { FitAddon } from '@xterm/addon-fit'; const terminalDebugEnabled = import.meta.env.DEV; @@ -52,6 +58,9 @@ export function initializeTerminalComponent() { // Visibility handling - prevent disconnects when tab loses focus isDocumentVisible: true, wasConnectedBeforeHidden: false, + terminalSessionStartedAt: null, + terminalSessionRemainingSeconds: null, + terminalSessionCountdownInterval: null, init() { this.setupTerminal(); @@ -135,6 +144,7 @@ export function initializeTerminalComponent() { this.clearAllTimers(); this.connectionState = 'disconnected'; this.pendingCommand = null; + this.resetTerminalSessionCountdown(); if (this.socket) { this.socket.close(1000, 'Client cleanup'); } @@ -157,11 +167,68 @@ export function initializeTerminalComponent() { } [this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout] .forEach(timer => timer && clearTimeout(timer)); + if (this.terminalSessionCountdownInterval) { + clearInterval(this.terminalSessionCountdownInterval); + } this.keepAliveInterval = null; this.reconnectInterval = null; this.connectionTimeoutId = null; this.pingTimeoutId = null; this.resizeTimeout = null; + this.terminalSessionCountdownInterval = null; + }, + + resetTerminalSessionCountdown() { + if (this.terminalSessionCountdownInterval) { + clearInterval(this.terminalSessionCountdownInterval); + } + + this.terminalSessionStartedAt = null; + this.terminalSessionRemainingSeconds = null; + this.terminalSessionCountdownInterval = null; + }, + + startTerminalSessionCountdown() { + this.resetTerminalSessionCountdown(); + this.terminalSessionStartedAt = Date.now(); + this.updateTerminalSessionCountdown(); + this.terminalSessionCountdownInterval = setInterval(() => { + this.updateTerminalSessionCountdown(); + }, 1000); + }, + + updateTerminalSessionCountdown() { + if (!this.terminalSessionStartedAt) { + this.terminalSessionRemainingSeconds = null; + return; + } + + const elapsedSeconds = (Date.now() - this.terminalSessionStartedAt) / 1000; + this.terminalSessionRemainingSeconds = Math.max(0, MAX_TERMINAL_SESSION_SECONDS - elapsedSeconds); + }, + + terminalSessionRemainingLabel() { + if (this.terminalSessionRemainingSeconds === null) { + return ''; + } + + return `Session expires in ${formatTerminalSessionRemainingTime(this.terminalSessionRemainingSeconds)}`; + }, + + terminalSessionTimerClass() { + if (this.terminalSessionRemainingSeconds === null) { + return 'text-neutral-300 bg-black/70 border-white/10'; + } + + if (this.terminalSessionRemainingSeconds <= TERMINAL_SESSION_DANGER_SECONDS) { + return 'text-red-200 bg-red-950/80 border-red-500/40'; + } + + if (this.terminalSessionRemainingSeconds <= TERMINAL_SESSION_WARNING_SECONDS) { + return 'text-yellow-200 bg-yellow-950/80 border-yellow-500/40'; + } + + return 'text-neutral-300 bg-black/70 border-white/10'; }, resetTerminal() { @@ -181,6 +248,7 @@ export function initializeTerminalComponent() { this.paused = false; this.commandBuffer = ''; this.pendingCommand = null; + this.resetTerminalSessionCountdown(); // Notify parent component that terminal disconnected this.$wire.dispatch('terminalDisconnected'); @@ -328,6 +396,7 @@ export function initializeTerminalComponent() { this.connectionState = 'disconnected'; this.clearAllTimers(); + this.resetTerminalSessionCountdown(); // Only reset terminal and reconnect if it wasn't a clean close if (event.code !== 1000) { @@ -424,6 +493,7 @@ export function initializeTerminalComponent() { } } this.terminalActive = true; + this.startTerminalSessionCountdown(); this.term.focus(); document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded-sm'); @@ -450,12 +520,14 @@ export function initializeTerminalComponent() { if (this.term) this.term.reset(); this.terminalActive = false; this.lastSentCommand = null; + this.resetTerminalSessionCountdown(); this.message = '(sorry, something went wrong, please try again)'; // Notify parent component that terminal connection failed this.$wire.dispatch('terminalDisconnected'); } else if (event.data === 'pty-exited') { this.terminalActive = false; + this.resetTerminalSessionCountdown(); this.term.reset(); this.commandBuffer = ''; this.lastSentCommand = null; @@ -469,6 +541,7 @@ export function initializeTerminalComponent() { logTerminal('error', '[Terminal] Backend rejected terminal startup:', event.data); this.$wire.dispatch('error', event.data); this.terminalActive = false; + this.resetTerminalSessionCountdown(); } else { try { this.pendingWrites++; diff --git a/resources/js/terminal.test.js b/resources/js/terminal.test.js new file mode 100644 index 000000000..e0a4fb852 --- /dev/null +++ b/resources/js/terminal.test.js @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + MAX_TERMINAL_SESSION_SECONDS, + formatTerminalSessionRemainingTime, +} from './terminal-session-timer.js'; + +test('formatTerminalSessionRemainingTime formats the eight hour terminal limit countdown', () => { + assert.equal(MAX_TERMINAL_SESSION_SECONDS, 8 * 60 * 60); + assert.equal(formatTerminalSessionRemainingTime(MAX_TERMINAL_SESSION_SECONDS), '8h 00m 00s'); + assert.equal(formatTerminalSessionRemainingTime((7 * 60 * 60) + (59 * 60) + 59), '7h 59m 59s'); + assert.equal(formatTerminalSessionRemainingTime(65 * 60), '1h 05m 00s'); + assert.equal(formatTerminalSessionRemainingTime(59), '0m 59s'); + assert.equal(formatTerminalSessionRemainingTime(0), 'expired'); +}); diff --git a/resources/views/livewire/project/shared/terminal.blade.php b/resources/views/livewire/project/shared/terminal.blade.php index abcb366f5..97f4c65d6 100644 --- a/resources/views/livewire/project/shared/terminal.blade.php +++ b/resources/views/livewire/project/shared/terminal.blade.php @@ -23,6 +23,11 @@