From eb743d34eda46c3a3e4a5beb864d2c07cd3ebae7 Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 00:10:15 +0200 Subject: [PATCH] Show WireGuard tunnel state per host in the hosts list --- app/Livewire/Admin/Hosts.php | 24 +++ app/Models/Host.php | 41 +++++- app/Services/Wireguard/FakeWireguardHub.php | 12 ++ lang/de/hosts.php | 8 + lang/en/hosts.php | 8 + .../views/livewire/admin/hosts.blade.php | 16 ++ tests/Feature/Admin/HostVpnStateTest.php | 137 ++++++++++++++++++ 7 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/Admin/HostVpnStateTest.php diff --git a/app/Livewire/Admin/Hosts.php b/app/Livewire/Admin/Hosts.php index 62ce79b..c988954 100644 --- a/app/Livewire/Admin/Hosts.php +++ b/app/Livewire/Admin/Hosts.php @@ -4,6 +4,8 @@ namespace App\Livewire\Admin; use App\Models\Datacenter; use App\Models\Host; +use App\Services\Wireguard\PeerSnapshot; +use App\Services\Wireguard\WireguardHub; use Livewire\Attributes\Layout; use Livewire\Attributes\Url; use Livewire\Component; @@ -48,6 +50,28 @@ class Hosts extends Component 'datacenters' => Datacenter::query()->orderBy('name')->get(), 'statuses' => ['pending', 'onboarding', 'active', 'error', 'disabled'], 'total' => Host::query()->count(), + ...$this->vpnPeerState(), ]); } + + /** + * One hub call for the whole list, not one per host — peers() already + * returns every peer keyed by public key, so a row just looks itself up. + * + * The hub can be down, or (dev/self-hosted) the `vpn` compose profile not + * running at all; either way this must not turn into a page-breaking + * exception. $hubReachable false is what tells vpnState() to answer + * "unknown" instead of guessing "silent" from an empty map — a wrong green + * (or a wrong amber) is worse than an honest blank. + * + * @return array{vpnPeersByKey: array, vpnHubReachable: bool} + */ + private function vpnPeerState(): array + { + try { + return ['vpnPeersByKey' => app(WireguardHub::class)->peers(), 'vpnHubReachable' => true]; + } catch (\Throwable) { + return ['vpnPeersByKey' => [], 'vpnHubReachable' => false]; + } + } } diff --git a/app/Models/Host.php b/app/Models/Host.php index 381d92f..05f7afd 100644 --- a/app/Models/Host.php +++ b/app/Models/Host.php @@ -4,6 +4,8 @@ namespace App\Models; use App\Models\Concerns\HasUuid; use App\Provisioning\Contracts\ProvisioningSubject; +use App\Services\Wireguard\PeerSnapshot; +use Database\Factories\HostFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -12,7 +14,7 @@ use Illuminate\Database\Eloquent\Relations\MorphMany; class Host extends Model implements ProvisioningSubject { - /** @use HasFactory<\Database\Factories\HostFactory> */ + /** @use HasFactory */ use HasFactory, HasUuid; protected $fillable = [ @@ -123,6 +125,43 @@ class Host extends Model implements ProvisioningSubject }; } + /** + * WireGuard rekeys a live tunnel every REKEY_AFTER_TIME (120s, protocol + * constant) whenever there is traffic, and considers a session dead outright + * after REJECT_AFTER_TIME (180s). 180s is therefore "the tunnel protocol + * itself would call this gone" — one rekey cycle of slack over the 120s + * baseline, not a round number picked for looks. + */ + public const VPN_HANDSHAKE_FRESH_SECONDS = 180; + + /** + * VPN tunnel state for the console's hosts list. + * + * `wg_pubkey` only proves onboarding CONFIGURED a peer — a tunnel silent for + * two days looks identical in the database to one that handshaked a minute + * ago. $snapshot (from WireguardHub::peers(), live and not persisted) is + * what actually answers "is it up right now"; $hubReachable distinguishes + * "the hub said no recent handshake" from "the hub could not be asked at + * all" — collapsing those would show a false state instead of an honest one. + */ + public function vpnState(?PeerSnapshot $snapshot, bool $hubReachable): string + { + if ($this->wg_pubkey === null) { + return 'not_configured'; + } + + if (! $hubReachable) { + return 'unknown'; + } + + if ($snapshot?->latestHandshake !== null + && $snapshot->latestHandshake->diffInSeconds(now()) <= self::VPN_HANDSHAKE_FRESH_SECONDS) { + return 'connected'; + } + + return 'silent'; + } + /** * Placement (spec §1): first active host in the datacenter with enough free * committable storage. Cluster is ignored in v1.0. diff --git a/app/Services/Wireguard/FakeWireguardHub.php b/app/Services/Wireguard/FakeWireguardHub.php index 7e6422a..160aa95 100644 --- a/app/Services/Wireguard/FakeWireguardHub.php +++ b/app/Services/Wireguard/FakeWireguardHub.php @@ -16,6 +16,12 @@ class FakeWireguardHub implements WireguardHub /** Snapshots the tests can shape; defaults to "configured, never handshaked". */ public array $snapshots = []; + /** When set, peers() throws — simulates the hub being unreachable (down, or the `vpn` profile not running). */ + public bool $failPeers = false; + + /** How many times peers() was called — tests use this to prove a caller fetches once, not once per host. */ + public int $peersCalls = 0; + public function allocateIp(): string { return '10.66.0.'.$this->next++; @@ -33,6 +39,12 @@ class FakeWireguardHub implements WireguardHub public function peers(): array { + $this->peersCalls++; + + if ($this->failPeers) { + throw new \RuntimeException('wg show failed: interface not found'); + } + $peers = []; foreach ($this->peers as $publicKey => $ip) { $peers[$publicKey] = $this->snapshots[$publicKey] ?? new PeerSnapshot( diff --git a/lang/de/hosts.php b/lang/de/hosts.php index e7b71cb..4aa6984 100644 --- a/lang/de/hosts.php +++ b/lang/de/hosts.php @@ -15,6 +15,7 @@ return [ 'host' => 'Host', 'datacenter' => 'RZ', 'health' => 'Zustand', + 'vpn' => 'VPN', 'instances' => 'Instanzen', 'capacity' => 'Kapazität', 'status' => 'Status', @@ -24,6 +25,13 @@ return [ 'stale' => 'Verzögert', 'offline' => 'Offline', ], + 'vpn' => [ + 'not_configured' => 'Nicht eingerichtet', + 'connected' => 'Verbunden', + 'silent' => 'Still', + 'silent_since' => 'Still · :time', + 'unknown' => 'Unbekannt', + ], 'create_title' => 'Host hinzufügen', 'create_sub' => 'Frischen Server (Debian, nur Root) automatisch als Proxmox-Host aufnehmen.', diff --git a/lang/en/hosts.php b/lang/en/hosts.php index c8e3a1e..ba4d75b 100644 --- a/lang/en/hosts.php +++ b/lang/en/hosts.php @@ -15,6 +15,7 @@ return [ 'host' => 'Host', 'datacenter' => 'DC', 'health' => 'Health', + 'vpn' => 'VPN', 'instances' => 'Instances', 'capacity' => 'Capacity', 'status' => 'Status', @@ -24,6 +25,13 @@ return [ 'stale' => 'Stale', 'offline' => 'Offline', ], + 'vpn' => [ + 'not_configured' => 'Not configured', + 'connected' => 'Connected', + 'silent' => 'Silent', + 'silent_since' => 'Silent · :time', + 'unknown' => 'Unknown', + ], 'create_title' => 'Add host', 'create_sub' => 'Onboard a fresh server (Debian, root only) as a Proxmox host automatically.', diff --git a/resources/views/livewire/admin/hosts.blade.php b/resources/views/livewire/admin/hosts.blade.php index 01f2112..1399be7 100644 --- a/resources/views/livewire/admin/hosts.blade.php +++ b/resources/views/livewire/admin/hosts.blade.php @@ -47,6 +47,7 @@ {{ __('hosts.col.host') }} {{ __('hosts.col.datacenter') }} {{ __('hosts.col.health') }} + {{ __('hosts.col.vpn') }} {{ __('hosts.col.instances') }} {{ __('hosts.col.capacity') }} {{ __('hosts.col.status') }} @@ -59,6 +60,11 @@ $health = $host->healthState(); $hdot = ['online' => 'bg-success-bright', 'stale' => 'bg-warning', 'offline' => 'bg-danger'][$health]; $usedPct = $host->usedPct(); + + $vpnSnapshot = $host->wg_pubkey !== null ? ($vpnPeersByKey[$host->wg_pubkey] ?? null) : null; + $vpnState = $host->vpnState($vpnSnapshot, $vpnHubReachable); + $vpnIcon = ['not_configured' => 'unlock', 'connected' => 'lock', 'silent' => 'alert-triangle', 'unknown' => 'life-buoy'][$vpnState]; + $vpnTone = ['not_configured' => 'text-muted', 'connected' => 'text-success', 'silent' => 'text-warning', 'unknown' => 'text-muted'][$vpnState]; @endphp @@ -76,6 +82,16 @@ {{ __('hosts.health.'.$health) }} + + + + @if ($vpnState === 'silent' && $vpnSnapshot?->latestHandshake) + {{ __('hosts.vpn.silent_since', ['time' => $vpnSnapshot->latestHandshake->diffForHumans()]) }} + @else + {{ __('hosts.vpn.'.$vpnState) }} + @endif + + {{ $host->instances_count }} @if ($host->total_gb) diff --git a/tests/Feature/Admin/HostVpnStateTest.php b/tests/Feature/Admin/HostVpnStateTest.php new file mode 100644 index 0000000..4737b80 --- /dev/null +++ b/tests/Feature/Admin/HostVpnStateTest.php @@ -0,0 +1,137 @@ +subSeconds($seconds), + rxBytes: 100, + txBytes: 100, + ); +} + +it('reports not_configured for a host with no wg_pubkey at all', function () { + $host = Host::factory()->create(['wg_pubkey' => null]); + + expect($host->vpnState(null, hubReachable: true))->toBe('not_configured'); +}); + +it('reports connected for a fresh handshake', function () { + $host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']); + $snapshot = snapshotAgedSeconds('HOSTPUB=', 30); + + expect($host->vpnState($snapshot, hubReachable: true))->toBe('connected'); +}); + +it('reports silent for a stale handshake', function () { + $host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']); + $snapshot = snapshotAgedSeconds('HOSTPUB=', 60 * 60 * 24 * 2); // two days + + expect($host->vpnState($snapshot, hubReachable: true))->toBe('silent'); +}); + +it('reports silent for a configured peer that never handshaked', function () { + $host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']); + $snapshot = new PeerSnapshot('HOSTPUB=', null, '10.66.0.9/32', null, 0, 0); + + expect($host->vpnState($snapshot, hubReachable: true))->toBe('silent'); +}); + +it('reports unknown when the hub could not be asked, even for a configured host', function () { + $host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']); + + expect($host->vpnState(null, hubReachable: false))->toBe('unknown'); +}); + +it('never reports not_configured as unknown or vice versa regardless of hub reachability', function () { + $unconfigured = Host::factory()->create(['wg_pubkey' => null]); + + expect($unconfigured->vpnState(null, hubReachable: false))->toBe('not_configured'); +}); + +/** + * Pins the exact threshold boundary — this is the comparison the mutation + * pass targets (`<=` flipped to `<`, or the constant changed) to prove the + * test actually fails when a stale handshake would otherwise read as connected. + */ +it('treats a handshake older than the freshness threshold as silent, not connected', function () { + $host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']); + + // Frozen clock: a wall-clock version of this test is one CPU hiccup away + // from putting "now" a millisecond past the boundary it means to sit at. + // travelTo() freezes both Carbon and CarbonImmutable's now(). + $now = CarbonImmutable::parse('2026-07-28 12:00:00'); + $this->travelTo($now); + + $atThreshold = new PeerSnapshot('HOSTPUB=', null, '10.66.0.9/32', $now->subSeconds(Host::VPN_HANDSHAKE_FRESH_SECONDS), 0, 0); + $pastThreshold = new PeerSnapshot('HOSTPUB=', null, '10.66.0.9/32', $now->subSeconds(Host::VPN_HANDSHAKE_FRESH_SECONDS + 1), 0, 0); + + expect($host->vpnState($atThreshold, hubReachable: true))->toBe('connected') + ->and($host->vpnState($pastThreshold, hubReachable: true))->toBe('silent'); + + $this->travelBack(); +}); + +it('renders the three VPN states distinctly in the hosts list', function () { + $hub = new FakeWireguardHub; + app()->instance(WireguardHub::class, $hub); + + $unconfigured = Host::factory()->active()->create(['name' => 'pve-none-1', 'wg_pubkey' => null]); + $connected = Host::factory()->active()->create(['name' => 'pve-conn-1', 'wg_pubkey' => 'CONNPUB=']); + $silent = Host::factory()->active()->create(['name' => 'pve-silent-1', 'wg_pubkey' => 'SILENTPUB=']); + + $hub->addPeer('CONNPUB=', '10.66.0.10'); + $hub->addPeer('SILENTPUB=', '10.66.0.11'); + $hub->snapshots['CONNPUB='] = snapshotAgedSeconds('CONNPUB=', 20); + $hub->snapshots['SILENTPUB='] = snapshotAgedSeconds('SILENTPUB=', 60 * 60 * 24 * 3); + + $response = $this->actingAs(admin(), 'operator')->get(route('admin.hosts')); + + $response->assertOk() + ->assertSeeInOrder([$unconfigured->name, __('hosts.vpn.not_configured')]) + ->assertSeeInOrder([$connected->name, __('hosts.vpn.connected')]) + // "Still · vor 3 Tagen" (or English "Silent · 3 days ago") — the fixed + // word proves the state, the diffForHumans() tail proves "how long". + ->assertSee(__('hosts.vpn.silent')); +}); + +it('shows unknown, not a false connected or silent, when the hub is unreachable', function () { + $hub = new FakeWireguardHub; + $hub->failPeers = true; + app()->instance(WireguardHub::class, $hub); + + $host = Host::factory()->active()->create(['name' => 'pve-unreachable-1', 'wg_pubkey' => 'HOSTPUB=']); + + $this->actingAs(admin(), 'operator')->get(route('admin.hosts')) + ->assertOk() + ->assertSeeInOrder([$host->name, __('hosts.vpn.unknown')]); +}); + +it('calls the hub once for the whole list, not once per host', function () { + $hub = new FakeWireguardHub; + app()->instance(WireguardHub::class, $hub); + + foreach (range(1, 5) as $i) { + Host::factory()->active()->create(['wg_pubkey' => 'PUB'.$i.'=']); + } + + Livewire::actingAs(admin(), 'operator')->test(Hosts::class); + + expect($hub->peersCalls)->toBe(1); +});