diff --git a/app/Livewire/Wireguard/Index.php b/app/Livewire/Wireguard/Index.php index 71ae6cf..808e16f 100644 --- a/app/Livewire/Wireguard/Index.php +++ b/app/Livewire/Wireguard/Index.php @@ -27,6 +27,12 @@ class Index extends Component public string $newPeer = ''; + public string $newEndpoint = ''; + + public string $newPort = ''; + + public string $newSubnet = ''; + /** id of an in-flight write-request we are polling for. */ public ?string $pendingId = null; @@ -112,6 +118,156 @@ class Index extends Component $this->audit('wg.remove-peer', $name); } + public function setEndpoint(WgBridge $bridge): void + { + $this->validate(['newEndpoint' => ['required', 'regex:/^[A-Za-z0-9.:_-]{1,128}$/']], [ + 'newEndpoint.regex' => __('wireguard.endpoint_invalid'), + 'newEndpoint.required' => __('wireguard.endpoint_invalid'), + ]); + if (! $this->throttle()) { + return; + } + $this->pendingId = $bridge->request('set-endpoint', ['endpoint' => $this->newEndpoint]); + $this->pendingAction = 'set-endpoint'; + $this->audit('wg.set-endpoint', $this->newEndpoint); + $this->newEndpoint = ''; + } + + public function confirmGate(bool $on): void + { + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => $on ? __('wireguard.gate_on_title') : __('wireguard.gate_off_title'), + 'body' => $on ? __('wireguard.gate_on_body') : __('wireguard.gate_off_body'), + 'confirmLabel' => $on ? __('wireguard.gate_turn_on') : __('wireguard.gate_turn_off'), + 'danger' => ! $on, + 'icon' => 'shield', + 'notify' => '', + 'token' => ConfirmToken::issue( + 'wgGateToggle', + ['on' => $on ? '1' : '0'], + $on ? 'wg.gate-up' : 'wg.gate-down', + $on ? 'on' : 'off', + ), + ], + ); + } + + #[On('wgGateToggle')] + public function applyGateToggle(string $confirmToken, WgBridge $bridge): void + { + try { + $payload = ConfirmToken::consume($confirmToken, 'wgGateToggle'); + } catch (InvalidConfirmToken) { + return; + } + + $this->runGate(($payload['params']['on'] ?? '0') === '1', $bridge); + } + + public function runGate(bool $on, ?WgBridge $bridge = null): void + { + if (! $this->throttle()) { + return; + } + $bridge ??= app(WgBridge::class); + $this->pendingId = $bridge->request($on ? 'gate-up' : 'gate-down', []); + $this->pendingAction = $on ? 'gate-up' : 'gate-down'; + $this->audit($on ? 'wg.gate-up' : 'wg.gate-down', $on ? 'on' : 'off'); + } + + public function confirmSetPort(): void + { + $this->validate( + ['newPort' => ['required', 'regex:/^\d{1,5}$/']], + ['newPort.regex' => __('wireguard.port_invalid'), 'newPort.required' => __('wireguard.port_invalid')], + ); + if ((int) $this->newPort < 1 || (int) $this->newPort > 65535) { + $this->addError('newPort', __('wireguard.port_invalid')); + + return; + } + + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => __('wireguard.port_confirm_title'), + 'body' => __('wireguard.port_confirm_body'), + 'confirmLabel' => __('wireguard.apply'), + 'danger' => true, + 'icon' => 'alert', + 'notify' => '', + 'token' => ConfirmToken::issue( + 'wgSetPort', + ['port' => $this->newPort], + 'wg.set-port', + $this->newPort, + ), + ], + ); + } + + #[On('wgSetPort')] + public function applySetPort(string $confirmToken, WgBridge $bridge): void + { + try { + $payload = ConfirmToken::consume($confirmToken, 'wgSetPort'); + } catch (InvalidConfirmToken) { + return; + } + + if (! $this->throttle()) { + return; + } + $this->pendingId = $bridge->request('set-port', ['port' => (string) ($payload['params']['port'] ?? '')]); + $this->pendingAction = 'set-port'; + $this->newPort = ''; + } + + public function confirmSetSubnet(): void + { + $this->validate( + ['newSubnet' => ['required', 'regex:#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#']], + ['newSubnet.regex' => __('wireguard.subnet_invalid'), 'newSubnet.required' => __('wireguard.subnet_invalid')], + ); + + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => __('wireguard.subnet_confirm_title'), + 'body' => __('wireguard.subnet_confirm_body'), + 'confirmLabel' => __('wireguard.apply'), + 'danger' => true, + 'icon' => 'alert', + 'notify' => '', + 'token' => ConfirmToken::issue( + 'wgSetSubnet', + ['subnet' => $this->newSubnet], + 'wg.set-subnet', + $this->newSubnet, + ), + ], + ); + } + + #[On('wgSetSubnet')] + public function applySetSubnet(string $confirmToken, WgBridge $bridge): void + { + try { + $payload = ConfirmToken::consume($confirmToken, 'wgSetSubnet'); + } catch (InvalidConfirmToken) { + return; + } + + if (! $this->throttle()) { + return; + } + $this->pendingId = $bridge->request('set-subnet', ['subnet' => (string) ($payload['params']['subnet'] ?? '')]); + $this->pendingAction = 'set-subnet'; + $this->newSubnet = ''; + } + public function pollResult(WgBridge $bridge): void { if ($this->pendingId === null) { diff --git a/app/Support/Confirm/ConfirmToken.php b/app/Support/Confirm/ConfirmToken.php index d740b39..f3aac14 100644 --- a/app/Support/Confirm/ConfirmToken.php +++ b/app/Support/Confirm/ConfirmToken.php @@ -57,6 +57,9 @@ class ConfirmToken 'banCleared', 'bansClearedAll', 'wgPeerRemoved', + 'wgGateToggle', + 'wgSetPort', + 'wgSetSubnet', ]; /** 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 caaff12..1e0a783 100644 --- a/lang/de/wireguard.php +++ b/lang/de/wireguard.php @@ -46,4 +46,26 @@ return [ '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', + 'settings_title' => 'Einstellungen', + 'apply' => 'Anwenden', + 'gate_toggle' => 'Gate', + 'gate_turn_on' => 'Einschalten', + 'gate_turn_off' => 'Ausschalten', + 'gate_on_title' => 'Gate einschalten?', + 'gate_on_body' => 'Das Panel ist danach nur noch über den Tunnel erreichbar. Vorher sicherstellen, dass der Tunnel das Panel erreicht — sonst nur per SSH ("clusev wg down") zurück.', + 'gate_off_title' => 'Gate ausschalten?', + 'gate_off_body' => 'Das Panel wird wieder öffentlich erreichbar (nur 2FA + Anmeldeschutz schützen dann).', + 'endpoint_label' => 'Öffentlicher Endpoint', + 'endpoint_hint' => 'IP oder DNS:Port — betrifft nur künftige Peer-Configs.', + 'endpoint_invalid' => 'Ungültiger Endpoint.', + 'port_label' => 'Listen-Port (UDP)', + 'port_hint' => 'Ändern startet den Tunnel neu — alle Clients müssen den Port aktualisieren.', + 'port_invalid' => 'Port 1–65535.', + 'port_confirm_title' => 'Port ändern?', + 'port_confirm_body' => 'Der Tunnel wird neu gestartet. Alle Clients müssen den neuen Port in ihrer Config eintragen, sonst verlieren sie die Verbindung.', + 'subnet_label' => 'Subnetz', + 'subnet_hint' => 'Stark destruktiv — re-adressiert den Server; bestehende Peers müssen neu angelegt werden.', + 'subnet_invalid' => 'Ungültiges Subnetz (CIDR).', + 'subnet_confirm_title' => 'Subnetz ändern?', + 'subnet_confirm_body' => 'Der Server bekommt eine neue Adresse und der Tunnel wird neu gestartet. ALLE bestehenden Peers verlieren den Zugang und müssen neu angelegt werden.', ]; diff --git a/lang/en/wireguard.php b/lang/en/wireguard.php index 11810f0..7fb500e 100644 --- a/lang/en/wireguard.php +++ b/lang/en/wireguard.php @@ -46,4 +46,26 @@ return [ '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', + 'settings_title' => 'Settings', + 'apply' => 'Apply', + 'gate_toggle' => 'Gate', + 'gate_turn_on' => 'Turn on', + 'gate_turn_off' => 'Turn off', + 'gate_on_title' => 'Turn gate on?', + 'gate_on_body' => 'The panel will then only be reachable through the tunnel. Make sure the tunnel reaches the panel first — otherwise only SSH ("clusev wg down") gets you back.', + 'gate_off_title' => 'Turn gate off?', + 'gate_off_body' => 'The panel becomes publicly reachable again (only 2FA + login protection then guard it).', + 'endpoint_label' => 'Public endpoint', + 'endpoint_hint' => 'IP or DNS:port — affects only future peer configs.', + 'endpoint_invalid' => 'Invalid endpoint.', + 'port_label' => 'Listen port (UDP)', + 'port_hint' => 'Changing this restarts the tunnel — all clients must update the port.', + 'port_invalid' => 'Port 1–65535.', + 'port_confirm_title' => 'Change port?', + 'port_confirm_body' => 'The tunnel restarts. All clients must set the new port in their config or they lose the connection.', + 'subnet_label' => 'Subnet', + 'subnet_hint' => 'Highly destructive — re-addresses the server; existing peers must be re-created.', + 'subnet_invalid' => 'Invalid subnet (CIDR).', + 'subnet_confirm_title' => 'Change subnet?', + 'subnet_confirm_body' => 'The server gets a new address and the tunnel restarts. ALL existing peers lose access and must be re-created.', ]; diff --git a/resources/views/livewire/wireguard/index.blade.php b/resources/views/livewire/wireguard/index.blade.php index 9988ee7..0c1b48a 100644 --- a/resources/views/livewire/wireguard/index.blade.php +++ b/resources/views/livewire/wireguard/index.blade.php @@ -150,6 +150,67 @@
{{ __('wireguard.service') }}
{{ $status['wg_quick'] }}
+ + +
+ {{-- Gate toggle --}} +
+
+ {{ __('wireguard.gate_toggle') }} + @if ($status['gate']['marker'] && $status['gate']['chain']) + {{ __('wireguard.gate_turn_off') }} + @else + {{ __('wireguard.gate_turn_on') }} + @endif +
+
+ + {{-- Endpoint --}} +
+
+ +
+ + {{ __('wireguard.apply') }} +
+

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

+ @error('newEndpoint') {{ $message }} @enderror +
+
+ + {{-- Port --}} +
+
+ +
+ + {{ __('wireguard.apply') }} +
+

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

+ @error('newPort') {{ $message }} @enderror +
+
+ + {{-- Subnet --}} +
+
+ +
+ + {{ __('wireguard.apply') }} +
+

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

+ @error('newSubnet') {{ $message }} @enderror +
+
+
+
@endif diff --git a/tests/Feature/WireguardPageTest.php b/tests/Feature/WireguardPageTest.php index aed14d3..ad2c63e 100644 --- a/tests/Feature/WireguardPageTest.php +++ b/tests/Feature/WireguardPageTest.php @@ -3,7 +3,9 @@ namespace Tests\Feature; use App\Livewire\Wireguard\Index; +use App\Models\AuditEvent; use App\Models\User; +use App\Models\WgTrafficSample; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; use Tests\TestCase; @@ -56,10 +58,10 @@ class WireguardPageTest extends TestCase public function test_window_selector_switches_and_passes_traffic(): void { - \App\Models\WgTrafficSample::create(['peer_pubkey' => 'p1', 'peer_name' => 'a', 'rx' => 0, 'tx' => 0, 'sampled_at' => now()->subMinutes(10)]); - \App\Models\WgTrafficSample::create(['peer_pubkey' => 'p1', 'peer_name' => 'a', 'rx' => 500, 'tx' => 100, 'sampled_at' => now()->subMinutes(1)]); + WgTrafficSample::create(['peer_pubkey' => 'p1', 'peer_name' => 'a', 'rx' => 0, 'tx' => 0, 'sampled_at' => now()->subMinutes(10)]); + WgTrafficSample::create(['peer_pubkey' => 'p1', 'peer_name' => 'a', 'rx' => 500, 'tx' => 100, 'sampled_at' => now()->subMinutes(1)]); - \Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class) + Livewire::test(Index::class) ->assertViewHas('traffic', fn ($t) => $t['total_down'] === 500) ->call('setWindow', 86400) ->assertSet('window', 86400) @@ -68,44 +70,71 @@ class WireguardPageTest extends TestCase public function test_window_clamps_to_allowed_values(): void { - \Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class) + Livewire::test(Index::class) ->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) + Livewire::test(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->assertTrue(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) + Livewire::test(Index::class) ->set('newPeer', 'bad name') ->call('addPeer') ->assertHasErrors('newPeer'); - $this->assertSame(0, \App\Models\AuditEvent::where('action', 'wg.add-peer')->count()); + $this->assertSame(0, 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) + Livewire::test(Index::class) ->call('removePeer', 'laptop') ->assertSet('pendingId', fn ($v) => is_string($v) && $v !== ''); - $this->assertTrue(\App\Models\AuditEvent::where('action', 'wg.remove-peer')->exists()); + $this->assertTrue(AuditEvent::where('action', 'wg.remove-peer')->exists()); + @unlink(storage_path('app/restart-signal/wg-request.json')); + } + + public function test_set_endpoint_writes_a_request_and_audits(): void + { + Livewire::test(Index::class) + ->set('newEndpoint', 'vpn.example.com:51820') + ->call('setEndpoint') + ->assertSet('pendingId', fn ($v) => is_string($v) && $v !== ''); + $this->assertTrue(AuditEvent::where('action', 'wg.set-endpoint')->exists()); + @unlink(storage_path('app/restart-signal/wg-request.json')); + } + + public function test_set_endpoint_rejects_a_bad_value(): void + { + Livewire::test(Index::class) + ->set('newEndpoint', 'has spaces!!') + ->call('setEndpoint') + ->assertHasErrors('newEndpoint'); + } + + public function test_run_gate_writes_the_right_action_and_audits(): void + { + Livewire::test(Index::class) + ->call('runGate', false) + ->assertSet('pendingId', fn ($v) => is_string($v) && $v !== ''); + $this->assertTrue(AuditEvent::where('action', 'wg.gate-down')->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'); + $c = Livewire::test(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()]));