authorize('viewAny', VpnPeer::class); $this->ownerId = auth()->id(); } /** * mount() runs once; every later request — including the five-second poll — * only hydrates. Without this, revoking vpn.manage would not take effect * until the operator happened to reload the page, and the open tab would * keep serving fresh peer state. */ public function hydrate(): void { $this->authorize('viewAny', VpnPeer::class); } public function create(): void { $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')], ]); // 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')); return; } $keypair = $ownKey ? null : Keypair::generate(); $publicKey = $ownKey ? trim($this->publicKey) : $keypair->publicKey; $hub = app(WireguardHub::class); // Same lock the host pipeline holds while it reserves an address // (ConfigureWireguard). Addresses come from one subnet but live in two // tables, so neither unique index can catch the other's insert — the // shared lock is what keeps a host and an access off the same tunnel IP. try { // Filled inside the transaction; only the handoff needs it afterwards. $plainConfig = null; $peer = Cache::lock('wireguard:allocate', 30)->block(10, function () use ($hub, $publicKey, $keypair, &$plainConfig) { return DB::transaction(function () use ($hub, $publicKey, $keypair, &$plainConfig) { // The owner row is locked for the whole insert, and revokeStaff() // locks the same row: without that, a revocation could commit // between the check and the insert, find no peer to remove, and // leave the revoked colleague with a brand-new tunnel. $owner = User::query()->whereKey($this->ownerId)->lockForUpdate()->first(); if ($owner === null || ! $owner->isOperator()) { return 'owner_must_be_operator'; } // Inside the lock: checking before it would let two concurrent // requests both pass and the loser hit the unique index as a // 500. withTrashed, because a revoked peer keeps its key until // the hub confirms removal. $existing = VpnPeer::withTrashed()->where('public_key', $publicKey)->first(); if ($existing !== null) { return $existing->trashed() ? 'pending_removal' : 'duplicate_key'; } // A host's key may not have been adopted into vpn_peers yet. // Re-using it would make `wg set` rewrite that host's allowed-ip // to the address allocated here — cutting the management tunnel // to a live machine. if (Host::query()->where('wg_pubkey', $publicKey)->exists()) { return 'host_key'; } $ip = $hub->allocateIp(); // Built and encrypted here so the stored config is part of the // same insert. Doing it afterwards could leave a live access // whose config was never stored — unrecoverable, and the // operator would create a second one not knowing why. $secret = null; if ($keypair !== null) { $plainConfig = $this->clientConfig($keypair, $ip); if ($this->storeConfig) { $secret = ConfigVault::encrypt($plainConfig); } } return VpnPeer::create([ 'name' => trim($this->name), 'kind' => VpnPeer::KIND_STAFF, 'user_id' => $this->ownerId, 'public_key' => $publicKey, 'allowed_ip' => $ip, 'config_secret' => $secret, 'enabled' => true, 'present' => false, 'created_by' => auth()->id(), ]); }); }); } catch (QueryException) { // Backstop: the unique index caught a writer that did not take this // lock. Report it like any other duplicate instead of a 500. $this->addError('publicKey', __('vpn.duplicate_key')); return; } if (is_string($peer)) { $field = $peer === 'owner_must_be_operator' ? 'ownerId' : 'publicKey'; $this->addError($field, __('vpn.'.$peer)); return; } ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, true); // Whatever was on screen belongs to the previous access. Leaving it up // after creating another one would present the wrong private config // under the new name — and someone would hand it out. $this->dismissConfig(); // Only a key we generated can be turned into a ready-to-use config. if ($plainConfig !== null) { $this->configToken = ConfigHandoff::put($plainConfig); $this->newConfigName = $peer->name; } $this->reset('name', 'publicKey', 'storeConfig'); $this->ownerId = auth()->id(); $this->dispatch('notify', message: __('vpn.created')); } public function toggle(string $uuid): void { // 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); $this->dispatch('notify', message: $peer->enabled ? __('vpn.unblocked') : __('vpn.blocked')); } #[On('vpn-peer-deleted')] public function peerDeleted(): void { $this->dispatch('notify', message: __('vpn.deleted')); } public function dismissConfig(): void { 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. */ public function refreshPeers(): void { if (Cache::add('vpn:sync-dispatched', true, 8)) { SyncVpnPeers::dispatch(); } } private function clientConfig(Keypair $keypair, string $ip): string { $hub = app(WireguardHub::class); return implode("\n", [ '[Interface]', 'PrivateKey = '.$keypair->privateKey, 'Address = '.$ip.'/32', '', '[Peer]', 'PublicKey = '.$hub->publicKey(), 'Endpoint = '.$hub->endpoint(), 'AllowedIPs = '.config('provisioning.wireguard.subnet', '10.66.0.0/24'), 'PersistentKeepalive = 25', '', ]); } public function render() { $hub = app(WireguardHub::class); return view('livewire.admin.vpn', [ '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'), ]); } }