feat(control): real SSH-key + file actions (no sudo needed)

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) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-12 22:49:08 +02:00
parent b7fe66406c
commit 1376772a0c
6 changed files with 263 additions and 13 deletions

View File

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

View File

@ -0,0 +1,71 @@
<?php
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Services\FleetService;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
use Throwable;
/**
* Form modal: paste an SSH public key, append it to the server's authorized_keys
* over SSH (real control), write an AuditEvent, and tell the page to reload.
*/
class AddSshKey extends ModalComponent
{
public int $serverId;
public string $publicKey = '';
public ?string $error = null;
public function mount(int $serverId): void
{
$this->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');
}
}

View File

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

View File

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

View File

@ -0,0 +1,42 @@
<div class="p-5 sm:p-6">
<div class="flex items-start gap-3.5">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-accent/30 bg-accent/10 text-accent-text">
<x-icon name="shield" class="h-5 w-5" />
</span>
<div class="min-w-0 flex-1">
<h2 class="font-display text-base font-semibold text-ink">SSH-Schlüssel hinzufügen</h2>
<p class="mt-1 text-sm leading-relaxed text-ink-2">Öffentlichen Schlüssel einfügen er wird in <span class="font-mono text-ink-3">~/.ssh/authorized_keys</span> ergänzt.</p>
</div>
</div>
<div class="mt-4">
<textarea
wire:model="publicKey"
rows="4"
placeholder="ssh-ed25519 AAAA… kommentar"
class="w-full rounded-md border border-line bg-inset px-3 py-2.5 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none"
></textarea>
@if ($error)
<p class="mt-2 flex items-center gap-1.5 font-mono text-[11px] text-offline">
<x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $error }}
</p>
@endif
</div>
<div class="mt-6 flex items-center justify-end gap-2">
<button
type="button"
wire:click="$dispatch('closeModal')"
class="inline-flex min-h-11 items-center rounded-md border border-line bg-inset px-4 text-sm text-ink-2 transition-colors hover:bg-raised hover:text-ink"
>Abbrechen</button>
<button
type="button"
wire:click="save"
wire:loading.attr="disabled"
class="inline-flex min-h-11 items-center gap-2 rounded-md bg-accent px-4 font-display text-sm font-semibold uppercase tracking-wide text-void transition-colors hover:bg-accent-bright disabled:opacity-50"
>
<x-icon name="plus" class="h-4 w-4" />
Hinzufügen
</button>
</div>
</div>

View File

@ -42,7 +42,14 @@
<span>zuletzt gesehen {{ optional($server->last_seen_at)->diffForHumans() ?? '—' }}</span>
</p>
</div>
<x-status-pill :status="$server->status" class="shrink-0">{{ $statusLabel }}</x-status-pill>
<div class="flex shrink-0 items-center gap-2">
<button type="button" wire:click="openFiles"
class="inline-flex min-h-11 items-center gap-1.5 rounded-md border border-line bg-inset px-3 text-xs text-ink-2 transition-colors hover:bg-raised hover:text-ink">
<x-icon name="folder" class="h-3.5 w-3.5" />
Dateien
</button>
<x-status-pill :status="$server->status">{{ $statusLabel }}</x-status-pill>
</div>
</div>
</div>
@ -150,6 +157,7 @@
<x-slot:actions>
{{-- R5: wire to wire-elements/modal --}}
<button type="button"
wire:click="$dispatch('openModal', { component: 'modals.add-ssh-key', arguments: { serverId: {{ $server->id }} } })"
class="inline-flex min-h-11 items-center gap-1.5 rounded-md border border-accent/25 bg-accent/10 px-3 py-1.5 text-xs text-accent-text hover:bg-accent/15">
<x-icon name="plus" class="h-3.5 w-3.5" />
Schlüssel hinzufügen