authorize('viewAny', VpnPeer::class); $this->ownerId = Auth::guard('operator')->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 — and // now cannot even present as one, since operators live on their // own table. 'ownerId' => ['required', Rule::exists('operators', '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 = Operator::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::guard('operator')->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::guard('operator')->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')); } /** * The reissue button opens ConfirmReissueVpnPeer instead of calling * reissue() directly (R23); its confirm button dispatches back here. */ #[On('vpn-peer-reissue-confirmed')] public function onReissueConfirmed(string $uuid): void { $this->reissue($uuid); } /** * Replace an access's keypair. * * The way back for an access whose config was never stored: nobody can hand * out a private key that no longer exists, so the honest option is a new * one. The old key stops working the moment the hub is updated, which is * also what makes this the right tool for a lost or leaked device. */ public function reissue(string $uuid): void { $peer = VpnPeer::query()->where('uuid', $uuid)->first(); if ($peer === null) { return; } $this->authorize('update', $peer); if ($peer->kind !== VpnPeer::KIND_STAFF) { $this->dispatch('notify', message: __('vpn.reissue_staff_only')); return; } $keypair = Keypair::generate(); $oldKey = $peer->public_key; $config = $this->clientConfig($keypair, $peer->allowed_ip); $peer->forceFill([ 'public_key' => $keypair->publicKey, 'present' => false, // Only re-stored when the vault can actually be used: without the key // this would throw, and the console tells people to re-issue // precisely when a stored config has become unreadable. 'config_secret' => $peer->hasStoredConfig() && ConfigVault::available() ? ConfigVault::encrypt($config) : null, ])->save(); // Old key off the hub first, then the new one on: the other order would // briefly leave two peers claiming the same tunnel address. ApplyVpnPeer::dispatch($oldKey, null, false, force: true); ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, true); $this->dismissConfig(); $this->configToken = ConfigHandoff::put($config); $this->newConfigName = $peer->name; $this->dispatch('notify', message: __('vpn.reissued')); } 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::guard('operator')->user()?->can('vpn.view.all')) { $query->where('kind', VpnPeer::KIND_STAFF)->where('user_id', Auth::guard('operator')->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(); } } /** * What the client sends through the tunnel: the management subnet, and only * that. * * It is tempting to add the server's own public address here, because the * console lives at a public hostname and without that route the phone sends * the request out over the mobile network instead — it arrives from a * carrier address, the proxy refuses it, and the operator sees a blank page * while the VPN app says "connected". * * That fix cannot work. The WireGuard endpoint is the SAME address. Routing * it into the tunnel routes the handshake packets into the tunnel too, and * a tunnel cannot carry the packets that establish it — the result is a * loop and a connection that never comes up at all. Strictly worse: the * same symptom, now with no way in. * * The console has to be reachable at an address that is INSIDE the subnet * for the VPN to be the way in. That is a deployment change (the proxy * answering on the hub address, and a name that resolves to it), not * something this config line can express. */ private static function allowedIps(string $endpoint): string { return (string) config('provisioning.wireguard.subnet', '10.66.0.0/24'); } private function clientConfig(Keypair $keypair, string $ip): string { $hub = app(WireguardHub::class); return implode("\n", [ '[Interface]', 'PrivateKey = '.$keypair->privateKey, 'Address = '.$ip.'/32', // Only present when the console is actually reachable inside the // tunnel. Setting it otherwise would hand the device a resolver // that does not exist and take its name resolution with it. ...(config('admin_access.vpn_ready') ? ['DNS = '.config('provisioning.wireguard.hub_address', '10.66.0.1')] : []), '', '[Peer]', 'PublicKey = '.$hub->publicKey(), 'Endpoint = '.$hub->endpoint(), 'AllowedIPs = '.self::allowedIps($hub->endpoint()), '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' => Operator::query() ->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::OPERATOR_ROLES)) ->orderBy('name')->get(['id', 'name', 'email']), 'canManage' => Auth::guard('operator')->user()?->can('vpn.manage.all') ?? false, 'vaultAvailable' => ConfigVault::available(), 'hubEndpoint' => $hub->endpoint(), 'hubPublicKey' => $hub->publicKey(), 'lastSync' => VpnPeer::query()->max('observed_at'), ]); } }