Files
033f0c84da v4.7 (#12478)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com>
Co-authored-by: Aditya Tripathi <aditya@climactic.co>
2026-11-06 14:25:02 +01:00

744 lines
25 KiB
JavaScript
Executable File

import { WebSocketServer } from 'ws';
import http from 'http';
import pty from 'node-pty';
import 'dotenv/config';
import {
TERMINAL_CLOSE_CODES,
TERMINAL_IDLE_TIMEOUT_SECONDS,
TERMINAL_SESSION_CHECK_INTERVAL_MS,
TERMINAL_SESSION_CHECK_MAX_FAILURES,
authenticateTerminalUpgrade,
createTerminalUpgradeHandler,
extractHereDocContent,
extractSshArgs,
extractTargetHost,
extractTimeout,
detachPtyProcess,
getTerminalAuthHeaders,
getTerminalProcessEnv,
getTerminalSessionTimeout,
isAttachCommand,
isAuthorizedTargetHost,
rejectTerminalSocket,
sanitizeSshArgs,
terminalSessionCheckResult,
validateSshArgs,
} from './terminal-utils.js';
async function postToCoolify(path, headers, body = null) {
return new Promise((resolve, reject) => {
const requestBody = body === null ? '' : JSON.stringify(body);
const request = http.request({
hostname: process.env.TERMINAL_AUTH_HOST || '127.0.0.1',
port: 8080,
path,
method: 'POST',
headers: {
...headers,
...(body === null ? {} : {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(requestBody),
}),
},
}, (response) => {
let responseText = '';
response.setEncoding('utf8');
response.on('data', (chunk) => {
responseText += chunk;
});
response.on('end', () => {
try {
resolve({
status: response.statusCode ?? 0,
data: parseResponseData(response.headers['content-type'], responseText),
});
} catch (error) {
reject(error);
}
});
});
request.setTimeout(10_000, () => {
request.destroy(new Error('Request to Coolify timed out'));
});
request.on('error', reject);
request.end(requestBody);
});
}
function parseResponseData(contentType = '', responseText = '') {
if (responseText === '') {
return null;
}
if (contentType.includes('application/json')) {
return JSON.parse(responseText);
}
return responseText;
}
function createHttpError(response) {
const error = new Error(`Request failed with status code ${response.status}`);
error.response = response;
return error;
}
const userSessions = new Map();
const envName = String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase();
const debugOverride = String(process.env.TERMINAL_DEBUG || '').toLowerCase();
const terminalDebugEnabled =
['local', 'development'].includes(envName)
|| ['1', 'true', 'yes', 'on'].includes(debugOverride);
function logTerminal(level, message, context = {}) {
if (!terminalDebugEnabled) {
return;
}
const formattedMessage = `[TerminalServer] ${message}`;
if (Object.keys(context).length > 0) {
console[level](formattedMessage, context);
return;
}
console[level](formattedMessage);
}
const server = http.createServer((req, res) => {
if (req.url === '/ready') {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} else {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
}
});
const verifyClient = async (req) => {
const authHeaderCandidates = getTerminalAuthHeaders(req.headers.cookie);
const requestContext = {
remoteAddress: req.socket?.remoteAddress,
origin: req.headers.origin,
xsrfTokenCount: authHeaderCandidates.length,
};
logTerminal('log', 'Verifying websocket client.', requestContext);
// Authenticate with Laravel backend
const result = await authenticateTerminalUpgrade(authHeaderCandidates, postToCoolify);
if (result.authenticated) {
// The connection handler sends its requests with the XSRF token that Coolify accepted.
req.terminalAuthHeaders = result.authHeaders;
logTerminal('log', 'Websocket client authentication succeeded.', requestContext);
} else if (result.error) {
logTerminal('error', 'Websocket client authentication failed.', {
...requestContext,
error: result.error.message,
});
} else {
logTerminal('warn', 'Rejecting websocket client.', {
...requestContext,
reason: result.reason,
status: result.status,
closeCode: result.closeCode,
});
}
return result;
};
// Upgrades are authenticated manually (instead of ws `verifyClient`) so a
// rejected browser receives a readable close code rather than an opaque 1006.
const wss = new WebSocketServer({ noServer: true, path: '/terminal/ws' });
server.on('upgrade', createTerminalUpgradeHandler({ wss, authenticate: verifyClient }));
const HEARTBEAT_INTERVAL_MS = 30000;
wss.on('connection', async (ws, req) => {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
const userId = generateUserId();
ws.userId = userId;
const authHeaders = req.terminalAuthHeaders;
const userSession = {
ws,
userId,
ptyProcess: null,
isActive: false,
authorizedIPs: [],
authReady: false,
pendingMessages: [],
terminalSessionTimer: null,
authHeaders,
};
const connectionContext = {
userId,
remoteAddress: req.socket?.remoteAddress,
hasAuthTokens: Boolean(authHeaders),
};
// 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 (!authHeaders) {
logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext);
rejectTerminalSocket(ws, TERMINAL_CLOSE_CODES.AUTH_REJECTED, 'Unauthorized: Missing required tokens');
return;
}
try {
const response = await postToCoolify('/terminal/auth/ips', authHeaders);
if (response.status !== 200) {
throw createHttpError(response);
}
userSession.authorizedIPs = response.data.ipAddresses || [];
logTerminal('log', 'Fetched authorized terminal hosts for websocket session.', {
...connectionContext,
authorizedIPs: userSession.authorizedIPs,
});
} catch (error) {
logTerminal('error', 'Failed to fetch authorized terminal hosts.', {
...connectionContext,
error: error.message,
responseStatus: error.response?.status,
responseData: error.response?.data,
});
ws.close(1011, 'Failed to fetch terminal authorization data');
return;
}
userSessions.set(userId, userSession);
userSession.authReady = true;
logTerminal('log', 'Terminal websocket connection established.', {
...connectionContext,
authorizedHostCount: userSession.authorizedIPs.length,
bufferedMessages: userSession.pendingMessages.length,
});
// 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(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) {
logTerminal('warn', 'Terminating WS due to missed protocol pong.');
return ws.terminate();
}
ws.isAlive = false;
try {
ws.ping();
} catch (_) {
// ignore — close handler will follow
}
});
}, HEARTBEAT_INTERVAL_MS);
wss.on('close', () => clearInterval(heartbeat));
const messageHandlers = {
message: (session, data) => {
if (typeof data !== 'string') {
return;
}
session.lastActivityAt = Date.now();
session.ptyProcess.write(data);
},
resize: (session, size) => {
const cols = terminalDimension(size?.cols, 80);
const rows = terminalDimension(size?.rows, 30);
session.ptyProcess.resize(cols, rows);
},
pause: (session) => session.ptyProcess.pause(),
resume: (session) => session.ptyProcess.resume(),
ping: (session) => session.ws.send('pong'),
checkActive: (session, data) => {
if (data === 'force' && session.isActive) {
session.endReason = 'replaced';
killPtyProcess(session.userId);
} else {
session.ws.send(String(session.isActive));
}
},
terminalToken: (session, token) => handleTerminalToken(session, token)
};
function handleMessage(userSession, message) {
const parsed = parseMessage(message);
if (!parsed) {
logTerminal('warn', 'Ignoring websocket message because JSON parsing failed.', {
userId: userSession.userId,
rawMessage: String(message).slice(0, 500),
});
return;
}
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
logTerminal('warn', 'Ignoring websocket message because it is not a JSON object.', {
userId: userSession.userId,
});
return;
}
Object.entries(parsed).forEach(([key, value]) => {
const handler = Object.hasOwn(messageHandlers, key) ? messageHandlers[key] : null;
if (handler && (userSession.isActive || key === 'checkActive' || key === 'terminalToken' || key === 'ping')) {
runMessageHandler(handler, userSession, key, value);
} else if (!handler) {
logTerminal('warn', 'Ignoring websocket message with unknown handler key.', {
userId: userSession.userId,
key,
});
} else {
logTerminal('warn', 'Ignoring websocket message because no PTY session is active yet.', {
userId: userSession.userId,
key,
});
}
});
}
/**
* Run one handler. A handler that throws must not stop the process, because one
* process serves the terminals of all users.
*/
function runMessageHandler(handler, userSession, key, value) {
const logFailure = (error) => logTerminal('error', 'Websocket message handler failed.', {
userId: userSession.userId,
key,
error: error?.message ?? error,
});
try {
Promise.resolve(handler(userSession, value)).catch(logFailure);
} catch (error) {
logFailure(error);
}
}
function terminalDimension(value, fallback) {
return Number.isInteger(value) && value > 0 && value <= 1000 ? value : fallback;
}
function parseMessage(message) {
try {
return JSON.parse(message);
} catch (e) {
logTerminal('error', 'Failed to parse websocket message.', {
error: e?.message ?? e,
});
return null;
}
}
/**
* Starts the PTY for a redeemed terminal session.
*
* @returns {Promise<boolean>} false when the command was rejected and no PTY started
*/
async function handleCommand(ws, command, userId, terminalSessionId, reportEnd) {
const userSession = userSessions.get(userId);
if (userSession && userSession.isActive) {
userSession.endReason = 'replaced';
const result = await killPtyProcess(userId);
if (!result) {
logTerminal('warn', 'Rejecting new terminal command because the previous PTY could not be terminated.', {
userId,
});
// if terminal is still active, even after we tried to kill it, dont continue and show error
ws.send('unprocessable');
return false;
}
}
if (userSession.terminalSessionTimer) {
clearTimeout(userSession.terminalSessionTimer);
userSession.terminalSessionTimer = null;
}
const commandString = command[0].split('\n').join(' ');
const commandTimeout = extractTimeout(commandString);
const terminalSessionTimeout = getTerminalSessionTimeout();
const sshArgs = extractSshArgs(commandString);
const hereDocContent = extractHereDocContent(commandString);
// Extract target host from SSH command
const targetHost = extractTargetHost(sshArgs);
logTerminal('log', 'Parsed terminal command metadata.', {
userId,
targetHost,
commandTimeout,
terminalSessionTimeout,
sshArgs,
authorizedIPs: userSession?.authorizedIPs ?? [],
});
if (!targetHost) {
logTerminal('warn', 'Rejecting terminal command because no target host could be extracted.', {
userId,
sshArgs,
});
ws.send('Invalid SSH command: No target host found');
return false;
}
// Validate target host against authorized IPs
if (!isAuthorizedTargetHost(targetHost, userSession.authorizedIPs)) {
logTerminal('warn', 'Rejecting terminal command because target host is not authorized.', {
userId,
targetHost,
authorizedIPs: userSession.authorizedIPs,
});
ws.send(`Unauthorized: Target host ${targetHost} not in authorized list`);
return false;
}
if (!validateSshArgs(sshArgs, userSession.authorizedIPs)) {
logTerminal('warn', 'Rejecting terminal command because its SSH arguments are not allowed.', {
userId,
targetHost,
});
ws.send('Invalid SSH command: Unsupported SSH arguments');
return false;
}
const sanitizedSshArgs = sanitizeSshArgs(sshArgs);
const options = {
name: 'xterm-color',
cols: 80,
rows: 30,
cwd: process.env.HOME,
env: getTerminalProcessEnv(),
};
// NOTE: - Initiates a process within the Terminal container
// Establishes an SSH connection to root@coolify with RequestTTY enabled
// Executes the 'docker exec' command to connect to a specific container
logTerminal('log', 'Spawning PTY process for terminal session.', {
userId,
targetHost,
commandTimeout,
terminalSessionTimeout,
});
const ptyProcess = pty.spawn('ssh', sanitizedSshArgs.concat([hereDocContent]), options);
userSession.ptyProcess = ptyProcess;
userSession.isActive = true;
userSession.endReason = null;
userSession.lastActivityAt = Date.now();
const sessionWatch = watchTerminalSession(userSession, ptyProcess, terminalSessionId);
// Attach sessions share the container's main process, so they must be detached, not killed.
userSession.isAttach = isAttachCommand(hereDocContent);
ws.send('pty-ready');
// PTY output goes in binary frames. Text frames are only control messages of this server,
// so output of the remote shell cannot look like one.
ptyProcess.onData((data) => {
if (userSession.ptyProcess !== ptyProcess) return;
// Output counts as activity, so commands like `docker logs -f` are not ended as idle.
userSession.lastActivityAt = Date.now();
ws.send(Buffer.from(data, 'utf8'));
});
// when parent closes
ptyProcess.onExit(({ exitCode, signal }) => {
logTerminal(exitCode === 0 ? 'log' : 'error', 'PTY process exited.', {
userId,
exitCode,
signal,
});
clearInterval(sessionWatch);
// A previous PTY can exit late (e.g. an attach session detaching during a mode
// switch) after its replacement is already spawned. Only mutate the shared session
// state when this exit belongs to the session's current PTY.
if (userSession.ptyProcess !== ptyProcess) {
reportEnd('replaced');
return;
}
reportEnd(userSession.endReason ?? 'exited');
ws.send('pty-exited');
userSession.isActive = false;
if (userSession.terminalSessionTimer) {
clearTimeout(userSession.terminalSessionTimer);
userSession.terminalSessionTimer = null;
}
});
userSession.terminalSessionTimer = setTimeout(async () => {
await endTerminalSession(userSession, ptyProcess, 'max_duration', `it reached the maximum duration of ${terminalSessionTimeout / 3600} hours.`);
}, terminalSessionTimeout * 1000);
return true;
}
/**
* Ends the PTY when it has no input and no output for too long, or when Coolify no longer allows it:
* after a logout, a lost admin role, a removal from the team, or when terminal access
* was turned off on the server.
*/
function watchTerminalSession(userSession, ptyProcess, terminalSessionId) {
let failedChecks = 0;
let checking = false;
return setInterval(async () => {
if (userSession.ptyProcess !== ptyProcess) {
return;
}
if (TERMINAL_IDLE_TIMEOUT_SECONDS > 0 && Date.now() - userSession.lastActivityAt >= TERMINAL_IDLE_TIMEOUT_SECONDS * 1000) {
await endTerminalSession(userSession, ptyProcess, 'idle', `no input or output for ${TERMINAL_IDLE_TIMEOUT_SECONDS / 60} minutes.`);
return;
}
if (checking) {
return;
}
checking = true;
let response = null;
try {
// Accept JSON so a logged-out session gets a 401 instead of a redirect to the login page.
response = await postToCoolify('/terminal/session/check', { ...userSession.authHeaders, Accept: 'application/json' }, { session_id: terminalSessionId });
} catch (error) {
logTerminal('error', 'Failed to check terminal session.', {
userId: userSession.userId,
error: error.message,
});
}
checking = false;
const result = terminalSessionCheckResult(response);
if (result === 'active') {
failedChecks = 0;
return;
}
if (result === 'revoked') {
await endTerminalSession(userSession, ptyProcess, 'revoked', 'access to this terminal was revoked.');
return;
}
// Coolify is unavailable (for example during an upgrade) or gave an unexpected answer. Fail closed after a few minutes.
logTerminal('warn', 'Terminal session check failed.', {
userId: userSession.userId,
status: response?.status,
});
failedChecks++;
if (failedChecks >= TERMINAL_SESSION_CHECK_MAX_FAILURES) {
await endTerminalSession(userSession, ptyProcess, 'revoked', 'Coolify could not verify access to this terminal.');
}
}, TERMINAL_SESSION_CHECK_INTERVAL_MS);
}
async function endTerminalSession(userSession, ptyProcess, reason, notice) {
if (!userSession.isActive || userSession.ptyProcess !== ptyProcess) {
return;
}
logTerminal('log', 'Ending terminal session.', {
userId: userSession.userId,
reason,
});
userSession.endReason = reason;
if (userSession.ws.readyState === userSession.ws.OPEN) {
userSession.ws.send(`Terminal session ended: ${notice}`);
}
await killPtyProcess(userSession.userId);
}
// Sent without the browser cookie: the session id authenticates it, and the user may have logged out.
function reportTerminalSessionEnd(terminalSessionId, reason) {
postToCoolify('/terminal/session/end', {}, { session_id: terminalSessionId, reason })
.then((response) => {
if (response.status !== 204) {
throw createHttpError(response);
}
})
.catch((error) => {
logTerminal('error', 'Failed to report the end of a terminal session.', {
reason,
error: error.message,
});
});
}
function rejectTerminalToken(userSession, message) {
logTerminal('warn', 'Closing websocket connection because the terminal token was rejected.', {
userId: userSession.userId,
reason: message,
});
rejectTerminalSocket(userSession.ws, TERMINAL_CLOSE_CODES.TOKEN_REJECTED, message);
}
async function handleTerminalToken(userSession, token) {
if (typeof token !== 'string' || !/^[a-zA-Z0-9]{64}$/.test(token)) {
rejectTerminalToken(userSession, 'Unauthorized: Invalid terminal token');
return;
}
let terminalSessionId;
let endReported = false;
const reportEnd = (reason) => {
if (typeof terminalSessionId !== 'string' || endReported) return;
endReported = true;
reportTerminalSessionEnd(terminalSessionId, reason);
};
try {
const response = await postToCoolify('/terminal/session', userSession.authHeaders, { token });
terminalSessionId = response.data?.session_id;
if (response.status !== 200 || typeof response.data?.command !== 'string' || typeof terminalSessionId !== 'string') {
rejectTerminalToken(userSession, 'Unauthorized: Terminal token was rejected');
return;
}
const started = await handleCommand(userSession.ws, [response.data.command], userSession.userId, terminalSessionId, reportEnd);
if (!started) {
reportEnd('rejected');
}
} catch (error) {
reportEnd('rejected');
logTerminal('error', 'Failed to redeem terminal token.', {
userId: userSession.userId,
error: error.message,
});
rejectTerminalToken(userSession, 'Unauthorized: Terminal token was rejected');
}
}
async function handleError(err, userId) {
logTerminal('error', 'WebSocket error.', {
userId,
error: err?.message ?? err,
});
markDisconnected(userId);
await killPtyProcess(userId);
}
async function handleClose(userId) {
logTerminal('log', 'Cleaning up terminal websocket session.', {
userId,
});
markDisconnected(userId);
await killPtyProcess(userId);
userSessions.delete(userId);
}
function markDisconnected(userId) {
const session = userSessions.get(userId);
if (session?.isActive) {
session.endReason = 'disconnected';
}
}
async function killPtyProcess(userId) {
const session = userSessions.get(userId);
if (!session?.ptyProcess) return false;
if (session.isAttach) {
return detachPtyProcess(session);
}
return new Promise((resolve) => {
// Loop to ensure terminal is killed before continuing
let killAttempts = 0;
const maxAttempts = 5;
const attemptKill = () => {
killAttempts++;
logTerminal('log', 'Attempting to terminate PTY process.', {
userId,
killAttempts,
maxAttempts,
});
// session.ptyProcess.kill() wont work here because of https://github.com/moby/moby/issues/9098
// patch with https://github.com/moby/moby/issues/9098#issuecomment-189743947
// Ctrl-C first so interactive programs (htop, top, less) quit instead of reading the kill command as keys.
session.ptyProcess.write('\x03');
session.ptyProcess.write('set +o history\nkill -TERM -$$ && exit\nset -o history\n');
setTimeout(() => {
if (!session.isActive || !session.ptyProcess) {
if (session.terminalSessionTimer) {
clearTimeout(session.terminalSessionTimer);
session.terminalSessionTimer = null;
}
logTerminal('log', 'PTY process terminated successfully.', {
userId,
killAttempts,
});
resolve(true);
return;
}
if (killAttempts < maxAttempts) {
attemptKill();
} else {
logTerminal('warn', 'PTY process still active after maximum termination attempts. Killing the local SSH client.', {
userId,
killAttempts,
});
// Ending the local ssh client makes sshd hang up the remote session, so nothing keeps running there.
try {
session.ptyProcess.kill();
} catch (error) {
logTerminal('error', 'Failed to kill PTY process.', {
userId,
error: error.message,
});
}
resolve(false);
}
}, 500);
};
attemptKill();
});
}
function generateUserId() {
return Math.random().toString(36).substring(2, 11);
}
server.listen(6002, () => {
logTerminal('log', 'Terminal debug logging is enabled.', {
terminalDebugEnabled,
});
});