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
parent
8dfc11fd5d
commit
07fe404ce9
|
|
@ -106,6 +106,10 @@ SITE_ADDRESS=:80
|
||||||
CLUSEV_IMAGE=clusev-app:prod
|
CLUSEV_IMAGE=clusev-app:prod
|
||||||
# HMAC key for the (v1.x) in-dashboard self-updater intent file — separate from APP_KEY.
|
# HMAC key for the (v1.x) in-dashboard self-updater intent file — separate from APP_KEY.
|
||||||
UPDATE_HMAC_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) ─────────────────────
|
# ── Optional demo server (seeded by FleetSeeder) ─────────────────────
|
||||||
# Leave HOST empty for an empty fleet (add servers at runtime). With a host set,
|
# Leave HOST empty for an empty fleet (add servers at runtime). With a host set,
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,9 @@ class PanelScheme
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internal endpoints must answer on any host/scheme (Caddy's on-demand TLS ask is
|
// 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).
|
// a plain-HTTP call with an internal Host; the health check + the terminal sidecar's
|
||||||
if ($request->is('_caddy/*', 'up')) {
|
// resolve call likewise come over the private network with a service-name Host).
|
||||||
|
if ($request->is('_caddy/*', 'up', '_internal/*')) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,10 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||||
prepend: [PanelScheme::class],
|
prepend: [PanelScheme::class],
|
||||||
append: [SetLocale::class, SecurityHeaders::class, AuthenticateSession::class, BlockBannedIp::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 {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$exceptions->shouldRenderJsonWhen(
|
$exceptions->shouldRenderJsonWhen(
|
||||||
|
|
|
||||||
|
|
@ -39,5 +39,10 @@ return [
|
||||||
'public_slug' => env('CLUSEV_PUBLIC_SLUG', 'clusev/clusev'),
|
'public_slug' => env('CLUSEV_PUBLIC_SLUG', 'clusev/clusev'),
|
||||||
'release_branch' => env('CLUSEV_RELEASE_BRANCH', 'feat/v1-foundation'),
|
'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',
|
'license' => 'AGPL-3.0',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -81,6 +81,24 @@ services:
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
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:
|
caddy:
|
||||||
image: caddy:2-alpine # pin by @sha256 digest before the first prod push
|
image: caddy:2-alpine # pin by @sha256 digest before the first prod push
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
@ -106,6 +124,7 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
- reverb
|
- reverb
|
||||||
|
- terminal
|
||||||
networks:
|
networks:
|
||||||
- clusev
|
- clusev
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ x-app: &app
|
||||||
volumes:
|
volumes:
|
||||||
- .:/var/www/html
|
- .:/var/www/html
|
||||||
- ./run:/var/www/html/storage/app/restart-signal
|
- ./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:
|
networks:
|
||||||
- clusev
|
- clusev
|
||||||
|
|
||||||
|
|
@ -93,6 +96,25 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- clusev
|
- 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:
|
volumes:
|
||||||
mariadb-data:
|
mariadb-data:
|
||||||
redis-data:
|
redis-data:
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,10 @@
|
||||||
@reverb path /app/* /apps/*
|
@reverb path /app/* /apps/*
|
||||||
reverse_proxy @reverb reverb:8080
|
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
|
reverse_proxy app:80
|
||||||
|
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,21 @@ http {
|
||||||
index index.php;
|
index index.php;
|
||||||
charset utf-8;
|
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
|
# Reverb (Pusher protocol) over the SAME origin — mirrors Caddy in prod so the
|
||||||
# browser reaches Reverb through this front door (/app/* = WS, /apps/* = events).
|
# 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
|
# A resolver + variable defers DNS to request time, so nginx still starts if the
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}`));
|
||||||
|
|
@ -208,6 +208,7 @@ set_kv REVERB_APP_ID "$(rand_hex 8)"
|
||||||
set_kv REVERB_APP_KEY "$(rand_hex 16)"
|
set_kv REVERB_APP_KEY "$(rand_hex 16)"
|
||||||
set_kv REVERB_APP_SECRET "$(rand_hex 32)"
|
set_kv REVERB_APP_SECRET "$(rand_hex 32)"
|
||||||
set_kv UPDATE_HMAC_KEY "$(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_ENV production
|
||||||
force_kv APP_DEBUG false
|
force_kv APP_DEBUG false
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ return [
|
||||||
'nav_files' => 'Dateien',
|
'nav_files' => 'Dateien',
|
||||||
'nav_audit' => 'Audit-Log',
|
'nav_audit' => 'Audit-Log',
|
||||||
'nav_wireguard' => 'WireGuard',
|
'nav_wireguard' => 'WireGuard',
|
||||||
|
'nav_terminal' => 'Terminal',
|
||||||
'nav_settings' => 'Einstellungen',
|
'nav_settings' => 'Einstellungen',
|
||||||
'nav_system' => 'System',
|
'nav_system' => 'System',
|
||||||
'nav_versions' => 'Version',
|
'nav_versions' => 'Version',
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
];
|
||||||
|
|
@ -19,6 +19,7 @@ return [
|
||||||
'nav_files' => 'Files',
|
'nav_files' => 'Files',
|
||||||
'nav_audit' => 'Audit log',
|
'nav_audit' => 'Audit log',
|
||||||
'nav_wireguard' => 'WireGuard',
|
'nav_wireguard' => 'WireGuard',
|
||||||
|
'nav_terminal' => 'Terminal',
|
||||||
'nav_settings' => 'Settings',
|
'nav_settings' => 'Settings',
|
||||||
'nav_system' => 'System',
|
'nav_system' => 'System',
|
||||||
'nav_versions' => 'Version',
|
'nav_versions' => 'Version',
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
];
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"laravel-echo": "^2.3.7",
|
"laravel-echo": "^2.3.7",
|
||||||
"pusher-js": "^8.5.0"
|
"pusher-js": "^8.5.0"
|
||||||
},
|
},
|
||||||
|
|
@ -676,6 +678,21 @@
|
||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@
|
||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"laravel-echo": "^2.3.7",
|
"laravel-echo": "^2.3.7",
|
||||||
"pusher-js": "^8.5.0"
|
"pusher-js": "^8.5.0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ────────────────────────────
|
// ── Command palette + keyboard shortcuts ────────────────────────────
|
||||||
// Nav + action item labels are passed in from the Blade markup (already
|
// Nav + action item labels are passed in from the Blade markup (already
|
||||||
// translated via __()), so the palette stays in sync with the active locale.
|
// translated via __()), so the palette stays in sync with the active locale.
|
||||||
// CMDK_GO maps leader-key mnemonics to routes (i=dIenste, l=Log,
|
// CMDK_GO maps leader-key mnemonics to routes (i=dIenste, l=Log,
|
||||||
// e=Einstellungen, y=sYstem — d/s already taken) and stays in JS.
|
// 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 = '') => ({
|
window.Alpine.data('cmdk', (nav = [], actions = [], servers = [], failMsg = '') => ({
|
||||||
nav,
|
nav,
|
||||||
|
|
|
||||||
|
|
@ -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"/>',
|
'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"/>',
|
'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"/>',
|
'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"/>',
|
'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"/>',
|
'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"/>',
|
'switcher' => '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
|
||||||
|
|
|
||||||
|
|
@ -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="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="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="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>
|
<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>
|
<x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')">{{ __('shell.nav_settings') }}</x-nav-item>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Models\TerminalSession;
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\Schedule;
|
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).
|
// Refresh the cached latest-release tag so the sidebar update badge stays warm (cache TTL is 60 min).
|
||||||
Schedule::command('clusev:check-update')->everyThirtyMinutes();
|
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();
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
use App\Http\Middleware\EnsureSecurityOnboarded;
|
use App\Http\Middleware\EnsureSecurityOnboarded;
|
||||||
use App\Http\Middleware\SetLocale;
|
use App\Http\Middleware\SetLocale;
|
||||||
|
use App\Http\Middleware\VerifyTerminalSidecarSecret;
|
||||||
use App\Livewire\Audit;
|
use App\Livewire\Audit;
|
||||||
use App\Livewire\Auth;
|
use App\Livewire\Auth;
|
||||||
use App\Livewire\Dashboard;
|
use App\Livewire\Dashboard;
|
||||||
|
|
@ -12,9 +13,11 @@ use App\Livewire\Servers;
|
||||||
use App\Livewire\Services;
|
use App\Livewire\Services;
|
||||||
use App\Livewire\Settings;
|
use App\Livewire\Settings;
|
||||||
use App\Livewire\System;
|
use App\Livewire\System;
|
||||||
|
use App\Livewire\Terminal;
|
||||||
use App\Livewire\Versions;
|
use App\Livewire\Versions;
|
||||||
use App\Livewire\Wireguard;
|
use App\Livewire\Wireguard;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
|
use App\Models\TerminalSession;
|
||||||
use App\Services\DeploymentService;
|
use App\Services\DeploymentService;
|
||||||
use App\Services\MetricHistory;
|
use App\Services\MetricHistory;
|
||||||
use App\Services\WgStatus;
|
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]);
|
return response()->json(['stage' => $phase['stage'] ?? null, 'at' => $phase['at'] ?? 0]);
|
||||||
})->name('update.status');
|
})->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::middleware('guest')->group(function () {
|
||||||
Route::get('/login', Auth\Login::class)->name('login');
|
Route::get('/login', Auth\Login::class)->name('login');
|
||||||
Route::get('/forgot-password', Auth\ForgotPassword::class)->name('password.request');
|
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('/system', System\Index::class)->name('system');
|
||||||
Route::get('/help', Help\Index::class)->name('help');
|
Route::get('/help', Help\Index::class)->name('help');
|
||||||
Route::get('/wireguard', Wireguard\Index::class)->name('wireguard');
|
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
|
// 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
|
// the flag is on, so with it off /release does not exist (uncached route file is re-included
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue