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
parent
b7fe66406c
commit
1376772a0c
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue