diff --git a/app/Livewire/RunCommand.php b/app/Livewire/RunCommand.php index aae02d4e1..2d01cbca0 100644 --- a/app/Livewire/RunCommand.php +++ b/app/Livewire/RunCommand.php @@ -29,7 +29,10 @@ class RunCommand extends Component } 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) { $container_name = $resource->uuid; diff --git a/package-lock.json b/package-lock.json index ed15acfe0..a9e742135 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "alpinejs": "3.14.0", + "cookie": "^0.6.0", + "dotenv": "^16.4.5", "ioredis": "5.4.1", "node-pty": "^1.0.0", "tailwindcss-scrollbar": "0.1.0", @@ -18,7 +20,7 @@ "devDependencies": { "@vitejs/plugin-vue": "4.5.1", "autoprefixer": "10.4.19", - "axios": "1.7.2", + "axios": "^1.7.4", "laravel-echo": "1.16.1", "laravel-vite-plugin": "0.8.1", "postcss": "8.4.38", @@ -783,10 +785,11 @@ } }, "node_modules/axios": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", - "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", + "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", "dev": true, + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -956,6 +959,15 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "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": { "version": "3.0.0", "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", "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": { "version": "1.4.692", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.692.tgz", diff --git a/package.json b/package.json index 9c2541ecc..882ad2e01 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "devDependencies": { "@vitejs/plugin-vue": "4.5.1", "autoprefixer": "10.4.19", - "axios": "1.7.2", + "axios": "^1.7.4", "laravel-echo": "1.16.1", "laravel-vite-plugin": "0.8.1", "postcss": "8.4.38", @@ -23,6 +23,8 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "alpinejs": "3.14.0", + "cookie": "^0.6.0", + "dotenv": "^16.4.5", "ioredis": "5.4.1", "node-pty": "^1.0.0", "tailwindcss-scrollbar": "0.1.0", diff --git a/resources/views/livewire/project/shared/terminal.blade.php b/resources/views/livewire/project/shared/terminal.blade.php index ecb1a0e50..4e1a086e2 100644 --- a/resources/views/livewire/project/shared/terminal.blade.php +++ b/resources/views/livewire/project/shared/terminal.blade.php @@ -24,168 +24,169 @@ @script - + // 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() { + $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(); + @endscript - + \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index e2ccfc704..01cf2762b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -154,6 +154,12 @@ Route::middleware(['auth', 'verified'])->group(function () { }); 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::get('/{uuid}', [Controller::class, 'accept_invitation'])->name('team.invitation.accept'); diff --git a/terminal-server.js b/terminal-server.js index 3923c3b5c..a1555bf30 100755 --- a/terminal-server.js +++ b/terminal-server.js @@ -1,6 +1,9 @@ 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') { @@ -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(); wss.on('connection', (ws) => {