diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php index 343915d9c7..b560595b31 100644 --- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php +++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php @@ -2,17 +2,15 @@ namespace App\Livewire\Project\Shared; -use App\Actions\Server\RunCommand; use App\Models\Application; use App\Models\Server; use App\Models\Service; use Illuminate\Support\Collection; +use Livewire\Attributes\On; use Livewire\Component; class ExecuteContainerCommand extends Component { - public string $command; - public string $container; public Collection $containers; @@ -23,8 +21,6 @@ class ExecuteContainerCommand extends Component public string $type; - public string $workDir = ''; - public Server $server; public Collection $servers; @@ -33,7 +29,6 @@ class ExecuteContainerCommand extends Component 'server' => 'required', 'container' => 'required', 'command' => 'required', - 'workDir' => 'nullable', ]; public function mount() @@ -115,7 +110,8 @@ public function loadContainers() } } - public function runCommand() + #[On('connectToContainer')] + public function connectToContainer() { try { if (data_get($this->parameters, 'application_uuid')) { @@ -132,14 +128,13 @@ public function runCommand() if ($server->isForceDisabled()) { throw new \RuntimeException('Server is disabled.'); } - $cmd = "sh -c 'if [ -f ~/.profile ]; then . ~/.profile; fi; ".str_replace("'", "'\''", $this->command)."'"; - if (! empty($this->workDir)) { - $exec = "docker exec -w {$this->workDir} {$container_name} {$cmd}"; - } else { - $exec = "docker exec {$container_name} {$cmd}"; - } - $activity = RunCommand::run(server: $server, command: $exec); - $this->dispatch('activityMonitor', $activity->id); + + $this->dispatch('send-terminal-command', + true, + $container_name, + $server->uuid, + ); + } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php new file mode 100644 index 0000000000..331392118f --- /dev/null +++ b/app/Livewire/Project/Shared/Terminal.php @@ -0,0 +1,46 @@ +firstOrFail(); + + if (auth()->user()) { + $teams = auth()->user()->teams->pluck('id'); + if (! $teams->contains($server->team_id) && ! $teams->contains(0)) { + throw new \Exception('User is not part of the team that owns this server'); + } + } + + if ($isContainer) { + $status = getContainerStatus($server, $identifier); + if ($status !== 'running') { + return handleError(new \Exception('Container is not running'), $this); + } + $command = generateSshCommand($server, "docker exec -it {$identifier} sh -c 'if [ -f ~/.profile ]; then . ~/.profile; fi; if [ -n \"\$SHELL\" ]; then exec \$SHELL; else sh; fi'"); + } else { + $command = generateSshCommand($server, "sh -c 'if [ -f ~/.profile ]; then . ~/.profile; fi; if [ -n \"\$SHELL\" ]; then exec \$SHELL; else sh; fi'"); + } + + // ssh command is sent back to frontend then to websocket + // this is done because the websocket connection is not available here + // a better solution would be to remove websocket on NodeJS and work with something like + // 1. Laravel Pusher/Echo connection (not possible without a sdk) + // 2. Ratchet / Revolt / ReactPHP / Event Loop (possible but hard to implement and huge dependencies) + // 3. Just found out about this https://github.com/sirn-se/websocket-php, perhaps it can be used + $this->dispatch('send-back-command', $command); + } + + public function render() + { + return view('livewire.project.shared.terminal'); + } +} diff --git a/app/Livewire/RunCommand.php b/app/Livewire/RunCommand.php index c2d3adeeac..aae02d4e1c 100644 --- a/app/Livewire/RunCommand.php +++ b/app/Livewire/RunCommand.php @@ -2,42 +2,67 @@ namespace App\Livewire; -use App\Actions\Server\RunCommand as ServerRunCommand; use App\Models\Server; +use Livewire\Attributes\On; use Livewire\Component; class RunCommand extends Component { - public string $command; - - public $server; + public $selected_uuid; public $servers = []; - protected $rules = [ - 'server' => 'required', - 'command' => 'required', - ]; - - protected $validationAttributes = [ - 'server' => 'server', - 'command' => 'command', - ]; + public $containers = []; public function mount($servers) { $this->servers = $servers; - $this->server = $servers[0]->uuid; + $this->selected_uuid = $servers[0]->uuid; + $this->containers = $this->getAllActiveContainers(); } - public function runCommand() + private function getAllActiveContainers() { - $this->validate(); - try { - $activity = ServerRunCommand::run(server: Server::where('uuid', $this->server)->first(), command: $this->command); - $this->dispatch('activityMonitor', $activity->id); - } catch (\Throwable $e) { - return handleError($e, $this); - } + return Server::all()->flatMap(function ($server) { + if (! $server->isFunctional()) { + return []; + } + + return $server->definedResources() + ->filter(fn ($resource) => str_starts_with($resource->status, 'running:')) + ->map(function ($resource) use ($server) { + $container_name = $resource->uuid; + + if (class_basename($resource) === 'Application' || class_basename($resource) === 'Service') { + if ($server->isSwarm()) { + $container_name = $resource->uuid.'_'.$resource->uuid; + } else { + $current_containers = getCurrentApplicationContainerStatus($server, $resource->id, includePullrequests: true); + $container_name = data_get($current_containers->first(), 'Names'); + } + } + + return [ + 'name' => $resource->name, + 'connection_name' => $container_name, + 'uuid' => $resource->uuid, + 'status' => $resource->status, + 'server' => $server, + 'server_uuid' => $server->uuid, + ]; + }); + }); + } + + #[On('connectToContainer')] + public function connectToContainer() + { + $container = collect($this->containers)->firstWhere('uuid', $this->selected_uuid); + + $this->dispatch('send-terminal-command', + isset($container), + $container['connection_name'] ?? $this->selected_uuid, + $container['server_uuid'] ?? $this->selected_uuid + ); } } diff --git a/app/Models/Server.php b/app/Models/Server.php index 8a7325bebc..337d7d7fae 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -295,6 +295,13 @@ public function setupDynamicProxyConfiguration() 'service' => 'coolify-realtime', 'rule' => "Host(`{$host}`) && PathPrefix(`/app`)", ], + 'coolify-terminal-ws' => [ + 'entryPoints' => [ + 0 => 'http', + ], + 'service' => 'coolify-terminal', + 'rule' => "Host(`{$host}`) && PathPrefix(`/terminal`)", + ], ], 'services' => [ 'coolify' => [ @@ -315,6 +322,15 @@ public function setupDynamicProxyConfiguration() ], ], ], + 'coolify-terminal' => [ + 'loadBalancer' => [ + 'servers' => [ + 0 => [ + 'url' => 'http://coolify-terminal:6002', + ], + ], + ], + ], ], ], ]; @@ -344,6 +360,16 @@ public function setupDynamicProxyConfiguration() 'certresolver' => 'letsencrypt', ], ]; + $traefik_dynamic_conf['http']['routers']['coolify-terminal-wss'] = [ + 'entryPoints' => [ + 0 => 'https', + ], + 'service' => 'coolify-terminal', + 'rule' => "Host(`{$host}`) && PathPrefix(`/terminal`)", + 'tls' => [ + 'certresolver' => 'letsencrypt', + ], + ]; } $yaml = Yaml::dump($traefik_dynamic_conf, 12, 2); $yaml = @@ -377,6 +403,9 @@ public function setupDynamicProxyConfiguration() handle /app/* { reverse_proxy coolify-realtime:6001 } + handle /terminal/* { + reverse_proxy coolify-terminal:6002 + } reverse_proxy coolify:80 }"; $base64 = base64_encode($caddy_file); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 7eda14d415..9710e0fae7 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -52,8 +52,18 @@ services: SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}" SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY:-coolify}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" + terminal: + env_file: + - .env + pull_policy: always + working_dir: /var/www/html + ports: + - "${FORWARD_TERMINAL_PORT:-6002}:6002" + volumes: + - .:/var/www/html:cached + command: sh -c "apk add --no-cache openssh-client && node --watch /var/www/html/terminal-server.js" vite: - image: node:20 + image: node:alpine pull_policy: always working_dir: /var/www/html # environment: @@ -62,7 +72,7 @@ services: - "${VITE_PORT:-5173}:${VITE_PORT:-5173}" volumes: - .:/var/www/html:cached - command: sh -c "npm install && npm run dev" + command: sh -c "apk add --no-cache make g++ python3 && npm install && npm run dev" networks: - coolify testing-host: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b8156cab52..5f7b5e9357 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -125,6 +125,19 @@ services: interval: 5s retries: 10 timeout: 2s + terminal: + working_dir: /var/www/html + ports: + - "${TERMINAL_PORT:-6002}:6002" + volumes: + - .:/var/www/html:cached + command: sh -c "apk add --no-cache openssh-client && node /var/www/html/terminal-server.js" + healthcheck: + test: wget -qO- http://localhost:6002/ready || exit 1 + interval: 5s + retries: 10 + timeout: 2s + volumes: coolify-db: name: coolify-db diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index af5ecc0f7d..ab3c7197a6 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -121,6 +121,24 @@ services: interval: 5s retries: 10 timeout: 2s + terminal: + image: node:alpine + pull_policy: always + container_name: coolify-terminal + restart: always + env_file: + - .env + working_dir: /var/www/html + ports: + - "${TERMINAL_PORT:-6002}:6002" + volumes: + - .:/var/www/html:cached + command: sh -c "apk add --no-cache openssh-client && node /var/www/html/terminal-server.js" + healthcheck: + test: wget -qO- http://localhost:6002/ready || exit 1 + interval: 5s + retries: 10 + timeout: 2s volumes: coolify-db: name: coolify-db diff --git a/docker-compose.yml b/docker-compose.yml index 8eed44f8cc..4f1c031274 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,12 @@ services: restart: always networks: - coolify + terminal: + image: node:alpine + container_name: coolify-terminal + restart: always + networks: + - coolify networks: coolify: name: coolify diff --git a/package-lock.json b/package-lock.json index bec5a7f66e..ed15acfe07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,9 +7,13 @@ "dependencies": { "@tailwindcss/forms": "0.5.7", "@tailwindcss/typography": "0.5.13", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "alpinejs": "3.14.0", "ioredis": "5.4.1", - "tailwindcss-scrollbar": "0.1.0" + "node-pty": "^1.0.0", + "tailwindcss-scrollbar": "0.1.0", + "ws": "^8.17.0" }, "devDependencies": { "@vitejs/plugin-vue": "4.5.1", @@ -692,6 +696,19 @@ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", "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": { "version": "3.14.0", "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.0.tgz", @@ -1474,6 +1491,11 @@ "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": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", @@ -1491,6 +1513,15 @@ "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": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", @@ -2123,6 +2154,26 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "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": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", diff --git a/package.json b/package.json index b4609a0255..9c2541ecc6 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,12 @@ "dependencies": { "@tailwindcss/forms": "0.5.7", "@tailwindcss/typography": "0.5.13", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "alpinejs": "3.14.0", "ioredis": "5.4.1", - "tailwindcss-scrollbar": "0.1.0" + "node-pty": "^1.0.0", + "tailwindcss-scrollbar": "0.1.0", + "ws": "^8.17.0" } } diff --git a/resources/js/app.js b/resources/js/app.js index befec919ed..f450cbe293 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -4,3 +4,18 @@ // const app = createApp({}); // app.component("magic-bar", MagicBar); // 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); +} diff --git a/resources/views/livewire/project/shared/execute-container-command.blade.php b/resources/views/livewire/project/shared/execute-container-command.blade.php index 680d6e0e17..167f4178ba 100644 --- a/resources/views/livewire/project/shared/execute-container-command.blade.php +++ b/resources/views/livewire/project/shared/execute-container-command.blade.php @@ -22,11 +22,8 @@
@if (count($containers) > 0) -
-
- - -
+ @if (data_get($this->parameters, 'application_uuid')) @@ -47,14 +44,14 @@ @endif - Run + Start Connection
@else
No containers are not running.
@endif
-
- +
+
diff --git a/resources/views/livewire/project/shared/terminal.blade.php b/resources/views/livewire/project/shared/terminal.blade.php new file mode 100644 index 0000000000..ecb1a0e50f --- /dev/null +++ b/resources/views/livewire/project/shared/terminal.blade.php @@ -0,0 +1,191 @@ +
+
+
+ (connection closed) +
+
+
+
+ + +
+ + @script + + @endscript +
diff --git a/resources/views/livewire/run-command.blade.php b/resources/views/livewire/run-command.blade.php index 7911f04707..1f01629407 100644 --- a/resources/views/livewire/run-command.blade.php +++ b/resources/views/livewire/run-command.blade.php @@ -1,19 +1,23 @@
-
- - + + @foreach ($servers as $server) @if ($loop->first) @else @endif + @foreach ($containers as $container) + @if ($container['server_uuid'] == $server->uuid) + + @endif + @endforeach @endforeach - Execute Command - + Start Connection -
- -
+
diff --git a/terminal-server.js b/terminal-server.js new file mode 100755 index 0000000000..3923c3b5c9 --- /dev/null +++ b/terminal-server.js @@ -0,0 +1,163 @@ +import { WebSocketServer } from 'ws'; +import http from 'http'; +import pty from 'node-pty'; + +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 wss = new WebSocketServer({ server, path: '/terminal' }); +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) { + await killPtyProcess(userId); + } + + 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'); + ptyProcess.write('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) => { + session.ptyProcess.on('exit', () => { + session.isActive = false; + resolve(true); + }); + + session.ptyProcess.kill(); + }); +} + +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'); +});