Read VPN host state from the synced peer table, not a live hub call
tests / pest (push) Failing after 7m20s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details

feat/granted-plans
nexxo 2026-07-29 00:18:21 +02:00
parent b1ab0f7f31
commit 7a5a71dc89
5 changed files with 139 additions and 107 deletions

View File

@ -4,8 +4,6 @@ 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;
@ -36,6 +34,11 @@ class Hosts extends Component
{
$hosts = Host::query()
->withCount('instances')
// The VPN column reads SyncVpnPeers' synced row, not a live hub
// call — the console container has no wg0 (see VpnPeer::vpnPeer()
// and SyncVpnPeers' docblock). One eager-loaded query for the
// whole page, not one per host.
->with('vpnPeer')
->when($this->search !== '', function ($q) {
$term = '%'.$this->search.'%';
$q->where(fn ($w) => $w->where('name', 'like', $term)->orWhere('public_ip', 'like', $term));
@ -50,28 +53,6 @@ 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,12 +4,12 @@ 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;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Host extends Model implements ProvisioningSubject
@ -55,6 +55,16 @@ class Host extends Model implements ProvisioningSubject
return $this->hasMany(Instance::class);
}
/**
* The synced VPN peer row SyncVpnPeers adopts for this host (see that
* job's docblock the console container has no wg0, so this table, not a
* live hub call, is the only honest source for handshake state here).
*/
public function vpnPeer(): HasOne
{
return $this->hasOne(VpnPeer::class);
}
public function maintenanceWindows(): BelongsToMany
{
return $this->belongsToMany(MaintenanceWindow::class);
@ -134,28 +144,46 @@ class Host extends Model implements ProvisioningSubject
*/
public const VPN_HANDSHAKE_FRESH_SECONDS = 180;
/**
* How old `vpn_peers.observed_at` may be before its `last_handshake_at` is
* no longer trusted. SyncVpnPeers runs every minute (routes/console.php);
* 300s is five missed scheduler ticks the same order of magnitude as
* healthState()'s own "still counts as online" heartbeat window (5 min)
* for a per-minute signal, generous enough to absorb ordinary queue
* latency without being trigger-happy, tight enough to actually catch the
* sync worker being down rather than the tunnel.
*/
public const VPN_SYNC_STALE_SECONDS = 300;
/**
* 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.
* ago. $peer (the row SyncVpnPeers keeps current see that job's docblock:
* the console container has no wg0, so this table is the only honest source
* here) carries both `last_handshake_at` and `observed_at`.
*
* Two different kinds of "we don't know" collapse to `unknown`, not to a
* guessed `silent`: no row at all (never synced yet), and a row whose
* `observed_at` is stale because a stopped sync worker leaves
* `last_handshake_at` frozen at whatever it last saw, which would
* otherwise be misread as "the tunnel went silent" when the truth is
* "nobody has looked lately".
*/
public function vpnState(?PeerSnapshot $snapshot, bool $hubReachable): string
public function vpnState(?VpnPeer $peer): string
{
if ($this->wg_pubkey === null) {
return 'not_configured';
}
if (! $hubReachable) {
if ($peer === null || $peer->observed_at === null
|| $peer->observed_at->diffInSeconds(now()) > self::VPN_SYNC_STALE_SECONDS) {
return 'unknown';
}
if ($snapshot?->latestHandshake !== null
&& $snapshot->latestHandshake->diffInSeconds(now()) <= self::VPN_HANDSHAKE_FRESH_SECONDS) {
if ($peer->last_handshake_at !== null
&& $peer->last_handshake_at->diffInSeconds(now()) <= self::VPN_HANDSHAKE_FRESH_SECONDS) {
return 'connected';
}

View File

@ -16,12 +16,6 @@ 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++;
@ -39,12 +33,6 @@ 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

@ -61,8 +61,8 @@
$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);
$vpnPeer = $host->vpnPeer;
$vpnState = $host->vpnState($vpnPeer);
$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
@ -85,8 +85,8 @@
<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()]) }}
@if ($vpnState === 'silent' && $vpnPeer?->last_handshake_at)
{{ __('hosts.vpn.silent_since', ['time' => $vpnPeer->last_handshake_at->diffForHumans()]) }}
@else
{{ __('hosts.vpn.'.$vpnState) }}
@endif

View File

@ -1,105 +1,124 @@
<?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 App\Models\VpnPeer;
use Carbon\CarbonImmutable;
use Livewire\Livewire;
use Illuminate\Support\Facades\DB;
/**
* 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.
* peer, so nothing else fails loudly. State comes from the row SyncVpnPeers
* keeps current, not a live hub call: the console container has no wg0 (see
* that job's docblock), so a direct WireguardHub::peers() call from here would
* always throw in this deployment's compose topology.
*/
function snapshotAgedSeconds(string $publicKey, int $seconds): PeerSnapshot
function vpnPeerFor(Host $host, ?int $handshakeSecondsAgo, int $observedSecondsAgo = 5): VpnPeer
{
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,
);
return VpnPeer::factory()->forHost()->create([
'host_id' => $host->id,
'name' => $host->name,
'last_handshake_at' => $handshakeSecondsAgo === null ? null : CarbonImmutable::now()->subSeconds($handshakeSecondsAgo),
'observed_at' => CarbonImmutable::now()->subSeconds($observedSecondsAgo),
]);
}
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');
expect($host->vpnState(null))->toBe('not_configured');
});
it('reports connected for a fresh handshake', function () {
$host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']);
$snapshot = snapshotAgedSeconds('HOSTPUB=', 30);
$peer = vpnPeerFor($host, handshakeSecondsAgo: 30);
expect($host->vpnState($snapshot, hubReachable: true))->toBe('connected');
expect($host->vpnState($peer))->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
$peer = vpnPeerFor($host, handshakeSecondsAgo: 60 * 60 * 24 * 2); // two days
expect($host->vpnState($snapshot, hubReachable: true))->toBe('silent');
expect($host->vpnState($peer))->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);
$peer = vpnPeerFor($host, handshakeSecondsAgo: null);
expect($host->vpnState($snapshot, hubReachable: true))->toBe('silent');
expect($host->vpnState($peer))->toBe('silent');
});
it('reports unknown when the hub could not be asked, even for a configured host', function () {
it('reports unknown when the host is configured but has never been synced', function () {
$host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']);
expect($host->vpnState(null, hubReachable: false))->toBe('unknown');
expect($host->vpnState(null))->toBe('unknown');
});
it('never reports not_configured as unknown or vice versa regardless of hub reachability', function () {
it('reports unknown when the sync itself has gone stale, even with an old handshake on record', function () {
$host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']);
// The sync worker stopped an hour ago; the last thing it saw was a fresh
// handshake. Trusting last_handshake_at here would say "connected" for a
// host nobody has actually checked on in an hour.
$peer = vpnPeerFor($host, handshakeSecondsAgo: 10, observedSecondsAgo: 60 * 60);
expect($host->vpnState($peer))->toBe('unknown');
});
it('never reports not_configured as unknown or vice versa regardless of sync state', function () {
$unconfigured = Host::factory()->create(['wg_pubkey' => null]);
expect($unconfigured->vpnState(null, hubReachable: false))->toBe('not_configured');
expect($unconfigured->vpnState(null))->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.
* Pins the exact freshness boundary the comparison a 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);
$this->travelTo(CarbonImmutable::parse('2026-07-28 12:00:00'));
$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);
$atThreshold = vpnPeerFor($host, handshakeSecondsAgo: Host::VPN_HANDSHAKE_FRESH_SECONDS);
$pastThreshold = vpnPeerFor($host, handshakeSecondsAgo: Host::VPN_HANDSHAKE_FRESH_SECONDS + 1);
expect($host->vpnState($atThreshold, hubReachable: true))->toBe('connected')
->and($host->vpnState($pastThreshold, hubReachable: true))->toBe('silent');
expect($host->vpnState($atThreshold))->toBe('connected')
->and($host->vpnState($pastThreshold))->toBe('silent');
$this->travelBack();
});
/**
* Pins the sync-staleness boundary the second comparison a mutation pass
* targets, proving a frozen sync reads as unknown rather than as whatever
* last_handshake_at happens to say.
*/
it('treats a stale observed_at as unknown, not as whatever the frozen handshake says', function () {
$host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']);
$this->travelTo(CarbonImmutable::parse('2026-07-28 12:00:00'));
$atThreshold = vpnPeerFor($host, handshakeSecondsAgo: 10, observedSecondsAgo: Host::VPN_SYNC_STALE_SECONDS);
$pastThreshold = vpnPeerFor($host, handshakeSecondsAgo: 10, observedSecondsAgo: Host::VPN_SYNC_STALE_SECONDS + 1);
expect($host->vpnState($atThreshold))->toBe('connected')
->and($host->vpnState($pastThreshold))->toBe('unknown');
$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);
vpnPeerFor($connected, handshakeSecondsAgo: 20);
vpnPeerFor($silent, handshakeSecondsAgo: 60 * 60 * 24 * 3);
$response = $this->actingAs(admin(), 'operator')->get(route('admin.hosts'));
@ -111,27 +130,43 @@ it('renders the three VPN states distinctly in the hosts list', function () {
->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=']);
it('shows unknown, not a false connected or silent, when a configured host was never synced', function () {
$host = Host::factory()->active()->create(['name' => 'pve-unsynced-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);
it('loads the VPN column with one eager-loaded query, not one per host', function () {
// Isolated to exactly what the VPN column added — Host::query()->with('vpnPeer')
// — rather than the full page response, which also carries an unrelated,
// pre-existing capacity-column query per host (committedGb()) not part of
// this change and out of scope here.
$queryCount = 0;
DB::listen(function () use (&$queryCount) {
$queryCount++;
});
foreach (range(1, 5) as $i) {
Host::factory()->active()->create(['wg_pubkey' => 'PUB'.$i.'=']);
}
$countQueriesFor = function (int $hostCount) use (&$queryCount) {
Host::query()->delete();
VpnPeer::query()->forceDelete();
Livewire::actingAs(admin(), 'operator')->test(Hosts::class);
foreach (range(1, $hostCount) as $i) {
$host = Host::factory()->active()->create(['wg_pubkey' => 'PUB'.$i.'=']);
vpnPeerFor($host, handshakeSecondsAgo: 20);
}
expect($hub->peersCalls)->toBe(1);
// Reset right before the load being measured.
$queryCount = 0;
Host::query()->with('vpnPeer')->get()->each(fn ($h) => $h->vpnPeer);
return $queryCount;
};
$queriesFor2 = $countQueriesFor(2);
$queriesFor10 = $countQueriesFor(10);
expect($queriesFor2)->toBe(2) // one for hosts, one eager-loaded query for their vpn peers
->and($queriesFor10)->toBe($queriesFor2);
});