fix(terminal): enforce eight hour session expiry

Add a visible countdown in the terminal UI and terminate realtime PTY
sessions after the fixed maximum lifetime.
This commit is contained in:
Andras Bacsai
2026-06-01 09:45:56 +02:00
parent 4d3182c938
commit 53f24df0a0
7 changed files with 156 additions and 8 deletions
+26 -8
View File
@@ -8,6 +8,7 @@ import {
extractSshArgs, extractSshArgs,
extractTargetHost, extractTargetHost,
extractTimeout, extractTimeout,
getTerminalSessionTimeout,
isAuthorizedTargetHost, isAuthorizedTargetHost,
} from './terminal-utils.js'; } from './terminal-utils.js';
@@ -171,6 +172,7 @@ wss.on('connection', async (ws, req) => {
authorizedIPs: [], authorizedIPs: [],
authReady: false, authReady: false,
pendingMessages: [], pendingMessages: [],
terminalSessionTimer: null,
}; };
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req); const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
const connectionContext = { 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 commandString = command[0].split('\n').join(' ');
const timeout = extractTimeout(commandString); const commandTimeout = extractTimeout(commandString);
const terminalSessionTimeout = getTerminalSessionTimeout();
const sshArgs = extractSshArgs(commandString); const sshArgs = extractSshArgs(commandString);
const hereDocContent = extractHereDocContent(commandString); const hereDocContent = extractHereDocContent(commandString);
@@ -350,7 +358,8 @@ async function handleCommand(ws, command, userId) {
logTerminal('log', 'Parsed terminal command metadata.', { logTerminal('log', 'Parsed terminal command metadata.', {
userId, userId,
targetHost, targetHost,
timeout, commandTimeout,
terminalSessionTimeout,
sshArgs, sshArgs,
authorizedIPs: userSession?.authorizedIPs ?? [], authorizedIPs: userSession?.authorizedIPs ?? [],
}); });
@@ -389,7 +398,8 @@ async function handleCommand(ws, command, userId) {
logTerminal('log', 'Spawning PTY process for terminal session.', { logTerminal('log', 'Spawning PTY process for terminal session.', {
userId, userId,
targetHost, targetHost,
timeout, commandTimeout,
terminalSessionTimeout,
}); });
const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options); const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options);
@@ -411,13 +421,16 @@ async function handleCommand(ws, command, userId) {
}); });
ws.send('pty-exited'); ws.send('pty-exited');
userSession.isActive = false; userSession.isActive = false;
if (userSession.terminalSessionTimer) {
clearTimeout(userSession.terminalSessionTimer);
userSession.terminalSessionTimer = null;
}
}); });
if (timeout) { userSession.terminalSessionTimer = setTimeout(async () => {
setTimeout(async () => { await killPtyProcess(userId);
await killPtyProcess(userId); }, terminalSessionTimeout * 1000);
}, timeout * 1000);
}
} }
async function handleError(err, userId) { async function handleError(err, userId) {
@@ -459,6 +472,11 @@ async function killPtyProcess(userId) {
setTimeout(() => { setTimeout(() => {
if (!session.isActive || !session.ptyProcess) { if (!session.isActive || !session.ptyProcess) {
if (session.terminalSessionTimer) {
clearTimeout(session.terminalSessionTimer);
session.terminalSessionTimer = null;
}
logTerminal('log', 'PTY process terminated successfully.', { logTerminal('log', 'PTY process terminated successfully.', {
userId, userId,
killAttempts, killAttempts,
@@ -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) { export function extractTimeout(commandString) {
const timeoutMatch = commandString.match(/timeout (\d+)/); const timeoutMatch = commandString.match(/timeout (\d+)/);
return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null; return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null;
@@ -1,8 +1,10 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
MAX_TERMINAL_SESSION_TIMEOUT_SECONDS,
extractSshArgs, extractSshArgs,
extractTargetHost, extractTargetHost,
getTerminalSessionTimeout,
isAuthorizedTargetHost, isAuthorizedTargetHost,
normalizeHostForAuthorization, normalizeHostForAuthorization,
} from './terminal-utils.js'; } from './terminal-utils.js';
@@ -45,3 +47,10 @@ test('normalizeHostForAuthorization unwraps bracketed IPv6 hosts', () => {
test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => { test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => {
assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false); 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);
});
+22
View File
@@ -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`;
}
+73
View File
@@ -1,5 +1,11 @@
import { Terminal } from '@xterm/xterm'; import { Terminal } from '@xterm/xterm';
import '@xterm/xterm/css/xterm.css'; 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'; import { FitAddon } from '@xterm/addon-fit';
const terminalDebugEnabled = import.meta.env.DEV; const terminalDebugEnabled = import.meta.env.DEV;
@@ -52,6 +58,9 @@ export function initializeTerminalComponent() {
// Visibility handling - prevent disconnects when tab loses focus // Visibility handling - prevent disconnects when tab loses focus
isDocumentVisible: true, isDocumentVisible: true,
wasConnectedBeforeHidden: false, wasConnectedBeforeHidden: false,
terminalSessionStartedAt: null,
terminalSessionRemainingSeconds: null,
terminalSessionCountdownInterval: null,
init() { init() {
this.setupTerminal(); this.setupTerminal();
@@ -135,6 +144,7 @@ export function initializeTerminalComponent() {
this.clearAllTimers(); this.clearAllTimers();
this.connectionState = 'disconnected'; this.connectionState = 'disconnected';
this.pendingCommand = null; this.pendingCommand = null;
this.resetTerminalSessionCountdown();
if (this.socket) { if (this.socket) {
this.socket.close(1000, 'Client cleanup'); this.socket.close(1000, 'Client cleanup');
} }
@@ -157,11 +167,68 @@ export function initializeTerminalComponent() {
} }
[this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout] [this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
.forEach(timer => timer && clearTimeout(timer)); .forEach(timer => timer && clearTimeout(timer));
if (this.terminalSessionCountdownInterval) {
clearInterval(this.terminalSessionCountdownInterval);
}
this.keepAliveInterval = null; this.keepAliveInterval = null;
this.reconnectInterval = null; this.reconnectInterval = null;
this.connectionTimeoutId = null; this.connectionTimeoutId = null;
this.pingTimeoutId = null; this.pingTimeoutId = null;
this.resizeTimeout = 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() { resetTerminal() {
@@ -181,6 +248,7 @@ export function initializeTerminalComponent() {
this.paused = false; this.paused = false;
this.commandBuffer = ''; this.commandBuffer = '';
this.pendingCommand = null; this.pendingCommand = null;
this.resetTerminalSessionCountdown();
// Notify parent component that terminal disconnected // Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected'); this.$wire.dispatch('terminalDisconnected');
@@ -328,6 +396,7 @@ export function initializeTerminalComponent() {
this.connectionState = 'disconnected'; this.connectionState = 'disconnected';
this.clearAllTimers(); this.clearAllTimers();
this.resetTerminalSessionCountdown();
// Only reset terminal and reconnect if it wasn't a clean close // Only reset terminal and reconnect if it wasn't a clean close
if (event.code !== 1000) { if (event.code !== 1000) {
@@ -424,6 +493,7 @@ export function initializeTerminalComponent() {
} }
} }
this.terminalActive = true; this.terminalActive = true;
this.startTerminalSessionCountdown();
this.term.focus(); this.term.focus();
document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded-sm'); document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded-sm');
@@ -450,12 +520,14 @@ export function initializeTerminalComponent() {
if (this.term) this.term.reset(); if (this.term) this.term.reset();
this.terminalActive = false; this.terminalActive = false;
this.lastSentCommand = null; this.lastSentCommand = null;
this.resetTerminalSessionCountdown();
this.message = '(sorry, something went wrong, please try again)'; this.message = '(sorry, something went wrong, please try again)';
// Notify parent component that terminal connection failed // Notify parent component that terminal connection failed
this.$wire.dispatch('terminalDisconnected'); this.$wire.dispatch('terminalDisconnected');
} else if (event.data === 'pty-exited') { } else if (event.data === 'pty-exited') {
this.terminalActive = false; this.terminalActive = false;
this.resetTerminalSessionCountdown();
this.term.reset(); this.term.reset();
this.commandBuffer = ''; this.commandBuffer = '';
this.lastSentCommand = null; this.lastSentCommand = null;
@@ -469,6 +541,7 @@ export function initializeTerminalComponent() {
logTerminal('error', '[Terminal] Backend rejected terminal startup:', event.data); logTerminal('error', '[Terminal] Backend rejected terminal startup:', event.data);
this.$wire.dispatch('error', event.data); this.$wire.dispatch('error', event.data);
this.terminalActive = false; this.terminalActive = false;
this.resetTerminalSessionCountdown();
} else { } else {
try { try {
this.pendingWrites++; this.pendingWrites++;
+15
View File
@@ -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');
});
@@ -23,6 +23,11 @@
<div x-ref="terminalWrapper" <div x-ref="terminalWrapper"
:class="fullscreen ? 'fullscreen !bg-black' : 'relative w-full h-full py-4 mx-auto max-h-[510px]'"> :class="fullscreen ? 'fullscreen !bg-black' : 'relative w-full h-full py-4 mx-auto max-h-[510px]'">
<!-- Terminal container --> <!-- Terminal container -->
<div x-show="terminalActive" x-cloak class="mb-2 flex justify-start">
<div class="inline-flex rounded-sm border px-2 py-1 text-xs font-medium"
:class="terminalSessionTimerClass()" x-text="terminalSessionRemainingLabel()">
</div>
</div>
<div id="terminal" wire:ignore <div id="terminal" wire:ignore
:class="fullscreen ? 'px-2 py-1 h-full bg-black' : 'px-2 py-1 rounded-sm bg-black'" x-show="terminalActive"> :class="fullscreen ? 'px-2 py-1 h-full bg-black' : 'px-2 py-1 rounded-sm bg-black'" x-show="terminalActive">
</div> </div>