CluPilotCloud/app/Livewire/Admin/Vpn.php

183 lines
6.0 KiB
PHP

<?php
namespace App\Livewire\Admin;
use App\Models\VpnPeer;
use App\Provisioning\Jobs\ApplyVpnPeer;
use App\Provisioning\Jobs\SyncVpnPeers;
use App\Services\Wireguard\Keypair;
use App\Services\Wireguard\WireguardHub;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* VPN access management. The console container has no wg0, so live state is
* read from the database and refreshed by SyncVpnPeers on the provisioning
* queue — the poll below only nudges that job, it never talks to WireGuard.
*/
#[Layout('layouts.admin')]
class Vpn extends Component
{
public string $name = '';
/** 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;
public ?string $newConfigName = null;
public function mount(): void
{
$this->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'),
]);
}
}