mirror of
https://github.com/coollabsio/coolify.git
synced 2026-06-13 19:09:50 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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++;
|
||||
|
||||
@@ -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"
|
||||
:class="fullscreen ? 'fullscreen !bg-black' : 'relative w-full h-full py-4 mx-auto max-h-[510px]'">
|
||||
<!-- 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
|
||||
:class="fullscreen ? 'px-2 py-1 h-full bg-black' : 'px-2 py-1 rounded-sm bg-black'" x-show="terminalActive">
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user