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