mirror of
https://github.com/coollabsio/coolify.git
synced 2026-06-14 03:19:51 +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,
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 { 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++;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user