183 lines
6.0 KiB
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'),
|
|
]);
|
|
}
|
|
}
|