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' => '
{{ __('shell.group_account') }}
{{ __('terminal.eyebrow') }}
+{{ __('terminal.subtitle') }}
+{{ __('terminal.no_servers') }}
+ @endforelse +{{ __('terminal.pick_target') }}
+{{ __('terminal.hint') }}
+