Show WireGuard tunnel state per host in the hosts list
parent
4d4d1dd5d5
commit
eb743d34ed
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue