chore: prepare for PR
This commit is contained in:
parent
201998638a
commit
5c5f67f48b
11 changed files with 513 additions and 134 deletions
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
127
docker/coolify-realtime/terminal-utils.js
Normal file
127
docker/coolify-realtime/terminal-utils.js
Normal 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);
|
||||||
|
}
|
||||||
47
docker/coolify-realtime/terminal-utils.test.js
Normal file
47
docker/coolify-realtime/terminal-utils.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
}">
|
}">
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
34
tests/Feature/RealtimeTerminalPackagingTest.php
Normal file
34
tests/Feature/RealtimeTerminalPackagingTest.php
Normal 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!');");
|
||||||
|
});
|
||||||
51
tests/Feature/TerminalAuthIpsRouteTest.php
Normal file
51
tests/Feature/TerminalAuthIpsRouteTest.php
Normal 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');
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue