Merge branch 'next' of github.com:coollabsio/coolify into next
This commit is contained in:
9
docker/coolify-realtime/Dockerfile
Normal file
9
docker/coolify-realtime/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
FROM quay.io/soketi/soketi:1.6-16-alpine
|
||||||
|
WORKDIR /terminal
|
||||||
|
RUN apk add --no-cache openssh-client make g++ python3
|
||||||
|
COPY docker/coolify-realtime/package.json ./
|
||||||
|
RUN npm i
|
||||||
|
RUN npm rebuild node-pty --update-binary
|
||||||
|
COPY docker/coolify-realtime/soketi-entrypoint.sh /soketi-entrypoint.sh
|
||||||
|
COPY docker/coolify-realtime/terminal-server.js /terminal/terminal-server.js
|
||||||
|
ENTRYPOINT ["/bin/sh", "/soketi-entrypoint.sh"]
|
||||||
13
docker/coolify-realtime/package.json
Normal file
13
docker/coolify-realtime/package.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"cookie": "^0.6.0",
|
||||||
|
"axios": "1.7.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"node-pty": "^1.0.0",
|
||||||
|
"ws": "^8.17.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
27
docker/coolify-realtime/soketi-entrypoint.sh
Normal file
27
docker/coolify-realtime/soketi-entrypoint.sh
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Function to timestamp logs
|
||||||
|
timestamp() {
|
||||||
|
date "+%Y-%m-%d %H:%M:%S"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start the terminal server in the background with logging
|
||||||
|
node --watch /terminal/terminal-server.js > >(while read line; do echo "$(timestamp) [TERMINAL] $line"; done) 2>&1 &
|
||||||
|
TERMINAL_PID=$!
|
||||||
|
|
||||||
|
# Start the Soketi process in the background with logging
|
||||||
|
node /app/bin/server.js start > >(while read line; do echo "$(timestamp) [SOKETI] $line"; done) 2>&1 &
|
||||||
|
SOKETI_PID=$!
|
||||||
|
|
||||||
|
# Function to forward signals to child processes
|
||||||
|
forward_signal() {
|
||||||
|
kill -$1 $TERMINAL_PID $SOKETI_PID
|
||||||
|
}
|
||||||
|
|
||||||
|
# Forward SIGTERM to child processes
|
||||||
|
trap 'forward_signal TERM' TERM
|
||||||
|
|
||||||
|
# Wait for any process to exit
|
||||||
|
wait -n
|
||||||
|
|
||||||
|
# Exit with status of process that exited first
|
||||||
|
exit $?
|
||||||
230
docker/coolify-realtime/terminal-server.js
Executable file
230
docker/coolify-realtime/terminal-server.js
Executable file
@@ -0,0 +1,230 @@
|
|||||||
|
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(`${protocol}//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', 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 }) => 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: process.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
|
||||||
|
// If the user types 'exit', it terminates the container connection and reverts to the server.
|
||||||
|
const ptyProcess = pty.spawn('ssh', sshArgs.concat(['bash']), options);
|
||||||
|
userSession.ptyProcess = ptyProcess;
|
||||||
|
userSession.isActive = true;
|
||||||
|
ptyProcess.write(hereDocContent + '\n');
|
||||||
|
// clear the terminal if the user has clear command
|
||||||
|
ptyProcess.write('command -v clear >/dev/null 2>&1 && clear\n');
|
||||||
|
|
||||||
|
ws.send('pty-ready');
|
||||||
|
|
||||||
|
ptyProcess.onData((data) => ws.send(data));
|
||||||
|
|
||||||
|
ptyProcess.onExit(({ exitCode, signal }) => {
|
||||||
|
console.error(`Process exited with code ${exitCode} and signal ${signal}`);
|
||||||
|
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('kill -TERM -$$ && exit\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('Server listening on port 6002');
|
||||||
|
});
|
||||||
76
package-lock.json
generated
76
package-lock.json
generated
@@ -7,9 +7,15 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/forms": "0.5.7",
|
"@tailwindcss/forms": "0.5.7",
|
||||||
"@tailwindcss/typography": "0.5.13",
|
"@tailwindcss/typography": "0.5.13",
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
"alpinejs": "3.14.0",
|
"alpinejs": "3.14.0",
|
||||||
|
"cookie": "^0.6.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
"ioredis": "5.4.1",
|
"ioredis": "5.4.1",
|
||||||
"tailwindcss-scrollbar": "0.1.0"
|
"node-pty": "^1.0.0",
|
||||||
|
"tailwindcss-scrollbar": "0.1.0",
|
||||||
|
"ws": "^8.17.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "4.5.1",
|
"@vitejs/plugin-vue": "4.5.1",
|
||||||
@@ -692,6 +698,19 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
|
||||||
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA=="
|
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-fit": {
|
||||||
|
"version": "0.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||||
|
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@xterm/xterm": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/xterm": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="
|
||||||
|
},
|
||||||
"node_modules/alpinejs": {
|
"node_modules/alpinejs": {
|
||||||
"version": "3.14.0",
|
"version": "3.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.0.tgz",
|
||||||
@@ -940,6 +959,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
|
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -1000,6 +1028,18 @@
|
|||||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="
|
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "16.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
|
||||||
|
"integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.4.692",
|
"version": "1.4.692",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.692.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.692.tgz",
|
||||||
@@ -1475,6 +1515,11 @@
|
|||||||
"thenify-all": "^1.0.0"
|
"thenify-all": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nan": {
|
||||||
|
"version": "2.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/nan/-/nan-2.20.0.tgz",
|
||||||
|
"integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw=="
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.7",
|
"version": "3.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||||
@@ -1492,6 +1537,15 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-pty": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"dependencies": {
|
||||||
|
"nan": "^2.17.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.14",
|
"version": "2.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
|
||||||
@@ -2124,6 +2178,26 @@
|
|||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
||||||
|
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz",
|
||||||
|
|||||||
@@ -20,8 +20,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/forms": "0.5.7",
|
"@tailwindcss/forms": "0.5.7",
|
||||||
"@tailwindcss/typography": "0.5.13",
|
"@tailwindcss/typography": "0.5.13",
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
"alpinejs": "3.14.0",
|
"alpinejs": "3.14.0",
|
||||||
|
"cookie": "^0.6.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
"ioredis": "5.4.1",
|
"ioredis": "5.4.1",
|
||||||
"tailwindcss-scrollbar": "0.1.0"
|
"node-pty": "^1.0.0",
|
||||||
|
"tailwindcss-scrollbar": "0.1.0",
|
||||||
|
"ws": "^8.17.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,3 +4,18 @@
|
|||||||
// const app = createApp({});
|
// const app = createApp({});
|
||||||
// app.component("magic-bar", MagicBar);
|
// app.component("magic-bar", MagicBar);
|
||||||
// app.mount("#vue");
|
// app.mount("#vue");
|
||||||
|
|
||||||
|
import { Terminal } from '@xterm/xterm';
|
||||||
|
import '@xterm/xterm/css/xterm.css';
|
||||||
|
import { FitAddon } from '@xterm/addon-fit';
|
||||||
|
|
||||||
|
if (!window.term) {
|
||||||
|
window.term = new Terminal({
|
||||||
|
cols: 80,
|
||||||
|
rows: 30,
|
||||||
|
fontFamily: '"Fira Code", courier-new, courier, monospace, "Powerline Extra Symbols"',
|
||||||
|
cursorBlink: true
|
||||||
|
});
|
||||||
|
window.fitAddon = new FitAddon();
|
||||||
|
window.term.loadAddon(window.fitAddon);
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,11 +22,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div wire:loading.remove wire:target='loadContainers'>
|
<div wire:loading.remove wire:target='loadContainers'>
|
||||||
@if (count($containers) > 0)
|
@if (count($containers) > 0)
|
||||||
<form class="flex flex-col gap-2 pt-4" wire:submit='runCommand'>
|
<form class="flex flex-col justify-center gap-2 pt-4 xl:items-end xl:flex-row"
|
||||||
<div class="flex gap-2">
|
wire:submit="$dispatchSelf('connectToContainer')">
|
||||||
<x-forms.input placeholder="ls -l" autofocus id="command" label="Command" required />
|
|
||||||
<x-forms.input id="workDir" label="Working directory" />
|
|
||||||
</div>
|
|
||||||
<x-forms.select label="Container" id="container" required>
|
<x-forms.select label="Container" id="container" required>
|
||||||
<option disabled selected>Select container</option>
|
<option disabled selected>Select container</option>
|
||||||
@if (data_get($this->parameters, 'application_uuid'))
|
@if (data_get($this->parameters, 'application_uuid'))
|
||||||
@@ -47,14 +44,14 @@
|
|||||||
</option>
|
</option>
|
||||||
@endif
|
@endif
|
||||||
</x-forms.select>
|
</x-forms.select>
|
||||||
<x-forms.button type="submit">Run</x-forms.button>
|
<x-forms.button type="submit">Start Connection</x-forms.button>
|
||||||
</form>
|
</form>
|
||||||
@else
|
@else
|
||||||
<div class="pt-4">No containers are not running.</div>
|
<div class="pt-4">No containers are not running.</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full pt-10 mx-auto">
|
<div class="w-full mx-auto">
|
||||||
<livewire:activity-monitor header="Command output" />
|
<livewire:project.shared.terminal />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
199
resources/views/livewire/project/shared/terminal.blade.php
Normal file
199
resources/views/livewire/project/shared/terminal.blade.php
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
<div x-data="data()">
|
||||||
|
<div x-show="!terminalActive" class="flex items-center justify-center w-full py-4 mx-auto h-[510px]">
|
||||||
|
<div class="w-full h-full border rounded dark:bg-coolgray-100 dark:border-coolgray-300 p-1">
|
||||||
|
<span class="font-mono text-sm text-gray-500" x-text="message"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div x-ref="terminalWrapper"
|
||||||
|
:class="fullscreen ? 'fullscreen' : 'relative w-full h-full py-4 mx-auto max-h-[510px]'">
|
||||||
|
<div id="terminal" wire:ignore></div>
|
||||||
|
<button title="Minimize" x-show="fullscreen" class="fixed top-4 right-4 text-white"
|
||||||
|
x-on:click="makeFullscreen"><svg class="icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
stroke-width="2" d="M6 14h4m0 0v4m0-4l-6 6m14-10h-4m0 0V6m0 4l6-6" />
|
||||||
|
</svg></button>
|
||||||
|
<button title="Fullscreen" x-show="!fullscreen && terminalActive" class="absolute top-6 right-4 text-white"
|
||||||
|
x-on:click="makeFullscreen"><svg class="icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g fill="none">
|
||||||
|
<path
|
||||||
|
d="M24 0v24H0V0h24ZM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.017-.018Zm.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022Zm-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01l-.184-.092Z" />
|
||||||
|
<path fill="currentColor"
|
||||||
|
d="M9.793 12.793a1 1 0 0 1 1.497 1.32l-.083.094L6.414 19H9a1 1 0 0 1 .117 1.993L9 21H4a1 1 0 0 1-.993-.883L3 20v-5a1 1 0 0 1 1.993-.117L5 15v2.586l4.793-4.793ZM20 3a1 1 0 0 1 .993.883L21 4v5a1 1 0 0 1-1.993.117L19 9V6.414l-4.793 4.793a1 1 0 0 1-1.497-1.32l.083-.094L17.586 5H15a1 1 0 0 1-.117-1.993L15 3h5Z" />
|
||||||
|
</g>
|
||||||
|
</svg></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@script
|
||||||
|
<script>
|
||||||
|
const MAX_PENDING_WRITES = 5;
|
||||||
|
let pendingWrites = 0;
|
||||||
|
let paused = false;
|
||||||
|
|
||||||
|
let socket;
|
||||||
|
let commandBuffer = '';
|
||||||
|
|
||||||
|
function initializeWebSocket() {
|
||||||
|
if (!socket || socket.readyState === WebSocket.CLOSED) {
|
||||||
|
let url = "{{ str_replace(['http://', 'https://'], '', config('app.url')) }}" || window.location.hostname;
|
||||||
|
// make sure the port is not included
|
||||||
|
url = url.split(':')[0];
|
||||||
|
socket = new WebSocket((window.location.protocol === 'https:' ? 'wss://' : 'ws://') +
|
||||||
|
url +
|
||||||
|
':6002/terminal');
|
||||||
|
socket.onmessage = handleSocketMessage;
|
||||||
|
socket.onerror = (e) => {
|
||||||
|
console.error('WebSocket error:', e);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSocketMessage(event) {
|
||||||
|
$data.message = '(connection closed)';
|
||||||
|
// Initialize Terminal
|
||||||
|
if (event.data === 'pty-ready') {
|
||||||
|
term.open(document.getElementById('terminal'));
|
||||||
|
$data.terminalActive = true;
|
||||||
|
term.reset();
|
||||||
|
term.focus();
|
||||||
|
document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded')
|
||||||
|
$data.resizeTerminal()
|
||||||
|
} else if (event.data === 'unprocessable') {
|
||||||
|
term.reset();
|
||||||
|
$data.terminalActive = false;
|
||||||
|
$data.message = '(sorry, something went wrong, please try again)';
|
||||||
|
} else {
|
||||||
|
pendingWrites++;
|
||||||
|
term.write(event.data, flowControlCallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flowControlCallback() {
|
||||||
|
pendingWrites--;
|
||||||
|
if (pendingWrites > MAX_PENDING_WRITES && !paused) {
|
||||||
|
paused = true;
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
pause: true
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingWrites <= MAX_PENDING_WRITES && paused) {
|
||||||
|
paused = false;
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
resume: true
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
term.onData((data) => {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
message: data
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Type CTRL + D or exit in the terminal
|
||||||
|
if (data === '\x04' || (data === '\r' && stripAnsiCommands(commandBuffer).trim() === 'exit')) {
|
||||||
|
checkIfProcessIsRunningAndKillIt();
|
||||||
|
setTimeout(() => {
|
||||||
|
term.reset();
|
||||||
|
$data.terminalActive = false;
|
||||||
|
}, 500);
|
||||||
|
commandBuffer = '';
|
||||||
|
} else if (data === '\r') {
|
||||||
|
commandBuffer = '';
|
||||||
|
} else {
|
||||||
|
commandBuffer += data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function stripAnsiCommands(input) {
|
||||||
|
return input.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy and paste
|
||||||
|
// Enables ctrl + c and ctrl + v
|
||||||
|
// defaults otherwise to ctrl + insert, shift + insert
|
||||||
|
term.attachCustomKeyEventHandler((arg) => {
|
||||||
|
if (arg.ctrlKey && arg.code === "KeyV" && arg.type === "keydown") {
|
||||||
|
navigator.clipboard.readText()
|
||||||
|
.then(text => {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
message: text
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
if (arg.ctrlKey && arg.code === "KeyC" && arg.type === "keydown") {
|
||||||
|
const selection = term.getSelection();
|
||||||
|
if (selection) {
|
||||||
|
navigator.clipboard.writeText(selection);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$wire.on('send-back-command', function(command) {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
command: command
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', function(e) {
|
||||||
|
checkIfProcessIsRunningAndKillIt();
|
||||||
|
});
|
||||||
|
|
||||||
|
function checkIfProcessIsRunningAndKillIt() {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
checkActive: 'force'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onresize = function() {
|
||||||
|
$data.resizeTerminal()
|
||||||
|
};
|
||||||
|
|
||||||
|
Alpine.data('data', () => ({
|
||||||
|
fullscreen: false,
|
||||||
|
terminalActive: false,
|
||||||
|
message: '(connection closed)',
|
||||||
|
init() {
|
||||||
|
this.$watch('terminalActive', (value) => {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (value) {
|
||||||
|
$refs.terminalWrapper.style.display = 'block';
|
||||||
|
this.resizeTerminal();
|
||||||
|
} else {
|
||||||
|
$refs.terminalWrapper.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
makeFullscreen() {
|
||||||
|
this.fullscreen = !this.fullscreen;
|
||||||
|
$nextTick(() => {
|
||||||
|
this.resizeTerminal()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
resizeTerminal() {
|
||||||
|
if (!this.terminalActive) return;
|
||||||
|
|
||||||
|
fitAddon.fit();
|
||||||
|
const height = $refs.terminalWrapper.clientHeight;
|
||||||
|
const rows = height / term._core._renderService._charSizeService.height - 1;
|
||||||
|
var termWidth = term.cols;
|
||||||
|
var termHeight = parseInt(rows.toString(), 10);
|
||||||
|
term.resize(termWidth, termHeight);
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
resize: {
|
||||||
|
cols: termWidth,
|
||||||
|
rows: termHeight
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
initializeWebSocket();
|
||||||
|
</script>
|
||||||
|
@endscript
|
||||||
|
</div>
|
||||||
@@ -1,19 +1,23 @@
|
|||||||
<div>
|
<div>
|
||||||
<form class="flex flex-col justify-center gap-2 xl:items-end xl:flex-row" wire:submit='runCommand'>
|
<form class="flex flex-col justify-center gap-2 xl:items-end xl:flex-row"
|
||||||
<x-forms.input placeholder="ls -l" autofocus id="command" label="Command" required />
|
wire:submit="$dispatchSelf('connectToContainer')">
|
||||||
<x-forms.select label="Server" id="server" required>
|
<x-forms.select label="Select Server or Container" id="server" required wire:model="selected_uuid">
|
||||||
@foreach ($servers as $server)
|
@foreach ($servers as $server)
|
||||||
@if ($loop->first)
|
@if ($loop->first)
|
||||||
<option selected value="{{ $server->uuid }}">{{ $server->name }}</option>
|
<option selected value="{{ $server->uuid }}">{{ $server->name }}</option>
|
||||||
@else
|
@else
|
||||||
<option value="{{ $server->uuid }}">{{ $server->name }}</option>
|
<option value="{{ $server->uuid }}">{{ $server->name }}</option>
|
||||||
@endif
|
@endif
|
||||||
|
@foreach ($containers as $container)
|
||||||
|
@if ($container['server_uuid'] == $server->uuid)
|
||||||
|
<option value="{{ $container['uuid'] }}">
|
||||||
|
{{ $server->name }} -> {{ $container['name'] }}
|
||||||
|
</option>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
@endforeach
|
@endforeach
|
||||||
</x-forms.select>
|
</x-forms.select>
|
||||||
<x-forms.button type="submit">Execute Command
|
<x-forms.button type="submit">Start Connection</x-forms.button>
|
||||||
</x-forms.button>
|
|
||||||
</form>
|
</form>
|
||||||
<div class="w-full pt-10 mx-auto">
|
<livewire:project.shared.terminal />
|
||||||
<livewire:activity-monitor header="Command output" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -154,6 +154,12 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Route::get('/command-center', CommandCenterIndex::class)->name('command-center');
|
Route::get('/command-center', CommandCenterIndex::class)->name('command-center');
|
||||||
|
Route::post('/terminal/auth', function () {
|
||||||
|
if (auth()->check()) {
|
||||||
|
return response()->json(['authenticated' => true], 200);
|
||||||
|
}
|
||||||
|
return response()->json(['authenticated' => false], 401);
|
||||||
|
})->name('terminal.auth');
|
||||||
|
|
||||||
Route::prefix('invitations')->group(function () {
|
Route::prefix('invitations')->group(function () {
|
||||||
Route::get('/{uuid}', [Controller::class, 'accept_invitation'])->name('team.invitation.accept');
|
Route::get('/{uuid}', [Controller::class, 'accept_invitation'])->name('team.invitation.accept');
|
||||||
|
|||||||
Reference in New Issue
Block a user