173 lines
7.2 KiB
PHP
173 lines
7.2 KiB
PHP
<?php
|
|
|
|
use App\Models\Host;
|
|
use App\Models\VpnPeer;
|
|
use Carbon\CarbonImmutable;
|
|
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. 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 vpnPeerFor(Host $host, ?int $handshakeSecondsAgo, int $observedSecondsAgo = 5): VpnPeer
|
|
{
|
|
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))->toBe('not_configured');
|
|
});
|
|
|
|
it('reports connected for a fresh handshake', function () {
|
|
$host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']);
|
|
$peer = vpnPeerFor($host, handshakeSecondsAgo: 30);
|
|
|
|
expect($host->vpnState($peer))->toBe('connected');
|
|
});
|
|
|
|
it('reports silent for a stale handshake', function () {
|
|
$host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']);
|
|
$peer = vpnPeerFor($host, handshakeSecondsAgo: 60 * 60 * 24 * 2); // two days
|
|
|
|
expect($host->vpnState($peer))->toBe('silent');
|
|
});
|
|
|
|
it('reports silent for a configured peer that never handshaked', function () {
|
|
$host = Host::factory()->create(['wg_pubkey' => 'HOSTPUB=']);
|
|
$peer = vpnPeerFor($host, handshakeSecondsAgo: null);
|
|
|
|
expect($host->vpnState($peer))->toBe('silent');
|
|
});
|
|
|
|
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))->toBe('unknown');
|
|
});
|
|
|
|
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))->toBe('not_configured');
|
|
});
|
|
|
|
/**
|
|
* 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.
|
|
$this->travelTo(CarbonImmutable::parse('2026-07-28 12:00:00'));
|
|
|
|
$atThreshold = vpnPeerFor($host, handshakeSecondsAgo: Host::VPN_HANDSHAKE_FRESH_SECONDS);
|
|
$pastThreshold = vpnPeerFor($host, handshakeSecondsAgo: Host::VPN_HANDSHAKE_FRESH_SECONDS + 1);
|
|
|
|
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 () {
|
|
$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=']);
|
|
|
|
vpnPeerFor($connected, handshakeSecondsAgo: 20);
|
|
vpnPeerFor($silent, handshakeSecondsAgo: 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 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('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++;
|
|
});
|
|
|
|
$countQueriesFor = function (int $hostCount) use (&$queryCount) {
|
|
Host::query()->delete();
|
|
VpnPeer::query()->forceDelete();
|
|
|
|
foreach (range(1, $hostCount) as $i) {
|
|
$host = Host::factory()->active()->create(['wg_pubkey' => 'PUB'.$i.'=']);
|
|
vpnPeerFor($host, handshakeSecondsAgo: 20);
|
|
}
|
|
|
|
// 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);
|
|
});
|