From 8e05cf62ec6311ce825367b136f87d23430cddf6 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 21 Jun 2026 08:09:35 +0200 Subject: [PATCH] feat(wg): set WireGuard up from the dashboard (setup form + bridge action) Co-Authored-By: Claude Sonnet 4.6 --- app/Livewire/Wireguard/Index.php | 43 ++++++++++++++++++- app/Services/WgBridge.php | 21 ++++++++- lang/de/wireguard.php | 9 ++++ lang/en/wireguard.php | 9 ++++ .../views/livewire/wireguard/index.blade.php | 40 +++++++++++++++-- tests/Feature/WgBridgeTest.php | 15 +++++++ tests/Feature/WireguardPageTest.php | 20 ++++++++- 7 files changed, 150 insertions(+), 7 deletions(-) diff --git a/app/Livewire/Wireguard/Index.php b/app/Livewire/Wireguard/Index.php index 3fea3f5..cdac581 100644 --- a/app/Livewire/Wireguard/Index.php +++ b/app/Livewire/Wireguard/Index.php @@ -33,6 +33,15 @@ class Index extends Component public string $newSubnet = ''; + // first-time setup form + public string $setupSubnet = '10.99.0.0/24'; + + public string $setupPort = '51820'; + + public string $setupEndpoint = ''; + + public string $setupPeer = 'client-1'; + /** id of an in-flight write-request we are polling for. */ public ?string $pendingId = null; @@ -64,6 +73,35 @@ class Index extends Component $this->audit('wg.add-peer', $name); } + public function setupWg(WgBridge $bridge): void + { + $this->validate([ + 'setupSubnet' => ['required', 'regex:#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#'], + 'setupPort' => ['required', 'regex:/^\d{1,5}$/'], + 'setupEndpoint' => ['nullable', 'regex:/^[A-Za-z0-9.:_-]{1,128}$/'], + 'setupPeer' => ['required', 'regex:/^[A-Za-z0-9._-]{1,64}$/'], + ], [ + 'setupSubnet.regex' => __('wireguard.subnet_invalid'), + 'setupPort.regex' => __('wireguard.port_invalid'), + 'setupEndpoint.regex' => __('wireguard.endpoint_invalid'), + 'setupPeer.regex' => __('wireguard.peer_name_invalid'), + ]); + if ((int) $this->setupPort < 1 || (int) $this->setupPort > 65535) { + $this->addError('setupPort', __('wireguard.port_invalid')); + + return; + } + if (! $this->throttle()) { + return; + } + $this->pendingId = $bridge->request('setup', [ + 'subnet' => $this->setupSubnet, 'port' => $this->setupPort, + 'endpoint' => $this->setupEndpoint, 'name' => $this->setupPeer, + ]); + $this->pendingAction = 'setup'; + $this->audit('wg.setup', $this->setupSubnet); + } + /** Opens the wire-elements/modal confirm dialog for peer removal. */ public function confirmRemovePeer(string $name): void { @@ -264,11 +302,12 @@ class Index extends Component return; } $this->pendingId = null; - if ($res['ok'] && $this->pendingAction === 'add-peer' && $res['config'] !== null) { + if ($res['ok'] && in_array($this->pendingAction, ['add-peer', 'setup'], true) && $res['config'] !== null) { $this->resultConfig = $res['config']; $this->resultQr = $res['qr']; } elseif (! $res['ok']) { - $this->dispatch('notify', message: __('wireguard.action_failed'), level: 'error'); + $msg = ($res['message'] ?? '') !== '' ? $res['message'] : __('wireguard.action_failed'); + $this->dispatch('notify', message: $msg, level: 'error'); } else { $this->dispatch('notify', message: __('wireguard.action_done')); } diff --git a/app/Services/WgBridge.php b/app/Services/WgBridge.php index 7e10299..09af78a 100644 --- a/app/Services/WgBridge.php +++ b/app/Services/WgBridge.php @@ -12,7 +12,7 @@ use Illuminate\Support\Str; class WgBridge { /** Actions the UI may request; the host whitelists the same set. */ - public const ACTIONS = ['add-peer', 'remove-peer', 'gate-up', 'gate-down', 'set-endpoint', 'set-port', 'set-subnet']; + public const ACTIONS = ['add-peer', 'remove-peer', 'gate-up', 'gate-down', 'set-endpoint', 'set-port', 'set-subnet', 'setup']; private function dir(): string { @@ -110,6 +110,25 @@ class WgBridge } return ['subnet' => $subnet]; + case 'setup': + $subnet = (string) ($args['subnet'] ?? ''); + $port = (string) ($args['port'] ?? ''); + $endpoint = (string) ($args['endpoint'] ?? ''); + $name = (string) ($args['name'] ?? ''); + if (preg_match('#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#', $subnet) !== 1) { + throw new \InvalidArgumentException('invalid subnet'); + } + if (preg_match('/^\d{1,5}$/', $port) !== 1 || (int) $port < 1 || (int) $port > 65535) { + throw new \InvalidArgumentException('invalid port'); + } + if ($endpoint !== '' && preg_match('/^[A-Za-z0-9.:_-]{1,128}$/', $endpoint) !== 1) { + throw new \InvalidArgumentException('invalid endpoint'); + } + if (preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1) { + throw new \InvalidArgumentException('invalid name'); + } + + return ['subnet' => $subnet, 'port' => $port, 'endpoint' => $endpoint, 'name' => $name]; } return []; diff --git a/lang/de/wireguard.php b/lang/de/wireguard.php index 1e0a783..dad400b 100644 --- a/lang/de/wireguard.php +++ b/lang/de/wireguard.php @@ -68,4 +68,13 @@ return [ '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.', + 'setup_title' => 'WireGuard einrichten', + 'setup_intro' => 'Tunnel direkt hier einrichten. Das Gate bleibt danach AUS — das Panel ist weiter öffentlich, bis du es einschaltest.', + 'setup_subnet' => 'Subnetz', + 'setup_port' => 'Listen-Port (UDP)', + 'setup_endpoint' => 'Öffentlicher Endpoint (optional)', + 'setup_endpoint_hint' => 'Leer = automatisch erkannte öffentliche IP. Hinter NAT/Cloud-LB manuell setzen.', + 'setup_peer' => 'Name des ersten Clients', + 'setup_submit' => 'Einrichten', + 'setup_or_ssh' => 'Alternativ per SSH:', ]; diff --git a/lang/en/wireguard.php b/lang/en/wireguard.php index 7fb500e..d27458b 100644 --- a/lang/en/wireguard.php +++ b/lang/en/wireguard.php @@ -68,4 +68,13 @@ return [ '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.', + 'setup_title' => 'Set up WireGuard', + 'setup_intro' => 'Set the tunnel up right here. The gate stays OFF afterwards — the panel remains public until you turn it on.', + 'setup_subnet' => 'Subnet', + 'setup_port' => 'Listen port (UDP)', + 'setup_endpoint' => 'Public endpoint (optional)', + 'setup_endpoint_hint' => 'Empty = auto-detected public IP. Behind NAT/cloud LB set it manually.', + 'setup_peer' => 'First client name', + 'setup_submit' => 'Set up', + 'setup_or_ssh' => 'Or over SSH:', ]; diff --git a/resources/views/livewire/wireguard/index.blade.php b/resources/views/livewire/wireguard/index.blade.php index 0c1b48a..098ab80 100644 --- a/resources/views/livewire/wireguard/index.blade.php +++ b/resources/views/livewire/wireguard/index.blade.php @@ -24,9 +24,43 @@ @if (! $status['configured']) - -

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

-
sudo clusev wg setup
+ +

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

+
+
+
+ + + @error('setupSubnet')

{{ $message }}

@enderror +
+
+ + + @error('setupPort')

{{ $message }}

@enderror +
+
+ + + @error('setupEndpoint')

{{ $message }}

@enderror +
+
+ + + @error('setupPeer')

{{ $message }}

@enderror +
+
+

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

+
+ + {{ __('wireguard.setup_submit') }} + + @if ($pendingId) + {{ __('wireguard.pending') }} + @endif +
+
+

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

+
sudo clusev wg setup
@else @if ($status['stale']) diff --git a/tests/Feature/WgBridgeTest.php b/tests/Feature/WgBridgeTest.php index 5437456..42b81d2 100644 --- a/tests/Feature/WgBridgeTest.php +++ b/tests/Feature/WgBridgeTest.php @@ -82,4 +82,19 @@ class WgBridgeTest extends TestCase $this->assertNull(app(WgBridge::class)->result('../etc/passwd')); $this->assertNull(app(WgBridge::class)->result('x')); } + + public function test_setup_request_validates_and_writes(): void + { + $id = app(WgBridge::class)->request('setup', ['subnet' => '10.99.0.0/24', 'port' => '51820', 'endpoint' => '1.2.3.4:51820', 'name' => 'client-1']); + $this->assertMatchesRegularExpression('/^[A-Za-z0-9]{16,64}$/', $id); + $req = json_decode((string) file_get_contents(storage_path('app/restart-signal/wg-request.json')), true); + $this->assertSame('setup', $req['action']); + $this->assertSame('10.99.0.0/24', $req['subnet']); + } + + public function test_setup_request_rejects_a_bad_subnet(): void + { + $this->expectException(\InvalidArgumentException::class); + app(WgBridge::class)->request('setup', ['subnet' => 'nope', 'port' => '51820', 'name' => 'c']); + } } diff --git a/tests/Feature/WireguardPageTest.php b/tests/Feature/WireguardPageTest.php index ad2c63e..92c580e 100644 --- a/tests/Feature/WireguardPageTest.php +++ b/tests/Feature/WireguardPageTest.php @@ -37,7 +37,7 @@ class WireguardPageTest extends TestCase Livewire::test(Index::class) ->assertOk() ->assertViewHas('status', fn ($s) => $s['configured'] === false) - ->assertSee(__('wireguard.not_configured_title')); + ->assertSee(__('wireguard.setup_title')); } public function test_renders_peers_when_configured(): void @@ -143,4 +143,22 @@ class WireguardPageTest extends TestCase ->assertSet('resultConfig', fn ($v) => str_contains((string) $v, 'PrivateKey')); @unlink(storage_path("app/restart-signal/wg-result-{$id}.json")); } + + public function test_setup_writes_a_request_and_audits(): void + { + Livewire::test(Index::class) + ->set('setupSubnet', '10.99.0.0/24')->set('setupPort', '51820')->set('setupEndpoint', '1.2.3.4:51820')->set('setupPeer', 'client-1') + ->call('setupWg') + ->assertSet('pendingId', fn ($v) => is_string($v) && $v !== ''); + $this->assertTrue(AuditEvent::where('action', 'wg.setup')->exists()); + @unlink(storage_path('app/restart-signal/wg-request.json')); + } + + public function test_setup_rejects_a_bad_subnet(): void + { + Livewire::test(Index::class) + ->set('setupSubnet', 'nope')->set('setupPort', '51820')->set('setupPeer', 'c') + ->call('setupWg') + ->assertHasErrors('setupSubnet'); + } }