feat(terminal): web terminal — per-server SSH + Clusev host PTY (xterm.js + node sidecar)

A dedicated /terminal page with a target rail: one terminal per fleet server (SSH)
plus one for the Clusev host itself. php-fpm cannot hold an interactive PTY, so a
small Node sidecar (ws + ssh2 + node-pty) bridges xterm.js to the PTY.

Flow: the Livewire page mints a single-use, 60s TerminalSession token and dispatches
it to an Alpine xterm island; the browser opens a same-origin WS (/terminal/ws,
proxied by nginx in dev / Caddy in prod to terminal:3000); the sidecar burns the
token at an internal resolve endpoint (shared-secret header, private network only)
for a connection spec, then opens an SSH PTY (decrypted credential, never sent to the
browser) or a local host PTY. Real shell = native Tab/Shift-Tab completion.

Security:
- token burned ATOMICALLY (single conditional UPDATE) — no double-open race
- resolve endpoint guarded by hash_equals shared secret, exempt from CSRF + PanelScheme
  host enforcement (private net only), returns decrypted creds over the internal net
- sidecar enforces same-origin on the WS upgrade (hostname match, port-agnostic)
- single-use 60s tokens, swept daily so the table can't grow unbounded

Verified in-browser (R12): host + server terminals connect, accept keyboard input,
run commands, Tab-complete; cross-origin WS rejected; zero console errors. 9 feature
tests cover minting, the resolve spec, single-use burn, expiry, and locked creds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-25 03:21:10 +02:00
parent 8dfc11fd5d
commit 07fe404ce9
29 changed files with 851 additions and 3 deletions

View File

@ -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,

View File

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

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Guards the internal terminal-resolve endpoint: only the terminal sidecar (which holds the shared
* secret, injected from the same .env) may call it. The endpoint hands out decrypted SSH credentials,
* so it must never be reachable without the secret and it is only routed on the private network.
*/
class VerifyTerminalSidecarSecret
{
public function handle(Request $request, Closure $next): Response
{
$expected = (string) config('clusev.terminal_secret');
$given = (string) $request->header('X-Sidecar-Secret', '');
abort_if($expected === '' || ! hash_equals($expected, $given), 403);
return $next($request);
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Livewire\Terminal;
use App\Models\Server;
use App\Models\TerminalSession;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Terminal page. Lists the targets (the Clusev host + each server) and, on selection, mints a
* single-use session token that the xterm.js island uses to open a WebSocket to the terminal
* sidecar. The component itself never touches the SSH connection or credentials it only authorizes
* the operator and hands out a short-lived token (resolved server-side over the internal network).
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** 'host' or a server uuid — drives the active-target highlight in the rail. */
public string $activeKey = '';
public function title(): string
{
return __('terminal.title');
}
/** Mint a single-use token for the chosen target and hand it to the xterm island via an event. */
public function open(string $kind, ?string $uuid = null): void
{
$serverId = null;
$label = __('terminal.host_label');
$this->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());
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* A single-use, short-lived handle the browser passes to the terminal sidecar; the sidecar exchanges
* it (over the internal network, authenticated by a shared secret) for a connection spec. Burned on
* first resolve. Never holds credentials only points at the user + target.
*/
class TerminalSession extends Model
{
public const UPDATED_AT = null;
protected $guarded = [];
protected $casts = [
'expires_at' => '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);
}
}

View File

@ -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(

View File

@ -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',
];

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('terminal_sessions', function (Blueprint $table) {
$table->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');
}
};

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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"]

View File

@ -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"
}
}

157
docker/terminal/server.js Normal file
View File

@ -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}`));

View File

@ -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

View File

@ -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',

26
lang/de/terminal.php Normal file
View File

@ -0,0 +1,26 @@
<?php
return [
'title' => '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.',
];

View File

@ -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',

26
lang/en/terminal.php Normal file
View File

@ -0,0 +1,26 @@
<?php
return [
'title' => '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.',
];

17
package-lock.json generated
View File

@ -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",

View File

@ -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"
}

View File

@ -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,

View File

@ -18,6 +18,7 @@
'audit' => '<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>',
'file' => '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
'command' => '<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3"/>',
'terminal' => '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
'corner-down-left' => '<path d="M20 4v7a4 4 0 0 1-4 4H4"/><path d="m9 10-5 5 5 5"/>',
'activity' => '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
'switcher' => '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',

View File

@ -34,6 +34,7 @@
<x-nav-item icon="folder" href="/files" :active="request()->is('files*')">{{ __('shell.nav_files') }}</x-nav-item>
<x-nav-item icon="audit" href="/audit" :active="request()->is('audit*')">{{ __('shell.nav_audit') }}</x-nav-item>
<x-nav-item icon="lock" href="/wireguard" :active="request()->is('wireguard*')">{{ __('shell.nav_wireguard') }}</x-nav-item>
<x-nav-item icon="terminal" href="/terminal" :active="request()->is('terminal*')">{{ __('shell.nav_terminal') }}</x-nav-item>
<p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_account') }}</p>
<x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')">{{ __('shell.nav_settings') }}</x-nav-item>

View File

@ -0,0 +1,80 @@
<div class="space-y-5">
{{-- Header --}}
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('terminal.eyebrow') }}</p>
<h1 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('terminal.heading') }}</h1>
<p class="mt-1 text-sm text-ink-3">{{ __('terminal.subtitle') }}</p>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-[260px_minmax(0,1fr)]">
{{-- Target rail --}}
<x-panel :title="__('terminal.targets_title')" :subtitle="__('terminal.targets_subtitle')" :padded="false">
<div class="space-y-1 p-2">
{{-- Clusev host --}}
<button type="button" wire:click="open('host')" wire:loading.attr="disabled"
@class([
'flex w-full items-center gap-3 rounded-md border px-3 py-2.5 text-left transition-colors',
'border-accent/30 bg-accent/10' => $activeKey === 'host',
'border-transparent hover:border-line hover:bg-raised/60' => $activeKey !== 'host',
])>
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-md border border-accent/25 bg-accent/10 text-accent">
<x-icon name="command" class="h-4 w-4" />
</span>
<span class="min-w-0 flex-1">
<span @class(['block truncate font-mono text-sm', 'text-accent-text' => $activeKey === 'host', 'text-ink' => $activeKey !== 'host'])>{{ __('terminal.host_label') }}</span>
<span class="block truncate font-mono text-[11px] text-ink-4">{{ __('terminal.host_sub') }}</span>
</span>
</button>
<div class="my-1.5 px-3 font-mono text-[10px] uppercase tracking-[0.18em] text-ink-4">{{ __('terminal.servers_heading') }}</div>
@forelse ($servers as $s)
<button type="button" wire:key="term-{{ $s->uuid }}" wire:click="open('server', '{{ $s->uuid }}')" wire:loading.attr="disabled"
@class([
'flex w-full items-center gap-3 rounded-md border px-3 py-2.5 text-left transition-colors',
'border-accent/30 bg-accent/10' => $activeKey === $s->uuid,
'border-transparent hover:border-line hover:bg-raised/60' => $activeKey !== $s->uuid,
])>
<x-status-dot :status="$s->status" class="shrink-0" />
<span @class(['min-w-0 flex-1 truncate font-mono text-sm', 'text-accent-text' => $activeKey === $s->uuid, 'text-ink' => $activeKey !== $s->uuid])>{{ $s->name }}</span>
</button>
@empty
<p class="px-3 py-4 font-mono text-[11px] text-ink-4">{{ __('terminal.no_servers') }}</p>
@endforelse
</div>
</x-panel>
{{-- Terminal island (self-contained; Livewire never touches it after the first render) --}}
<div wire:ignore x-data="terminalView()" x-on:terminal-open.window="open($event.detail)"
class="flex min-h-[28rem] flex-col overflow-hidden rounded-xl border border-line bg-void shadow-panel">
{{-- Toolbar --}}
<div class="flex items-center gap-3 border-b border-line-soft bg-surface px-4 py-2.5">
<span class="flex gap-1.5">
<span class="h-2.5 w-2.5 rounded-full bg-offline/70"></span>
<span class="h-2.5 w-2.5 rounded-full bg-warning/70"></span>
<span class="h-2.5 w-2.5 rounded-full bg-online/70"></span>
</span>
<span class="truncate font-mono text-[12px] text-ink-2" x-text="title || '{{ __('terminal.no_target') }}'"></span>
<span class="ml-auto flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em]">
<span class="h-1.5 w-1.5 rounded-full"
:class="{ 'bg-ink-4': status==='idle', 'bg-warning': status==='connecting', 'bg-online': status==='connected', 'bg-offline': status==='error'||status==='closed' }"></span>
<span class="text-ink-3"
x-text="({idle:'{{ __('terminal.status_idle') }}',connecting:'{{ __('terminal.status_connecting') }}',connected:'{{ __('terminal.status_connected') }}',closed:'{{ __('terminal.status_closed') }}',error:'{{ __('terminal.status_error') }}'})[status]"></span>
</span>
<button type="button" x-on:click="clear()" class="rounded-md border border-line px-2.5 py-1 font-mono text-[11px] text-ink-3 transition-colors hover:border-accent/40 hover:text-accent-text">{{ __('terminal.clear') }}</button>
</div>
{{-- Screen --}}
<div class="relative flex-1">
<div x-ref="screen" class="absolute inset-0 p-2"></div>
{{-- Idle placeholder --}}
<div x-show="status==='idle'" class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-2 text-center">
<x-icon name="command" class="h-7 w-7 text-ink-4" />
<p class="font-mono text-[12px] text-ink-4">{{ __('terminal.pick_target') }}</p>
</div>
</div>
</div>
</div>
<p class="font-mono text-[11px] text-ink-4">{{ __('terminal.hint') }}</p>
</div>

View File

@ -1,5 +1,6 @@
<?php
use App\Models\TerminalSession;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
@ -22,3 +23,7 @@ Schedule::command('clusev:sample-metrics')->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();

View File

@ -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

View File

@ -0,0 +1,138 @@
<?php
namespace Tests\Feature;
use App\Livewire\Terminal\Index;
use App\Models\Server;
use App\Models\SshCredential;
use App\Models\TerminalSession;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class TerminalTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
config()->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'));
}
}