coolify/docker/coolify-realtime/terminal-server.js

401 lines
13 KiB
JavaScript
Raw Normal View History

import { WebSocketServer } from 'ws';
import http from 'http';
import pty from 'node-pty';
2024-08-15 05:38:06 +00:00
import axios from 'axios';
import cookie from 'cookie';
import 'dotenv/config';
2026-03-10 19:37:22 +00:00
import {
extractHereDocContent,
extractSshArgs,
extractTargetHost,
extractTimeout,
isAuthorizedTargetHost,
} from './terminal-utils.js';
const userSessions = new Map();
2026-03-10 19:37:22 +00:00
const terminalDebugEnabled = ['local', 'development'].includes(
String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase()
);
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 getSessionCookie = (req) => {
const cookies = cookie.parse(req.headers.cookie || '');
2024-09-11 10:19:27 +00:00
const xsrfToken = cookies['XSRF-TOKEN'];
const appName = process.env.APP_NAME || 'laravel';
const sessionCookieName = `${appName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase()}_session`;
return {
sessionCookieName,
xsrfToken: xsrfToken,
laravelSession: cookies[sessionCookieName]
}
}
const verifyClient = async (info, callback) => {
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(info.req);
2026-03-10 19:37:22 +00:00
const requestContext = {
remoteAddress: info.req.socket?.remoteAddress,
origin: info.origin,
sessionCookieName,
hasXsrfToken: Boolean(xsrfToken),
hasLaravelSession: Boolean(laravelSession),
};
logTerminal('log', 'Verifying websocket client.', requestContext);
2024-08-15 05:38:06 +00:00
2024-09-11 10:19:27 +00:00
// Verify presence of required tokens
if (!laravelSession || !xsrfToken) {
2026-03-10 19:37:22 +00:00
logTerminal('warn', 'Rejecting websocket client because required auth tokens are missing.', requestContext);
2024-09-11 10:19:27 +00:00
return callback(false, 401, 'Unauthorized: Missing required tokens');
}
2024-08-15 05:38:06 +00:00
2024-09-11 10:19:27 +00:00
try {
// Authenticate with Laravel backend
2024-12-23 14:33:12 +00:00
const response = await axios.post(`http://coolify:8080/terminal/auth`, null, {
2024-09-11 10:19:27 +00:00
headers: {
'Cookie': `${sessionCookieName}=${laravelSession}`,
'X-XSRF-TOKEN': xsrfToken
},
});
2024-08-15 05:38:06 +00:00
2024-09-11 10:19:27 +00:00
if (response.status === 200) {
2026-03-10 19:37:22 +00:00
logTerminal('log', 'Websocket client authentication succeeded.', requestContext);
2024-09-11 10:19:27 +00:00
callback(true);
} else {
2026-03-10 19:37:22 +00:00
logTerminal('warn', 'Websocket client authentication returned a non-success status.', {
...requestContext,
status: response.status,
});
2024-09-11 10:19:27 +00:00
callback(false, 401, 'Unauthorized: Invalid credentials');
}
} catch (error) {
2026-03-10 19:37:22 +00:00
logTerminal('error', 'Websocket client authentication failed.', {
...requestContext,
error: error.message,
responseStatus: error.response?.status,
responseData: error.response?.data,
});
2024-09-11 10:19:27 +00:00
callback(false, 500, 'Internal Server Error');
2024-08-15 05:38:06 +00:00
}
};
const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient });
wss.on('connection', async (ws, req) => {
const userId = generateUserId();
const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] };
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
2026-03-10 19:37:22 +00:00
const connectionContext = {
userId,
remoteAddress: req.socket?.remoteAddress,
sessionCookieName,
hasXsrfToken: Boolean(xsrfToken),
hasLaravelSession: Boolean(laravelSession),
};
// Verify presence of required tokens
if (!laravelSession || !xsrfToken) {
2026-03-10 19:37:22 +00:00
logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext);
ws.close(401, 'Unauthorized: Missing required tokens');
return;
}
2026-03-10 19:37:22 +00:00
try {
const response = await axios.post(`http://coolify:8080/terminal/auth/ips`, null, {
headers: {
'Cookie': `${sessionCookieName}=${laravelSession}`,
'X-XSRF-TOKEN': xsrfToken
},
});
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);
2026-03-10 19:37:22 +00:00
logTerminal('log', 'Terminal websocket connection established.', {
...connectionContext,
authorizedHostCount: userSession.authorizedIPs.length,
});
ws.on('message', (message) => {
handleMessage(userSession, message);
});
ws.on('error', (err) => handleError(err, userId));
2026-03-10 19:37:22 +00:00
ws.on('close', (code, reason) => {
logTerminal('log', 'Terminal websocket connection closed.', {
userId,
code,
reason: reason?.toString(),
});
handleClose(userId);
});
});
const messageHandlers = {
message: (session, data) => session.ptyProcess.write(data),
resize: (session, { cols, rows }) => {
cols = cols > 0 ? cols : 80;
rows = rows > 0 ? rows : 30;
session.ptyProcess.resize(cols, rows)
},
pause: (session) => session.ptyProcess.pause(),
resume: (session) => session.ptyProcess.resume(),
2026-03-10 19:37:22 +00:00
ping: (session) => session.ws.send('pong'),
checkActive: (session, data) => {
if (data === 'force' && session.isActive) {
killPtyProcess(session.userId);
} else {
session.ws.send(session.isActive);
}
},
command: (session, data) => handleCommand(session.ws, data, session.userId)
};
function handleMessage(userSession, message) {
const parsed = parseMessage(message);
2026-03-10 19:37:22 +00:00
if (!parsed) {
logTerminal('warn', 'Ignoring websocket message because JSON parsing failed.', {
userId: userSession.userId,
rawMessage: String(message).slice(0, 500),
});
return;
}
logTerminal('log', 'Received websocket message.', {
userId: userSession.userId,
keys: Object.keys(parsed),
isActive: userSession.isActive,
});
Object.entries(parsed).forEach(([key, value]) => {
const handler = messageHandlers[key];
2026-03-10 19:37:22 +00:00
if (handler && (userSession.isActive || key === 'checkActive' || key === 'command' || key === 'ping')) {
handler(userSession, value);
2026-03-10 19:37:22 +00:00
} 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,
});
}
});
}
function parseMessage(message) {
try {
return JSON.parse(message);
} catch (e) {
2026-03-10 19:37:22 +00:00
logTerminal('error', 'Failed to parse websocket message.', {
error: e?.message ?? e,
});
return null;
}
}
async function handleCommand(ws, command, userId) {
const userSession = userSessions.get(userId);
if (userSession && userSession.isActive) {
const result = await killPtyProcess(userId);
if (!result) {
2026-03-10 19:37:22 +00:00
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;
}
}
const commandString = command[0].split('\n').join(' ');
const timeout = extractTimeout(commandString);
const sshArgs = extractSshArgs(commandString);
const hereDocContent = extractHereDocContent(commandString);
// Extract target host from SSH command
const targetHost = extractTargetHost(sshArgs);
2026-03-10 19:37:22 +00:00
logTerminal('log', 'Parsed terminal command metadata.', {
userId,
targetHost,
timeout,
sshArgs,
authorizedIPs: userSession?.authorizedIPs ?? [],
});
if (!targetHost) {
2026-03-10 19:37:22 +00:00
logTerminal('warn', 'Rejecting terminal command because no target host could be extracted.', {
userId,
sshArgs,
});
ws.send('Invalid SSH command: No target host found');
return;
}
// Validate target host against authorized IPs
2026-03-10 19:37:22 +00:00
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;
}
const options = {
name: 'xterm-color',
cols: 80,
rows: 30,
cwd: process.env.HOME,
env: {},
};
// 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
2026-03-10 19:37:22 +00:00
logTerminal('log', 'Spawning PTY process for terminal session.', {
userId,
targetHost,
timeout,
});
const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options);
userSession.ptyProcess = ptyProcess;
userSession.isActive = true;
ws.send('pty-ready');
ptyProcess.onData((data) => {
ws.send(data);
});
// when parent closes
ptyProcess.onExit(({ exitCode, signal }) => {
2026-03-10 19:37:22 +00:00
logTerminal(exitCode === 0 ? 'log' : 'error', 'PTY process exited.', {
userId,
exitCode,
signal,
});
ws.send('pty-exited');
userSession.isActive = false;
});
if (timeout) {
setTimeout(async () => {
await killPtyProcess(userId);
}, timeout * 1000);
}
}
async function handleError(err, userId) {
2026-03-10 19:37:22 +00:00
logTerminal('error', 'WebSocket error.', {
userId,
error: err?.message ?? err,
});
await killPtyProcess(userId);
}
async function handleClose(userId) {
2026-03-10 19:37:22 +00:00
logTerminal('log', 'Cleaning up terminal websocket session.', {
userId,
});
await killPtyProcess(userId);
userSessions.delete(userId);
}
async function killPtyProcess(userId) {
const session = userSessions.get(userId);
if (!session?.ptyProcess) return false;
return new Promise((resolve) => {
// Loop to ensure terminal is killed before continuing
let killAttempts = 0;
const maxAttempts = 5;
const attemptKill = () => {
killAttempts++;
2026-03-10 19:37:22 +00:00
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
session.ptyProcess.write('set +o history\nkill -TERM -$$ && exit\nset -o history\n');
setTimeout(() => {
if (!session.isActive || !session.ptyProcess) {
2026-03-10 19:37:22 +00:00
logTerminal('log', 'PTY process terminated successfully.', {
userId,
killAttempts,
});
resolve(true);
return;
}
if (killAttempts < maxAttempts) {
attemptKill();
} else {
2026-03-10 19:37:22 +00:00
logTerminal('warn', 'PTY process still active after maximum termination attempts.', {
userId,
killAttempts,
});
resolve(false);
}
}, 500);
};
attemptKill();
});
}
function generateUserId() {
return Math.random().toString(36).substring(2, 11);
}
server.listen(6002, () => {
2026-03-10 19:37:22 +00:00
logTerminal('log', 'Terminal debug logging is enabled.', {
terminalDebugEnabled,
});
});