+
+ {{ __('vpn.download') }}
+
+
+
+
+ {{ $showQr ? __('vpn.qr_hide') : __('vpn.qr_show') }}
+
+ {{-- Only surfaces when copying is impossible (no clipboard API
+ outside a secure context) — silence would look like a dead button. --}}
+
{{-- New access --}}
-
+ @if ($canManage)
+
+ @endif
{{-- Hub facts an operator needs when configuring a peer by hand --}}
diff --git a/tests/Feature/Admin/VpnTest.php b/tests/Feature/Admin/VpnTest.php
index e82ee8f..776ee99 100644
--- a/tests/Feature/Admin/VpnTest.php
+++ b/tests/Feature/Admin/VpnTest.php
@@ -2,6 +2,7 @@
use App\Livewire\Admin\ConfirmDeleteVpnPeer;
use App\Livewire\Admin\Vpn;
+use App\Livewire\Admin\VpnConfigAccess;
use App\Models\Host;
use App\Models\VpnPeer;
use App\Provisioning\Jobs\ApplyVpnPeer;
@@ -11,6 +12,7 @@ use App\Services\Wireguard\Keypair;
use App\Services\Wireguard\LocalWireguardHub;
use App\Services\Wireguard\PeerSnapshot;
use App\Services\Wireguard\WireguardHub;
+use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
@@ -22,19 +24,46 @@ function vpnHub(): FakeWireguardHub
return $hub;
}
-it('keeps the page and the nav entry away from operators without the capability', function () {
+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)->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'))->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();
- // 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'))
+ // The form is not even offered to someone who cannot use it.
+ $this->actingAs(operator('Support'))->get(route('admin.vpn'))
->assertOk()
- ->assertDontSee(route('admin.vpn'));
+ ->assertDontSee(__('vpn.store_config'));
- $this->actingAs(operator('Admin'))->get(route('admin.vpn'))->assertOk();
- $this->actingAs(operator('Owner'))->get(route('admin.overview'))
+ $this->actingAs(operator('Owner'))->get(route('admin.vpn'))
->assertOk()
- ->assertSee(route('admin.vpn'));
+ ->assertSee(__('vpn.store_config'));
+
+ Livewire::actingAs(operator('Support'))->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 () {
@@ -52,16 +81,19 @@ it('creates an access, hands out the config once and pushes it to the hub', func
->and($peer->enabled)->toBeTrue()
->and($peer->present)->toBeFalse(); // not on the hub until the job ran
- $config = $component->get('newConfig');
+ $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 shown, never stored.
+ // 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(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);
});
@@ -79,7 +111,7 @@ it('accepts a peer-supplied public key and then has no private key to show', fun
->assertHasNoErrors();
expect(VpnPeer::query()->where('public_key', $own)->exists())->toBeTrue()
- ->and($component->get('newConfig'))->toBeNull();
+ ->and($component->get('configToken'))->toBeNull();
});
it('rejects a malformed key and a key that already has an access', function () {
@@ -139,14 +171,15 @@ it('warns that deleting a host access cuts CluPilot off from that host', functio
it('stops mutations from a session whose capability was revoked mid-flight', function () {
vpnHub();
Queue::fake();
- $peer = VpnPeer::factory()->create();
+ $peer = VpnPeer::factory()->create(); // someone else's access
$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.
+ // 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();
@@ -156,6 +189,18 @@ it('stops mutations from a session whose capability was revoked mid-flight', fun
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)->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;
@@ -274,16 +319,18 @@ it('keeps a revoked address and key reserved until the hub confirms', function (
->assertHasErrors('publicKey');
});
-it('stops an open page from polling once the capability is revoked', function () {
+it('narrows what an open page shows once the account is demoted', function () {
vpnHub();
$user = operator('Owner');
- $component = Livewire::actingAs($user)->test(Vpn::class);
+ VpnPeer::factory()->create(['name' => 'Fremdes-Notebook']);
+ $component = Livewire::actingAs($user)->test(Vpn::class)->assertSee('Fremdes-Notebook');
$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();
+ // 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 () {
@@ -451,3 +498,141 @@ it('does not leave a phantom access behind when a host is purged', function () {
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)->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'))->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)->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)->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)
+ ->test(VpnConfigAccess::class, ['uuid' => $peer->uuid])
+ ->assertForbidden();
+ }
+
+ // The owner still gets the button.
+ $this->actingAs($support)->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)->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('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'))
+ ->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'))->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);
+});