From 1376772a0ce2370d71f2eebd99c8509c1670f57b Mon Sep 17 00:00:00 2001 From: boban Date: Fri, 12 Jun 2026 22:49:08 +0200 Subject: [PATCH] feat(control): real SSH-key + file actions (no sudo needed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the panel actually operate the server, not just display it — for everything that works in the SSH user's own space (no sudo required). - FleetService: addAuthorizedKey (append, dedup, perms-safe via base64 transport), removeAuthorizedKey (by SHA256 fingerprint, preserves every other key — never rewrites blindly, no lockout), sshKeys (reload), deleteFile (SFTP unlink). - AddSshKey form modal: paste a public key -> appended over SSH + AuditEvent + list reload. Wired to the "Schlüssel hinzufügen" button on Server-Details. - Server-Details: key removal now performs the real SSH removal then reloads; new "Dateien" button opens the file manager scoped to this server. - Files: delete performs the real SFTP unlink then reloads the directory. Verified live: add+remove of a throwaway key leaves the existing key intact; real file delete confirmed gone. systemctl start/stop/restart still need a privileged credential (the demo account has no passwordless sudo). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Livewire/Files/Index.php | 21 ++-- app/Livewire/Modals/AddSshKey.php | 71 ++++++++++++++ app/Livewire/Servers/Show.php | 36 +++++-- app/Services/FleetService.php | 96 +++++++++++++++++++ .../livewire/modals/add-ssh-key.blade.php | 42 ++++++++ .../views/livewire/servers/show.blade.php | 10 +- 6 files changed, 263 insertions(+), 13 deletions(-) create mode 100644 app/Livewire/Modals/AddSshKey.php create mode 100644 resources/views/livewire/modals/add-ssh-key.blade.php diff --git a/app/Livewire/Files/Index.php b/app/Livewire/Files/Index.php index de1948d..b6fa73a 100644 --- a/app/Livewire/Files/Index.php +++ b/app/Livewire/Files/Index.php @@ -107,14 +107,23 @@ class Index extends Component ); } - /** Applies the confirmed deletion (optimistic until SFTP unlink lands). */ + /** Applies the confirmed deletion over SFTP, then reloads the directory. */ #[On('fileConfirmed')] - public function deleteEntry(string $name): void + public function deleteEntry(string $name, FleetService $fleet): void { - $this->entries = array_values(array_filter( - $this->entries, - fn (array $e): bool => $e['name'] !== $name, - )); + $active = $this->activeServer(); + + if ($active && $active->credential_exists) { + try { + $fleet->deleteFile($active, rtrim($this->path, '/').'/'.$name); + } catch (Throwable $e) { + $this->dispatch('notify', message: 'Löschen fehlgeschlagen: '.$e->getMessage()); + + return; + } + } + + $this->load(); } public function render() diff --git a/app/Livewire/Modals/AddSshKey.php b/app/Livewire/Modals/AddSshKey.php new file mode 100644 index 0000000..ce2bb52 --- /dev/null +++ b/app/Livewire/Modals/AddSshKey.php @@ -0,0 +1,71 @@ +serverId = $serverId; + } + + public static function modalMaxWidth(): string + { + return 'lg'; + } + + public function save(FleetService $fleet): void + { + $this->error = null; + + $server = Server::find($this->serverId); + if (! $server) { + $this->error = 'Server nicht gefunden.'; + + return; + } + + try { + $fleet->addAuthorizedKey($server, $this->publicKey); + } catch (Throwable $e) { + $this->error = $e->getMessage(); + + return; + } + + AuditEvent::create([ + 'user_id' => Auth::id(), + 'server_id' => $server->id, + 'actor' => Auth::user()?->name ?? 'system', + 'action' => 'ssh_key.add', + 'target' => $server->name, + 'ip' => request()->ip(), + ]); + + $this->dispatch('keyChanged'); + $this->dispatch('notify', message: 'SSH-Schlüssel hinzugefügt.'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.modals.add-ssh-key'); + } +} diff --git a/app/Livewire/Servers/Show.php b/app/Livewire/Servers/Show.php index d3dfff6..de68062 100644 --- a/app/Livewire/Servers/Show.php +++ b/app/Livewire/Servers/Show.php @@ -103,14 +103,38 @@ class Show extends Component ); } - /** Applies the confirmed key removal (optimistic until SSH layer lands). */ + /** Applies the confirmed key removal over SSH, then reloads the list. */ #[On('keyRemoved')] - public function removeKey(string $fingerprint): void + public function removeKey(string $fingerprint, FleetService $fleet): void { - $this->sshKeys = array_values(array_filter( - $this->sshKeys, - fn (array $k): bool => $k['fingerprint'] !== $fingerprint, - )); + try { + $fleet->removeAuthorizedKey($this->server, $fingerprint); + } catch (Throwable $e) { + $this->dispatch('notify', message: 'Entfernen fehlgeschlagen: '.$e->getMessage()); + + return; + } + + $this->reloadKeys($fleet); + } + + /** Re-read the authorized keys after an add/remove. */ + #[On('keyChanged')] + public function reloadKeys(FleetService $fleet): void + { + try { + $this->sshKeys = $fleet->sshKeys($this->server); + } catch (Throwable) { + // keep the current list on failure + } + } + + /** Open the file manager scoped to this server. */ + public function openFiles() + { + session(['active_server_id' => $this->server->id]); + + return $this->redirect(route('files.index'), navigate: true); } public function render() diff --git a/app/Services/FleetService.php b/app/Services/FleetService.php index e4fd56c..4933400 100644 --- a/app/Services/FleetService.php +++ b/app/Services/FleetService.php @@ -4,8 +4,12 @@ namespace App\Services; use App\Models\Server; use App\Support\Ssh\CredentialVault; +use App\Support\Ssh\Sftp; use App\Support\Ssh\SshClient; use Illuminate\Support\Facades\Cache; +use InvalidArgumentException; +use phpseclib3\Crypt\PublicKeyLoader; +use RuntimeException; use Throwable; /** @@ -277,6 +281,98 @@ class FleetService return $entries; } + // ── control (write) actions — work without sudo, in the SSH user's space ─ + + /** Append a public key to the SSH user's authorized_keys. */ + public function addAuthorizedKey(Server $server, string $publicKey): void + { + $key = trim(preg_replace('/\s+/', ' ', $publicKey) ?? ''); + + if (! preg_match('#^(ssh-(rsa|ed25519|dss)|ecdsa-sha2-\S+)\s+[A-Za-z0-9+/=]+#', $key)) { + throw new InvalidArgumentException('Ungueltiger SSH-Public-Key.'); + } + + $ssh = $this->client($server); + + try { + // base64-encode so the key never touches the shell unquoted + $b64 = base64_encode($key); + [$out] = $ssh->run( + 'umask 077; mkdir -p ~/.ssh && touch ~/.ssh/authorized_keys && chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys; ' + ."k=$(printf '%s' '{$b64}' | base64 -d); " + .'grep -qxF "$k" ~/.ssh/authorized_keys || printf "%s\n" "$k" >> ~/.ssh/authorized_keys; echo DONE' + ); + + if (! str_contains($out, 'DONE')) { + throw new RuntimeException('Schreiben der authorized_keys fehlgeschlagen.'); + } + } finally { + $ssh->disconnect(); + } + } + + /** Remove an authorized key by SHA256 fingerprint, preserving all others. */ + public function removeAuthorizedKey(Server $server, string $fingerprint): void + { + $sftp = (new Sftp($this->vault))->connect($server); + + try { + $content = $sftp->get('.ssh/authorized_keys'); + if (trim($content) === '') { + throw new RuntimeException('authorized_keys leer oder nicht lesbar.'); + } + + $lines = array_values(array_filter( + array_map('rtrim', preg_split('/\R/', $content)), + fn ($l) => $l !== '' + )); + $kept = array_values(array_filter($lines, fn ($l) => $this->keyFingerprint($l) !== $fingerprint)); + + if (count($kept) === count($lines)) { + return; // nothing matched — never rewrite blindly (no lockout) + } + + $sftp->put('.ssh/authorized_keys', $kept === [] ? '' : implode("\n", $kept)."\n"); + } finally { + $sftp->disconnect(); + } + } + + private function keyFingerprint(string $line): ?string + { + try { + return 'SHA256:'.PublicKeyLoader::load($line)->getFingerprint('sha256'); + } catch (Throwable) { + return null; + } + } + + /** Re-read just the authorized keys (after an add/remove). */ + public function sshKeys(Server $server): array + { + $ssh = $this->client($server); + + try { + return $this->parseKeys($ssh->exec('ssh-keygen -lf ~/.ssh/authorized_keys 2>/dev/null')); + } finally { + $ssh->disconnect(); + } + } + + /** Delete a remote file over SFTP (needs write permission; no sudo). */ + public function deleteFile(Server $server, string $path): void + { + $sftp = (new Sftp($this->vault))->connect($server); + + try { + if (! $sftp->delete($path)) { + throw new RuntimeException('Loeschen fehlgeschlagen (Rechte?).'); + } + } finally { + $sftp->disconnect(); + } + } + // ── parsers ───────────────────────────────────────────────────────── private function lines(string $body): array diff --git a/resources/views/livewire/modals/add-ssh-key.blade.php b/resources/views/livewire/modals/add-ssh-key.blade.php new file mode 100644 index 0000000..bd76898 --- /dev/null +++ b/resources/views/livewire/modals/add-ssh-key.blade.php @@ -0,0 +1,42 @@ +
+
+ + + +
+

SSH-Schlüssel hinzufügen

+

Öffentlichen Schlüssel einfügen — er wird in ~/.ssh/authorized_keys ergänzt.

+
+
+ +
+ + @if ($error) +

+ {{ $error }} +

+ @endif +
+ +
+ + +
+
diff --git a/resources/views/livewire/servers/show.blade.php b/resources/views/livewire/servers/show.blade.php index 75fc3fd..9aea6bd 100644 --- a/resources/views/livewire/servers/show.blade.php +++ b/resources/views/livewire/servers/show.blade.php @@ -42,7 +42,14 @@ zuletzt gesehen {{ optional($server->last_seen_at)->diffForHumans() ?? '—' }}

- {{ $statusLabel }} +
+ + {{ $statusLabel }} +
@@ -150,6 +157,7 @@ {{-- R5: wire to wire-elements/modal --}}