authorize('vpn.manage'); } /** * 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('vpn.manage'); } public function create(): void { $this->authorize('vpn.manage'); $this->validate([ 'name' => 'required|string|max:255', 'publicKey' => 'nullable|string|max:64', ]); $ownKey = trim($this->publicKey) !== ''; 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 { $peer = Cache::lock('wireguard:allocate', 30)->block(10, function () use ($hub, $publicKey) { // 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'; } return VpnPeer::create([ 'name' => trim($this->name), 'public_key' => $publicKey, 'allowed_ip' => $hub->allocateIp(), '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)) { $this->addError('publicKey', __('vpn.'.$peer)); return; } 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; $this->reset('name', 'publicKey'); $this->dispatch('notify', message: __('vpn.created')); } public function toggle(string $uuid): void { $this->authorize('vpn.manage'); $peer = VpnPeer::query()->where('uuid', $uuid)->first(); if ($peer === null) { return; } $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 { $this->reset('newConfig', 'newConfigName'); } /** 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', [ 'peers' => VpnPeer::query()->with('host')->orderByDesc('present')->orderBy('name')->get(), 'hubEndpoint' => $hub->endpoint(), 'hubPublicKey' => $hub->publicKey(), 'lastSync' => VpnPeer::query()->max('observed_at'), ]); } }