diff --git a/.env.example b/.env.example index 4931a6f..d256458 100644 --- a/.env.example +++ b/.env.example @@ -106,6 +106,10 @@ SITE_ADDRESS=:80 CLUSEV_IMAGE=clusev-app:prod # HMAC key for the (v1.x) in-dashboard self-updater intent file — separate from APP_KEY. UPDATE_HMAC_KEY= +# Shared secret between the app and the terminal sidecar (the /terminal page). The sidecar presents +# it to the internal resolve endpoint that hands out decrypted SSH credentials. install.sh generates +# it; leave empty to DISABLE the terminal (the resolve endpoint then 403s). +TERMINAL_SIDECAR_SECRET= # ── Optional demo server (seeded by FleetSeeder) ───────────────────── # Leave HOST empty for an empty fleet (add servers at runtime). With a host set, diff --git a/app/Http/Middleware/PanelScheme.php b/app/Http/Middleware/PanelScheme.php index 3a1dc27..bbae83c 100644 --- a/app/Http/Middleware/PanelScheme.php +++ b/app/Http/Middleware/PanelScheme.php @@ -44,8 +44,9 @@ class PanelScheme } // Internal endpoints must answer on any host/scheme (Caddy's on-demand TLS ask is - // a plain-HTTP call with an internal Host; the health check likewise). - if ($request->is('_caddy/*', 'up')) { + // a plain-HTTP call with an internal Host; the health check + the terminal sidecar's + // resolve call likewise come over the private network with a service-name Host). + if ($request->is('_caddy/*', 'up', '_internal/*')) { return $next($request); } diff --git a/app/Http/Middleware/VerifyTerminalSidecarSecret.php b/app/Http/Middleware/VerifyTerminalSidecarSecret.php new file mode 100644 index 0000000..2091b4c --- /dev/null +++ b/app/Http/Middleware/VerifyTerminalSidecarSecret.php @@ -0,0 +1,25 @@ +header('X-Sidecar-Secret', ''); + + abort_if($expected === '' || ! hash_equals($expected, $given), 403); + + return $next($request); + } +} diff --git a/app/Livewire/Terminal/Index.php b/app/Livewire/Terminal/Index.php new file mode 100644 index 0000000..63082e8 --- /dev/null +++ b/app/Livewire/Terminal/Index.php @@ -0,0 +1,66 @@ +activeKey = 'host'; + + if ($kind !== 'host') { + $kind = 'server'; + $server = Server::where('uuid', $uuid)->first(); + if ($server === null) { + return; + } + $serverId = $server->id; + $label = $server->name; + $this->activeKey = $server->uuid; + } + + $token = Str::random(48); + TerminalSession::create([ + 'token' => $token, + 'user_id' => Auth::id(), + 'kind' => $kind, + 'server_id' => $serverId, + 'expires_at' => now()->addSeconds(60), + 'created_at' => now(), + ]); + + $this->dispatch('terminal-open', token: $token, title: $label); + } + + public function render() + { + return view('livewire.terminal.index', [ + 'servers' => Server::orderBy('name')->get(['uuid', 'name', 'status']), + ])->title($this->title()); + } +} diff --git a/app/Models/TerminalSession.php b/app/Models/TerminalSession.php new file mode 100644 index 0000000..77d57c5 --- /dev/null +++ b/app/Models/TerminalSession.php @@ -0,0 +1,34 @@ + 'datetime', + 'used_at' => 'datetime', + 'created_at' => 'datetime', + ]; + + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index a15261b..3fe343b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -42,6 +42,10 @@ return Application::configure(basePath: dirname(__DIR__)) prepend: [PanelScheme::class], append: [SetLocale::class, SecurityHeaders::class, AuthenticateSession::class, BlockBannedIp::class], ); + + // The terminal sidecar POSTs to the internal resolve endpoint from the private network with + // no browser session/CSRF token — it is guarded by the shared-secret header instead. + $middleware->validateCsrfTokens(except: ['_internal/terminal/*']); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( diff --git a/config/clusev.php b/config/clusev.php index b78b895..ed27068 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -39,5 +39,10 @@ return [ 'public_slug' => env('CLUSEV_PUBLIC_SLUG', 'clusev/clusev'), 'release_branch' => env('CLUSEV_RELEASE_BRANCH', 'feat/v1-foundation'), + // Shared secret the terminal sidecar presents to the internal resolve endpoint (which hands out + // decrypted SSH credentials). Set in .env; the sidecar gets the SAME value. Empty = endpoint 403s + // (terminal disabled). install.sh generates it alongside the other secrets. + 'terminal_secret' => env('TERMINAL_SIDECAR_SECRET', ''), + 'license' => 'AGPL-3.0', ]; diff --git a/database/migrations/2026_06_25_000002_create_terminal_sessions_table.php b/database/migrations/2026_06_25_000002_create_terminal_sessions_table.php new file mode 100644 index 0000000..9b2d800 --- /dev/null +++ b/database/migrations/2026_06_25_000002_create_terminal_sessions_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('token', 64)->unique(); // single-use handle the sidecar exchanges for a connection spec + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('kind'); // 'server' | 'host' + $table->foreignId('server_id')->nullable()->constrained()->cascadeOnDelete(); + $table->timestamp('expires_at')->index(); // short TTL to connect + $table->timestamp('used_at')->nullable(); // burned on first resolve (single use) + $table->timestamp('created_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('terminal_sessions'); + } +}; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 1a99aea..538b994 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -81,6 +81,24 @@ services: redis: condition: service_started + # Terminal sidecar: WebSocket (/terminal/ws via Caddy) ↔ SSH PTY (per server) or local shell PTY + # (the Clusev "host" terminal). Internal only; authorizes the single-use token before opening a PTY. + terminal: + build: + context: ./docker/terminal + image: "${CLUSEV_TERMINAL_IMAGE:-clusev-terminal:prod}" + restart: unless-stopped + environment: + TERMINAL_SIDECAR_SECRET: "${TERMINAL_SIDECAR_SECRET:-}" + APP_INTERNAL_URL: "http://app:80" + TERMINAL_HOST_CWD: "/workspace" + volumes: + - .:/workspace:ro + expose: + - "3000" + networks: + - clusev + caddy: image: caddy:2-alpine # pin by @sha256 digest before the first prod push restart: unless-stopped @@ -106,6 +124,7 @@ services: depends_on: - app - reverb + - terminal networks: - clusev diff --git a/docker-compose.yml b/docker-compose.yml index dcfb913..4e0c664 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,9 @@ x-app: &app volumes: - .:/var/www/html - ./run:/var/www/html/storage/app/restart-signal + # Bind the nginx config so a proxy change (e.g. the /terminal/ws upgrade) is picked up on a + # container restart instead of an image rebuild. Harmless on the non-nginx (*app) workers. + - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro networks: - clusev @@ -93,6 +96,25 @@ services: networks: - clusev + # Terminal sidecar: bridges the browser WebSocket (/terminal/ws via nginx) to an SSH PTY (per + # server) or a local shell PTY (the Clusev "host" terminal). php-fpm cannot hold a PTY. + terminal: + build: + context: ./docker/terminal + image: clusev-terminal:dev + restart: unless-stopped + environment: + TERMINAL_SIDECAR_SECRET: "${TERMINAL_SIDECAR_SECRET:-}" + APP_INTERNAL_URL: "http://app:80" + TERMINAL_HOST_CWD: "/workspace" + volumes: + - .:/workspace:ro + # Dev: bind the sidecar source over the baked copy so edits need only `restart terminal` + # (no rebuild). Prod bakes it into the image (no mount) — see docker-compose.prod.yml. + - ./docker/terminal/server.js:/app/server.js:ro + networks: + - clusev + volumes: mariadb-data: redis-data: diff --git a/docker/caddy/Caddyfile b/docker/caddy/Caddyfile index 8364880..5610f9f 100644 --- a/docker/caddy/Caddyfile +++ b/docker/caddy/Caddyfile @@ -37,6 +37,10 @@ @reverb path /app/* /apps/* reverse_proxy @reverb reverb:8080 + # Terminal sidecar websocket (xterm.js ↔ PTY); Caddy handles the WS upgrade automatically. + @terminal path /terminal/ws + reverse_proxy @terminal terminal:3000 + reverse_proxy app:80 encode zstd gzip diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index c7d1597..df0c06e 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -25,6 +25,21 @@ http { index index.php; charset utf-8; + # Terminal sidecar websocket (xterm.js ↔ PTY). Same origin; the sidecar authorizes the + # single-use token before opening the PTY. Long read timeout — sessions are long-lived. + location = /terminal/ws { + resolver 127.0.0.11 valid=10s; + set $terminal_upstream terminal:3000; + proxy_pass http://$terminal_upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + # Reverb (Pusher protocol) over the SAME origin — mirrors Caddy in prod so the # browser reaches Reverb through this front door (/app/* = WS, /apps/* = events). # A resolver + variable defers DNS to request time, so nginx still starts if the diff --git a/docker/terminal/Dockerfile b/docker/terminal/Dockerfile new file mode 100644 index 0000000..757ece1 --- /dev/null +++ b/docker/terminal/Dockerfile @@ -0,0 +1,19 @@ +# Clusev terminal sidecar — Node ws↔SSH/PTY bridge. +FROM node:20-bookworm-slim + +# node-pty compiles a native addon → needs python3 + a toolchain at install time. +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev && npm cache clean --force + +COPY server.js ./ + +# Run as the bundled unprivileged node user. +USER node + +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/docker/terminal/package.json b/docker/terminal/package.json new file mode 100644 index 0000000..37d3a51 --- /dev/null +++ b/docker/terminal/package.json @@ -0,0 +1,15 @@ +{ + "name": "clusev-terminal-sidecar", + "version": "1.0.0", + "private": true, + "description": "Clusev terminal sidecar — bridges a browser WebSocket to an SSH PTY (per server) or a local shell PTY (the Clusev host).", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "ssh2": "^1.15.0", + "ws": "^8.18.0", + "node-pty": "^1.0.0" + } +} diff --git a/docker/terminal/server.js b/docker/terminal/server.js new file mode 100644 index 0000000..fcb30b8 --- /dev/null +++ b/docker/terminal/server.js @@ -0,0 +1,157 @@ +'use strict'; + +/* + * Clusev terminal sidecar. + * + * php-fpm cannot hold an interactive PTY, so this small Node service does it. The browser opens a + * WebSocket (proxied by nginx/Caddy at /terminal/ws) carrying a single-use token. The sidecar + * exchanges that token at the Clusev app's internal resolve endpoint (authenticated by a shared + * secret over the private Docker network) for a connection spec, then: + * - kind=server → opens an SSH PTY to the target server (ssh2) with the decrypted credential, or + * - kind=host → spawns a local shell PTY (node-pty) in this sidecar ("the Clusev terminal"). + * It then forwards bytes + window resizes both ways. Credentials never reach the browser; the token + * is single-use and short-lived; the sidecar is only reachable behind the app's reverse proxy. + */ + +const http = require('http'); +const { WebSocketServer } = require('ws'); +const { Client } = require('ssh2'); +const pty = require('node-pty'); + +const PORT = parseInt(process.env.TERMINAL_PORT || '3000', 10); +const APP_URL = (process.env.APP_INTERNAL_URL || 'http://app:80').replace(/\/$/, ''); +const SECRET = process.env.TERMINAL_SIDECAR_SECRET || ''; +const HOST_SHELL = process.env.TERMINAL_HOST_SHELL || 'bash'; +const HOST_CWD = process.env.TERMINAL_HOST_CWD || '/workspace'; + +const server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('clusev-terminal-sidecar\n'); +}); + +// No path filter: only /terminal/ws is routed here by the reverse proxy, so every upgrade is a +// terminal session. The token comes from the query string. +const wss = new WebSocketServer({ server, maxPayload: 1 << 20 }); + +function send(ws, obj) { + if (ws.readyState === 1) { + try { ws.send(JSON.stringify(obj)); } catch (_) { /* socket gone */ } + } +} + +async function resolve(token) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 5000); + try { + const r = await fetch(`${APP_URL}/_internal/terminal/resolve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Sidecar-Secret': SECRET }, + body: JSON.stringify({ token }), + signal: ctrl.signal, + }); + if (!r.ok) throw new Error('resolve ' + r.status); + return await r.json(); + } finally { + clearTimeout(t); + } +} + +// Client → sidecar messages: {type:'data',data} keystrokes, {type:'resize',cols,rows} window change. +function bindInput(ws, io) { + ws.on('message', (raw) => { + let m; + try { m = JSON.parse(raw.toString()); } catch (_) { return; } + if (m.type === 'data' && typeof m.data === 'string') io.write(m.data); + else if (m.type === 'resize') io.resize(Math.max(1, m.cols | 0), Math.max(1, m.rows | 0)); + }); +} + +function startHost(ws) { + let p; + try { + p = pty.spawn(HOST_SHELL, ['-l'], { + name: 'xterm-256color', cwd: HOST_CWD, cols: 80, rows: 24, + env: { ...process.env, TERM: 'xterm-256color' }, + }); + } catch (e) { + send(ws, { type: 'error', data: 'Shell konnte nicht gestartet werden.' }); + ws.close(); + return; + } + send(ws, { type: 'ready' }); + p.onData((d) => send(ws, { type: 'data', data: d })); + p.onExit(() => { send(ws, { type: 'exit' }); try { ws.close(); } catch (_) {} }); + bindInput(ws, { write: (d) => p.write(d), resize: (c, r) => { try { p.resize(c, r); } catch (_) {} } }); + ws.on('close', () => { try { p.kill(); } catch (_) {} }); +} + +function startSsh(ws, spec) { + const conn = new Client(); + let stream = null; + + conn.on('ready', () => { + conn.shell({ term: 'xterm-256color', cols: 80, rows: 24 }, (err, s) => { + if (err) { send(ws, { type: 'error', data: 'PTY: ' + err.message }); conn.end(); return; } + stream = s; + send(ws, { type: 'ready' }); + s.on('data', (d) => send(ws, { type: 'data', data: d.toString('utf8') })); + if (s.stderr) s.stderr.on('data', (d) => send(ws, { type: 'data', data: d.toString('utf8') })); + s.on('close', () => conn.end()); + bindInput(ws, { + write: (d) => { try { s.write(d); } catch (_) {} }, + resize: (c, r) => { try { s.setWindow(r, c, 0, 0); } catch (_) {} }, + }); + }); + }); + conn.on('error', (e) => { send(ws, { type: 'error', data: 'SSH: ' + e.message }); try { ws.close(); } catch (_) {} }); + conn.on('close', () => { try { ws.close(); } catch (_) {} }); + // Some hosts negotiate the password via keyboard-interactive rather than the password method. + conn.on('keyboard-interactive', (name, instr, lang, prompts, finish) => finish(prompts.map(() => spec.secret || ''))); + ws.on('close', () => { try { if (stream) stream.close(); } catch (_) {} conn.end(); }); + + const cfg = { host: spec.host, port: spec.port || 22, username: spec.username, readyTimeout: 15000, keepaliveInterval: 20000 }; + if (spec.auth_type === 'key') { + cfg.privateKey = spec.secret; + if (spec.passphrase) cfg.passphrase = spec.passphrase; + } else { + cfg.password = spec.secret; + cfg.tryKeyboard = true; + } + try { conn.connect(cfg); } catch (e) { send(ws, { type: 'error', data: 'SSH: ' + e.message }); ws.close(); } +} + +wss.on('connection', async (ws, req) => { + // Same-origin guard (defence in depth): the reverse proxy forwards the panel's real Host (nginx + // `Host $host`; Caddy passes it through), so a browser's Origin must match it. A cross-site page + // could never read a token anyway, but this rejects any future leakage being replayed off-origin. + // Non-browser clients send no Origin and are still gated by the single-use token + shared secret. + const origin = req.headers.origin; + if (origin) { + // Compare HOSTNAME only — the proxy's forwarded Host (nginx `$host`) carries no port, while the + // browser's Origin does on a non-standard panel port (APP_PORT≠80/443). Stripping the port both + // sides keeps the hostname boundary (the security-relevant part) without false-rejecting those. + const reqHost = (req.headers.host || '').replace(/:\d+$/, ''); + let sameOrigin = false; + try { sameOrigin = new URL(origin).hostname === reqHost; } catch (_) {} + if (!sameOrigin) { send(ws, { type: 'error', data: 'Ungültige Herkunft.' }); ws.close(); return; } + } + + let token = ''; + try { token = new URL(req.url, 'http://x').searchParams.get('token') || ''; } catch (_) {} + if (!token || !SECRET) { send(ws, { type: 'error', data: 'Sitzung ungültig.' }); ws.close(); return; } + + let spec; + try { + spec = await resolve(token); + } catch (e) { + send(ws, { type: 'error', data: 'Sitzung abgelaufen oder ungültig.' }); + ws.close(); + return; + } + + if (spec && spec.kind === 'host') startHost(ws); + else if (spec && spec.kind === 'server' && spec.host && spec.username) startSsh(ws, spec); + else { send(ws, { type: 'error', data: 'Kein gültiges Ziel.' }); ws.close(); } +}); + +server.listen(PORT, '0.0.0.0', () => console.log(`clusev-terminal-sidecar listening on :${PORT}`)); diff --git a/install.sh b/install.sh index a046a59..30861c9 100755 --- a/install.sh +++ b/install.sh @@ -208,6 +208,7 @@ set_kv REVERB_APP_ID "$(rand_hex 8)" set_kv REVERB_APP_KEY "$(rand_hex 16)" set_kv REVERB_APP_SECRET "$(rand_hex 32)" set_kv UPDATE_HMAC_KEY "$(rand_hex 32)" +set_kv TERMINAL_SIDECAR_SECRET "$(rand_hex 24)" force_kv APP_ENV production force_kv APP_DEBUG false diff --git a/lang/de/shell.php b/lang/de/shell.php index 522e96f..4a6a7e8 100644 --- a/lang/de/shell.php +++ b/lang/de/shell.php @@ -19,6 +19,7 @@ return [ 'nav_files' => 'Dateien', 'nav_audit' => 'Audit-Log', 'nav_wireguard' => 'WireGuard', + 'nav_terminal' => 'Terminal', 'nav_settings' => 'Einstellungen', 'nav_system' => 'System', 'nav_versions' => 'Version', diff --git a/lang/de/terminal.php b/lang/de/terminal.php new file mode 100644 index 0000000..1c11a37 --- /dev/null +++ b/lang/de/terminal.php @@ -0,0 +1,26 @@ + 'Terminal — Clusev', + 'eyebrow' => 'Betrieb', + 'heading' => 'Terminal', + 'subtitle' => 'Interaktive Shell — pro Server per SSH, oder die Clusev-Umgebung selbst.', + + 'targets_title' => 'Ziele', + 'targets_subtitle' => 'Wähle eine Sitzung', + 'host_label' => 'Clusev', + 'host_sub' => 'Lokale Shell', + 'servers_heading' => 'Server', + 'no_servers' => 'Noch keine Server angelegt.', + 'no_target' => 'Kein Ziel gewählt', + 'pick_target' => 'Ziel links wählen, um eine Sitzung zu starten.', + + 'status_idle' => 'Bereit', + 'status_connecting' => 'Verbinde…', + 'status_connected' => 'Verbunden', + 'status_closed' => 'Getrennt', + 'status_error' => 'Fehler', + 'clear' => 'Leeren', + + 'hint' => 'Echte PTY — dieselben Befehle wie im Terminal, inkl. Autovervollständigung (Tab / Shift-Tab). Eingaben gehen direkt an die Shell.', +]; diff --git a/lang/en/shell.php b/lang/en/shell.php index 55a2671..4b7e9f2 100644 --- a/lang/en/shell.php +++ b/lang/en/shell.php @@ -19,6 +19,7 @@ return [ 'nav_files' => 'Files', 'nav_audit' => 'Audit log', 'nav_wireguard' => 'WireGuard', + 'nav_terminal' => 'Terminal', 'nav_settings' => 'Settings', 'nav_system' => 'System', 'nav_versions' => 'Version', diff --git a/lang/en/terminal.php b/lang/en/terminal.php new file mode 100644 index 0000000..0838dbc --- /dev/null +++ b/lang/en/terminal.php @@ -0,0 +1,26 @@ + 'Terminal — Clusev', + 'eyebrow' => 'Operations', + 'heading' => 'Terminal', + 'subtitle' => 'Interactive shell — per server over SSH, or the Clusev environment itself.', + + 'targets_title' => 'Targets', + 'targets_subtitle' => 'Pick a session', + 'host_label' => 'Clusev', + 'host_sub' => 'Local shell', + 'servers_heading' => 'Servers', + 'no_servers' => 'No servers added yet.', + 'no_target' => 'No target selected', + 'pick_target' => 'Pick a target on the left to start a session.', + + 'status_idle' => 'Ready', + 'status_connecting' => 'Connecting…', + 'status_connected' => 'Connected', + 'status_closed' => 'Disconnected', + 'status_error' => 'Error', + 'clear' => 'Clear', + + 'hint' => 'A real PTY — the same commands as your terminal, including autocompletion (Tab / Shift-Tab). Input goes straight to the shell.', +]; diff --git a/package-lock.json b/package-lock.json index 653fa19..5abbb31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,8 @@ "packages": { "": { "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "laravel-echo": "^2.3.7", "pusher-js": "^8.5.0" }, @@ -676,6 +678,21 @@ "tslib": "^2.4.0" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", diff --git a/package.json b/package.json index 4b59dcb..f10dfa9 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "vite": "^8.0.0" }, "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "laravel-echo": "^2.3.7", "pusher-js": "^8.5.0" } diff --git a/resources/js/app.js b/resources/js/app.js index 0680086..171384c 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -344,12 +344,105 @@ document.addEventListener('alpine:init', () => { }, })); + // Interactive terminal (xterm.js). xterm + its CSS are dynamically imported so they only load on + // the terminal page (code-split chunk). On a 'terminal-open' event (from the Livewire rail) it + // opens a same-origin WebSocket to /terminal/ws?token=… — the sidecar holds the PTY. A real PTY + // means native shell features work, including Tab/Shift-Tab autocompletion. + window.Alpine.data('terminalView', () => ({ + term: null, + fit: null, + ws: null, + status: 'idle', // idle | connecting | connected | closed | error + title: '', + _ro: null, + _pending: null, // a token that arrived before xterm finished its async load + + async init() { + const [{ Terminal }, { FitAddon }] = await Promise.all([ + import('@xterm/xterm'), + import('@xterm/addon-fit'), + ]); + await import('@xterm/xterm/css/xterm.css'); + + this.term = new Terminal({ + fontFamily: "'JetBrains Mono', ui-monospace, 'SF Mono', monospace", + fontSize: 13, + cursorBlink: true, + scrollback: 5000, + theme: { + background: '#0B0E12', foreground: '#E9EEF3', cursor: '#FF6B2C', cursorAccent: '#0B0E12', + selectionBackground: 'rgba(255,107,44,0.25)', + black: '#0F1318', red: '#FF5247', green: '#35D07F', yellow: '#E8B931', + blue: '#43C1D8', magenta: '#FF8A4D', cyan: '#74D7E8', white: '#9DAAB6', + brightBlack: '#707B85', brightWhite: '#E9EEF3', + }, + }); + this.fit = new FitAddon(); + this.term.loadAddon(this.fit); + this.term.open(this.$refs.screen); + try { this.fit.fit(); } catch (_) {} + + this.term.onData((d) => { if (this.ws && this.ws.readyState === 1) this.ws.send(JSON.stringify({ type: 'data', data: d })); }); + this._ro = new ResizeObserver(() => { try { this.fit.fit(); this._sendResize(); } catch (_) {} }); + this._ro.observe(this.$refs.screen); + + // A target picked before xterm finished loading is connected now. + if (this._pending) { const t = this._pending; this._pending = null; this._connect(t); } + }, + + destroy() { + this._close(); + if (this._ro) this._ro.disconnect(); + try { if (this.term) this.term.dispose(); } catch (_) {} + }, + + open(detail) { + if (!detail || !detail.token) return; + this.title = detail.title || ''; + if (this.term) this._connect(detail.token); + else this._pending = detail.token; // xterm still loading → connect on init + }, + + _connect(token) { + this._close(); + this.status = 'connecting'; + if (this.term) this.term.reset(); + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; + const ws = new WebSocket(`${proto}://${window.location.host}/terminal/ws?token=${encodeURIComponent(token)}`); + this.ws = ws; + // Every handler is bound to THIS socket: a superseded connection (the user picked another + // target) still fires onclose/onerror asynchronously, which would otherwise clobber the + // freshly-connecting terminal's state. Ignore any event from a socket we've moved past. + ws.onmessage = (ev) => { + if (this.ws !== ws) return; + let m; + try { m = JSON.parse(ev.data); } catch (_) { return; } + if (m.type === 'data') this.term.write(m.data); + else if (m.type === 'ready') { this.status = 'connected'; this._sendResize(); setTimeout(() => this.term.focus(), 0); } + else if (m.type === 'error') { this.status = 'error'; this.term.write(`\r\n\x1b[31m${m.data}\x1b[0m\r\n`); } + else if (m.type === 'exit') { this.status = 'closed'; this.term.write('\r\n\x1b[90m— Sitzung beendet —\x1b[0m\r\n'); } + }; + ws.onclose = () => { if (this.ws !== ws) return; if (this.status === 'connected' || this.status === 'connecting') { this.status = 'closed'; this.term.write('\r\n\x1b[90m— Verbindung getrennt —\x1b[0m\r\n'); } }; + ws.onerror = () => { if (this.ws !== ws) return; if (this.status !== 'closed') this.status = 'error'; }; + }, + + _sendResize() { + if (this.ws && this.ws.readyState === 1 && this.term) this.ws.send(JSON.stringify({ type: 'resize', cols: this.term.cols, rows: this.term.rows })); + }, + + _close() { + if (this.ws) { try { this.ws.close(); } catch (_) {} this.ws = null; } + }, + + clear() { if (this.term) this.term.clear(); }, + })); + // ── Command palette + keyboard shortcuts ──────────────────────────── // Nav + action item labels are passed in from the Blade markup (already // translated via __()), so the palette stays in sync with the active locale. // CMDK_GO maps leader-key mnemonics to routes (i=dIenste, l=Log, // e=Einstellungen, y=sYstem — d/s already taken) and stays in JS. - const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions', h: '/help' }; + const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions', h: '/help', t: '/terminal' }; window.Alpine.data('cmdk', (nav = [], actions = [], servers = [], failMsg = '') => ({ nav, diff --git a/resources/views/components/icon.blade.php b/resources/views/components/icon.blade.php index 5b1dda7..00edc0e 100644 --- a/resources/views/components/icon.blade.php +++ b/resources/views/components/icon.blade.php @@ -18,6 +18,7 @@ 'audit' => '', 'file' => '', 'command' => '', + 'terminal' => '', 'corner-down-left' => '', 'activity' => '', 'switcher' => '', diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index 05339be..514572a 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -34,6 +34,7 @@ {{ __('shell.nav_files') }} {{ __('shell.nav_audit') }} {{ __('shell.nav_wireguard') }} + {{ __('shell.nav_terminal') }}

{{ __('shell.group_account') }}

{{ __('shell.nav_settings') }} diff --git a/resources/views/livewire/terminal/index.blade.php b/resources/views/livewire/terminal/index.blade.php new file mode 100644 index 0000000..901df4f --- /dev/null +++ b/resources/views/livewire/terminal/index.blade.php @@ -0,0 +1,80 @@ +
+ {{-- Header --}} +
+

{{ __('terminal.eyebrow') }}

+

{{ __('terminal.heading') }}

+

{{ __('terminal.subtitle') }}

+
+ +
+ {{-- Target rail --}} + +
+ {{-- Clusev host --}} + + +
{{ __('terminal.servers_heading') }}
+ + @forelse ($servers as $s) + + @empty +

{{ __('terminal.no_servers') }}

+ @endforelse +
+
+ + {{-- Terminal island (self-contained; Livewire never touches it after the first render) --}} +
+ {{-- Toolbar --}} +
+ + + + + + + + + + + +
+ + {{-- Screen --}} +
+
+ {{-- Idle placeholder --}} +
+ +

{{ __('terminal.pick_target') }}

+
+
+
+
+ +

{{ __('terminal.hint') }}

+
diff --git a/routes/console.php b/routes/console.php index db580df..8d776e8 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,5 +1,6 @@ everyMinute(); // Refresh the cached latest-release tag so the sidebar update badge stays warm (cache TTL is 60 min). Schedule::command('clusev:check-update')->everyThirtyMinutes(); + +// Sweep terminal-session tokens daily. They are single-use and expire in 60s, so any row older than a +// day is spent/dead — this keeps the table from growing unbounded over the panel's lifetime. +Schedule::call(fn () => TerminalSession::where('created_at', '<', now()->subDay())->delete())->daily(); diff --git a/routes/web.php b/routes/web.php index 36a9c02..6ee1dd6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Http\Middleware\EnsureSecurityOnboarded; use App\Http\Middleware\SetLocale; +use App\Http\Middleware\VerifyTerminalSidecarSecret; use App\Livewire\Audit; use App\Livewire\Auth; use App\Livewire\Dashboard; @@ -12,9 +13,11 @@ use App\Livewire\Servers; use App\Livewire\Services; use App\Livewire\Settings; use App\Livewire\System; +use App\Livewire\Terminal; use App\Livewire\Versions; use App\Livewire\Wireguard; use App\Models\Server; +use App\Models\TerminalSession; use App\Services\DeploymentService; use App\Services\MetricHistory; use App\Services\WgStatus; @@ -62,6 +65,41 @@ Route::get('/update-status.json', function (DeploymentService $deployment) { return response()->json(['stage' => $phase['stage'] ?? null, 'at' => $phase['at'] ?? 0]); })->name('update.status'); +// INTERNAL: the terminal sidecar exchanges a single-use token for a connection spec. Authenticated by +// the shared secret (VerifyTerminalSidecarSecret) and only routed on the private Docker network — it +// returns DECRYPTED SSH credentials, so it must never be publicly reachable. Token is burned on use. +Route::post('/_internal/terminal/resolve', function (Request $request) { + $token = (string) $request->input('token'); + + // Burn the token ATOMICALLY: a single conditional UPDATE (used_at IS NULL AND not expired) — the + // DB row-locks it, so two racing resolves can never both succeed and open two PTYs from one token. + $burned = TerminalSession::where('token', $token) + ->whereNull('used_at') + ->where('expires_at', '>', now()) + ->update(['used_at' => now()]); + + abort_if($burned !== 1, 404); + $session = TerminalSession::where('token', $token)->firstOrFail(); + + if ($session->kind === 'host') { + return response()->json(['kind' => 'host']); + } + + $server = $session->server()->with('credential')->first(); + $cred = $server?->credential; + abort_if($server === null || $cred === null || $cred->disabled_at !== null, 404); + + return response()->json([ + 'kind' => 'server', + 'host' => $server->ip, + 'port' => $server->ssh_port ?: 22, + 'username' => $cred->username, + 'auth_type' => $cred->auth_type, + 'secret' => $cred->secret, // 'encrypted' cast → decrypted here; sent only over the internal net + 'passphrase' => $cred->passphrase, // decrypted or null + ]); +})->middleware(VerifyTerminalSidecarSecret::class)->name('internal.terminal.resolve'); + Route::middleware('guest')->group(function () { Route::get('/login', Auth\Login::class)->name('login'); Route::get('/forgot-password', Auth\ForgotPassword::class)->name('password.request'); @@ -136,6 +174,7 @@ Route::middleware('auth')->group(function () { Route::get('/system', System\Index::class)->name('system'); Route::get('/help', Help\Index::class)->name('help'); Route::get('/wireguard', Wireguard\Index::class)->name('wireguard'); + Route::get('/terminal', Terminal\Index::class)->name('terminal'); // Dev-only Release page — first of the gating triple (spec): the route is registered ONLY when // the flag is on, so with it off /release does not exist (uncached route file is re-included diff --git a/tests/Feature/TerminalTest.php b/tests/Feature/TerminalTest.php new file mode 100644 index 0000000..a532b95 --- /dev/null +++ b/tests/Feature/TerminalTest.php @@ -0,0 +1,138 @@ +set('clusev.terminal_secret', 'test-secret'); + $this->actingAs(User::factory()->create(['must_change_password' => false])); + } + + private function server(): Server + { + $s = Server::create(['name' => 'box', 'ip' => '10.0.0.9', 'ssh_port' => 2222, 'status' => 'online']); + SshCredential::create(['server_id' => $s->id, 'username' => 'deploy', 'auth_type' => 'password', 'secret' => 's3cret-pw']); + + return $s; + } + + private function token(string $kind, ?int $serverId = null, ?\DateTimeInterface $expires = null): string + { + $token = 'tok-'.bin2hex(random_bytes(6)); + TerminalSession::create([ + 'token' => $token, + 'user_id' => auth()->id(), + 'kind' => $kind, + 'server_id' => $serverId, + 'expires_at' => $expires ?? now()->addMinute(), + 'created_at' => now(), + ]); + + return $token; + } + + private function resolve(string $token, string $secret = 'test-secret') + { + return $this->withHeader('X-Sidecar-Secret', $secret) + ->postJson(route('internal.terminal.resolve'), ['token' => $token]); + } + + // ── token minting (Livewire) ────────────────────────────────────────────── + + public function test_open_host_mints_a_single_use_host_session(): void + { + Livewire::test(Index::class)->call('open', 'host')->assertSet('activeKey', 'host'); + + $s = TerminalSession::first(); + $this->assertNotNull($s); + $this->assertSame('host', $s->kind); + $this->assertNull($s->server_id); + $this->assertNull($s->used_at); + $this->assertTrue($s->expires_at->isFuture()); + } + + public function test_open_server_mints_a_session_for_that_server(): void + { + $server = $this->server(); + Livewire::test(Index::class)->call('open', 'server', $server->uuid)->assertSet('activeKey', $server->uuid); + + $this->assertSame($server->id, TerminalSession::first()->server_id); + } + + public function test_open_ignores_an_unknown_server(): void + { + Livewire::test(Index::class)->call('open', 'server', 'nope'); + $this->assertSame(0, TerminalSession::count()); + } + + // ── resolve endpoint ────────────────────────────────────────────────────── + + public function test_resolve_requires_the_sidecar_secret(): void + { + $token = $this->token('host'); + $this->postJson(route('internal.terminal.resolve'), ['token' => $token])->assertForbidden(); + $this->resolve($token, 'wrong-secret')->assertForbidden(); + } + + public function test_resolve_returns_host_kind_and_burns_the_token(): void + { + $token = $this->token('host'); + + $this->resolve($token)->assertOk()->assertExactJson(['kind' => 'host']); + // single use → second resolve fails + $this->resolve($token)->assertNotFound(); + } + + public function test_resolve_returns_the_server_spec_with_decrypted_credentials(): void + { + $server = $this->server(); + $token = $this->token('server', $server->id); + + $this->resolve($token)->assertOk()->assertExactJson([ + 'kind' => 'server', + 'host' => '10.0.0.9', + 'port' => 2222, + 'username' => 'deploy', + 'auth_type' => 'password', + 'secret' => 's3cret-pw', // decrypted by the model cast, only over the internal net + 'passphrase' => null, + ]); + } + + public function test_resolve_rejects_an_expired_token(): void + { + $token = $this->token('host', null, now()->subMinute()); + $this->resolve($token)->assertNotFound(); + } + + public function test_resolve_rejects_a_server_token_whose_credential_is_locked(): void + { + $server = $this->server(); + $server->credential->update(['disabled_at' => now()]); + $token = $this->token('server', $server->id); + + $this->resolve($token)->assertNotFound(); + } + + // ── page ────────────────────────────────────────────────────────────────── + + public function test_terminal_page_renders_with_the_rail(): void + { + $this->server(); + $this->get(route('terminal'))->assertOk()->assertSee(__('terminal.heading')); + } +}