diff --git a/app/Livewire/Wireguard/Index.php b/app/Livewire/Wireguard/Index.php index d01f417..71ae6cf 100644 --- a/app/Livewire/Wireguard/Index.php +++ b/app/Livewire/Wireguard/Index.php @@ -2,29 +2,167 @@ namespace App\Livewire\Wireguard; +use App\Models\AuditEvent; +use App\Services\WgBridge; use App\Services\WgStatus; use App\Services\WgTraffic; +use App\Support\Confirm\ConfirmToken; +use App\Support\Confirm\InvalidConfirmToken; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Layout; +use Livewire\Attributes\On; use Livewire\Component; /** - * WireGuard dashboard — live status (P1) + traffic history (P2). Renders the host-collected - * status (WgStatus) and a bucketed throughput series (WgTraffic), wire:polls every 5s. Read-only. + * WireGuard dashboard — live status (P1) + traffic (P2) + peer management (P3). Reads the + * host-collected status; mutations go through the host write-bridge (WgBridge), never a shell. */ #[Layout('layouts.app')] class Index extends Component { - /** Selected traffic window in seconds. One of WINDOWS. */ public int $window = 3600; - /** Allowed windows (seconds): 1h / 24h / 7d. */ public const WINDOWS = [3600, 86400, 604800]; + public string $newPeer = ''; + + /** id of an in-flight write-request we are polling for. */ + public ?string $pendingId = null; + + public ?string $pendingAction = null; + + /** Show-once add-peer result (config text + QR svg). Never persisted. */ + public ?string $resultConfig = null; + + public ?string $resultQr = null; + public function setWindow(int $seconds): void { $this->window = in_array($seconds, self::WINDOWS, true) ? $seconds : 3600; } + public function addPeer(WgBridge $bridge): void + { + $this->validate(['newPeer' => ['required', 'regex:/^[A-Za-z0-9._-]{1,64}$/']], [ + 'newPeer.regex' => __('wireguard.peer_name_invalid'), + 'newPeer.required' => __('wireguard.peer_name_invalid'), + ]); + if (! $this->throttle()) { + return; + } + $name = $this->newPeer; + $this->pendingId = $bridge->request('add-peer', ['name' => $name]); + $this->pendingAction = 'add-peer'; + $this->newPeer = ''; + $this->audit('wg.add-peer', $name); + } + + /** Opens the wire-elements/modal confirm dialog for peer removal. */ + public function confirmRemovePeer(string $name): void + { + if (preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1) { + return; + } + + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => __('wireguard.remove_confirm_title'), + 'body' => __('wireguard.remove_confirm_body'), + 'confirmLabel' => __('wireguard.remove'), + 'danger' => true, + 'icon' => 'trash', + 'notify' => '', + 'token' => ConfirmToken::issue( + 'wgPeerRemoved', + ['name' => $name], + 'wg.remove-peer', + $name, + ), + ], + ); + } + + /** Apply handler — called after the ConfirmAction modal confirms the removal. */ + #[On('wgPeerRemoved')] + public function applyRemovePeer(string $confirmToken, WgBridge $bridge): void + { + try { + $payload = ConfirmToken::consume($confirmToken, 'wgPeerRemoved'); + } catch (InvalidConfirmToken) { + return; + } + + $name = $payload['params']['name'] ?? ''; + if ($name === '' || preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1) { + return; + } + + $this->removePeer($bridge, $name); + } + + public function removePeer(WgBridge $bridge, string $name): void + { + if (preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1 || ! $this->throttle()) { + return; + } + $this->pendingId = $bridge->request('remove-peer', ['name' => $name]); + $this->pendingAction = 'remove-peer'; + $this->audit('wg.remove-peer', $name); + } + + public function pollResult(WgBridge $bridge): void + { + if ($this->pendingId === null) { + return; + } + $res = $bridge->result($this->pendingId); + if ($res === null) { + return; + } + $this->pendingId = null; + if ($res['ok'] && $this->pendingAction === 'add-peer' && $res['config'] !== null) { + $this->resultConfig = $res['config']; + $this->resultQr = $res['qr']; + } elseif (! $res['ok']) { + $this->dispatch('notify', message: __('wireguard.action_failed'), level: 'error'); + } else { + $this->dispatch('notify', message: __('wireguard.action_done')); + } + $this->pendingAction = null; + } + + public function dismissResult(): void + { + $this->resultConfig = null; + $this->resultQr = null; + } + + private function throttle(): bool + { + $key = 'wg-request:'.Auth::id(); + if (RateLimiter::tooManyAttempts($key, 10)) { + $this->dispatch('notify', message: __('wireguard.throttled'), level: 'error'); + + return false; + } + RateLimiter::hit($key, 60); + + return true; + } + + private function audit(string $action, string $target): void + { + AuditEvent::create([ + 'user_id' => Auth::id(), + 'actor' => Auth::user()?->name ?? 'system', + 'action' => $action, + 'target' => $target, + 'ip' => request()->ip(), + ]); + } + public function render(WgStatus $wg, WgTraffic $traffic) { $window = in_array($this->window, self::WINDOWS, true) ? $this->window : 3600; diff --git a/app/Support/Confirm/ConfirmToken.php b/app/Support/Confirm/ConfirmToken.php index 53c7f01..d740b39 100644 --- a/app/Support/Confirm/ConfirmToken.php +++ b/app/Support/Confirm/ConfirmToken.php @@ -56,6 +56,7 @@ class ConfirmToken 'twoFactorDisabled', 'banCleared', 'bansClearedAll', + 'wgPeerRemoved', ]; /** How long an issued confirm stays valid (seconds) — generous for a human click. */ diff --git a/lang/de/wireguard.php b/lang/de/wireguard.php index 1fdc86c..caaff12 100644 --- a/lang/de/wireguard.php +++ b/lang/de/wireguard.php @@ -33,4 +33,17 @@ return [ 'total_down' => 'Empfangen', 'total_up' => 'Gesendet', 'no_traffic' => 'Noch keine Verlaufsdaten — sie sammeln sich, sobald Peers verbunden sind.', + 'add_peer' => 'Peer hinzufügen', + 'peer_name_placeholder' => 'Name (z. B. laptop)', + 'peer_name_invalid' => 'Nur A–Z a–z 0–9 . _ - (max. 64).', + 'remove' => 'Entfernen', + 'remove_confirm_title' => 'Peer entfernen?', + 'remove_confirm_body' => 'Der Client verliert sofort den Zugang. Das lässt sich nicht rückgängig machen.', + 'pending' => 'Wird angewendet …', + 'action_done' => 'Aktion angewendet.', + 'action_failed' => 'Aktion fehlgeschlagen — auf dem Host prüfen.', + 'throttled' => 'Zu viele Aktionen — kurz warten.', + 'new_peer_title' => 'Neuer Peer — Konfiguration', + 'new_peer_once' => 'Diese Konfiguration wird NUR EINMAL angezeigt und nicht gespeichert. Jetzt importieren (QR scannen oder Text kopieren).', + 'close' => 'Schließen', ]; diff --git a/lang/en/wireguard.php b/lang/en/wireguard.php index 2ab4467..11810f0 100644 --- a/lang/en/wireguard.php +++ b/lang/en/wireguard.php @@ -33,4 +33,17 @@ return [ 'total_down' => 'Received', 'total_up' => 'Sent', 'no_traffic' => 'No history yet — it accrues once peers are connected.', + 'add_peer' => 'Add peer', + 'peer_name_placeholder' => 'Name (e.g. laptop)', + 'peer_name_invalid' => 'Only A–Z a–z 0–9 . _ - (max 64).', + 'remove' => 'Remove', + 'remove_confirm_title' => 'Remove peer?', + 'remove_confirm_body' => 'The client loses access immediately. This cannot be undone.', + 'pending' => 'Applying …', + 'action_done' => 'Action applied.', + 'action_failed' => 'Action failed — check on the host.', + 'throttled' => 'Too many actions — wait a moment.', + 'new_peer_title' => 'New peer — configuration', + 'new_peer_once' => 'This configuration is shown ONCE and never stored. Import it now (scan the QR or copy the text).', + 'close' => 'Close', ]; diff --git a/resources/views/livewire/wireguard/index.blade.php b/resources/views/livewire/wireguard/index.blade.php index 19f818c..9988ee7 100644 --- a/resources/views/livewire/wireguard/index.blade.php +++ b/resources/views/livewire/wireguard/index.blade.php @@ -36,6 +36,13 @@ @endif + @if ($pendingId) +
{{ __('wireguard.no_peers') }}
@@ -133,4 +153,20 @@{{ __('wireguard.new_peer_once') }}
+ @if ($resultQr) +{{ $resultConfig }}
+