added ws authentication

This commit is contained in:
Luan Estradioto
2024-08-15 02:38:06 -03:00
parent c2ea8996ee
commit 548fc21e40
6 changed files with 238 additions and 161 deletions

View File

@@ -29,7 +29,10 @@ class RunCommand extends Component
} }
return $server->definedResources() return $server->definedResources()
->filter(fn ($resource) => str_starts_with($resource->status, 'running:')) ->filter(function ($resource) {
$status = method_exists($resource, 'realStatus') ? $resource->realStatus() : (method_exists($resource, 'status') ? $resource->status() : 'exited');
return str_starts_with($status, 'running:');
})
->map(function ($resource) use ($server) { ->map(function ($resource) use ($server) {
$container_name = $resource->uuid; $container_name = $resource->uuid;

32
package-lock.json generated
View File

@@ -10,6 +10,8 @@
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.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",
"node-pty": "^1.0.0", "node-pty": "^1.0.0",
"tailwindcss-scrollbar": "0.1.0", "tailwindcss-scrollbar": "0.1.0",
@@ -18,7 +20,7 @@
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "4.5.1", "@vitejs/plugin-vue": "4.5.1",
"autoprefixer": "10.4.19", "autoprefixer": "10.4.19",
"axios": "1.7.2", "axios": "^1.7.4",
"laravel-echo": "1.16.1", "laravel-echo": "1.16.1",
"laravel-vite-plugin": "0.8.1", "laravel-vite-plugin": "0.8.1",
"postcss": "8.4.38", "postcss": "8.4.38",
@@ -783,10 +785,11 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.7.2", "version": "1.7.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz",
"integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
"form-data": "^4.0.0", "form-data": "^4.0.0",
@@ -956,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",
@@ -1016,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",

View File

@@ -8,7 +8,7 @@
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "4.5.1", "@vitejs/plugin-vue": "4.5.1",
"autoprefixer": "10.4.19", "autoprefixer": "10.4.19",
"axios": "1.7.2", "axios": "^1.7.4",
"laravel-echo": "1.16.1", "laravel-echo": "1.16.1",
"laravel-vite-plugin": "0.8.1", "laravel-vite-plugin": "0.8.1",
"postcss": "8.4.38", "postcss": "8.4.38",
@@ -23,6 +23,8 @@
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.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",
"node-pty": "^1.0.0", "node-pty": "^1.0.0",
"tailwindcss-scrollbar": "0.1.0", "tailwindcss-scrollbar": "0.1.0",

View File

@@ -24,168 +24,169 @@
</div> </div>
@script @script
<script> <script>
const MAX_PENDING_WRITES = 5; const MAX_PENDING_WRITES = 5;
let pendingWrites = 0; let pendingWrites = 0;
let paused = false; let paused = false;
let socket; let socket;
let commandBuffer = ''; let commandBuffer = '';
function initializeWebSocket() { function initializeWebSocket() {
if (!socket || socket.readyState === WebSocket.CLOSED) { if (!socket || socket.readyState === WebSocket.CLOSED) {
socket = new WebSocket((window.location.protocol === 'https:' ? 'wss://' : 'ws://') + const url = "{{ str_replace(['http://', 'https://'], '', config('app.url')) }}" || window.location.hostname;
"{{ str_replace(['http://', 'https://'], '', config('app.url')) }}" + socket = new WebSocket((window.location.protocol === 'https:' ? 'wss://' : 'ws://') +
':6002/terminal'); url +
socket.onmessage = handleSocketMessage; ':6002/terminal');
socket.onerror = (e) => { socket.onmessage = handleSocketMessage;
console.error('WebSocket error:', e); socket.onerror = (e) => {
}; console.error('WebSocket error:', e);
}
}
function handleSocketMessage(event) {
// 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 {
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();
term.write('(connection closed)');
$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() { function handleSocketMessage(event) {
// 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() $data.resizeTerminal()
}; } else {
pendingWrites++;
term.write(event.data, flowControlCallback);
}
}
Alpine.data('data', () => ({ function flowControlCallback() {
fullscreen: false, pendingWrites--;
terminalActive: false, if (pendingWrites > MAX_PENDING_WRITES && !paused) {
init() { paused = true;
this.$watch('terminalActive', (value) => { socket.send(JSON.stringify({
this.$nextTick(() => { pause: true
if (value) { }));
$refs.terminalWrapper.style.display = 'block'; return;
this.resizeTerminal(); }
} else { if (pendingWrites <= MAX_PENDING_WRITES && paused) {
$refs.terminalWrapper.style.display = 'none'; paused = false;
} socket.send(JSON.stringify({
}); resume: true
}); }));
}, return;
makeFullscreen() { }
this.fullscreen = !this.fullscreen; }
$nextTick(() => {
this.resizeTerminal()
})
},
resizeTerminal() { term.onData((data) => {
if (!this.terminalActive) return; socket.send(JSON.stringify({
message: data
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(); // Type CTRL + D or exit in the terminal
</script> if (data === '\x04' || (data === '\r' && stripAnsiCommands(commandBuffer).trim() === 'exit')) {
checkIfProcessIsRunningAndKillIt();
setTimeout(() => {
term.reset();
term.write('(connection closed)');
$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,
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 @endscript
</div> </div>

View File

@@ -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');

View File

@@ -1,6 +1,9 @@
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
import http from 'http'; import http from 'http';
import pty from 'node-pty'; import pty from 'node-pty';
import axios from 'axios';
import cookie from 'cookie';
import 'dotenv/config'
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
if (req.url === '/ready') { if (req.url === '/ready') {
@@ -12,7 +15,45 @@ const server = http.createServer((req, res) => {
} }
}); });
const wss = new WebSocketServer({ server, path: '/terminal' }); 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(); const userSessions = new Map();
wss.on('connection', (ws) => { wss.on('connection', (ws) => {