CluPilotCloud/tests/Feature/Admin/VpnTest.php

956 lines
38 KiB
PHP

<?php
use App\Livewire\Admin\ConfirmDeleteVpnPeer;
use App\Livewire\Admin\Vpn;
use App\Livewire\Admin\VpnConfigAccess;
use App\Models\Host;
use App\Models\Operator;
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\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
function vpnHub(): FakeWireguardHub
{
$hub = new FakeWireguardHub;
app()->instance(WireguardHub::class, $hub);
return $hub;
}
it('keeps the VPN entry in the navigation for every operator', function () {
vpnHub();
// Regression guard: the entry used to hang on a capability that the
// capability split deleted, which would have hidden the page from everyone.
foreach (['Owner', 'Admin', 'Support', 'Developer', 'Read-only'] as $role) {
$this->actingAs(operator($role), 'operator')->get(route('admin.overview'))
->assertOk()
->assertSee(route('admin.vpn'));
}
});
it('shows each operator only their own access unless they may see all', function () {
vpnHub();
$support = operator('Support');
$mine = VpnPeer::factory()->ownedBy($support)->create(['name' => 'Support-Notebook']);
$foreign = VpnPeer::factory()->create(['name' => 'Fremdes-Notebook']);
// Support reaches the page — to find their own access — but sees only it.
$this->actingAs($support, 'operator')->get(route('admin.vpn'))
->assertOk()
->assertSee('Support-Notebook')
->assertDontSee('Fremdes-Notebook');
// Developer may see everything, by capability rather than by ownership.
$this->actingAs(operator('Developer'), 'operator')->get(route('admin.vpn'))
->assertOk()
->assertSee('Support-Notebook')
->assertSee('Fremdes-Notebook');
expect($mine->fresh()->user_id)->toBe($support->id)
->and($foreign->fresh()->user_id)->not->toBe($support->id);
});
it('lets only managers create an access', function () {
vpnHub();
// The form is not even offered to someone who cannot use it.
$this->actingAs(operator('Support'), 'operator')->get(route('admin.vpn'))
->assertOk()
->assertDontSee(__('vpn.store_config'));
$this->actingAs(operator('Owner'), 'operator')->get(route('admin.vpn'))
->assertOk()
->assertSee(__('vpn.store_config'));
Livewire::actingAs(operator('Support'), 'operator')->test(Vpn::class)
->set('name', 'selbst ausgestellt')
->call('create')
->assertForbidden();
expect(VpnPeer::withTrashed()->count())->toBe(0);
});
it('creates an access, hands out the config once and pushes it to the hub', function () {
vpnHub();
Queue::fake();
$component = Livewire::actingAs(operator('Owner'), 'operator')
->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 = App\Services\Wireguard\ConfigHandoff::get($component->get('configToken'));
expect($config)->toContain('[Interface]')
->toContain('Address = '.$peer->allowed_ip.'/32')
->toContain('PublicKey = HUBPUBLICKEY0000000000000000000000000000000=')
->toContain('Endpoint = vpn.clupilot.test:51820');
// The private key is handed over once and not kept anywhere.
preg_match('/PrivateKey = (\S+)/', $config, $m);
expect(Keypair::isValidKey($m[1]))->toBeTrue()
->and(VpnPeer::query()->where('public_key', $m[1])->exists())->toBeFalse()
->and($peer->config_secret)->toBeNull()
// …and it never travels in the component snapshot, only a handle does.
->and($component->get('configToken'))->not->toContain($m[1]);
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'), 'operator')
->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('configToken'))->toBeNull();
});
it('rejects a malformed key and a key that already has an access', function () {
vpnHub();
$existing = VpnPeer::factory()->create();
Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class)
->set('name', 'kaputt')->set('publicKey', 'nope')
->call('create')
->assertHasErrors('publicKey');
Livewire::actingAs(operator('Owner'), 'operator')->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'), 'operator')->test(Vpn::class)->call('toggle', $peer->uuid);
expect($peer->fresh()->enabled)->toBeFalse();
Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => ! $job->enabled);
Livewire::actingAs(operator('Owner'), 'operator')->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'), 'operator')
->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'), 'operator')
->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(); // someone else's access
$user = operator('Owner');
// Page is open and legitimate…
$component = Livewire::actingAs($user, 'operator')->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. And it must
// refuse out loud, not quietly do nothing.
$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('lets an owner switch their own access off and on', function () {
vpnHub();
Queue::fake();
$support = operator('Support');
$peer = VpnPeer::factory()->ownedBy($support)->create();
Livewire::actingAs($support, 'operator')->test(Vpn::class)->call('toggle', $peer->uuid);
expect($peer->fresh()->enabled)->toBeFalse();
Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => ! $job->enabled);
});
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->kind)->toBe(VpnPeer::KIND_HOST)
->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'), 'operator')
->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'), 'operator')->test(Vpn::class)
->set('name', 'wieder da')->set('publicKey', $peer->public_key)
->call('create')
->assertHasErrors('publicKey');
});
it('narrows what an open page shows once the account is demoted', function () {
vpnHub();
$user = operator('Owner');
VpnPeer::factory()->create(['name' => 'Fremdes-Notebook']);
$component = Livewire::actingAs($user, 'operator')->test(Vpn::class)->assertSee('Fremdes-Notebook');
$user->syncRoles(['Support']);
app(Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions();
// The five-second poll keeps working — the page is still theirs — but it
// must stop serving other people's accesses to a demoted account.
$component->call('refreshPeers')->assertDontSee('Fremdes-Notebook');
});
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'), 'operator')->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();
});
it('reports a lost creation race as a validation error, not a 500', function () {
// A hub whose allocation inserts a colliding row mid-flight: exactly what a
// concurrent request that skipped the lock would do between our check and
// our insert.
$key = Keypair::generate()->publicKey;
$hub = new class($key) extends FakeWireguardHub
{
public function __construct(private string $collidingKey) {}
public function allocateIp(): string
{
VpnPeer::create([
'name' => 'gleichzeitig',
'public_key' => $this->collidingKey,
'allowed_ip' => '10.66.0.90',
'enabled' => true,
]);
return '10.66.0.91';
}
};
app()->instance(WireguardHub::class, $hub);
Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class)
->set('name', 'verloren')
->set('publicKey', $key)
->call('create')
->assertHasErrors('publicKey');
// What matters: the loser gets told, not a 500, and creates nothing.
// (The competing row is written inside our own transaction here, so the
// rollback removes it too — a real competitor's row would survive, which a
// single-connection sqlite test cannot reproduce.)
expect(VpnPeer::withTrashed()->where('name', 'verloren')->count())->toBe(0);
});
it('keeps host peer removal behind the same hub lock', function () {
$hub = vpnHub();
$hub->addPeer('HOSTKEY', '10.66.0.30');
expect(Illuminate\Support\Facades\Cache::lock('wireguard:hub', 1)->get())->toBeTrue();
$started = microtime(true);
(new App\Provisioning\Jobs\RemoveWireguardPeer('HOSTKEY'))->handle($hub);
expect(microtime(true) - $started)->toBeGreaterThan(0.8)
->and($hub->peerIps())->toBe([]);
});
it('refuses a public key that already belongs to a host', function () {
vpnHub();
$key = Keypair::generate()->publicKey;
// The host exists but the scheduled sync has not adopted its peer yet.
Host::factory()->active()->create(['name' => 'pve-fsn1', 'wg_pubkey' => $key]);
Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class)
->set('name', 'entführt')
->set('publicKey', $key)
->call('create')
->assertHasErrors('publicKey');
expect(VpnPeer::withTrashed()->count())->toBe(0);
});
it('renames a peer it adopted before the host link was known', function () {
$hub = vpnHub();
$key = Keypair::generate()->publicKey;
$hub->addPeer($key, '10.66.0.7');
// First sync: the step has not stored wg_pubkey yet → unknown peer.
(new SyncVpnPeers)->handle($hub);
$peer = VpnPeer::query()->where('public_key', $key)->firstOrFail();
expect($peer->name)->toBe(__('vpn.unknown_peer'))
->and($peer->host_id)->toBeNull()
// Not staff: a staff access has an owner, and this one has nobody.
->and($peer->kind)->toBe(VpnPeer::KIND_SYSTEM);
Host::factory()->active()->create(['name' => 'pve-fsn1', 'wg_pubkey' => $key]);
(new SyncVpnPeers)->handle($hub);
expect($peer->fresh()->name)->toBe('pve-fsn1')
->and($peer->fresh()->host_id)->not->toBeNull()
->and($peer->fresh()->kind)->toBe(VpnPeer::KIND_HOST);
});
it('does not rename an access an operator named themselves', function () {
$hub = vpnHub();
$user = operator('Owner');
$key = Keypair::generate()->publicKey;
$peer = VpnPeer::factory()->create(['public_key' => $key, 'name' => 'Notebook Boban', 'created_by' => $user->id]);
$hub->addPeer($key, $peer->allowed_ip);
Host::factory()->active()->create(['name' => 'pve-fsn1', 'wg_pubkey' => $key]);
(new SyncVpnPeers)->handle($hub);
expect($peer->fresh()->name)->toBe('Notebook Boban');
});
it('does not leave a phantom access behind when a host is purged', 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');
(new SyncVpnPeers)->handle($hub); // console adopts the host peer
expect(VpnPeer::query()->count())->toBe(1);
(new App\Provisioning\Jobs\PurgeHost($host->uuid))->handle();
// No enabled-but-absent leftover the operator could toggle back on.
expect(VpnPeer::query()->count())->toBe(0)
->and(VpnPeer::onlyTrashed()->count())->toBe(1);
// The queued removal clears both the hub peer and the tombstone.
(new ApplyVpnPeer($key, null, false))->handle($hub);
expect($hub->peerIps())->toBe([])
->and(VpnPeer::withTrashed()->count())->toBe(0);
});
it('stores the config only when asked, and encrypted', function () {
vpnHub();
Queue::fake();
$owner = operator('Owner');
Livewire::actingAs($owner, 'operator')->test(Vpn::class)
->set('name', 'Notebook')->set('ownerId', $owner->id)
->set('storeConfig', true)
->call('create')
->assertHasNoErrors();
$peer = VpnPeer::query()->firstOrFail();
expect($peer->config_secret)->not->toBeNull()
// Not readable without the key: no plaintext key material in the column.
->and($peer->config_secret)->not->toContain('PrivateKey')
->and($peer->config_secret)->toStartWith('v1:')
->and(App\Services\Wireguard\ConfigVault::decrypt($peer->config_secret))->toContain('[Interface]');
});
it('refuses to store when there is no key to store it under', function () {
vpnHub();
config()->set('admin_access.vpn_config_key', '');
Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class)
->set('name', 'Notebook')->set('storeConfig', true)
->call('create')
->assertHasErrors('storeConfig');
// Nothing half-created: no access without the storage it was asked for.
expect(VpnPeer::withTrashed()->count())->toBe(0);
});
it('hands a stored config back only to its owner, and only with the password', function () {
vpnHub();
$support = operator('Support');
$support->forceFill(['password' => Hash::make('geheim-1234')])->save();
$peer = VpnPeer::factory()->ownedBy($support)->create([
'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"),
]);
// Wrong password: nothing comes back.
$modal = Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid])
->set('password', 'falsch')
->call('reveal')
->assertHasErrors('password');
expect($modal->get('token'))->toBeNull();
// Right password: the config is handed over, and the retrieval is recorded.
$modal->set('password', 'geheim-1234')->call('reveal')->assertHasNoErrors();
expect(App\Services\Wireguard\ConfigHandoff::get($modal->get('token')))->toContain('PrivateKey = XYZ')
->and($peer->fresh()->download_count)->toBe(1)
->and($peer->fresh()->last_downloaded_at)->not->toBeNull();
});
it('never hands someone else the private key, not even an Owner', function () {
vpnHub();
$support = operator('Support');
$peer = VpnPeer::factory()->ownedBy($support)->create([
'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"),
]);
// The boss can see the access and revoke it — but holding its key is a
// different thing, and the policy says no to everyone but the owner.
foreach (['Owner', 'Admin', 'Developer'] as $role) {
$other = operator($role);
expect($other->can('view', $peer))->toBeTrue()
->and($other->can('downloadConfig', $peer))->toBeFalse();
// The page shows them the access without offering its config…
$this->actingAs($other, 'operator')->get(route('admin.vpn'))
->assertOk()
->assertSee($peer->name)
->assertDontSee('admin.vpn-config-access');
// …and going straight at the modal is refused, not merely hidden.
Livewire::actingAs($other, 'operator')
->test(VpnConfigAccess::class, ['uuid' => $peer->uuid])
->assertForbidden();
}
// The owner still gets the button.
$this->actingAs($support, 'operator')->get(route('admin.vpn'))
->assertOk()
->assertSee('admin.vpn-config-access');
});
it('locks out password guessing', function () {
vpnHub();
$support = operator('Support');
$peer = VpnPeer::factory()->ownedBy($support)->create([
'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\n"),
]);
$modal = Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]);
foreach (range(1, 5) as $attempt) {
$modal->set('password', 'falsch'.$attempt)->call('reveal')->assertHasErrors('password');
}
// Even the correct password is refused while the account is throttled.
$modal->set('password', 'password')->call('reveal');
expect($modal->get('token'))->toBeNull();
});
it('sends the operator to sign in instead of a 500 when the session has ended', function () {
// reveal() re-checks $this->authorize('downloadConfig', $peer) on every
// call, which already refuses a guest (Gate auto-denies a policy whose
// first parameter is a non-nullable Operator). That is a separate,
// already-correct guard — not the one this test is for. A Gate::before
// with a NULLABLE $user isolates the one line this regression is about:
// Auth::guard('operator')->user() coming back null past that point,
// which used to be dereferenced unguarded ($operator->password).
Gate::before(fn (?Operator $user, string $ability) => $ability === 'downloadConfig' ? true : null);
vpnHub();
$support = operator('Support');
$peer = VpnPeer::factory()->ownedBy($support)->create([
'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"),
]);
$modal = Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]);
// The modal sat open long enough for the operator's own session to end.
Auth::guard('operator')->logout();
$modal->set('password', 'geheim-1234')->call('reveal')
->assertRedirect(route('admin.login'));
});
it('destroys the stored config when the access is revoked', function () {
vpnHub();
Queue::fake();
$support = operator('Support');
$peer = VpnPeer::factory()->ownedBy($support)->create([
'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"),
]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid])
->call('delete');
// The tombstone must not keep a usable credential.
$tombstone = VpnPeer::withTrashed()->firstOrFail();
expect($tombstone->config_secret)->toBeNull()
->and($tombstone->secret_purged_at)->not->toBeNull();
});
it('takes the tunnel away when a staff member is revoked', function () {
vpnHub();
Queue::fake();
$support = operator('Support');
$peer = VpnPeer::factory()->ownedBy($support)->create([
'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\n"),
]);
Livewire::actingAs(operator('Owner'), 'operator')->test(App\Livewire\Admin\Settings::class)
->call('revokeStaff', $support->id);
expect($support->fresh()->isOperator())->toBeFalse()
->and(VpnPeer::query()->count())->toBe(0)
->and(VpnPeer::withTrashed()->first()->config_secret)->toBeNull();
Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $peer->public_key && ! $job->enabled);
});
it('removes a revoked colleague from the hub only after the rows are committed', function () {
$hub = vpnHub();
$support = operator('Support');
$peer = VpnPeer::factory()->ownedBy($support)->create();
$hub->addPeer($peer->public_key, $peer->allowed_ip);
// Sync queue: the job runs the moment it is dispatched, so if that happened
// inside the transaction it would still see a live peer and leave it up.
Livewire::actingAs(operator('Owner'), 'operator')->test(App\Livewire\Admin\Settings::class)
->call('revokeStaff', $support->id);
expect($hub->peerIps())->toBe([])
->and(VpnPeer::withTrashed()->count())->toBe(0); // tombstone cleared by the removal
});
it('stops serving a revealed config once the access is revoked', function () {
vpnHub();
Queue::fake();
$support = operator('Support');
$support->forceFill(['password' => Hash::make('geheim-1234')])->save();
$peer = VpnPeer::factory()->ownedBy($support)->create([
'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"),
]);
$modal = Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid])
->set('password', 'geheim-1234')
->call('reveal');
$token = $modal->get('token');
expect(App\Services\Wireguard\ConfigHandoff::get($token))->toContain('PrivateKey = XYZ');
// Revoked while the modal is still open on someone's screen.
Livewire::actingAs(operator('Owner'), 'operator')
->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid])
->call('delete');
// The open modal must not keep handing the key out of the cache.
$modal->call('toggleQr')->assertForbidden();
expect(App\Services\Wireguard\ConfigHandoff::get($token))->toBeNull();
});
it('never leaves the previous config on screen after another access is created', function () {
vpnHub();
Queue::fake();
$owner = operator('Owner');
$component = Livewire::actingAs($owner, 'operator')->test(Vpn::class)
->set('name', 'Erster')->set('ownerId', $owner->id)
->call('create');
$firstToken = $component->get('configToken');
expect(App\Services\Wireguard\ConfigHandoff::get($firstToken))->toContain('PrivateKey');
// Second access brings its own key, so there is no config to show — the
// first one must not stay up wearing the second one's name.
$component->set('name', 'Zweiter')->set('publicKey', Keypair::generate()->publicKey)
->call('create')
->assertHasNoErrors();
expect($component->get('configToken'))->toBeNull()
->and($component->get('newConfigName'))->toBeNull()
->and(App\Services\Wireguard\ConfigHandoff::get($firstToken))->toBeNull();
});
it('does not keep polling while a private config is on screen', function () {
vpnHub();
Queue::fake();
$owner = operator('Owner');
$component = Livewire::actingAs($owner, 'operator')->test(Vpn::class)->assertSee('wire:poll', false);
$component->set('name', 'Notebook')->set('ownerId', $owner->id)->call('create');
// Otherwise every poll would put the private key into another response.
$component->assertDontSee('wire:poll', false)->assertSee('PrivateKey');
$component->call('dismissConfig')
->assertSee('wire:poll', false)
->assertDontSee('PrivateKey');
});
it('will not issue an access to someone who is no longer staff', function () {
vpnHub();
$owner = operator('Owner');
$former = App\Models\User::factory()->create(); // no operator role
// Not $former->id directly: operators and users are independent id
// sequences, and the in-memory test database restarts both near 1 after
// every test's rollback, so a users id colliding with a real operator's is
// routine here, not a one-in-a-billion accident. Past the highest id either
// sequence has handed out this test is an id no operator can hold — proving
// the same thing ownerId=$former->id would, without depending on the two
// sequences staying apart by luck.
$ownerId = max($former->id, $owner->id) + 1;
Livewire::actingAs($owner, 'operator')->test(Vpn::class)
->set('name', 'Ex-Kollege')->set('ownerId', $ownerId)
->call('create')
->assertHasErrors('ownerId');
expect(VpnPeer::withTrashed()->count())->toBe(0);
});
it('closes the owner doors as soon as the roles are gone', function () {
vpnHub();
$support = operator('Support');
$peer = VpnPeer::factory()->ownedBy($support)->create([
'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\n"),
]);
expect($support->can('downloadConfig', $peer))->toBeTrue();
// Roles removed directly, not through revokeStaff() — ownership alone must
// not keep the modals reachable.
$support->syncRoles([]);
app(Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions();
$support = $support->fresh();
expect($support->can('downloadConfig', $peer))->toBeFalse()
->and($support->can('delete', $peer))->toBeFalse()
->and($support->can('block', $peer))->toBeFalse();
Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid])->assertForbidden();
});
it('re-issues a key for an access whose config was never stored', function () {
vpnHub();
Queue::fake();
$owner = operator('Owner');
$peer = VpnPeer::factory()->ownedBy($owner)->create();
$oldKey = $peer->public_key;
$component = Livewire::actingAs($owner, 'operator')->test(Vpn::class)->call('reissue', $peer->uuid);
// A new key, and the config handed over once — the only honest answer when
// the private half of the old one exists nowhere.
$peer->refresh();
expect($peer->public_key)->not->toBe($oldKey)
->and($peer->present)->toBeFalse()
->and(App\Services\Wireguard\ConfigHandoff::get($component->get('configToken')))
->toContain('Address = '.$peer->allowed_ip);
// Old key off the hub, new key on — in that order, or two peers would
// briefly claim the same tunnel address.
Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $oldKey && ! $job->enabled);
Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $peer->public_key && $job->enabled);
});
it('keeps a stored config in step when the key is re-issued', function () {
vpnHub();
Queue::fake();
$owner = operator('Owner');
$peer = VpnPeer::factory()->ownedBy($owner)->create([
'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = ALT\n"),
]);
Livewire::actingAs($owner, 'operator')->test(Vpn::class)->call('reissue', $peer->uuid);
// Otherwise the owner would download a configuration whose key the hub no
// longer accepts.
$stored = App\Services\Wireguard\ConfigVault::decrypt($peer->fresh()->config_secret);
expect($stored)->not->toContain('PrivateKey = ALT')
->and($stored)->toContain('Address = '.$peer->allowed_ip);
});
it('does not re-issue a host peer from the VPN page', function () {
vpnHub();
Queue::fake();
$host = Host::factory()->active()->create();
$peer = VpnPeer::factory()->forHost()->create(['host_id' => $host->id]);
$oldKey = $peer->public_key;
Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class)->call('reissue', $peer->uuid);
// Its key belongs to the host's own wg0 — swapping it here would cut the
// machine off with nothing to repair it.
expect($peer->fresh()->public_key)->toBe($oldKey);
Queue::assertNotPushed(ApplyVpnPeer::class);
});
it('revokes the replaced key even after a sync adopted it', function () {
$hub = vpnHub();
$owner = operator('Owner');
$peer = VpnPeer::factory()->ownedBy($owner)->create();
$oldKey = $peer->public_key;
$hub->addPeer($oldKey, $peer->allowed_ip);
// Queue faked on purpose: the window only exists while the removal is still
// pending, which a synchronous queue would close instantly.
Queue::fake();
Livewire::actingAs($owner, 'operator')->test(Vpn::class)->call('reissue', $peer->uuid);
// A sync lands before the removal runs. It must neither adopt the old key
// (its address belongs to the live access) nor blow up on the unique index,
// which would stop every other peer's state from being updated too.
(new SyncVpnPeers)->handle($hub);
expect(VpnPeer::query()->where('public_key', $oldKey)->exists())->toBeFalse()
->and(VpnPeer::query()->count())->toBe(1);
(new ApplyVpnPeer($oldKey, null, false, force: true))->handle($hub);
expect($hub->peerIps())->not->toHaveKey($oldKey)
->and(VpnPeer::withTrashed()->where('public_key', $oldKey)->exists())->toBeFalse();
});
it('does not let a late revocation delete an access that reused the key', function () {
$hub = vpnHub();
$owner = operator('Owner');
$key = Keypair::generate()->publicKey;
// Someone created a new access with a key that used to belong elsewhere.
$fresh = VpnPeer::factory()->ownedBy($owner)->create(['public_key' => $key]);
$hub->addPeer($key, $fresh->allowed_ip);
// The old revocation job finally runs.
(new ApplyVpnPeer($key, null, false, force: true))->handle($hub);
expect(VpnPeer::query()->whereKey($fresh->id)->exists())->toBeTrue()
->and($hub->peerIps())->toHaveKey($key); // and they stay connected
});
it('re-issues even when the config vault is unavailable', function () {
vpnHub();
Queue::fake();
$owner = operator('Owner');
$peer = VpnPeer::factory()->ownedBy($owner)->create([
'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\n"),
]);
// The very situation the console tells people to fix by re-issuing.
config()->set('admin_access.vpn_config_key', '');
Livewire::actingAs($owner, 'operator')->test(Vpn::class)->call('reissue', $peer->uuid);
expect($peer->fresh()->config_secret)->toBeNull(); // shown once instead
});
it('does not route the WireGuard endpoint through its own tunnel', function () {
// The obvious fix for "the VPN does not reach the console" is to add the
// server's public address to AllowedIPs. It cannot work: the endpoint is
// that same address, so the handshake packets would be routed into the
// tunnel they are trying to establish. Same blank page, now with no way in
// at all.
config()->set('provisioning.wireguard.subnet', '10.66.0.0/24');
$method = new ReflectionMethod(App\Livewire\Admin\Vpn::class, 'allowedIps');
$method->setAccessible(true);
foreach (['203.0.113.10:51820', '[2001:db8::10]:51820', 'vpn.example.test:51820'] as $endpoint) {
expect($method->invoke(null, $endpoint))->toBe('10.66.0.0/24', $endpoint);
}
});
it('points a client at the tunnel resolver only when the console is reachable there', function () {
// Setting DNS otherwise hands the device a resolver that does not exist and
// takes its name resolution down with it for as long as the tunnel is up.
config()->set('provisioning.wireguard.hub_address', '10.66.0.1');
$build = new ReflectionMethod(App\Livewire\Admin\Vpn::class, 'clientConfig');
$build->setAccessible(true);
$page = new App\Livewire\Admin\Vpn;
$keypair = new App\Services\Wireguard\Keypair('priv', 'pub');
// Not ready: no resolver is running, so naming one would take the device's
// whole name resolution down for as long as the tunnel is up.
config()->set('admin_access.vpn_ready', false);
expect($build->invoke($page, $keypair, '10.66.0.9'))->not->toContain('DNS =');
config()->set('admin_access.vpn_ready', true);
expect($build->invoke($page, $keypair, '10.66.0.9'))->toContain('DNS = 10.66.0.1');
});