340 lines
12 KiB
PHP
340 lines
12 KiB
PHP
<?php
|
|
|
|
use App\Livewire\Admin\ConfirmDeleteVpnPeer;
|
|
use App\Livewire\Admin\Vpn;
|
|
use App\Models\Host;
|
|
use App\Models\VpnPeer;
|
|
use App\Provisioning\Jobs\ApplyVpnPeer;
|
|
use App\Provisioning\Jobs\SyncVpnPeers;
|
|
use App\Services\Wireguard\FakeWireguardHub;
|
|
use App\Services\Wireguard\Keypair;
|
|
use App\Services\Wireguard\LocalWireguardHub;
|
|
use App\Services\Wireguard\PeerSnapshot;
|
|
use App\Services\Wireguard\WireguardHub;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Livewire\Livewire;
|
|
|
|
function vpnHub(): FakeWireguardHub
|
|
{
|
|
$hub = new FakeWireguardHub;
|
|
app()->instance(WireguardHub::class, $hub);
|
|
|
|
return $hub;
|
|
}
|
|
|
|
it('keeps the page and the nav entry away from operators without the capability', function () {
|
|
vpnHub();
|
|
|
|
// Support may run the console, but a VPN access reaches the management network.
|
|
$this->actingAs(operator('Support'))->get(route('admin.vpn'))->assertForbidden();
|
|
$this->actingAs(operator('Support'))->get(route('admin.overview'))
|
|
->assertOk()
|
|
->assertDontSee(route('admin.vpn'));
|
|
|
|
$this->actingAs(operator('Admin'))->get(route('admin.vpn'))->assertOk();
|
|
$this->actingAs(operator('Owner'))->get(route('admin.overview'))
|
|
->assertOk()
|
|
->assertSee(route('admin.vpn'));
|
|
});
|
|
|
|
it('creates an access, hands out the config once and pushes it to the hub', function () {
|
|
vpnHub();
|
|
Queue::fake();
|
|
|
|
$component = Livewire::actingAs(operator('Owner'))
|
|
->test(Vpn::class)
|
|
->set('name', 'Notebook Boban')
|
|
->call('create')
|
|
->assertHasNoErrors();
|
|
|
|
$peer = VpnPeer::query()->firstOrFail();
|
|
expect($peer->name)->toBe('Notebook Boban')
|
|
->and($peer->enabled)->toBeTrue()
|
|
->and($peer->present)->toBeFalse(); // not on the hub until the job ran
|
|
|
|
$config = $component->get('newConfig');
|
|
expect($config)->toContain('[Interface]')
|
|
->toContain('Address = '.$peer->allowed_ip.'/32')
|
|
->toContain('PublicKey = HUBPUBLICKEY0000000000000000000000000000000=')
|
|
->toContain('Endpoint = vpn.clupilot.test:51820');
|
|
|
|
// The private key is shown, never stored.
|
|
preg_match('/PrivateKey = (\S+)/', $config, $m);
|
|
expect(Keypair::isValidKey($m[1]))->toBeTrue()
|
|
->and(VpnPeer::query()->where('public_key', $m[1])->exists())->toBeFalse();
|
|
|
|
Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $peer->public_key && $job->enabled);
|
|
});
|
|
|
|
it('accepts a peer-supplied public key and then has no private key to show', function () {
|
|
vpnHub();
|
|
Queue::fake();
|
|
$own = Keypair::generate()->publicKey;
|
|
|
|
$component = Livewire::actingAs(operator('Owner'))
|
|
->test(Vpn::class)
|
|
->set('name', 'Proxmox fsn1')
|
|
->set('publicKey', $own)
|
|
->call('create')
|
|
->assertHasNoErrors();
|
|
|
|
expect(VpnPeer::query()->where('public_key', $own)->exists())->toBeTrue()
|
|
->and($component->get('newConfig'))->toBeNull();
|
|
});
|
|
|
|
it('rejects a malformed key and a key that already has an access', function () {
|
|
vpnHub();
|
|
$existing = VpnPeer::factory()->create();
|
|
|
|
Livewire::actingAs(operator('Owner'))->test(Vpn::class)
|
|
->set('name', 'kaputt')->set('publicKey', 'nope')
|
|
->call('create')
|
|
->assertHasErrors('publicKey');
|
|
|
|
Livewire::actingAs(operator('Owner'))->test(Vpn::class)
|
|
->set('name', 'doppelt')->set('publicKey', $existing->public_key)
|
|
->call('create')
|
|
->assertHasErrors('publicKey');
|
|
|
|
expect(VpnPeer::query()->count())->toBe(1);
|
|
});
|
|
|
|
it('blocks and unblocks an access without losing the record', function () {
|
|
vpnHub();
|
|
Queue::fake();
|
|
$peer = VpnPeer::factory()->create();
|
|
|
|
Livewire::actingAs(operator('Owner'))->test(Vpn::class)->call('toggle', $peer->uuid);
|
|
expect($peer->fresh()->enabled)->toBeFalse();
|
|
Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => ! $job->enabled);
|
|
|
|
Livewire::actingAs(operator('Owner'))->test(Vpn::class)->call('toggle', $peer->uuid);
|
|
expect($peer->fresh()->enabled)->toBeTrue()
|
|
->and(VpnPeer::query()->count())->toBe(1);
|
|
});
|
|
|
|
it('removes the peer from the hub before deleting the row', function () {
|
|
vpnHub();
|
|
Queue::fake();
|
|
$peer = VpnPeer::factory()->create();
|
|
|
|
Livewire::actingAs(operator('Owner'))
|
|
->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid])
|
|
->call('delete');
|
|
|
|
expect(VpnPeer::query()->count())->toBe(0);
|
|
Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $peer->public_key && ! $job->enabled);
|
|
});
|
|
|
|
it('warns that deleting a host access cuts CluPilot off from that host', function () {
|
|
vpnHub();
|
|
$host = Host::factory()->active()->create(['name' => 'pve-fsn1']);
|
|
$peer = VpnPeer::factory()->create(['host_id' => $host->id]);
|
|
|
|
Livewire::actingAs(operator('Owner'))
|
|
->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid])
|
|
->assertSee('pve-fsn1');
|
|
});
|
|
|
|
it('stops mutations from a session whose capability was revoked mid-flight', function () {
|
|
vpnHub();
|
|
Queue::fake();
|
|
$peer = VpnPeer::factory()->create();
|
|
$user = operator('Owner');
|
|
|
|
// Page is open and legitimate…
|
|
$component = Livewire::actingAs($user)->test(Vpn::class);
|
|
|
|
// …then the account is demoted. The already-rendered page must not keep its
|
|
// powers: every action re-checks, it does not trust the mount.
|
|
$user->syncRoles(['Support']);
|
|
app(Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions();
|
|
|
|
$component->call('toggle', $peer->uuid)->assertForbidden();
|
|
|
|
expect($peer->fresh()->enabled)->toBeTrue();
|
|
Queue::assertNotPushed(ApplyVpnPeer::class);
|
|
});
|
|
|
|
it('adopts host peers from the hub and marks vanished ones absent', function () {
|
|
$hub = vpnHub();
|
|
$key = Keypair::generate()->publicKey;
|
|
$host = Host::factory()->active()->create(['name' => 'pve-fsn1', 'wg_pubkey' => $key]);
|
|
|
|
$hub->addPeer($key, '10.66.0.7');
|
|
$hub->snapshots[$key] = new PeerSnapshot(
|
|
publicKey: $key,
|
|
endpoint: '203.0.113.9:51820',
|
|
allowedIps: '10.66.0.7/32',
|
|
latestHandshake: now()->subSeconds(30)->toImmutable(),
|
|
rxBytes: 2048,
|
|
txBytes: 4096,
|
|
);
|
|
|
|
// A peer that exists in the console but is no longer on the interface.
|
|
$stale = VpnPeer::factory()->create(['present' => true]);
|
|
|
|
(new SyncVpnPeers)->handle($hub);
|
|
|
|
$adopted = VpnPeer::query()->where('public_key', $key)->firstOrFail();
|
|
expect($adopted->name)->toBe('pve-fsn1') // named after its host, not "unknown"
|
|
->and($adopted->host_id)->toBe($host->id)
|
|
->and($adopted->present)->toBeTrue()
|
|
->and($adopted->rx_bytes)->toBe(2048)
|
|
->and($adopted->endpoint)->toBe('203.0.113.9:51820')
|
|
->and($adopted->status())->toBe('online')
|
|
->and($stale->fresh()->present)->toBeFalse();
|
|
});
|
|
|
|
it('never lets a sync overwrite the operator intent to block', function () {
|
|
$hub = vpnHub();
|
|
$peer = VpnPeer::factory()->blocked()->create();
|
|
|
|
// The hub still reports it (removal has not been applied yet).
|
|
$hub->addPeer($peer->public_key, $peer->allowed_ip);
|
|
(new SyncVpnPeers)->handle($hub);
|
|
|
|
$peer->refresh();
|
|
expect($peer->enabled)->toBeFalse() // intent survives
|
|
->and($peer->present)->toBeTrue() // reality is recorded
|
|
->and($peer->status())->toBe('pending');
|
|
});
|
|
|
|
it('does not hand the same tunnel address to a host and a VPN access', function () {
|
|
$host = Host::factory()->active()->create(['wg_ip' => '10.66.0.2']);
|
|
VpnPeer::factory()->create(['allowed_ip' => '10.66.0.3']);
|
|
|
|
// The real allocator, not the fake — this is the collision the fake cannot show.
|
|
expect((new LocalWireguardHub)->allocateIp())->toBe('10.66.0.4');
|
|
});
|
|
|
|
it('does not let a failed removal resurrect a revoked access', function () {
|
|
$hub = vpnHub();
|
|
Queue::fake();
|
|
$peer = VpnPeer::factory()->create();
|
|
$hub->addPeer($peer->public_key, $peer->allowed_ip);
|
|
|
|
Livewire::actingAs(operator('Owner'))
|
|
->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid])
|
|
->call('delete');
|
|
|
|
// Removal is queued but has not run — the peer is still on the hub.
|
|
expect(VpnPeer::query()->count())->toBe(0)
|
|
->and(VpnPeer::withTrashed()->count())->toBe(1);
|
|
|
|
(new SyncVpnPeers)->handle($hub);
|
|
|
|
// The sync must NOT adopt it back as a live access…
|
|
expect(VpnPeer::query()->count())->toBe(0);
|
|
// …it must retry the removal instead.
|
|
Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $peer->public_key && ! $job->enabled);
|
|
});
|
|
|
|
it('clears the tombstone once the peer is really gone', function () {
|
|
$hub = vpnHub();
|
|
$peer = VpnPeer::factory()->create();
|
|
$hub->addPeer($peer->public_key, $peer->allowed_ip);
|
|
$peer->delete();
|
|
|
|
(new ApplyVpnPeer($peer->public_key, null, false))->handle($hub);
|
|
|
|
expect($hub->peerIps())->toBe([])
|
|
->and(VpnPeer::withTrashed()->count())->toBe(0);
|
|
});
|
|
|
|
it('ignores a stale job that would undo a newer decision', function () {
|
|
$hub = vpnHub();
|
|
$peer = VpnPeer::factory()->create();
|
|
$hub->addPeer($peer->public_key, $peer->allowed_ip);
|
|
|
|
// Operator blocks, then immediately unblocks: two jobs are now in flight.
|
|
$blockJob = new ApplyVpnPeer($peer->public_key, $peer->allowed_ip, false);
|
|
$peer->update(['enabled' => false]);
|
|
$peer->update(['enabled' => true]); // newer intent wins
|
|
|
|
// The older block job runs late (a retry, say). It must not cut access.
|
|
$blockJob->handle($hub);
|
|
|
|
expect($hub->peerIps())->toHaveKey($peer->public_key)
|
|
->and($peer->fresh()->present)->toBeTrue();
|
|
});
|
|
|
|
it('keeps a revoked address and key reserved until the hub confirms', function () {
|
|
vpnHub();
|
|
$peer = VpnPeer::factory()->create(['allowed_ip' => '10.66.0.2']);
|
|
$peer->delete();
|
|
|
|
// The address is still configured on the hub — handing it out again would
|
|
// give two machines the same tunnel IP.
|
|
expect((new LocalWireguardHub)->allocateIp())->not->toBe('10.66.0.2');
|
|
|
|
Livewire::actingAs(operator('Owner'))->test(Vpn::class)
|
|
->set('name', 'wieder da')->set('publicKey', $peer->public_key)
|
|
->call('create')
|
|
->assertHasErrors('publicKey');
|
|
});
|
|
|
|
it('stops an open page from polling once the capability is revoked', function () {
|
|
vpnHub();
|
|
$user = operator('Owner');
|
|
$component = Livewire::actingAs($user)->test(Vpn::class);
|
|
|
|
$user->syncRoles(['Support']);
|
|
app(Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions();
|
|
|
|
// The five-second poll must not keep serving peer state to a demoted account.
|
|
$component->call('refreshPeers')->assertForbidden();
|
|
});
|
|
|
|
it('holds the same allocation lock the host pipeline uses', function () {
|
|
vpnHub();
|
|
|
|
// Onboarding (ConfigureWireguard) holds this exact lock while it reserves a
|
|
// host address. Addresses come from one subnet but live in two tables, so
|
|
// no unique index can catch the other side's insert — waiting is the only
|
|
// thing that keeps a host and an access off the same tunnel IP.
|
|
// Short TTL so the test measures the wait instead of sitting out a timeout.
|
|
expect(Illuminate\Support\Facades\Cache::lock('wireguard:allocate', 1)->get())->toBeTrue();
|
|
|
|
$started = microtime(true);
|
|
Livewire::actingAs(operator('Owner'))->test(Vpn::class)
|
|
->set('name', 'parallel')
|
|
->call('create')
|
|
->assertHasNoErrors();
|
|
$elapsed = microtime(true) - $started;
|
|
|
|
expect(VpnPeer::query()->count())->toBe(1)
|
|
->and($elapsed)->toBeGreaterThan(0.8); // it waited for the lock to expire
|
|
});
|
|
|
|
it('never re-adds a peer whose row is gone, however stale the job', function () {
|
|
$hub = vpnHub();
|
|
$peer = VpnPeer::factory()->create();
|
|
|
|
// The add job is retried late — after the access was revoked and its
|
|
// tombstone already purged by a sync that saw the peer gone.
|
|
$staleAdd = ApplyVpnPeer::for($peer);
|
|
$peer->forceDelete();
|
|
|
|
$staleAdd->handle($hub);
|
|
|
|
expect($hub->peerIps())->toBe([])
|
|
->and(VpnPeer::withTrashed()->count())->toBe(0);
|
|
});
|
|
|
|
it('serializes hub reads against hub mutations', function () {
|
|
$hub = vpnHub();
|
|
$peer = VpnPeer::factory()->create();
|
|
$hub->addPeer($peer->public_key, $peer->allowed_ip);
|
|
|
|
// A removal in flight holds this lock. A sync must wait for it instead of
|
|
// acting on a snapshot taken around the mutation.
|
|
expect(Illuminate\Support\Facades\Cache::lock('wireguard:hub', 1)->get())->toBeTrue();
|
|
|
|
$started = microtime(true);
|
|
(new SyncVpnPeers)->handle($hub);
|
|
|
|
expect(microtime(true) - $started)->toBeGreaterThan(0.8)
|
|
->and($peer->fresh()->present)->toBeTrue();
|
|
});
|