From e640a96ca0536242e1aefa11e9fee1158731f0ab Mon Sep 17 00:00:00 2001 From: boban Date: Sat, 20 Jun 2026 23:35:58 +0200 Subject: [PATCH] feat(wg): /wireguard live-status page (read-only) + nav (DE/EN) Co-Authored-By: Claude Sonnet 4.6 --- app/Livewire/Wireguard/Index.php | 22 +++++ lang/de/shell.php | 1 + lang/de/wireguard.php | 31 +++++++ lang/en/shell.php | 1 + lang/en/wireguard.php | 31 +++++++ resources/views/components/sidebar.blade.php | 1 + .../views/livewire/wireguard/index.blade.php | 83 +++++++++++++++++++ routes/web.php | 2 + tests/Feature/WireguardPageTest.php | 56 +++++++++++++ 9 files changed, 228 insertions(+) create mode 100644 app/Livewire/Wireguard/Index.php create mode 100644 lang/de/wireguard.php create mode 100644 lang/en/wireguard.php create mode 100644 resources/views/livewire/wireguard/index.blade.php create mode 100644 tests/Feature/WireguardPageTest.php diff --git a/app/Livewire/Wireguard/Index.php b/app/Livewire/Wireguard/Index.php new file mode 100644 index 0000000..40315aa --- /dev/null +++ b/app/Livewire/Wireguard/Index.php @@ -0,0 +1,22 @@ + $wg->read(), + ])->title(__('wireguard.title')); + } +} diff --git a/lang/de/shell.php b/lang/de/shell.php index f32255d..adc8c2a 100644 --- a/lang/de/shell.php +++ b/lang/de/shell.php @@ -18,6 +18,7 @@ return [ 'nav_services' => 'Dienste', 'nav_files' => 'Dateien', 'nav_audit' => 'Audit-Log', + 'nav_wireguard' => 'WireGuard', 'nav_settings' => 'Einstellungen', 'nav_system' => 'System', 'nav_versions' => 'Version', diff --git a/lang/de/wireguard.php b/lang/de/wireguard.php new file mode 100644 index 0000000..f5101c7 --- /dev/null +++ b/lang/de/wireguard.php @@ -0,0 +1,31 @@ + 'WireGuard — Clusev', + 'eyebrow' => 'Netzwerk', + 'heading' => 'WireGuard', + 'subtitle' => 'Tunnel-Zugang zum Panel — Live-Status.', + 'not_configured_title' => 'WireGuard ist nicht eingerichtet', + 'not_configured_body' => 'Auf dem Host einrichten: per SSH "sudo clusev wg setup". Danach erscheint hier der Live-Status.', + 'stale' => 'Status veraltet — der Host-Collector antwortet nicht. Läuft der Stack?', + 'gate' => 'Gate (Panel-Sperre)', + 'gate_on' => 'aktiv — Panel nur über den Tunnel', + 'gate_off' => 'aus — Panel öffentlich erreichbar', + 'gate_escape' => 'Notausgang per SSH: "clusev wg down".', + 'server_title' => 'Server', + 'subnet' => 'Subnetz', + 'server_ip' => 'Server-IP', + 'port' => 'Port', + 'endpoint' => 'Endpoint', + 'server_key' => 'Server-Schlüssel', + 'service' => 'Dienst', + 'peers_title' => 'Peers', + 'peers_count' => ':count Peer|:count Peers', + 'no_peers' => 'Noch keine Peers — per SSH "clusev wg add-peer ".', + 'online' => 'online', + 'offline' => 'offline', + 'last_handshake' => 'letzter Handshake', + 'never' => 'nie', + 'transfer' => 'Übertragung', + 'down_up' => '↓ :rx · ↑ :tx', +]; diff --git a/lang/en/shell.php b/lang/en/shell.php index 7855de9..19b9e9f 100644 --- a/lang/en/shell.php +++ b/lang/en/shell.php @@ -18,6 +18,7 @@ return [ 'nav_services' => 'Services', 'nav_files' => 'Files', 'nav_audit' => 'Audit log', + 'nav_wireguard' => 'WireGuard', 'nav_settings' => 'Settings', 'nav_system' => 'System', 'nav_versions' => 'Version', diff --git a/lang/en/wireguard.php b/lang/en/wireguard.php new file mode 100644 index 0000000..91b8a43 --- /dev/null +++ b/lang/en/wireguard.php @@ -0,0 +1,31 @@ + 'WireGuard — Clusev', + 'eyebrow' => 'Network', + 'heading' => 'WireGuard', + 'subtitle' => 'Tunnel access to the panel — live status.', + 'not_configured_title' => 'WireGuard is not set up', + 'not_configured_body' => 'Set it up on the host: over SSH run "sudo clusev wg setup". The live status appears here afterwards.', + 'stale' => 'Status is stale — the host collector is not responding. Is the stack running?', + 'gate' => 'Gate (panel lock)', + 'gate_on' => 'on — panel only via the tunnel', + 'gate_off' => 'off — panel publicly reachable', + 'gate_escape' => 'Escape over SSH: "clusev wg down".', + 'server_title' => 'Server', + 'subnet' => 'Subnet', + 'server_ip' => 'Server IP', + 'port' => 'Port', + 'endpoint' => 'Endpoint', + 'server_key' => 'Server key', + 'service' => 'Service', + 'peers_title' => 'Peers', + 'peers_count' => ':count peer|:count peers', + 'no_peers' => 'No peers yet — over SSH run "clusev wg add-peer ".', + 'online' => 'online', + 'offline' => 'offline', + 'last_handshake' => 'last handshake', + 'never' => 'never', + 'transfer' => 'Transfer', + 'down_up' => '↓ :rx · ↑ :tx', +]; diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index d0f5f0a..b931f08 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -29,6 +29,7 @@ {{ __('shell.nav_services') }} {{ __('shell.nav_files') }} {{ __('shell.nav_audit') }} + {{ __('shell.nav_wireguard') }}

{{ __('shell.group_account') }}

{{ __('shell.nav_settings') }} diff --git a/resources/views/livewire/wireguard/index.blade.php b/resources/views/livewire/wireguard/index.blade.php new file mode 100644 index 0000000..e032627 --- /dev/null +++ b/resources/views/livewire/wireguard/index.blade.php @@ -0,0 +1,83 @@ +@php + $fmtBytes = function (int $n): string { + $u = ['B', 'KB', 'MB', 'GB', 'TB']; + $i = 0; + $v = (float) $n; + while ($v >= 1024 && $i < count($u) - 1) { $v /= 1024; $i++; } + return ($i === 0 ? (string) $n : number_format($v, 1)).' '.$u[$i]; + }; + $ago = function (int $ts): string { + if ($ts <= 0) return __('wireguard.never'); + $d = max(0, time() - $ts); + if ($d < 60) return $d.'s'; + if ($d < 3600) return intdiv($d, 60).'m'; + if ($d < 86400) return intdiv($d, 3600).'h'; + return intdiv($d, 86400).'d'; + }; +@endphp + +
+
+

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

+

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

+

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

+
+ + @if (! $status['configured']) + +

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

+
sudo clusev wg setup
+
+ @else + @if ($status['stale']) +
+ + {{ __('wireguard.stale') }} +
+ @endif + +
+ +
+ @forelse ($status['peers'] as $peer) +
+ + + {{ $peer['name'] !== '' ? $peer['name'] : \Illuminate\Support\Str::limit($peer['pubkey'], 10, '…') }} + + {{ $peer['online'] ? __('wireguard.online') : __('wireguard.offline') }} + {{ __('wireguard.last_handshake') }}: {{ $ago($peer['latest_handshake']) }} + + {{ $peer['endpoint'] !== '' ? $peer['endpoint'] : '—' }} + {{ __('wireguard.down_up', ['rx' => $fmtBytes($peer['rx']), 'tx' => $fmtBytes($peer['tx'])]) }} + +
+ @empty +

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

+ @endforelse +
+
+ +
+ + @if ($status['gate']['marker'] && $status['gate']['chain']) + {{ __('wireguard.gate_on') }} + @else + {{ __('wireguard.gate_off') }} + @endif +

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

+
+ + +
+
{{ __('wireguard.subnet') }}
{{ $status['server']['subnet'] ?: '—' }}
+
{{ __('wireguard.server_ip') }}
{{ $status['server']['server_ip'] ?: '—' }}
+
{{ __('wireguard.port') }}
{{ $status['server']['port'] ?: '—' }}
+
{{ __('wireguard.endpoint') }}
{{ $status['server']['endpoint'] ?: '—' }}
+
{{ __('wireguard.service') }}
{{ $status['wg_quick'] }}
+
+
+
+
+ @endif +
diff --git a/routes/web.php b/routes/web.php index ee2ab78..7401540 100644 --- a/routes/web.php +++ b/routes/web.php @@ -12,6 +12,7 @@ use App\Livewire\Services; use App\Livewire\Settings; use App\Livewire\System; use App\Livewire\Versions; +use App\Livewire\Wireguard; use App\Services\DeploymentService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth as AuthFacade; @@ -131,6 +132,7 @@ Route::middleware('auth')->group(function () { Route::get('/versions', Versions\Index::class)->name('versions'); Route::get('/system', System\Index::class)->name('system'); Route::get('/help', Help\Index::class)->name('help'); + Route::get('/wireguard', Wireguard\Index::class)->name('wireguard'); Route::get('/wg-status.json', fn (\App\Services\WgStatus $wg) => response()->json($wg->read()))->name('wg.status'); diff --git a/tests/Feature/WireguardPageTest.php b/tests/Feature/WireguardPageTest.php new file mode 100644 index 0000000..7aa7b5a --- /dev/null +++ b/tests/Feature/WireguardPageTest.php @@ -0,0 +1,56 @@ +file = $dir.'/wg-status.json'; + @unlink($this->file); + $this->actingAs(User::factory()->create(['must_change_password' => false])); + } + + protected function tearDown(): void + { + @unlink($this->file); + parent::tearDown(); + } + + public function test_renders_unconfigured_state(): void + { + Livewire::test(Index::class) + ->assertOk() + ->assertViewHas('status', fn ($s) => $s['configured'] === false) + ->assertSee(__('wireguard.not_configured_title')); + } + + public function test_renders_peers_when_configured(): void + { + file_put_contents($this->file, json_encode([ + 'at' => time(), 'configured' => true, + 'gate' => ['marker' => true, 'chain' => true], 'wg_quick' => 'active', + 'server' => ['subnet' => '10.99.0.0/24', 'server_ip' => '10.99.0.1', 'port' => 51820, 'endpoint' => '1.2.3.4:51820', 'pubkey' => 'srvpub'], + 'peers' => [['name' => 'laptop', 'pubkey' => 'p1', 'endpoint' => '5.6.7.8:41820', 'latest_handshake' => time(), 'rx' => 100, 'tx' => 200]], + ])); + + Livewire::test(Index::class) + ->assertOk() + ->assertViewHas('status', fn ($s) => $s['configured'] === true && count($s['peers']) === 1) + ->assertSee('laptop') + ->assertSee('10.99.0.0/24'); + } +}