From 0d9b62eb5072726ec0da8937158b94ef98576811 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 22:31:54 +0200 Subject: [PATCH] feat(vpn): ownership, a Developer role, and password-gated config retrieval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworked after a design consultation with Codex, which pushed back on my first proposal in three useful ways. Ownership and rights: - vpn.manage split into vpn.view.all and vpn.manage.all. Seeing an access is not managing it, and neither is holding its private key. - Record-level rules live in VpnPeerPolicy, not in permissions: an access belongs to a person, and ownership is what grants sight of it. Every operator reaches the page, but sees only their own unless they may see all. - Issuing an access is not self-service — it reaches the management network, so it needs vpn.manage.all even for oneself. - New Developer role: sees everything, manages nothing. Writing code does not imply authority over other people's access. - kind (staff|host|system) replaces a null user_id that had to mean two different things at once. Config storage, opt-in per access: - Downloading is owner-only — explicitly NOT for view.all or manage.all. An admin who needs access revokes this one and issues their own, which keeps the record of who holds what honest. - The password is asked on EVERY retrieval, rate-limited. Laravel's password.confirm keeps a 15-minute session stamp, which would authorise unlimited later downloads from an unattended browser. - Stored under VPN_CONFIG_KEY, not APP_KEY: a leaked application key must not also hand over the management network. Purged on revocation and when a staff member is revoked — console taken away, tunnel left open was the worst case. - The plaintext never enters a Livewire snapshot (the page polls every five seconds); the component carries an opaque handle instead. Also: copy button works without HTTPS again (navigator.clipboard only exists in a secure context, so over http it silently did nothing), plus config download as a file and a QR code for the mobile apps. Co-Authored-By: Claude Opus 4.8 --- .env.example | 7 +- app/Livewire/Admin/ConfirmDeleteVpnPeer.php | 10 +- app/Livewire/Admin/Settings.php | 11 + app/Livewire/Admin/Vpn.php | 117 +++++++++- app/Livewire/Admin/VpnConfigAccess.php | 111 +++++++++ app/Models/User.php | 2 +- app/Models/VpnPeer.php | 35 +++ app/Policies/VpnPeerPolicy.php | 76 ++++++ app/Services/Wireguard/ConfigHandoff.php | 45 ++++ app/Services/Wireguard/ConfigVault.php | 79 +++++++ app/Services/Wireguard/QrCode.php | 44 ++++ composer.json | 1 + composer.lock | 2 +- config/admin_access.php | 10 + database/factories/VpnPeerFactory.php | 15 ++ ...7_25_210000_add_ownership_to_vpn_peers.php | 51 ++++ ...26_07_25_210001_split_vpn_capabilities.php | 55 +++++ lang/de/vpn.php | 23 ++ lang/en/vpn.php | 23 ++ phpunit.xml | 3 + resources/js/app.js | 49 ++++ resources/views/components/ui/icon.blade.php | 1 + .../admin/vpn-config-access.blade.php | 48 ++++ resources/views/livewire/admin/vpn.blade.php | 93 ++++++-- tests/Feature/Admin/VpnTest.php | 221 ++++++++++++++++-- 25 files changed, 1083 insertions(+), 49 deletions(-) create mode 100644 app/Livewire/Admin/VpnConfigAccess.php create mode 100644 app/Policies/VpnPeerPolicy.php create mode 100644 app/Services/Wireguard/ConfigHandoff.php create mode 100644 app/Services/Wireguard/ConfigVault.php create mode 100644 app/Services/Wireguard/QrCode.php create mode 100644 database/migrations/2026_07_25_210000_add_ownership_to_vpn_peers.php create mode 100644 database/migrations/2026_07_25_210001_split_vpn_capabilities.php create mode 100644 resources/views/livewire/admin/vpn-config-access.blade.php diff --git a/.env.example b/.env.example index 0986db0..d913509 100644 --- a/.env.example +++ b/.env.example @@ -163,7 +163,12 @@ MONITORING_ATTEMPTS=2 # dies ist die zweite Verteidigungslinie IN der App: auf jedem anderen Host # antwortet /admin mit 404 (nicht 403 — verrät nicht, dass es die Konsole gibt). # Komma-getrennt. LEER = keine Einschränkung (aktueller Dev-Stand). -# Beispiel: ADMIN_HOSTS=admin.dev.clupilot.com,10.10.90.185 +# Beispiel: # Schluessel fuer gespeicherte VPN-Konfigurationen (32 Byte, base64). +# Bewusst getrennt von APP_KEY. Leer = Speichern deaktiviert. +# Erzeugen: head -c 32 /dev/urandom | base64 +VPN_CONFIG_KEY= + +ADMIN_HOSTS=admin.dev.clupilot.com,10.10.90.185 ADMIN_HOSTS=admin.dev.clupilot.com,10.10.90.185,localhost,127.0.0.1 # ── Nextcloud blueprint template ───────────────────────────────────────── diff --git a/app/Livewire/Admin/ConfirmDeleteVpnPeer.php b/app/Livewire/Admin/ConfirmDeleteVpnPeer.php index e8ca79f..bcda3b5 100644 --- a/app/Livewire/Admin/ConfirmDeleteVpnPeer.php +++ b/app/Livewire/Admin/ConfirmDeleteVpnPeer.php @@ -21,8 +21,10 @@ class ConfirmDeleteVpnPeer extends ModalComponent public function mount(string $uuid): void { - $this->authorize('vpn.manage'); // modals are reachable without the route middleware + // Modals are reachable without the route middleware, so this is the + // real guard, not a convenience check. $peer = VpnPeer::query()->with('host')->where('uuid', $uuid)->firstOrFail(); + $this->authorize('delete', $peer); $this->uuid = $uuid; $this->name = $peer->name; $this->hostName = $peer->host?->name; @@ -30,10 +32,12 @@ class ConfirmDeleteVpnPeer extends ModalComponent public function delete(): void { - $this->authorize('vpn.manage'); - $peer = VpnPeer::query()->where('uuid', $this->uuid)->first(); if ($peer !== null) { + $this->authorize('delete', $peer); + + // A tombstone that still carries a usable private key is a liability. + $peer->purgeSecret(); // Soft-delete, so the row survives as a tombstone until the hub has // actually dropped the peer. A hard delete here would let the next // sync adopt the still-present peer back as a live access — diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 1705c20..432d3c3 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -4,6 +4,8 @@ namespace App\Livewire\Admin; use App\Models\Customer; use App\Models\User; +use App\Models\VpnPeer; +use App\Provisioning\Jobs\ApplyVpnPeer; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; @@ -169,6 +171,15 @@ class Settings extends Component $target->syncRoles([]); $target->update(['is_admin' => false]); + // Taking away the console but leaving the tunnel would be the worst + // of both: no access to the panel, still a route into the + // management network. Same revocation path as the VPN page uses. + foreach (VpnPeer::query()->where('user_id', $target->id)->get() as $peer) { + $peer->purgeSecret(); + $peer->delete(); + ApplyVpnPeer::dispatch($peer->public_key, null, false); + } + return 'revoked'; }); diff --git a/app/Livewire/Admin/Vpn.php b/app/Livewire/Admin/Vpn.php index 7b72547..72f15a7 100644 --- a/app/Livewire/Admin/Vpn.php +++ b/app/Livewire/Admin/Vpn.php @@ -6,10 +6,16 @@ use App\Models\Host; use App\Models\VpnPeer; use App\Provisioning\Jobs\ApplyVpnPeer; use App\Provisioning\Jobs\SyncVpnPeers; +use App\Models\User; +use App\Services\Wireguard\ConfigHandoff; +use App\Services\Wireguard\ConfigVault; use App\Services\Wireguard\Keypair; +use App\Services\Wireguard\QrCode; use App\Services\Wireguard\WireguardHub; use Illuminate\Database\QueryException; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Str; +use Illuminate\Validation\Rule; use Livewire\Attributes\Layout; use Livewire\Attributes\On; use Livewire\Component; @@ -27,14 +33,23 @@ class Vpn extends Component /** Optional: a peer that generated its own key never hands us the private half. */ public string $publicKey = ''; - /** Shown once, right after creation — the private key is never stored. */ - public ?string $newConfig = null; + /** Opaque handle for the freshly created config; see ConfigHandoff. */ + public ?string $configToken = null; public ?string $newConfigName = null; + /** Owner of the new access. Defaults to the operator creating it. */ + public ?int $ownerId = null; + + /** Keep the config so its owner can fetch it again behind their password. */ + public bool $storeConfig = false; + + public bool $showQr = false; + public function mount(): void { - $this->authorize('vpn.manage'); + $this->authorize('viewAny', VpnPeer::class); + $this->ownerId = auth()->id(); } /** @@ -45,19 +60,44 @@ class Vpn extends Component */ public function hydrate(): void { - $this->authorize('vpn.manage'); + $this->authorize('viewAny', VpnPeer::class); } public function create(): void { - $this->authorize('vpn.manage'); + $this->authorize('create', VpnPeer::class); $this->validate([ 'name' => 'required|string|max:255', 'publicKey' => 'nullable|string|max:64', + // An access belongs to a person, and only to an operator: a customer + // account must never own a way into the management network. + 'ownerId' => ['required', Rule::exists('users', 'id')], ]); + $owner = User::query()->find($this->ownerId); + if ($owner === null || ! $owner->isOperator()) { + $this->addError('ownerId', __('vpn.owner_must_be_operator')); + + return; + } + + // Storing needs a key. Without one we would either write the credential + // in the clear or pretend we stored it — both worse than saying no. + if ($this->storeConfig && ! ConfigVault::available()) { + $this->addError('storeConfig', __('vpn.vault_unavailable')); + + return; + } + $ownKey = trim($this->publicKey) !== ''; + + if ($this->storeConfig && $ownKey) { + $this->addError('storeConfig', __('vpn.cannot_store_foreign_key')); + + return; + } + if ($ownKey && ! Keypair::isValidKey(trim($this->publicKey))) { $this->addError('publicKey', __('vpn.invalid_key')); @@ -94,6 +134,8 @@ class Vpn extends Component return VpnPeer::create([ 'name' => trim($this->name), + 'kind' => VpnPeer::KIND_STAFF, + 'user_id' => $this->ownerId, 'public_key' => $publicKey, 'allowed_ip' => $hub->allocateIp(), 'enabled' => true, @@ -118,21 +160,31 @@ class Vpn extends Component ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, true); // Only a key we generated can be turned into a ready-to-use config. - $this->newConfig = $keypair === null ? null : $this->clientConfig($keypair, $peer->allowed_ip); - $this->newConfigName = $peer->name; + if ($keypair !== null) { + $config = $this->clientConfig($keypair, $peer->allowed_ip); + $this->configToken = ConfigHandoff::put($config); + $this->newConfigName = $peer->name; - $this->reset('name', 'publicKey'); + if ($this->storeConfig) { + $peer->forceFill(['config_secret' => ConfigVault::encrypt($config)])->saveQuietly(); + } + } + + $this->reset('name', 'publicKey', 'storeConfig'); + $this->ownerId = auth()->id(); $this->dispatch('notify', message: __('vpn.created')); } public function toggle(string $uuid): void { - $this->authorize('vpn.manage'); - + // Looked up globally and then judged by the policy: filtering it out of + // the query here would turn "not allowed" into a button that silently + // does nothing, which is exactly how the portal used to lie to people. $peer = VpnPeer::query()->where('uuid', $uuid)->first(); if ($peer === null) { return; } + $this->authorize('block', $peer); $peer->update(['enabled' => ! $peer->enabled]); ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, $peer->enabled); @@ -148,7 +200,41 @@ class Vpn extends Component public function dismissConfig(): void { - $this->reset('newConfig', 'newConfigName'); + ConfigHandoff::forget($this->configToken); + $this->reset('configToken', 'newConfigName', 'showQr'); + } + + /** + * The list this operator is allowed to see: their own accesses, plus + * everything when they hold vpn.view.all. Filtering in the query as well as + * in the actions, so a forged uuid cannot reach a peer the page never showed. + */ + private function visiblePeers() + { + $query = VpnPeer::query(); + + if (! auth()->user()?->can('vpn.view.all')) { + $query->where('kind', VpnPeer::KIND_STAFF)->where('user_id', auth()->id()); + } + + return $query; + } + + public function toggleQr(): void + { + $this->showQr = ! $this->showQr; + } + + /** + * wg-quick takes the interface name from the filename, and Linux caps + * interface names at 15 characters — so the label is slugged and cut rather + * than handing the operator a file that fails to come up. + */ + public function configFilename(): string + { + $slug = Str::slug((string) $this->newConfigName) ?: 'clupilot'; + + return Str::limit($slug, 15, '').'.conf'; } /** Polled by the view; throttled so a room full of open tabs cannot flood the queue. */ @@ -182,7 +268,14 @@ class Vpn extends Component $hub = app(WireguardHub::class); return view('livewire.admin.vpn', [ - 'peers' => VpnPeer::query()->with('host')->orderByDesc('present')->orderBy('name')->get(), + 'newConfig' => $config = ConfigHandoff::get($this->configToken), + 'qrSvg' => $this->showQr && $config !== null ? QrCode::svg($config) : null, + 'peers' => $this->visiblePeers()->with(['host', 'owner'])->orderByDesc('present')->orderBy('name')->get(), + 'operators' => User::query() + ->whereHas('roles', fn ($q) => $q->whereIn('name', User::OPERATOR_ROLES)) + ->orderBy('name')->get(['id', 'name', 'email']), + 'canManage' => auth()->user()?->can('vpn.manage.all') ?? false, + 'vaultAvailable' => ConfigVault::available(), 'hubEndpoint' => $hub->endpoint(), 'hubPublicKey' => $hub->publicKey(), 'lastSync' => VpnPeer::query()->max('observed_at'), diff --git a/app/Livewire/Admin/VpnConfigAccess.php b/app/Livewire/Admin/VpnConfigAccess.php new file mode 100644 index 0000000..ca871b1 --- /dev/null +++ b/app/Livewire/Admin/VpnConfigAccess.php @@ -0,0 +1,111 @@ +where('uuid', $uuid)->firstOrFail(); + $this->authorize('downloadConfig', $peer); + + $this->uuid = $uuid; + $this->name = $peer->name; + } + + public function reveal(): void + { + // A retry must not still be showing the previous attempt's complaint. + $this->resetErrorBag('password'); + + $peer = VpnPeer::query()->where('uuid', $this->uuid)->firstOrFail(); + $this->authorize('downloadConfig', $peer); + + // Rate-limited per user, so a stolen session cannot grind the password. + $key = 'vpn-config:'.auth()->id(); + if (RateLimiter::tooManyAttempts($key, 5)) { + $this->addError('password', __('vpn.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)])); + + return; + } + + if (! Hash::check($this->password, auth()->user()->password)) { + RateLimiter::hit($key, 300); + Log::warning('VPN config download refused: wrong password', [ + 'user_id' => auth()->id(), 'peer' => $peer->uuid, + ]); + $this->addError('password', __('vpn.wrong_password')); + + return; + } + + RateLimiter::clear($key); + + $plaintext = ConfigVault::decrypt((string) $peer->config_secret); + if ($plaintext === null) { + // Wrong or rotated key, or a tampered record. Never guess. + $this->addError('password', __('vpn.config_unreadable')); + + return; + } + + $peer->forceFill([ + 'last_downloaded_at' => now(), + 'download_count' => $peer->download_count + 1, + ])->saveQuietly(); + + Log::info('VPN config downloaded', ['user_id' => auth()->id(), 'peer' => $peer->uuid]); + + $this->reset('password'); + $this->token = ConfigHandoff::put($plaintext); + } + + public function toggleQr(): void + { + $this->showQr = ! $this->showQr; + } + + public function filename(): string + { + return Str::limit(Str::slug($this->name) ?: 'clupilot', 15, '').'.conf'; + } + + public function render() + { + $config = ConfigHandoff::get($this->token); + + return view('livewire.admin.vpn-config-access', [ + 'config' => $config, + 'qrSvg' => $config !== null && $this->showQr ? QrCode::svg($config) : null, + ]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index a502843..1df9bc7 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -20,7 +20,7 @@ class User extends Authenticatable use HasFactory, HasRoles, Notifiable, TwoFactorAuthenticatable; /** The five operator roles that grant access to the admin console. */ - public const OPERATOR_ROLES = ['Owner', 'Admin', 'Support', 'Billing', 'Read-only']; + public const OPERATOR_ROLES = ['Owner', 'Admin', 'Support', 'Billing', 'Read-only', 'Developer']; /** True when this user holds an operator role (the RBAC console boundary). */ public function isOperator(): bool diff --git a/app/Models/VpnPeer.php b/app/Models/VpnPeer.php index 6d78403..7deb45e 100644 --- a/app/Models/VpnPeer.php +++ b/app/Models/VpnPeer.php @@ -19,6 +19,15 @@ class VpnPeer extends Model * does not flap for an idle-but-connected peer. */ public const ONLINE_AFTER_MINUTES = 3; + /** A person's access. Requires an owner. */ + public const KIND_STAFF = 'staff'; + + /** Registered by the host pipeline for a Proxmox host. Requires host_id. */ + public const KIND_HOST = 'host'; + + /** Found on the interface without a match — adopted so it stays visible. */ + public const KIND_SYSTEM = 'system'; + protected $guarded = []; protected function casts(): array @@ -27,6 +36,8 @@ class VpnPeer extends Model 'enabled' => 'boolean', 'present' => 'boolean', 'last_handshake_at' => 'datetime', + 'secret_purged_at' => 'datetime', + 'last_downloaded_at' => 'datetime', 'observed_at' => 'datetime', 'rx_bytes' => 'integer', 'tx_bytes' => 'integer', @@ -48,6 +59,30 @@ class VpnPeer extends Model return $this->belongsTo(User::class, 'created_by'); } + /** The person this access belongs to (staff peers only). */ + public function owner(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function hasStoredConfig(): bool + { + return $this->config_secret !== null; + } + + /** + * Overwrite the stored credential. Called on revocation: a tombstone that + * still carries a usable private key is a liability, not a record. + */ + public function purgeSecret(): void + { + if ($this->config_secret === null) { + return; + } + + $this->forceFill(['config_secret' => null, 'secret_purged_at' => now()])->saveQuietly(); + } + public function isOnline(): bool { return $this->enabled diff --git a/app/Policies/VpnPeerPolicy.php b/app/Policies/VpnPeerPolicy.php new file mode 100644 index 0000000..e8abfe9 --- /dev/null +++ b/app/Policies/VpnPeerPolicy.php @@ -0,0 +1,76 @@ +isOperator(); + } + + public function view(User $user, VpnPeer $peer): bool + { + return $this->owns($user, $peer) || $user->can('vpn.view.all'); + } + + public function create(User $user): bool + { + // Not self-service: an access reaches the management network, so issuing + // one is an administrative act even for oneself. + return $user->can('vpn.manage.all'); + } + + /** Rename, reassign, change stored-config settings. */ + public function update(User $user, VpnPeer $peer): bool + { + return $user->can('vpn.manage.all'); + } + + /** Block / unblock. Owners may switch their own access off and on again. */ + public function block(User $user, VpnPeer $peer): bool + { + return $user->can('vpn.manage.all') || $this->owns($user, $peer); + } + + public function delete(User $user, VpnPeer $peer): bool + { + return $user->can('vpn.manage.all') || $this->owns($user, $peer); + } + + /** + * The private half of someone's credential. Owner only — explicitly NOT + * vpn.view.all and NOT vpn.manage.all: an administrator who needs access + * revokes this one and issues themselves their own, which keeps the record + * of who holds what intact. + */ + public function downloadConfig(User $user, VpnPeer $peer): bool + { + return $this->owns($user, $peer) + && $peer->config_secret !== null + && ! $peer->trashed(); + } + + private function owns(User $user, VpnPeer $peer): bool + { + return $peer->kind === VpnPeer::KIND_STAFF + && $peer->user_id !== null + && $peer->user_id === $user->id; + } +} diff --git a/app/Services/Wireguard/ConfigHandoff.php b/app/Services/Wireguard/ConfigHandoff.php new file mode 100644 index 0000000..6129d81 --- /dev/null +++ b/app/Services/Wireguard/ConfigHandoff.php @@ -0,0 +1,45 @@ +getMatrix(); + $width = $matrix->getWidth(); + $quiet = 2; + $total = $width + 2 * $quiet; + + $cells = ''; + for ($y = 0; $y < $width; $y++) { + for ($x = 0; $x < $width; $x++) { + if ($matrix->get($x, $y) === 1) { + $cells .= sprintf('', $x + $quiet, $y + $quiet); + } + } + } + + return sprintf( + '' + .'%3$s', + $total, + $size, + $cells, + ); + } +} diff --git a/composer.json b/composer.json index 6ea5080..c78ddb6 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,7 @@ "license": "MIT", "require": { "php": "^8.3", + "bacon/bacon-qr-code": "^3.1", "laravel/fortify": "^1.37", "laravel/framework": "^13.8", "laravel/reverb": "^1.11", diff --git a/composer.lock b/composer.lock index 67a6ce6..c080988 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "090088615979540ccfee56eba3d133af", + "content-hash": "4afacce87fbd845b941534e375dc082b", "packages": [ { "name": "bacon/bacon-qr-code", diff --git a/config/admin_access.php b/config/admin_access.php index fb11675..bd86eda 100644 --- a/config/admin_access.php +++ b/config/admin_access.php @@ -23,4 +23,14 @@ return [ explode(',', (string) env('ADMIN_HOSTS', '')) ))), + /* + | Key for VPN configs stored at rest (32 bytes, base64). Separate from + | APP_KEY on purpose — see App\Services\Wireguard\ConfigVault. Empty means + | storing configs is switched off, and the console says so rather than + | quietly writing credentials in the clear. + | + | Generate with: head -c 32 /dev/urandom | base64 + */ + 'vpn_config_key' => env('VPN_CONFIG_KEY', ''), + ]; diff --git a/database/factories/VpnPeerFactory.php b/database/factories/VpnPeerFactory.php index c4736d3..1cbbfe9 100644 --- a/database/factories/VpnPeerFactory.php +++ b/database/factories/VpnPeerFactory.php @@ -2,6 +2,8 @@ namespace Database\Factories; +use App\Models\User; +use App\Models\VpnPeer; use App\Services\Wireguard\Keypair; use Illuminate\Database\Eloquent\Factories\Factory; @@ -13,6 +15,8 @@ class VpnPeerFactory extends Factory return [ 'name' => $this->faker->userName(), + 'kind' => VpnPeer::KIND_STAFF, + 'user_id' => User::factory()->operator('Admin'), 'public_key' => Keypair::generate()->publicKey, 'allowed_ip' => '10.66.0.'.($octet++), 'enabled' => true, @@ -20,6 +24,17 @@ class VpnPeerFactory extends Factory ]; } + /** A peer registered by the host pipeline: no human owner. */ + public function forHost(): static + { + return $this->state(['kind' => VpnPeer::KIND_HOST, 'user_id' => null]); + } + + public function ownedBy(User $user): static + { + return $this->state(['kind' => VpnPeer::KIND_STAFF, 'user_id' => $user->id]); + } + public function blocked(): static { return $this->state(['enabled' => false, 'present' => false]); diff --git a/database/migrations/2026_07_25_210000_add_ownership_to_vpn_peers.php b/database/migrations/2026_07_25_210000_add_ownership_to_vpn_peers.php new file mode 100644 index 0000000..a238d0a --- /dev/null +++ b/database/migrations/2026_07_25_210000_add_ownership_to_vpn_peers.php @@ -0,0 +1,51 @@ +string('kind')->default('staff')->after('name'); // staff|host|system + $table->foreignId('user_id')->nullable()->after('kind')->constrained()->restrictOnDelete(); + // Encrypted with VPN_CONFIG_KEY (see ConfigVault) — never APP_KEY, so + // a leaked application key does not also hand over VPN credentials. + $table->text('config_secret')->nullable(); + $table->timestamp('secret_purged_at')->nullable(); + $table->timestamp('last_downloaded_at')->nullable(); + $table->unsignedInteger('download_count')->default(0); + }); + + // Existing rows: pipeline peers are host peers, the rest belong to + // whoever created them (created_by is the only ownership hint we have). + DB::table('vpn_peers')->whereNotNull('host_id')->update(['kind' => 'host']); + DB::table('vpn_peers')->whereNull('host_id')->update([ + 'kind' => 'staff', + 'user_id' => DB::raw('created_by'), + ]); + } + + public function down(): void + { + Schema::table('vpn_peers', function (Blueprint $table) { + $table->dropForeign(['user_id']); + $table->dropColumn(['kind', 'user_id', 'config_secret', 'secret_purged_at', 'last_downloaded_at', 'download_count']); + }); + } +}; diff --git a/database/migrations/2026_07_25_210001_split_vpn_capabilities.php b/database/migrations/2026_07_25_210001_split_vpn_capabilities.php new file mode 100644 index 0000000..81b3c4a --- /dev/null +++ b/database/migrations/2026_07_25_210001_split_vpn_capabilities.php @@ -0,0 +1,55 @@ +forgetCachedPermissions(); + + Permission::findOrCreate('vpn.view.all', 'web'); + Permission::findOrCreate('vpn.manage.all', 'web'); + + foreach (['Owner', 'Admin'] as $role) { + Role::findOrCreate($role, 'web')->givePermissionTo(['vpn.view.all', 'vpn.manage.all']); + } + Role::findOrCreate('Developer', 'web')->syncPermissions(['console.view', 'vpn.view.all']); + + Permission::query()->where('name', 'vpn.manage')->delete(); + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } + + public function down(): void + { + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + Permission::findOrCreate('vpn.manage', 'web'); + foreach (['Owner', 'Admin'] as $role) { + Role::findOrCreate($role, 'web')->givePermissionTo('vpn.manage'); + } + Role::query()->where('name', 'Developer')->delete(); + Permission::query()->whereIn('name', ['vpn.view.all', 'vpn.manage.all'])->delete(); + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } +}; diff --git a/lang/de/vpn.php b/lang/de/vpn.php index caae889..68789ed 100644 --- a/lang/de/vpn.php +++ b/lang/de/vpn.php @@ -41,6 +41,11 @@ return [ 'config_ready' => 'Konfiguration für :name', 'config_once' => 'Der private Schlüssel wird nicht gespeichert und ist nur jetzt sichtbar.', + 'download' => 'Herunterladen', + 'copy_failed' => 'Kopieren wurde vom Browser blockiert — bitte den Text markieren oder die Datei herunterladen.', + 'qr_show' => 'QR-Code', + 'qr_hide' => 'QR ausblenden', + 'qr_hint' => 'Mit der WireGuard-App scannen.', 'copy' => 'Kopieren', 'copied' => 'Kopiert', @@ -53,6 +58,24 @@ return [ 'delete_host_warning' => 'Achtung: Dieser Zugang gehört zum Host :host. CluPilot erreicht diesen Host danach nicht mehr.', 'delete_confirm' => 'Zugang entfernen', + 'owner' => 'Besitzer', + 'owner_hint' => 'Nur dem Besitzer gehört der Zugang — nur er kann die Konfiguration später abrufen.', + 'owner_must_be_operator' => 'Ein VPN-Zugang darf nur einem Mitarbeiter gehören.', + 'you' => 'Sie', + 'no_owner' => 'Ohne Besitzer', + 'store_config' => 'Konfiguration speichern', + 'store_config_hint' => 'Verschlüsselt abgelegt. Der Besitzer kann sie später mit seinem Passwort erneut abrufen.', + 'vault_unavailable' => 'Speichern ist nicht möglich: VPN_CONFIG_KEY ist nicht gesetzt.', + 'cannot_store_foreign_key' => 'Bei eigenem Public Key haben wir den privaten Schlüssel nie — es gibt nichts zu speichern.', + 'get_config' => 'Konfiguration abrufen', + 'get_config_title' => 'Konfiguration für :name', + 'get_config_body' => 'Zur Sicherheit bei jedem Abruf: bitte das eigene Passwort eingeben.', + 'your_password' => 'Ihr Passwort', + 'reveal' => 'Anzeigen', + 'wrong_password' => 'Passwort stimmt nicht.', + 'too_many_attempts' => 'Zu viele Versuche. Bitte in :seconds Sekunden erneut versuchen.', + 'config_unreadable' => 'Die gespeicherte Konfiguration lässt sich nicht entschlüsseln. Bitte den Zugang neu ausstellen.', + 'hub' => 'Hub', 'endpoint' => 'Endpunkt', 'hub_key' => 'Public Key', diff --git a/lang/en/vpn.php b/lang/en/vpn.php index 756f490..8be3b62 100644 --- a/lang/en/vpn.php +++ b/lang/en/vpn.php @@ -41,6 +41,11 @@ return [ 'config_ready' => 'Configuration for :name', 'config_once' => 'The private key is not stored and is only visible now.', + 'download' => 'Download', + 'copy_failed' => 'The browser blocked copying — select the text or download the file instead.', + 'qr_show' => 'QR code', + 'qr_hide' => 'Hide QR', + 'qr_hint' => 'Scan with the WireGuard app.', 'copy' => 'Copy', 'copied' => 'Copied', @@ -53,6 +58,24 @@ return [ 'delete_host_warning' => 'Careful: this access belongs to host :host. CluPilot will no longer reach that host.', 'delete_confirm' => 'Remove access', + 'owner' => 'Owner', + 'owner_hint' => 'The access belongs to its owner — only they can retrieve the config later.', + 'owner_must_be_operator' => 'A VPN access may only belong to a staff member.', + 'you' => 'You', + 'no_owner' => 'No owner', + 'store_config' => 'Store configuration', + 'store_config_hint' => 'Kept encrypted. The owner can retrieve it later with their password.', + 'vault_unavailable' => 'Storing is unavailable: VPN_CONFIG_KEY is not set.', + 'cannot_store_foreign_key' => 'With your own public key we never hold the private half — there is nothing to store.', + 'get_config' => 'Retrieve configuration', + 'get_config_title' => 'Configuration for :name', + 'get_config_body' => 'Asked every time, on purpose: please enter your own password.', + 'your_password' => 'Your password', + 'reveal' => 'Show', + 'wrong_password' => 'That password is not correct.', + 'too_many_attempts' => 'Too many attempts. Try again in :seconds seconds.', + 'config_unreadable' => 'The stored configuration cannot be decrypted. Please re-issue the access.', + 'hub' => 'Hub', 'endpoint' => 'Endpoint', 'hub_key' => 'Public key', diff --git a/phpunit.xml b/phpunit.xml index dc686d3..50f2621 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -27,6 +27,9 @@ the restriction set admin_access.hosts themselves. --> + + diff --git a/resources/js/app.js b/resources/js/app.js index db585de..bc27da2 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -73,6 +73,55 @@ function applyLineGradients(cfg) { } document.addEventListener('alpine:init', () => { + // ── VPN config: copy / download ────────────────────────────────────── + // navigator.clipboard only exists in a secure context, so over plain http + // (the panel reached by IP) the copy button silently did nothing. Falls + // back to the legacy selection copy, and says so when even that fails. + window.Alpine.data('vpnConfigActions', (filename) => ({ + copied: false, + failed: false, + text() { + return this.$refs.config.textContent; + }, + async copy() { + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(this.text()); + } else { + const area = document.createElement('textarea'); + area.value = this.text(); + area.setAttribute('readonly', ''); + area.style.position = 'fixed'; + area.style.opacity = '0'; + document.body.appendChild(area); + area.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(area); + if (!ok) throw new Error('copy rejected'); + } + this.failed = false; + this.copied = true; + setTimeout(() => (this.copied = false), 2000); + } catch (e) { + this.copied = false; + this.failed = true; + setTimeout(() => (this.failed = false), 6000); + } + }, + download() { + // Built in the browser from what is already on the page: the private + // key never has to travel a second time, and no route has to exist. + const url = URL.createObjectURL(new Blob([this.text()], { type: 'text/plain' })); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + setTimeout(() => URL.revokeObjectURL(url), 1000); + }, + })); + // ── Segmented OTP input ────────────────────────────────────────────── window.Alpine.data('otpInput', ({ length = 6 } = {}) => ({ length, diff --git a/resources/views/components/ui/icon.blade.php b/resources/views/components/ui/icon.blade.php index 0b5d6d2..9350f38 100644 --- a/resources/views/components/ui/icon.blade.php +++ b/resources/views/components/ui/icon.blade.php @@ -18,6 +18,7 @@ 'download' => '', 'alert-triangle' => '', 'check' => '', + 'qr-code' => '', 'lock' => '', 'unlock' => '', 'copy' => '', diff --git a/resources/views/livewire/admin/vpn-config-access.blade.php b/resources/views/livewire/admin/vpn-config-access.blade.php new file mode 100644 index 0000000..87ca32c --- /dev/null +++ b/resources/views/livewire/admin/vpn-config-access.blade.php @@ -0,0 +1,48 @@ +
+

{{ __('vpn.get_config_title', ['name' => $name]) }}

+ + @if ($config === null) +

{{ __('vpn.get_config_body') }}

+ +
+ {{-- autocomplete hint keeps password managers from offering to save + this as a new login for the site. --}} + +
+ {{ __('vpn.cancel') }} + + {{ __('vpn.reveal') }} + +
+ + @else +
+
{{ $config }}
+
+ + {{ __('vpn.download') }} + + + + + + + {{ $showQr ? __('vpn.qr_hide') : __('vpn.qr_show') }} + +

{{ __('vpn.copy_failed') }}

+
+ + @if ($qrSvg) +
+
{!! $qrSvg !!}
+

{{ __('vpn.qr_hint') }}

+
+ @endif + +
+ {{ __('common.close') }} +
+
+ @endif +
diff --git a/resources/views/livewire/admin/vpn.blade.php b/resources/views/livewire/admin/vpn.blade.php index fe9aaf7..a3c15d9 100644 --- a/resources/views/livewire/admin/vpn.blade.php +++ b/resources/views/livewire/admin/vpn.blade.php @@ -27,15 +27,31 @@ -
-
{{ $newConfig }}
-
- +
+
{{ $newConfig }}
+
+ + {{ __('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. --}} +

{{ __('vpn.copy_failed') }}

+ + @if ($qrSvg) +
+
{!! $qrSvg !!}
+

{{ __('vpn.qr_hint') }}

+
+ @endif
@endif @@ -58,6 +74,7 @@ {{ __('vpn.peer') }} + {{ __('vpn.owner') }} {{ __('vpn.address') }} {{ __('vpn.traffic') }} {{ __('vpn.handshake') }} @@ -87,6 +104,16 @@ @endif
+ + @if ($peer->owner) + {{ $peer->owner->name }} + @if ($peer->user_id === auth()->id()) + {{ __('vpn.you') }} + @endif + @else + {{ $peer->host ? __('vpn.host_peer') : __('vpn.no_owner') }} + @endif +
{{ $peer->allowed_ip }}
@if ($peer->endpoint) @@ -108,16 +135,27 @@
+ @can('downloadConfig', $peer) + + @endcan + @can('block', $peer) + @endcan + @can('delete', $peer) + @endcan
@@ -130,14 +168,43 @@
{{-- New access --}} -
-

{{ __('vpn.add') }}

- - - - {{ __('vpn.add') }} - - + @if ($canManage) +
+

{{ __('vpn.add') }}

+ + +
+ + +

{{ __('vpn.owner_hint') }}

+ @error('ownerId')

{{ $message }}

@enderror +
+ + + +
+ + @error('storeConfig')

{{ $message }}

@enderror +
+ + + {{ __('vpn.add') }} + + + @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); +});