240 lines
		
	
	
		
			7.5 KiB
		
	
	
	
		
			JavaScript
		
	
	
		
			Executable File
		
	
	
	
	
			
		
		
	
	
			240 lines
		
	
	
		
			7.5 KiB
		
	
	
	
		
			JavaScript
		
	
	
		
			Executable File
		
	
	
	
	
import { WebSocketServer } from 'ws';
 | 
						|
import http from 'http';
 | 
						|
import pty from 'node-pty';
 | 
						|
import axios from 'axios';
 | 
						|
import cookie from 'cookie';
 | 
						|
import 'dotenv/config'
 | 
						|
 | 
						|
const server = http.createServer((req, res) => {
 | 
						|
    if (req.url === '/ready') {
 | 
						|
        res.writeHead(200, { 'Content-Type': 'text/plain' });
 | 
						|
        res.end('OK');
 | 
						|
    } else {
 | 
						|
        res.writeHead(404, { 'Content-Type': 'text/plain' });
 | 
						|
        res.end('Not Found');
 | 
						|
    }
 | 
						|
});
 | 
						|
 | 
						|
const verifyClient = async (info, callback) => {
 | 
						|
    const cookies = cookie.parse(info.req.headers.cookie || '');
 | 
						|
    // const origin = new URL(info.origin);
 | 
						|
    // const protocol = origin.protocol;
 | 
						|
    const xsrfToken = cookies['XSRF-TOKEN'];
 | 
						|
 | 
						|
    // Generate session cookie name based on APP_NAME
 | 
						|
    const appName = process.env.APP_NAME || 'laravel';
 | 
						|
    const sessionCookieName = `${appName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase()}_session`;
 | 
						|
    const laravelSession = cookies[sessionCookieName];
 | 
						|
 | 
						|
    // Verify presence of required tokens
 | 
						|
    if (!laravelSession || !xsrfToken) {
 | 
						|
        return callback(false, 401, 'Unauthorized: Missing required tokens');
 | 
						|
    }
 | 
						|
 | 
						|
    try {
 | 
						|
        // Authenticate with Laravel backend
 | 
						|
        const response = await axios.post(`http://coolify/terminal/auth`, null, {
 | 
						|
            headers: {
 | 
						|
                'Cookie': `${sessionCookieName}=${laravelSession}`,
 | 
						|
                'X-XSRF-TOKEN': xsrfToken
 | 
						|
            },
 | 
						|
        });
 | 
						|
 | 
						|
        if (response.status === 200) {
 | 
						|
            // Authentication successful
 | 
						|
            callback(true);
 | 
						|
        } else {
 | 
						|
            callback(false, 401, 'Unauthorized: Invalid credentials');
 | 
						|
        }
 | 
						|
    } catch (error) {
 | 
						|
        console.error('Authentication error:', error.message);
 | 
						|
        callback(false, 500, 'Internal Server Error');
 | 
						|
    }
 | 
						|
};
 | 
						|
 | 
						|
 | 
						|
const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient });
 | 
						|
const userSessions = new Map();
 | 
						|
 | 
						|
wss.on('connection', (ws) => {
 | 
						|
    const userId = generateUserId();
 | 
						|
    const userSession = { ws, userId, ptyProcess: null, isActive: false };
 | 
						|
    userSessions.set(userId, userSession);
 | 
						|
 | 
						|
    ws.on('message', (message) => {
 | 
						|
        handleMessage(userSession, message);
 | 
						|
 | 
						|
    });
 | 
						|
    ws.on('error', (err) => handleError(err, userId));
 | 
						|
    ws.on('close', () => handleClose(userId));
 | 
						|
 | 
						|
});
 | 
						|
 | 
						|
const messageHandlers = {
 | 
						|
    message: (session, data) => session.ptyProcess.write(data),
 | 
						|
    resize: (session, { cols, rows }) => {
 | 
						|
        cols = cols > 0 ? cols : 80;
 | 
						|
        rows = rows > 0 ? rows : 30;
 | 
						|
        session.ptyProcess.resize(cols, rows)
 | 
						|
    },
 | 
						|
    pause: (session) => session.ptyProcess.pause(),
 | 
						|
    resume: (session) => session.ptyProcess.resume(),
 | 
						|
    checkActive: (session, data) => {
 | 
						|
        if (data === 'force' && session.isActive) {
 | 
						|
            killPtyProcess(session.userId);
 | 
						|
        } else {
 | 
						|
            session.ws.send(session.isActive);
 | 
						|
        }
 | 
						|
    },
 | 
						|
    command: (session, data) => handleCommand(session.ws, data, session.userId)
 | 
						|
};
 | 
						|
 | 
						|
function handleMessage(userSession, message) {
 | 
						|
    const parsed = parseMessage(message);
 | 
						|
    if (!parsed) return;
 | 
						|
 | 
						|
    Object.entries(parsed).forEach(([key, value]) => {
 | 
						|
        const handler = messageHandlers[key];
 | 
						|
        if (handler && (userSession.isActive || key === 'checkActive' || key === 'command')) {
 | 
						|
            handler(userSession, value);
 | 
						|
        }
 | 
						|
    });
 | 
						|
}
 | 
						|
 | 
						|
function parseMessage(message) {
 | 
						|
    try {
 | 
						|
        return JSON.parse(message);
 | 
						|
    } catch (e) {
 | 
						|
        console.error('Failed to parse message:', e);
 | 
						|
        return null;
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
async function handleCommand(ws, command, userId) {
 | 
						|
    const userSession = userSessions.get(userId);
 | 
						|
    if (userSession && userSession.isActive) {
 | 
						|
        const result = await killPtyProcess(userId);
 | 
						|
        if (!result) {
 | 
						|
            // if terminal is still active, even after we tried to kill it, dont continue and show error
 | 
						|
            ws.send('unprocessable');
 | 
						|
            return;
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    const commandString = command[0].split('\n').join(' ');
 | 
						|
    const timeout = extractTimeout(commandString);
 | 
						|
    const sshArgs = extractSshArgs(commandString);
 | 
						|
    const hereDocContent = extractHereDocContent(commandString);
 | 
						|
    const options = {
 | 
						|
        name: 'xterm-color',
 | 
						|
        cols: 80,
 | 
						|
        rows: 30,
 | 
						|
        cwd: process.env.HOME,
 | 
						|
        env: {},
 | 
						|
    };
 | 
						|
 | 
						|
    // NOTE: - Initiates a process within the Terminal container
 | 
						|
    //         Establishes an SSH connection to root@coolify with RequestTTY enabled
 | 
						|
    //         Executes the 'docker exec' command to connect to a specific container
 | 
						|
    const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options);
 | 
						|
 | 
						|
    userSession.ptyProcess = ptyProcess;
 | 
						|
    userSession.isActive = true;
 | 
						|
 | 
						|
    ws.send('pty-ready');
 | 
						|
 | 
						|
    ptyProcess.onData((data) => {
 | 
						|
        ws.send(data);
 | 
						|
    });
 | 
						|
 | 
						|
    // when parent closes
 | 
						|
    ptyProcess.onExit(({ exitCode, signal }) => {
 | 
						|
        console.error(`Process exited with code ${exitCode} and signal ${signal}`);
 | 
						|
        ws.send('pty-exited');
 | 
						|
        userSession.isActive = false;
 | 
						|
 | 
						|
    });
 | 
						|
 | 
						|
    if (timeout) {
 | 
						|
        setTimeout(async () => {
 | 
						|
            await killPtyProcess(userId);
 | 
						|
        }, timeout * 1000);
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
async function handleError(err, userId) {
 | 
						|
    console.error('WebSocket error:', err);
 | 
						|
    await killPtyProcess(userId);
 | 
						|
}
 | 
						|
 | 
						|
async function handleClose(userId) {
 | 
						|
    await killPtyProcess(userId);
 | 
						|
    userSessions.delete(userId);
 | 
						|
}
 | 
						|
 | 
						|
async function killPtyProcess(userId) {
 | 
						|
    const session = userSessions.get(userId);
 | 
						|
    if (!session?.ptyProcess) return false;
 | 
						|
 | 
						|
    return new Promise((resolve) => {
 | 
						|
        // Loop to ensure terminal is killed before continuing
 | 
						|
        let killAttempts = 0;
 | 
						|
        const maxAttempts = 5;
 | 
						|
 | 
						|
        const attemptKill = () => {
 | 
						|
            killAttempts++;
 | 
						|
 | 
						|
            // session.ptyProcess.kill() wont work here because of https://github.com/moby/moby/issues/9098
 | 
						|
            // patch with https://github.com/moby/moby/issues/9098#issuecomment-189743947
 | 
						|
            session.ptyProcess.write('set +o history\nkill -TERM -$$ && exit\nset -o history\n');
 | 
						|
 | 
						|
            setTimeout(() => {
 | 
						|
                if (!session.isActive || !session.ptyProcess) {
 | 
						|
                    resolve(true);
 | 
						|
                    return;
 | 
						|
                }
 | 
						|
 | 
						|
                if (killAttempts < maxAttempts) {
 | 
						|
                    attemptKill();
 | 
						|
                } else {
 | 
						|
                    resolve(false);
 | 
						|
                }
 | 
						|
            }, 500);
 | 
						|
        };
 | 
						|
 | 
						|
        attemptKill();
 | 
						|
    });
 | 
						|
}
 | 
						|
 | 
						|
function generateUserId() {
 | 
						|
    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'/);
 | 
						|
    let sshArgs = sshCommandMatch ? sshCommandMatch[1].split(' ') : [];
 | 
						|
    sshArgs = sshArgs.map(arg => arg === 'RequestTTY=no' ? 'RequestTTY=yes' : arg);
 | 
						|
    if (!sshArgs.includes('RequestTTY=yes')) {
 | 
						|
        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, () => {
 | 
						|
    console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');
 | 
						|
});
 |