chore: prepare for PR

This commit is contained in:
Andras Bacsai 2026-03-10 20:37:22 +01:00
parent 201998638a
commit 5c5f67f48b
11 changed files with 513 additions and 134 deletions

View file

@ -73,6 +73,7 @@ services:
volumes: volumes:
- ./storage:/var/www/html/storage - ./storage:/var/www/html/storage
- ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js - ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js
- ./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js
environment: environment:
SOKETI_DEBUG: "false" SOKETI_DEBUG: "false"
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}" SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}"

View file

@ -73,6 +73,7 @@ services:
volumes: volumes:
- ./storage:/var/www/html/storage - ./storage:/var/www/html/storage
- ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js - ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js
- ./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js
environment: environment:
SOKETI_DEBUG: "false" SOKETI_DEBUG: "false"
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}" SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}"

View file

@ -16,6 +16,7 @@ RUN npm i
RUN npm rebuild node-pty --update-binary RUN npm rebuild node-pty --update-binary
COPY docker/coolify-realtime/soketi-entrypoint.sh /soketi-entrypoint.sh COPY docker/coolify-realtime/soketi-entrypoint.sh /soketi-entrypoint.sh
COPY docker/coolify-realtime/terminal-server.js /terminal/terminal-server.js COPY docker/coolify-realtime/terminal-server.js /terminal/terminal-server.js
COPY docker/coolify-realtime/terminal-utils.js /terminal/terminal-utils.js
# Install Cloudflared based on architecture # Install Cloudflared based on architecture
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \ RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \

View file

@ -4,8 +4,33 @@ import pty from 'node-pty';
import axios from 'axios'; import axios from 'axios';
import cookie from 'cookie'; import cookie from 'cookie';
import 'dotenv/config'; import 'dotenv/config';
import {
extractHereDocContent,
extractSshArgs,
extractTargetHost,
extractTimeout,
isAuthorizedTargetHost,
} from './terminal-utils.js';
const userSessions = new Map(); const userSessions = new Map();
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) => { const server = http.createServer((req, res) => {
if (req.url === '/ready') { if (req.url === '/ready') {
@ -31,9 +56,19 @@ const getSessionCookie = (req) => {
const verifyClient = async (info, callback) => { const verifyClient = async (info, callback) => {
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(info.req); const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(info.req);
const requestContext = {
remoteAddress: info.req.socket?.remoteAddress,
origin: info.origin,
sessionCookieName,
hasXsrfToken: Boolean(xsrfToken),
hasLaravelSession: Boolean(laravelSession),
};
logTerminal('log', 'Verifying websocket client.', requestContext);
// Verify presence of required tokens // Verify presence of required tokens
if (!laravelSession || !xsrfToken) { if (!laravelSession || !xsrfToken) {
logTerminal('warn', 'Rejecting websocket client because required auth tokens are missing.', requestContext);
return callback(false, 401, 'Unauthorized: Missing required tokens'); return callback(false, 401, 'Unauthorized: Missing required tokens');
} }
@ -47,13 +82,22 @@ const verifyClient = async (info, callback) => {
}); });
if (response.status === 200) { if (response.status === 200) {
// Authentication successful logTerminal('log', 'Websocket client authentication succeeded.', requestContext);
callback(true); callback(true);
} else { } else {
logTerminal('warn', 'Websocket client authentication returned a non-success status.', {
...requestContext,
status: response.status,
});
callback(false, 401, 'Unauthorized: Invalid credentials'); callback(false, 401, 'Unauthorized: Invalid credentials');
} }
} catch (error) { } catch (error) {
console.error('Authentication error:', error.message); logTerminal('error', 'Websocket client authentication failed.', {
...requestContext,
error: error.message,
responseStatus: error.response?.status,
responseData: error.response?.data,
});
callback(false, 500, 'Internal Server Error'); callback(false, 500, 'Internal Server Error');
} }
}; };
@ -65,12 +109,22 @@ wss.on('connection', async (ws, req) => {
const userId = generateUserId(); const userId = generateUserId();
const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] }; const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] };
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req); const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
const connectionContext = {
userId,
remoteAddress: req.socket?.remoteAddress,
sessionCookieName,
hasXsrfToken: Boolean(xsrfToken),
hasLaravelSession: Boolean(laravelSession),
};
// Verify presence of required tokens // Verify presence of required tokens
if (!laravelSession || !xsrfToken) { if (!laravelSession || !xsrfToken) {
logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext);
ws.close(401, 'Unauthorized: Missing required tokens'); ws.close(401, 'Unauthorized: Missing required tokens');
return; return;
} }
try {
const response = await axios.post(`http://coolify:8080/terminal/auth/ips`, null, { const response = await axios.post(`http://coolify:8080/terminal/auth/ips`, null, {
headers: { headers: {
'Cookie': `${sessionCookieName}=${laravelSession}`, 'Cookie': `${sessionCookieName}=${laravelSession}`,
@ -78,15 +132,39 @@ wss.on('connection', async (ws, req) => {
}, },
}); });
userSession.authorizedIPs = response.data.ipAddresses || []; 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); userSessions.set(userId, userSession);
logTerminal('log', 'Terminal websocket connection established.', {
...connectionContext,
authorizedHostCount: userSession.authorizedIPs.length,
});
ws.on('message', (message) => { ws.on('message', (message) => {
handleMessage(userSession, message); handleMessage(userSession, message);
}); });
ws.on('error', (err) => handleError(err, userId)); ws.on('error', (err) => handleError(err, userId));
ws.on('close', () => handleClose(userId)); ws.on('close', (code, reason) => {
logTerminal('log', 'Terminal websocket connection closed.', {
userId,
code,
reason: reason?.toString(),
});
handleClose(userId);
});
}); });
const messageHandlers = { const messageHandlers = {
@ -98,6 +176,7 @@ const messageHandlers = {
}, },
pause: (session) => session.ptyProcess.pause(), pause: (session) => session.ptyProcess.pause(),
resume: (session) => session.ptyProcess.resume(), resume: (session) => session.ptyProcess.resume(),
ping: (session) => session.ws.send('pong'),
checkActive: (session, data) => { checkActive: (session, data) => {
if (data === 'force' && session.isActive) { if (data === 'force' && session.isActive) {
killPtyProcess(session.userId); killPtyProcess(session.userId);
@ -110,12 +189,34 @@ const messageHandlers = {
function handleMessage(userSession, message) { function handleMessage(userSession, message) {
const parsed = parseMessage(message); const parsed = parseMessage(message);
if (!parsed) return; 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]) => { Object.entries(parsed).forEach(([key, value]) => {
const handler = messageHandlers[key]; const handler = messageHandlers[key];
if (handler && (userSession.isActive || key === 'checkActive' || key === 'command')) { if (handler && (userSession.isActive || key === 'checkActive' || key === 'command' || key === 'ping')) {
handler(userSession, value); handler(userSession, 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,
});
} }
}); });
} }
@ -124,7 +225,9 @@ function parseMessage(message) {
try { try {
return JSON.parse(message); return JSON.parse(message);
} catch (e) { } catch (e) {
console.error('Failed to parse message:', e); logTerminal('error', 'Failed to parse websocket message.', {
error: e?.message ?? e,
});
return null; return null;
} }
} }
@ -134,6 +237,9 @@ async function handleCommand(ws, command, userId) {
if (userSession && userSession.isActive) { if (userSession && userSession.isActive) {
const result = await killPtyProcess(userId); const result = await killPtyProcess(userId);
if (!result) { 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 // if terminal is still active, even after we tried to kill it, dont continue and show error
ws.send('unprocessable'); ws.send('unprocessable');
return; return;
@ -147,13 +253,30 @@ async function handleCommand(ws, command, userId) {
// Extract target host from SSH command // Extract target host from SSH command
const targetHost = extractTargetHost(sshArgs); const targetHost = extractTargetHost(sshArgs);
logTerminal('log', 'Parsed terminal command metadata.', {
userId,
targetHost,
timeout,
sshArgs,
authorizedIPs: userSession?.authorizedIPs ?? [],
});
if (!targetHost) { 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'); ws.send('Invalid SSH command: No target host found');
return; return;
} }
// Validate target host against authorized IPs // Validate target host against authorized IPs
if (!userSession.authorizedIPs.includes(targetHost)) { 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`); ws.send(`Unauthorized: Target host ${targetHost} not in authorized list`);
return; return;
} }
@ -169,6 +292,11 @@ async function handleCommand(ws, command, userId) {
// NOTE: - Initiates a process within the Terminal container // NOTE: - Initiates a process within the Terminal container
// Establishes an SSH connection to root@coolify with RequestTTY enabled // Establishes an SSH connection to root@coolify with RequestTTY enabled
// Executes the 'docker exec' command to connect to a specific container // Executes the 'docker exec' command to connect to a specific container
logTerminal('log', 'Spawning PTY process for terminal session.', {
userId,
targetHost,
timeout,
});
const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options); const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options);
userSession.ptyProcess = ptyProcess; userSession.ptyProcess = ptyProcess;
@ -182,7 +310,11 @@ async function handleCommand(ws, command, userId) {
// when parent closes // when parent closes
ptyProcess.onExit(({ exitCode, signal }) => { ptyProcess.onExit(({ exitCode, signal }) => {
console.error(`Process exited with code ${exitCode} and signal ${signal}`); logTerminal(exitCode === 0 ? 'log' : 'error', 'PTY process exited.', {
userId,
exitCode,
signal,
});
ws.send('pty-exited'); ws.send('pty-exited');
userSession.isActive = false; userSession.isActive = false;
}); });
@ -194,28 +326,18 @@ async function handleCommand(ws, command, userId) {
} }
} }
function extractTargetHost(sshArgs) {
// Find the argument that matches the pattern user@host
const userAtHost = sshArgs.find(arg => {
// Skip paths that contain 'storage/app/ssh/keys/'
if (arg.includes('storage/app/ssh/keys/')) {
return false;
}
return /^[^@]+@[^@]+$/.test(arg);
});
if (!userAtHost) return null;
// Extract host from user@host
const host = userAtHost.split('@')[1];
return host;
}
async function handleError(err, userId) { async function handleError(err, userId) {
console.error('WebSocket error:', err); logTerminal('error', 'WebSocket error.', {
userId,
error: err?.message ?? err,
});
await killPtyProcess(userId); await killPtyProcess(userId);
} }
async function handleClose(userId) { async function handleClose(userId) {
logTerminal('log', 'Cleaning up terminal websocket session.', {
userId,
});
await killPtyProcess(userId); await killPtyProcess(userId);
userSessions.delete(userId); userSessions.delete(userId);
} }
@ -231,6 +353,11 @@ async function killPtyProcess(userId) {
const attemptKill = () => { const attemptKill = () => {
killAttempts++; 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 // 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 // patch with https://github.com/moby/moby/issues/9098#issuecomment-189743947
@ -238,6 +365,10 @@ async function killPtyProcess(userId) {
setTimeout(() => { setTimeout(() => {
if (!session.isActive || !session.ptyProcess) { if (!session.isActive || !session.ptyProcess) {
logTerminal('log', 'PTY process terminated successfully.', {
userId,
killAttempts,
});
resolve(true); resolve(true);
return; return;
} }
@ -245,6 +376,10 @@ async function killPtyProcess(userId) {
if (killAttempts < maxAttempts) { if (killAttempts < maxAttempts) {
attemptKill(); attemptKill();
} else { } else {
logTerminal('warn', 'PTY process still active after maximum termination attempts.', {
userId,
killAttempts,
});
resolve(false); resolve(false);
} }
}, 500); }, 500);
@ -258,76 +393,8 @@ function generateUserId() {
return Math.random().toString(36).substring(2, 11); return Math.random().toString(36).substring(2, 11);
} }
function extractTimeout(commandString) {
const timeoutMatch = commandString.match(/timeout (\d+)/);
return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null;
}
function extractSshArgs(commandString) {
const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/);
if (!sshCommandMatch) return [];
const argsString = sshCommandMatch[1];
let sshArgs = [];
// Parse shell arguments respecting quotes
let current = '';
let inQuotes = false;
let quoteChar = '';
let i = 0;
while (i < argsString.length) {
const char = argsString[i];
const nextChar = argsString[i + 1];
if (!inQuotes && (char === '"' || char === "'")) {
// Starting a quoted section
inQuotes = true;
quoteChar = char;
current += char;
} else if (inQuotes && char === quoteChar) {
// Ending a quoted section
inQuotes = false;
current += char;
quoteChar = '';
} else if (!inQuotes && char === ' ') {
// Space outside quotes - end of argument
if (current.trim()) {
sshArgs.push(current.trim());
current = '';
}
} else {
// Regular character
current += char;
}
i++;
}
// Add final argument if exists
if (current.trim()) {
sshArgs.push(current.trim());
}
// Replace RequestTTY=no with RequestTTY=yes
sshArgs = sshArgs.map(arg => arg === 'RequestTTY=no' ? 'RequestTTY=yes' : arg);
// Add RequestTTY=yes if not present
if (!sshArgs.includes('RequestTTY=yes') && !sshArgs.some(arg => arg.includes('RequestTTY='))) {
sshArgs.push('-o', 'RequestTTY=yes');
}
return sshArgs;
}
function extractHereDocContent(commandString) {
const delimiterMatch = commandString.match(/<< (\S+)/);
const delimiter = delimiterMatch ? delimiterMatch[1] : null;
const escapedDelimiter = delimiter.slice(1).trim().replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
const hereDocRegex = new RegExp(`<< \\\\${escapedDelimiter}([\\s\\S\\.]*?)${escapedDelimiter}`);
const hereDocMatch = commandString.match(hereDocRegex);
return hereDocMatch ? hereDocMatch[1] : '';
}
server.listen(6002, () => { server.listen(6002, () => {
console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!'); logTerminal('log', 'Terminal debug logging is enabled.', {
terminalDebugEnabled,
});
}); });

View file

@ -0,0 +1,127 @@
export function extractTimeout(commandString) {
const timeoutMatch = commandString.match(/timeout (\d+)/);
return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null;
}
function normalizeShellArgument(argument) {
if (!argument) {
return argument;
}
return argument
.replace(/'([^']*)'/g, '$1')
.replace(/"([^"]*)"/g, '$1');
}
export function extractSshArgs(commandString) {
const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/);
if (!sshCommandMatch) return [];
const argsString = sshCommandMatch[1];
let sshArgs = [];
let current = '';
let inQuotes = false;
let quoteChar = '';
let i = 0;
while (i < argsString.length) {
const char = argsString[i];
if (!inQuotes && (char === '"' || char === "'")) {
inQuotes = true;
quoteChar = char;
current += char;
} else if (inQuotes && char === quoteChar) {
inQuotes = false;
current += char;
quoteChar = '';
} else if (!inQuotes && char === ' ') {
if (current.trim()) {
sshArgs.push(current.trim());
current = '';
}
} else {
current += char;
}
i++;
}
if (current.trim()) {
sshArgs.push(current.trim());
}
sshArgs = sshArgs.map((arg) => normalizeShellArgument(arg));
sshArgs = sshArgs.map(arg => arg === 'RequestTTY=no' ? 'RequestTTY=yes' : arg);
if (!sshArgs.includes('RequestTTY=yes') && !sshArgs.some(arg => arg.includes('RequestTTY='))) {
sshArgs.push('-o', 'RequestTTY=yes');
}
return sshArgs;
}
export function extractHereDocContent(commandString) {
const delimiterMatch = commandString.match(/<< (\S+)/);
const delimiter = delimiterMatch ? delimiterMatch[1] : null;
const escapedDelimiter = delimiter?.slice(1).trim().replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
if (!escapedDelimiter) {
return '';
}
const hereDocRegex = new RegExp(`<< \\\\${escapedDelimiter}([\\s\\S\\.]*?)${escapedDelimiter}`);
const hereDocMatch = commandString.match(hereDocRegex);
return hereDocMatch ? hereDocMatch[1] : '';
}
export function normalizeHostForAuthorization(host) {
if (!host) {
return null;
}
let normalizedHost = host.trim();
while (
normalizedHost.length >= 2 &&
((normalizedHost.startsWith("'") && normalizedHost.endsWith("'")) ||
(normalizedHost.startsWith('"') && normalizedHost.endsWith('"')))
) {
normalizedHost = normalizedHost.slice(1, -1).trim();
}
if (normalizedHost.startsWith('[') && normalizedHost.endsWith(']')) {
normalizedHost = normalizedHost.slice(1, -1);
}
return normalizedHost.toLowerCase();
}
export function extractTargetHost(sshArgs) {
const userAtHost = sshArgs.find(arg => {
if (arg.includes('storage/app/ssh/keys/')) {
return false;
}
return /^[^@]+@[^@]+$/.test(arg);
});
if (!userAtHost) {
return null;
}
const atIndex = userAtHost.indexOf('@');
return normalizeHostForAuthorization(userAtHost.slice(atIndex + 1));
}
export function isAuthorizedTargetHost(targetHost, authorizedHosts = []) {
const normalizedTargetHost = normalizeHostForAuthorization(targetHost);
if (!normalizedTargetHost) {
return false;
}
return authorizedHosts
.map(host => normalizeHostForAuthorization(host))
.includes(normalizedTargetHost);
}

View file

@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
extractSshArgs,
extractTargetHost,
isAuthorizedTargetHost,
normalizeHostForAuthorization,
} from './terminal-utils.js';
test('extractTargetHost normalizes quoted IPv4 hosts from generated ssh commands', () => {
const sshArgs = extractSshArgs(
"timeout 3600 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ServerAliveInterval=20 -o ConnectTimeout=10 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc"
);
assert.equal(extractTargetHost(sshArgs), '10.0.0.5');
});
test('extractSshArgs strips shell quotes from port and user host arguments before spawning ssh', () => {
const sshArgs = extractSshArgs(
"timeout 3600 ssh -p '22' -o StrictHostKeyChecking=no 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc"
);
assert.deepEqual(sshArgs.slice(0, 5), ['-p', '22', '-o', 'StrictHostKeyChecking=no', 'root@10.0.0.5']);
});
test('extractSshArgs preserves proxy command as a single normalized ssh option value', () => {
const sshArgs = extractSshArgs(
"timeout 3600 ssh -o ProxyCommand='cloudflared access ssh --hostname %h' -o StrictHostKeyChecking=no 'root'@'example.com' 'bash -se' << \\\\$abc\necho hi\nabc"
);
assert.equal(sshArgs[1], 'ProxyCommand=cloudflared access ssh --hostname %h');
assert.equal(sshArgs[4], 'root@example.com');
});
test('isAuthorizedTargetHost matches normalized hosts against plain allowlist values', () => {
assert.equal(isAuthorizedTargetHost("'10.0.0.5'", ['10.0.0.5']), true);
assert.equal(isAuthorizedTargetHost('"host.docker.internal"', ['host.docker.internal']), true);
});
test('normalizeHostForAuthorization unwraps bracketed IPv6 hosts', () => {
assert.equal(normalizeHostForAuthorization("'[2001:db8::10]'"), '2001:db8::10');
assert.equal(isAuthorizedTargetHost("'[2001:db8::10]'", ['2001:db8::10']), true);
});
test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => {
assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false);
});

View file

@ -2,6 +2,16 @@ import { Terminal } from '@xterm/xterm';
import '@xterm/xterm/css/xterm.css'; import '@xterm/xterm/css/xterm.css';
import { FitAddon } from '@xterm/addon-fit'; import { FitAddon } from '@xterm/addon-fit';
const terminalDebugEnabled = import.meta.env.DEV;
function logTerminal(level, message, ...context) {
if (!terminalDebugEnabled) {
return;
}
console[level](message, ...context);
}
export function initializeTerminalComponent() { export function initializeTerminalComponent() {
function terminalData() { function terminalData() {
return { return {
@ -30,6 +40,8 @@ export function initializeTerminalComponent() {
pingTimeoutId: null, pingTimeoutId: null,
heartbeatMissed: 0, heartbeatMissed: 0,
maxHeartbeatMisses: 3, maxHeartbeatMisses: 3,
// Command buffering for race condition prevention
pendingCommand: null,
// Resize handling // Resize handling
resizeObserver: null, resizeObserver: null,
resizeTimeout: null, resizeTimeout: null,
@ -120,6 +132,7 @@ export function initializeTerminalComponent() {
this.checkIfProcessIsRunningAndKillIt(); this.checkIfProcessIsRunningAndKillIt();
this.clearAllTimers(); this.clearAllTimers();
this.connectionState = 'disconnected'; this.connectionState = 'disconnected';
this.pendingCommand = null;
if (this.socket) { if (this.socket) {
this.socket.close(1000, 'Client cleanup'); this.socket.close(1000, 'Client cleanup');
} }
@ -154,6 +167,7 @@ export function initializeTerminalComponent() {
this.pendingWrites = 0; this.pendingWrites = 0;
this.paused = false; this.paused = false;
this.commandBuffer = ''; this.commandBuffer = '';
this.pendingCommand = null;
// Notify parent component that terminal disconnected // Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected'); this.$wire.dispatch('terminalDisconnected');
@ -188,7 +202,7 @@ export function initializeTerminalComponent() {
initializeWebSocket() { initializeWebSocket() {
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) { if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
console.log('[Terminal] WebSocket already connecting/connected, skipping'); logTerminal('log', '[Terminal] WebSocket already connecting/connected, skipping');
return; // Already connecting or connected return; // Already connecting or connected
} }
@ -197,7 +211,7 @@ export function initializeTerminalComponent() {
// Ensure terminal config is available // Ensure terminal config is available
if (!window.terminalConfig) { if (!window.terminalConfig) {
console.warn('[Terminal] Terminal config not available, using defaults'); logTerminal('warn', '[Terminal] Terminal config not available, using defaults');
window.terminalConfig = {}; window.terminalConfig = {};
} }
@ -223,7 +237,7 @@ export function initializeTerminalComponent() {
} }
const url = `${connectionString.protocol}://${connectionString.host}${connectionString.port}${connectionString.path}` const url = `${connectionString.protocol}://${connectionString.host}${connectionString.port}${connectionString.path}`
console.log(`[Terminal] Attempting connection to: ${url}`); logTerminal('log', `[Terminal] Attempting connection to: ${url}`);
try { try {
this.socket = new WebSocket(url); this.socket = new WebSocket(url);
@ -232,7 +246,7 @@ export function initializeTerminalComponent() {
const timeoutMs = this.reconnectAttempts === 0 ? 15000 : this.connectionTimeout; const timeoutMs = this.reconnectAttempts === 0 ? 15000 : this.connectionTimeout;
this.connectionTimeoutId = setTimeout(() => { this.connectionTimeoutId = setTimeout(() => {
if (this.connectionState === 'connecting') { if (this.connectionState === 'connecting') {
console.error(`[Terminal] Connection timeout after ${timeoutMs}ms`); logTerminal('error', `[Terminal] Connection timeout after ${timeoutMs}ms`);
this.socket.close(); this.socket.close();
this.handleConnectionError('Connection timeout'); this.handleConnectionError('Connection timeout');
} }
@ -244,13 +258,13 @@ export function initializeTerminalComponent() {
this.socket.onclose = this.handleSocketClose.bind(this); this.socket.onclose = this.handleSocketClose.bind(this);
} catch (error) { } catch (error) {
console.error('[Terminal] Failed to create WebSocket:', error); logTerminal('error', '[Terminal] Failed to create WebSocket:', error);
this.handleConnectionError(`Failed to create WebSocket connection: ${error.message}`); this.handleConnectionError(`Failed to create WebSocket connection: ${error.message}`);
} }
}, },
handleSocketOpen() { handleSocketOpen() {
console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.'); logTerminal('log', '[Terminal] WebSocket connection established.');
this.connectionState = 'connected'; this.connectionState = 'connected';
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
this.heartbeatMissed = 0; this.heartbeatMissed = 0;
@ -262,6 +276,12 @@ export function initializeTerminalComponent() {
this.connectionTimeoutId = null; this.connectionTimeoutId = null;
} }
// Flush any buffered command from before WebSocket was ready
if (this.pendingCommand) {
this.sendMessage(this.pendingCommand);
this.pendingCommand = null;
}
// Start ping timeout monitoring // Start ping timeout monitoring
this.resetPingTimeout(); this.resetPingTimeout();
@ -270,16 +290,16 @@ export function initializeTerminalComponent() {
}, },
handleSocketError(error) { handleSocketError(error) {
console.error('[Terminal] WebSocket error:', error); logTerminal('error', '[Terminal] WebSocket error:', error);
console.error('[Terminal] WebSocket state:', this.socket ? this.socket.readyState : 'No socket'); logTerminal('error', '[Terminal] WebSocket state:', this.socket ? this.socket.readyState : 'No socket');
console.error('[Terminal] Connection attempt:', this.reconnectAttempts + 1); logTerminal('error', '[Terminal] Connection attempt:', this.reconnectAttempts + 1);
this.handleConnectionError('WebSocket error occurred'); this.handleConnectionError('WebSocket error occurred');
}, },
handleSocketClose(event) { handleSocketClose(event) {
console.warn(`[Terminal] WebSocket connection closed. Code: ${event.code}, Reason: ${event.reason || 'No reason provided'}`); logTerminal('warn', `[Terminal] WebSocket connection closed. Code: ${event.code}, Reason: ${event.reason || 'No reason provided'}`);
console.log('[Terminal] Was clean close:', event.code === 1000); logTerminal('log', '[Terminal] Was clean close:', event.code === 1000);
console.log('[Terminal] Connection attempt:', this.reconnectAttempts + 1); logTerminal('log', '[Terminal] Connection attempt:', this.reconnectAttempts + 1);
this.connectionState = 'disconnected'; this.connectionState = 'disconnected';
this.clearAllTimers(); this.clearAllTimers();
@ -297,7 +317,7 @@ export function initializeTerminalComponent() {
}, },
handleConnectionError(reason) { handleConnectionError(reason) {
console.error(`[Terminal] Connection error: ${reason} (attempt ${this.reconnectAttempts + 1})`); logTerminal('error', `[Terminal] Connection error: ${reason} (attempt ${this.reconnectAttempts + 1})`);
this.connectionState = 'disconnected'; this.connectionState = 'disconnected';
// Only dispatch error to UI after a few failed attempts to avoid immediate error on page load // Only dispatch error to UI after a few failed attempts to avoid immediate error on page load
@ -310,7 +330,7 @@ export function initializeTerminalComponent() {
scheduleReconnect() { scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) { if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('[Terminal] Max reconnection attempts reached'); logTerminal('error', '[Terminal] Max reconnection attempts reached');
this.message = '(connection failed - max retries exceeded)'; this.message = '(connection failed - max retries exceeded)';
return; return;
} }
@ -323,7 +343,7 @@ export function initializeTerminalComponent() {
this.maxReconnectDelay this.maxReconnectDelay
); );
console.warn(`[Terminal] Scheduling reconnect attempt ${this.reconnectAttempts + 1} in ${delay}ms`); logTerminal('warn', `[Terminal] Scheduling reconnect attempt ${this.reconnectAttempts + 1} in ${delay}ms`);
this.reconnectInterval = setTimeout(() => { this.reconnectInterval = setTimeout(() => {
this.reconnectAttempts++; this.reconnectAttempts++;
@ -335,17 +355,21 @@ export function initializeTerminalComponent() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) { if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message)); this.socket.send(JSON.stringify(message));
} else { } else {
console.warn('[Terminal] WebSocket not ready, message not sent:', message); logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message);
} }
}, },
sendCommandWhenReady(message) { sendCommandWhenReady(message) {
if (this.isWebSocketReady()) { if (this.isWebSocketReady()) {
this.sendMessage(message); this.sendMessage(message);
} else {
this.pendingCommand = message;
} }
}, },
handleSocketMessage(event) { handleSocketMessage(event) {
logTerminal('log', '[Terminal] Received WebSocket message:', event.data);
// Handle pong responses // Handle pong responses
if (event.data === 'pong') { if (event.data === 'pong') {
this.heartbeatMissed = 0; this.heartbeatMissed = 0;
@ -354,6 +378,10 @@ export function initializeTerminalComponent() {
return; return;
} }
if (!this.term?._initialized && event.data !== 'pty-ready') {
logTerminal('warn', '[Terminal] Received message before PTY initialization:', event.data);
}
if (event.data === 'pty-ready') { if (event.data === 'pty-ready') {
if (!this.term._initialized) { if (!this.term._initialized) {
this.term.open(document.getElementById('terminal')); this.term.open(document.getElementById('terminal'));
@ -398,17 +426,24 @@ export function initializeTerminalComponent() {
// Notify parent component that terminal disconnected // Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected'); this.$wire.dispatch('terminalDisconnected');
} else if (
typeof event.data === 'string' &&
(event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:'))
) {
logTerminal('error', '[Terminal] Backend rejected terminal startup:', event.data);
this.$wire.dispatch('error', event.data);
this.terminalActive = false;
} else { } else {
try { try {
this.pendingWrites++; this.pendingWrites++;
this.term.write(event.data, (err) => { this.term.write(event.data, (err) => {
if (err) { if (err) {
console.error('[Terminal] Write error:', err); logTerminal('error', '[Terminal] Write error:', err);
} }
this.flowControlCallback(); this.flowControlCallback();
}); });
} catch (error) { } catch (error) {
console.error('[Terminal] Write operation failed:', error); logTerminal('error', '[Terminal] Write operation failed:', error);
this.pendingWrites = Math.max(0, this.pendingWrites - 1); this.pendingWrites = Math.max(0, this.pendingWrites - 1);
} }
} }
@ -483,10 +518,10 @@ export function initializeTerminalComponent() {
clearTimeout(this.pingTimeoutId); clearTimeout(this.pingTimeoutId);
this.pingTimeoutId = null; this.pingTimeoutId = null;
} }
console.log('[Terminal] Tab hidden, pausing heartbeat monitoring'); logTerminal('log', '[Terminal] Tab hidden, pausing heartbeat monitoring');
} else if (wasVisible === false) { } else if (wasVisible === false) {
// Tab is now visible again // Tab is now visible again
console.log('[Terminal] Tab visible, resuming connection management'); logTerminal('log', '[Terminal] Tab visible, resuming connection management');
if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) { if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) {
// Send immediate ping to verify connection is still alive // Send immediate ping to verify connection is still alive
@ -508,10 +543,10 @@ export function initializeTerminalComponent() {
this.pingTimeoutId = setTimeout(() => { this.pingTimeoutId = setTimeout(() => {
this.heartbeatMissed++; this.heartbeatMissed++;
console.warn(`[Terminal] Ping timeout - missed ${this.heartbeatMissed}/${this.maxHeartbeatMisses}`); logTerminal('warn', `[Terminal] Ping timeout - missed ${this.heartbeatMissed}/${this.maxHeartbeatMisses}`);
if (this.heartbeatMissed >= this.maxHeartbeatMisses) { if (this.heartbeatMissed >= this.maxHeartbeatMisses) {
console.error('[Terminal] Too many missed heartbeats, closing connection'); logTerminal('error', '[Terminal] Too many missed heartbeats, closing connection');
this.socket.close(1001, 'Heartbeat timeout'); this.socket.close(1001, 'Heartbeat timeout');
} }
}, this.pingTimeout); }, this.pingTimeout);
@ -553,7 +588,7 @@ export function initializeTerminalComponent() {
// Check if dimensions are valid // Check if dimensions are valid
if (height <= 0 || width <= 0) { if (height <= 0 || width <= 0) {
console.warn('[Terminal] Invalid wrapper dimensions, retrying...', { height, width }); logTerminal('warn', '[Terminal] Invalid wrapper dimensions, retrying...', { height, width });
setTimeout(() => this.resizeTerminal(), 100); setTimeout(() => this.resizeTerminal(), 100);
return; return;
} }
@ -562,7 +597,7 @@ export function initializeTerminalComponent() {
if (!charSize.height || !charSize.width) { if (!charSize.height || !charSize.width) {
// Fallback values if char size not available yet // Fallback values if char size not available yet
console.warn('[Terminal] Character size not available, retrying...'); logTerminal('warn', '[Terminal] Character size not available, retrying...');
setTimeout(() => this.resizeTerminal(), 100); setTimeout(() => this.resizeTerminal(), 100);
return; return;
} }
@ -583,10 +618,10 @@ export function initializeTerminalComponent() {
}); });
} }
} else { } else {
console.warn('[Terminal] Invalid calculated dimensions:', { rows, cols, height, width, charSize }); logTerminal('warn', '[Terminal] Invalid calculated dimensions:', { rows, cols, height, width, charSize });
} }
} catch (error) { } catch (error) {
console.error('[Terminal] Resize error:', error); logTerminal('error', '[Terminal] Resize error:', error);
} }
}, },

View file

@ -21,7 +21,8 @@
<div>No containers are running or terminal access is disabled on this server.</div> <div>No containers are running or terminal access is disabled on this server.</div>
@else @else
<form class="w-96 min-w-fit flex gap-2 items-end" wire:submit="$dispatchSelf('connectToContainer')" <form class="w-96 min-w-fit flex gap-2 items-end" wire:submit="$dispatchSelf('connectToContainer')"
x-data="{ autoConnected: false }" x-init="if ({{ count($containers) }} === 1 && !autoConnected) { x-data="{ autoConnected: false }"
x-on:terminal-websocket-ready.window="if ({{ count($containers) }} === 1 && !autoConnected) {
autoConnected = true; autoConnected = true;
$nextTick(() => $wire.dispatchSelf('connectToContainer')); $nextTick(() => $wire.dispatchSelf('connectToContainer'));
}"> }">

View file

@ -168,9 +168,23 @@
Route::post('/terminal/auth/ips', function () { Route::post('/terminal/auth/ips', function () {
if (auth()->check()) { if (auth()->check()) {
$team = auth()->user()->currentTeam(); $team = auth()->user()->currentTeam();
$ipAddresses = $team->servers->where('settings.is_terminal_enabled', true)->pluck('ip')->toArray(); $ipAddresses = $team->servers
->where('settings.is_terminal_enabled', true)
->pluck('ip')
->filter()
->values();
return response()->json(['ipAddresses' => $ipAddresses], 200); if (isDev()) {
$ipAddresses = $ipAddresses->merge([
'coolify-testing-host',
'host.docker.internal',
'localhost',
'127.0.0.1',
base_ip(),
])->filter()->unique()->values();
}
return response()->json(['ipAddresses' => $ipAddresses->all()], 200);
} }
return response()->json(['ipAddresses' => []], 401); return response()->json(['ipAddresses' => []], 401);

View file

@ -0,0 +1,34 @@
<?php
it('copies the realtime terminal utilities into the container image', function () {
$dockerfile = file_get_contents(base_path('docker/coolify-realtime/Dockerfile'));
expect($dockerfile)->toContain('COPY docker/coolify-realtime/terminal-utils.js /terminal/terminal-utils.js');
});
it('mounts the realtime terminal utilities in local development compose files', function (string $composeFile) {
$composeContents = file_get_contents(base_path($composeFile));
expect($composeContents)->toContain('./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js');
})->with([
'default dev compose' => 'docker-compose.dev.yml',
'maxio dev compose' => 'docker-compose-maxio.dev.yml',
]);
it('keeps terminal browser logging restricted to Vite development mode', function () {
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
expect($terminalClient)
->toContain('const terminalDebugEnabled = import.meta.env.DEV;')
->toContain("logTerminal('log', '[Terminal] WebSocket connection established.');")
->not->toContain("console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.');");
});
it('keeps realtime terminal server logging restricted to development environments', function () {
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
expect($terminalServer)
->toContain("const terminalDebugEnabled = ['local', 'development'].includes(")
->toContain('if (!terminalDebugEnabled) {')
->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');");
});

View file

@ -0,0 +1,51 @@
<?php
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.env', 'local');
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
'team_id' => $this->team->id,
]);
});
it('includes development terminal host aliases for authenticated users', function () {
Server::factory()->create([
'name' => 'Localhost',
'ip' => 'coolify-testing-host',
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
$response = $this->postJson('/terminal/auth/ips');
$response->assertSuccessful();
$response->assertJsonPath('ipAddresses.0', 'coolify-testing-host');
expect($response->json('ipAddresses'))
->toContain('coolify-testing-host')
->toContain('localhost')
->toContain('127.0.0.1')
->toContain('host.docker.internal');
});