Show WireGuard tunnel state per host in the hosts list

feat/granted-plans
nexxo 2026-07-29 00:10:15 +02:00
parent 4d4d1dd5d5
commit eb743d34ed
7 changed files with 245 additions and 1 deletions

View File

@ -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<string, PeerSnapshot>, vpnHubReachable: bool}
*/
private function vpnPeerState(): array
{
try {
return ['vpnPeersByKey' => app(WireguardHub::class)->peers(), 'vpnHubReachable' => true];
} catch (\Throwable) {
return ['vpnPeersByKey' => [], 'vpnHubReachable' => false];
}
}
}

View File

@ -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<HostFactory> */
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.

View File

@ -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(

View File

@ -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.',

View File

@ -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.',

View File

@ -47,6 +47,7 @@
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.host') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.datacenter') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.health') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.vpn') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.instances') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.capacity') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.status') }}</th>
@ -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
<tr wire:key="host-{{ $host->uuid }}" class="border-b border-line last:border-0 hover:bg-surface-hover">
<td class="px-4 py-3">
@ -76,6 +82,16 @@
<span class="size-2 rounded-pill {{ $hdot }}" aria-hidden="true"></span>{{ __('hosts.health.'.$health) }}
</span>
</td>
<td class="px-4 py-3">
<span class="inline-flex items-center gap-1.5 text-xs {{ $vpnTone }}">
<x-ui.icon name="{{ $vpnIcon }}" class="size-4" />
@if ($vpnState === 'silent' && $vpnSnapshot?->latestHandshake)
{{ __('hosts.vpn.silent_since', ['time' => $vpnSnapshot->latestHandshake->diffForHumans()]) }}
@else
{{ __('hosts.vpn.'.$vpnState) }}
@endif
</span>
</td>
<td class="px-4 py-3 font-mono text-body">{{ $host->instances_count }}</td>
<td class="px-4 py-3">
@if ($host->total_gb)

View File

@ -0,0 +1,137 @@
<?php
use App\Livewire\Admin\Hosts;
use App\Models\Host;
use App\Services\Wireguard\FakeWireguardHub;
use App\Services\Wireguard\PeerSnapshot;
use App\Services\Wireguard\WireguardHub;
use Carbon\CarbonImmutable;
use Livewire\Livewire;
/**
* The hosts list is the only place a dead management tunnel is visible at all
* (see host-vpn-state-report.md) Traefik keeps serving customers on a dead
* peer, so nothing else fails loudly. These tests pin the three states the
* console must tell apart, plus the honest "don't know" fourth.
*/
function snapshotAgedSeconds(string $publicKey, int $seconds): PeerSnapshot
{
return new PeerSnapshot(
publicKey: $publicKey,
endpoint: '203.0.113.50:51820',
allowedIps: '10.66.0.9/32',
latestHandshake: CarbonImmutable::now()->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);
});