403 lines
16 KiB
PHP
403 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Admin;
|
|
|
|
use App\Models\Host;
|
|
use App\Models\Operator;
|
|
use App\Models\VpnPeer;
|
|
use App\Provisioning\Jobs\ApplyVpnPeer;
|
|
use App\Provisioning\Jobs\SyncVpnPeers;
|
|
use App\Services\Wireguard\ConfigHandoff;
|
|
use App\Services\Wireguard\ConfigVault;
|
|
use App\Services\Wireguard\Keypair;
|
|
use App\Services\Wireguard\QrCode;
|
|
use App\Services\Wireguard\WireguardHub;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\Rule;
|
|
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 = '';
|
|
|
|
/** Opaque handle for the freshly created config; see ConfigHandoff. */
|
|
public ?string $configToken = null;
|
|
|
|
public ?string $newConfigName = null;
|
|
|
|
/** Owner of the new access. Defaults to the operator creating it. */
|
|
public ?int $ownerId = null;
|
|
|
|
/** Keep the config so its owner can fetch it again behind their password. */
|
|
public bool $storeConfig = false;
|
|
|
|
public bool $showQr = false;
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->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'),
|
|
]);
|
|
}
|
|
}
|