fix(ssh): address adversarial review — host-key pinning, escaping, parsers

Resolve all 12 confirmed findings from the SSH-integration review.

Security:
- TOFU host-key pinning (new VerifiesHostKey trait + servers.ssh_host_key column):
  SshClient/Sftp record the server host key on first connect and refuse the
  connection on any later mismatch — phpseclib does not pin host keys itself.
- PollMetrics keeps one long-lived SSH connection per server and reuses it across
  ticks (a single login, not one per interval) — avoids fail2ban/auth-log churn;
  dead connections are dropped and re-established next tick.
- @js() escaping for every server-controlled value interpolated into wire:click
  (file names, paths, service names, SSH-key comments/fingerprints) — prevents
  JS-string breakage / injection from untrusted remote data.

Correctness:
- parseKeys regex makes the key comment optional (keys without a comment were
  silently dropped).
- parseVolumes pops the three trailing single-token df fields and rejoins the
  rest, so mount points containing spaces parse correctly.
- Sftp gains a disconnect() method to match SshClient (explicit cleanup).

Rules:
- Services "Start" -> "Starten" (R9, German).
- Files breadcrumb buttons get min-w-11 + padding (R7, >=44px touch target).

Verified live: host key stored + mismatch refused + correct key reconnects;
poller reuse polls ok; all pages still render real data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-12 21:55:27 +02:00
parent b9b0f62d78
commit 5a9b6d317f
9 changed files with 149 additions and 24 deletions

View File

@ -5,6 +5,8 @@ namespace App\Console\Commands;
use App\Events\MetricsTicked;
use App\Models\Server;
use App\Services\FleetService;
use App\Support\Ssh\CredentialVault;
use App\Support\Ssh\SshClient;
use Illuminate\Console\Command;
use Throwable;
@ -12,7 +14,11 @@ use Throwable;
* Replaces the dev mock emitter: polls REAL CPU/MEM/disk over SSH for every
* server that has a stored credential, persists the values onto the Server row,
* and broadcasts MetricsTicked so the dashboard chart moves with real data.
* Unreachable servers are flagged offline; the loop never dies on one failure.
*
* One long-lived SSH connection is kept per server and reused across ticks (a
* single login, not one per interval avoids fail2ban/auth-log churn). A dead
* connection is dropped and re-established on the next tick; one server failing
* never kills the loop.
*/
class PollMetrics extends Command
{
@ -22,10 +28,13 @@ class PollMetrics extends Command
protected $description = 'Poll real metrics over SSH for credentialed servers, persist + broadcast';
public function handle(FleetService $fleet): int
public function handle(FleetService $fleet, CredentialVault $vault): int
{
$interval = max(2, (int) $this->option('interval'));
/** @var array<int, SshClient> $clients */
$clients = [];
do {
$servers = Server::has('credential')->get();
@ -35,20 +44,35 @@ class PollMetrics extends Command
foreach ($servers as $server) {
try {
$m = $fleet->refresh($server);
$clients[$server->id] ??= (new SshClient($vault, timeout: 12))->connect($server);
$m = $fleet->readMetrics($clients[$server->id]);
$fleet->applyMetrics($server, $m);
broadcast(new MetricsTicked($m['cpu'], $m['mem'], $server->name));
$this->line("ok {$server->name} cpu={$m['cpu']} mem={$m['mem']} disk={$m['disk']} load={$m['load']}");
} catch (Throwable $e) {
// drop the (possibly dead) connection so the next tick reconnects
if (isset($clients[$server->id])) {
$clients[$server->id]->disconnect();
unset($clients[$server->id]);
}
$server->forceFill(['status' => 'offline'])->save();
$this->warn("offline {$server->name}: ".$e->getMessage());
}
}
if ($this->option('once')) {
return self::SUCCESS;
break;
}
sleep($interval);
} while (true);
foreach ($clients as $client) {
$client->disconnect();
}
return self::SUCCESS;
}
}

View File

@ -53,6 +53,22 @@ class FleetService
public function metrics(Server $server): array
{
$ssh = $this->client($server);
try {
return $this->readMetrics($ssh);
} finally {
$ssh->disconnect();
}
}
/**
* Run the metrics command on an already-connected client. The poller reuses
* one long-lived connection per server (a single login, not one per tick).
*
* @return array{cpu:int,mem:int,disk:int,load:float}
*/
public function readMetrics(SshClient $ssh): array
{
$cmd = 'export LC_ALL=C; '
.'echo '.self::MARK.'mem===; grep -E "MemTotal|MemAvailable" /proc/meminfo; '
.'echo '.self::MARK.'stat1===; head -1 /proc/stat; sleep 1; '
@ -60,11 +76,7 @@ class FleetService
.'echo '.self::MARK.'load===; cat /proc/loadavg; '
.'echo '.self::MARK.'disk===; df -B1 --output=pcent / | tail -1';
try {
$s = $this->sections($ssh->exec($cmd));
} finally {
$ssh->disconnect();
}
$s = $this->sections($ssh->exec($cmd));
return [
'cpu' => $this->cpuPercent($s['stat1'] ?? '', $s['stat2'] ?? ''),
@ -78,7 +90,14 @@ class FleetService
public function refresh(Server $server): array
{
$m = $this->metrics($server);
$this->applyMetrics($server, $m);
return $m;
}
/** Persist a metrics reading onto the Server row. */
public function applyMetrics(Server $server, array $m): void
{
$server->forceFill([
'cpu' => $m['cpu'],
'mem' => $m['mem'],
@ -86,8 +105,6 @@ class FleetService
'status' => $this->statusFor($m),
'last_seen_at' => now(),
])->save();
return $m;
}
private function statusFor(array $m): string
@ -299,12 +316,20 @@ class FleetService
private function parseVolumes(string $body): array
{
$out = [];
foreach ($this->lines($body) as $i => $line) {
foreach ($this->lines($body) as $line) {
// df --output=target,fstype,size,pcent; the mount (target) can contain
// spaces, so pop the three trailing single-token fields and rejoin the rest.
$p = preg_split('/\s+/', trim($line));
if ($i === 0 || count($p) < 4 || ! str_starts_with($p[0], '/')) {
if (count($p) < 4 || ! str_starts_with($p[0], '/')) {
continue; // header / noise
}
[$mount, $fs, $size, $pcent] = $p;
$pcent = array_pop($p);
$size = array_pop($p);
$fs = array_pop($p);
$mount = implode(' ', $p);
if (! ctype_digit($size)) {
continue;
}
$out[] = [
'mount' => $mount,
'fs' => $fs,
@ -379,7 +404,7 @@ class FleetService
$out = [];
foreach ($this->lines($body) as $line) {
// "3072 SHA256:xxxx comment with spaces (RSA)"
if (! preg_match('/^(\d+)\s+(\S+)\s+(.*)\s+\(([^)]+)\)\s*$/', $line, $m)) {
if (! preg_match('/^(\d+)\s+(\S+)\s+(.*?)\s*\(([^)]+)\)\s*$/', $line, $m)) {
continue;
}
$algo = strtolower($m[4]);

View File

@ -11,6 +11,8 @@ use RuntimeException;
*/
class Sftp
{
use VerifiesHostKey;
private ?SftpProtocol $sftp = null;
public function __construct(private readonly CredentialVault $vault) {}
@ -18,6 +20,9 @@ class Sftp
public function connect(Server $server): self
{
$sftp = new SftpProtocol($server->ip, $server->ssh_port ?: 22);
$this->verifyHostKey($sftp, $server);
$login = $this->vault->resolve($server);
if (! $sftp->login($login['username'], $login['secret'])) {
@ -71,6 +76,12 @@ class Sftp
return $this->client()->delete($remote);
}
public function disconnect(): void
{
$this->sftp?->disconnect();
$this->sftp = null;
}
private function client(): SftpProtocol
{
return $this->sftp ?? throw new RuntimeException('Nicht verbunden — connect() zuerst aufrufen.');

View File

@ -12,6 +12,8 @@ use RuntimeException;
*/
class SshClient
{
use VerifiesHostKey;
private ?SSH2 $ssh = null;
public function __construct(
@ -24,6 +26,8 @@ class SshClient
$ssh = new SSH2($server->ip, $server->ssh_port ?: 22);
$ssh->setTimeout($this->timeout);
$this->verifyHostKey($ssh, $server);
$login = $this->vault->resolve($server);
if (! $ssh->login($login['username'], $login['secret'])) {

View File

@ -0,0 +1,38 @@
<?php
namespace App\Support\Ssh;
use App\Models\Server;
use phpseclib3\Net\SSH2;
use RuntimeException;
/**
* TOFU (trust-on-first-use) host-key pinning. phpseclib verifies the key-exchange
* signature but does NOT pin the server's host key, so a MITM presenting its own
* valid key would be accepted. We record the host key on first connect and refuse
* the connection on any later mismatch.
*/
trait VerifiesHostKey
{
private function verifyHostKey(SSH2 $conn, Server $server): void
{
$hostKey = $conn->getServerPublicHostKey();
if ($hostKey === false) {
throw new RuntimeException("Host-Key von {$server->name} ({$server->ip}) nicht lesbar — Verbindung abgebrochen.");
}
$known = (string) ($server->ssh_host_key ?? '');
if ($known !== '') {
if (! hash_equals($known, $hostKey)) {
throw new RuntimeException("Host-Key-Mismatch fuer {$server->name} ({$server->ip}) — moegliches MITM. Verbindung abgebrochen.");
}
return;
}
// trust on first use
$server->forceFill(['ssh_host_key' => $hostKey])->save();
}
}

View File

@ -0,0 +1,23 @@
<?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::table('servers', function (Blueprint $table) {
// TOFU host-key pin (public key string from phpseclib getServerPublicHostKey()).
$table->text('ssh_host_key')->nullable()->after('ssh_port');
});
}
public function down(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('ssh_host_key');
});
}
};

View File

@ -37,8 +37,8 @@
@if (! $loop->first)
<span class="font-mono text-xs text-ink-4">/</span>
@endif
<button type="button" wire:click="go('{{ $crumb['path'] }}')" @class([
'min-h-11 font-mono text-xs',
<button type="button" wire:click="go(@js($crumb['path']))" @class([
'inline-flex min-h-11 min-w-11 items-center justify-center px-2 font-mono text-xs',
'text-ink-3' => $loop->last,
'text-accent-text hover:text-accent' => ! $loop->last,
])>{{ $crumb['label'] }}</button>
@ -78,7 +78,7 @@
</span>
@if ($isDir)
<button type="button" wire:click="open('{{ $e['name'] }}')" class="min-w-0 flex-1 text-left">
<button type="button" wire:click="open(@js($e['name']))" class="min-w-0 flex-1 text-left">
<span class="truncate font-mono text-sm text-ink group-hover:text-accent-text">{{ $e['name'] }}/</span>
</button>
@else
@ -117,7 +117,7 @@
Bearbeiten
</button>
{{-- R5: destructive wire-elements/modal confirm + audit --}}
<button type="button" wire:click="confirmDelete('{{ $e['name'] }}')" class="inline-flex min-h-11 items-center rounded-sm px-2.5 py-1 font-mono text-[11px] text-offline hover:bg-offline/10">
<button type="button" wire:click="confirmDelete(@js($e['name']))" class="inline-flex min-h-11 items-center rounded-sm px-2.5 py-1 font-mono text-[11px] text-offline hover:bg-offline/10">
Löschen
</button>
</div>

View File

@ -170,7 +170,7 @@
</div>
{{-- R5: destructive wire-elements/modal confirm + audit --}}
<button type="button"
wire:click="confirmKeyRemoval('{{ $key['fingerprint'] }}', '{{ $key['comment'] }}')"
wire:click="confirmKeyRemoval(@js($key['fingerprint']), @js($key['comment']))"
class="inline-flex min-h-11 shrink-0 items-center rounded-sm px-2 text-ink-3 transition-colors hover:bg-offline/10 hover:text-offline"
title="Schlüssel entfernen">
<x-icon name="trash" class="h-3.5 w-3.5" />

View File

@ -63,19 +63,19 @@
<div class="flex items-center gap-1.5">
<button
type="button"
wire:click="confirm('start', '{{ $svc['name'] }}')"
wire:click="confirm('start', @js($svc['name']))"
@disabled($svc['status'] === 'online')
class="inline-flex min-h-11 items-center rounded-md border border-line bg-inset px-3 text-xs text-ink-2 transition-colors hover:border-online/30 hover:bg-online/10 hover:text-online disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-line disabled:hover:bg-inset disabled:hover:text-ink-2"
>Start</button>
>Starten</button>
<button
type="button"
wire:click="confirm('stop', '{{ $svc['name'] }}')"
wire:click="confirm('stop', @js($svc['name']))"
@disabled($svc['status'] === 'offline')
class="inline-flex min-h-11 items-center rounded-md border border-line bg-inset px-3 text-xs text-ink-2 transition-colors hover:border-offline/30 hover:bg-offline/10 hover:text-offline disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-line disabled:hover:bg-inset disabled:hover:text-ink-2"
>Stopp</button>
<button
type="button"
wire:click="confirm('restart', '{{ $svc['name'] }}')"
wire:click="confirm('restart', @js($svc['name']))"
@disabled($svc['status'] === 'offline')
class="inline-flex min-h-11 items-center rounded-md border border-accent/25 bg-accent/10 px-3 text-xs text-accent-text transition-colors hover:bg-accent/15 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-accent/10"
>Neustart</button>