mirror of
https://github.com/coollabsio/coolify.git
synced 2026-06-14 03:19:51 +00:00
fix(terminal): add idle timeout, reconnect replay, and scrollback preservation
- Kill PTY and notify client after 30 min of inactivity (IDLE_TIMEOUT_MS) - Buffer client messages during async auth/IP fetch to prevent race-condition message loss on fast reconnects - Replay last sent command after transient reconnect so PTY respawns without user interaction - Preserve scrollback on disconnect/reconnect; write visible timestamp markers instead of wiping term state - Handle idle-timeout sentinel on client with user-facing error message
This commit is contained in:
@@ -106,13 +106,24 @@ 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;
|
||||
ws.on('pong', () => { ws.isAlive = true; });
|
||||
|
||||
const userId = generateUserId();
|
||||
const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] };
|
||||
ws.userId = userId;
|
||||
const userSession = {
|
||||
ws,
|
||||
userId,
|
||||
ptyProcess: null,
|
||||
isActive: false,
|
||||
authorizedIPs: [],
|
||||
lastActivityAt: Date.now(),
|
||||
authReady: false,
|
||||
pendingMessages: [],
|
||||
};
|
||||
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
|
||||
const connectionContext = {
|
||||
userId,
|
||||
@@ -122,6 +133,26 @@ wss.on('connection', async (ws, req) => {
|
||||
hasLaravelSession: Boolean(laravelSession),
|
||||
};
|
||||
|
||||
// Register socket handlers up front so messages sent immediately by the client
|
||||
// (e.g. a command replay on reconnect) are not dropped while the auth/IP fetch
|
||||
// below is still pending.
|
||||
ws.on('message', (message) => {
|
||||
if (userSession.authReady) {
|
||||
handleMessage(userSession, message);
|
||||
} else {
|
||||
userSession.pendingMessages.push(message);
|
||||
}
|
||||
});
|
||||
ws.on('error', (err) => handleError(err, userId));
|
||||
ws.on('close', (code, reason) => {
|
||||
logTerminal('log', 'Terminal websocket connection closed.', {
|
||||
userId,
|
||||
code,
|
||||
reason: reason?.toString(),
|
||||
});
|
||||
handleClose(userId);
|
||||
});
|
||||
|
||||
// Verify presence of required tokens
|
||||
if (!laravelSession || !xsrfToken) {
|
||||
logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext);
|
||||
@@ -153,23 +184,17 @@ wss.on('connection', async (ws, req) => {
|
||||
}
|
||||
|
||||
userSessions.set(userId, userSession);
|
||||
userSession.authReady = true;
|
||||
logTerminal('log', 'Terminal websocket connection established.', {
|
||||
...connectionContext,
|
||||
authorizedHostCount: userSession.authorizedIPs.length,
|
||||
bufferedMessages: userSession.pendingMessages.length,
|
||||
});
|
||||
|
||||
ws.on('message', (message) => {
|
||||
handleMessage(userSession, message);
|
||||
});
|
||||
ws.on('error', (err) => handleError(err, userId));
|
||||
ws.on('close', (code, reason) => {
|
||||
logTerminal('log', 'Terminal websocket connection closed.', {
|
||||
userId,
|
||||
code,
|
||||
reason: reason?.toString(),
|
||||
});
|
||||
handleClose(userId);
|
||||
});
|
||||
// Drain any messages that arrived while we were waiting on the IP auth call.
|
||||
while (userSession.pendingMessages.length > 0) {
|
||||
handleMessage(userSession, userSession.pendingMessages.shift());
|
||||
}
|
||||
});
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
@@ -184,14 +209,41 @@ 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);
|
||||
|
||||
wss.on('close', () => clearInterval(heartbeat));
|
||||
|
||||
const messageHandlers = {
|
||||
message: (session, data) => session.ptyProcess.write(data),
|
||||
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)
|
||||
@@ -323,6 +375,7 @@ async function handleCommand(ws, command, userId) {
|
||||
|
||||
userSession.ptyProcess = ptyProcess;
|
||||
userSession.isActive = true;
|
||||
userSession.lastActivityAt = Date.now();
|
||||
|
||||
ws.send('pty-ready');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user