From ee8f2bac91e01cefacd7c0914f1f4c5bd8299ccb Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 21 Jun 2026 00:36:26 +0200 Subject: [PATCH] feat(wg): add/remove peers from the dashboard (show-once config + QR) Co-Authored-By: Claude Sonnet 4.6 --- app/Livewire/Wireguard/Index.php | 146 +++++++++++++++++- app/Support/Confirm/ConfirmToken.php | 1 + lang/de/wireguard.php | 13 ++ lang/en/wireguard.php | 13 ++ .../views/livewire/wireguard/index.blade.php | 36 +++++ tests/Feature/WireguardPageTest.php | 42 +++++ 6 files changed, 247 insertions(+), 4 deletions(-) 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.pending') }} +
+ @endif + @php $win = collect($windows)->mapWithKeys(fn ($s) => [$s => match ($s) { 3600 => __('wireguard.window_1h'), 86400 => __('wireguard.window_24h'), default => __('wireguard.window_7d'), @@ -91,6 +98,14 @@
+
+ + + {{ __('wireguard.add_peer') }} + + @error('newPeer') {{ $message }} @enderror +
@forelse ($status['peers'] as $peer)
@@ -104,6 +119,11 @@ {{ $peer['endpoint'] !== '' ? $peer['endpoint'] : '—' }} {{ __('wireguard.down_up', ['rx' => $fmtBytes($peer['rx']), 'tx' => $fmtBytes($peer['tx'])]) }} +
+ + {{ __('wireguard.remove') }} + +
@empty

{{ __('wireguard.no_peers') }}

@@ -133,4 +153,20 @@
@endif + + @if ($resultConfig) +
+
+

{{ __('wireguard.new_peer_title') }}

+

{{ __('wireguard.new_peer_once') }}

+ @if ($resultQr) +
{!! $resultQr !!}
+ @endif +
{{ $resultConfig }}
+
+ {{ __('wireguard.close') }} +
+
+
+ @endif diff --git a/tests/Feature/WireguardPageTest.php b/tests/Feature/WireguardPageTest.php index 11b5299..aed14d3 100644 --- a/tests/Feature/WireguardPageTest.php +++ b/tests/Feature/WireguardPageTest.php @@ -72,4 +72,46 @@ class WireguardPageTest extends TestCase ->call('setWindow', 999) ->assertSet('window', 3600); } + + public function test_add_peer_writes_a_request_and_audits(): void + { + \Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class) + ->set('newPeer', 'laptop') + ->call('addPeer') + ->assertSet('pendingId', fn ($v) => is_string($v) && $v !== ''); + + $this->assertTrue(\App\Models\AuditEvent::where('action', 'wg.add-peer')->exists()); + $this->assertFileExists(storage_path('app/restart-signal/wg-request.json')); + @unlink(storage_path('app/restart-signal/wg-request.json')); + } + + public function test_add_peer_rejects_an_invalid_name(): void + { + \Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class) + ->set('newPeer', 'bad name') + ->call('addPeer') + ->assertHasErrors('newPeer'); + $this->assertSame(0, \App\Models\AuditEvent::where('action', 'wg.add-peer')->count()); + } + + public function test_remove_peer_writes_a_request_and_audits(): void + { + \Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class) + ->call('removePeer', 'laptop') + ->assertSet('pendingId', fn ($v) => is_string($v) && $v !== ''); + $this->assertTrue(\App\Models\AuditEvent::where('action', 'wg.remove-peer')->exists()); + @unlink(storage_path('app/restart-signal/wg-request.json')); + } + + public function test_poll_result_surfaces_the_config_once(): void + { + $c = \Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)->set('newPeer', 'laptop')->call('addPeer'); + $id = $c->get('pendingId'); + file_put_contents(storage_path("app/restart-signal/wg-result-{$id}.json"), json_encode(['id' => $id, 'ok' => true, 'message' => 'ok', 'config' => "[Interface]\nPrivateKey = x", 'at' => time()])); + + $c->call('pollResult') + ->assertSet('pendingId', null) + ->assertSet('resultConfig', fn ($v) => str_contains((string) $v, 'PrivateKey')); + @unlink(storage_path("app/restart-signal/wg-result-{$id}.json")); + } }