CluPilotCloud/app/Provisioning/Jobs/SyncVpnPeers.php

141 lines
5.6 KiB
PHP

<?php
namespace App\Provisioning\Jobs;
use App\Models\Host;
use App\Models\VpnPeer;
use App\Services\Wireguard\WireguardHub;
use Illuminate\Support\Facades\Cache;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
/**
* Copy the hub's live peer state into the database so the console can show it.
*
* The console runs in the web container, which has no wg0 — only this worker
* can read `wg show`. Everything the operator sees (handshakes, traffic, who is
* connected) therefore travels through this job.
*
* Reconciles in both directions: peers found on the interface are upserted
* (adopting host peers the provisioning pipeline registered, so the list has no
* blind spots), and rows no longer on the interface are marked absent rather
* than deleted — a missing peer is information, not a reason to forget it.
*/
class SyncVpnPeers implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/** Long enough that a stuck job cannot block syncing forever. */
public int $uniqueFor = 120;
public int $tries = 1;
public function __construct()
{
$this->onQueue('provisioning');
}
public function handle(WireguardHub $hub): void
{
// Reading the hub and reconciling must not straddle a peer removal: a
// snapshot taken before ApplyVpnPeer removes a peer and purges its
// tombstone would otherwise be adopted afterwards as a fresh, enabled
// access — restoring exactly what was revoked. ApplyVpnPeer holds the
// same lock, so read and mutation are mutually exclusive.
$retryRemoval = [];
Cache::lock('wireguard:hub', 30)->block(10, function () use ($hub, &$retryRemoval) {
$this->reconcile($hub, $retryRemoval);
});
// Dispatched outside the lock: with the sync queue driver this executes
// inline, and ApplyVpnPeer takes the very same lock.
foreach ($retryRemoval as $publicKey) {
ApplyVpnPeer::dispatch($publicKey, null, false);
}
}
/** @param list<string> $retryRemoval collected, dispatched by the caller */
private function reconcile(WireguardHub $hub, array &$retryRemoval): void
{
$snapshots = $hub->peers();
$now = now();
// hosts.wg_pubkey => host id, so pipeline-created peers show up
// under their host name instead of as an unknown key.
$hostsByKey = Host::query()
->whereNotNull('wg_pubkey')
->get(['id', 'name', 'wg_pubkey'])
->keyBy('wg_pubkey');
foreach ($snapshots as $publicKey => $snapshot) {
$peer = VpnPeer::withTrashed()->where('public_key', $publicKey)->first();
$host = $hostsByKey[$publicKey] ?? null;
// Revoked, yet still on the hub: the removal never landed. Retry it
// rather than adopting the peer back into the console — otherwise a
// failed removal quietly becomes a live access again.
if ($peer?->trashed()) {
$retryRemoval[] = $publicKey;
continue;
}
$observed = [
'present' => true,
'endpoint' => $snapshot->endpoint,
'last_handshake_at' => $snapshot->latestHandshake,
'rx_bytes' => $snapshot->rxBytes,
'tx_bytes' => $snapshot->txBytes,
'observed_at' => $now,
];
if ($peer === null) {
VpnPeer::create($observed + [
'public_key' => $publicKey,
// Fall back to the dump's allowed-ips for peers that were
// never created through the console.
'allowed_ip' => strtok($snapshot->allowedIps, '/') ?: $snapshot->allowedIps,
'host_id' => $host?->id,
// Never "staff": a staff access has an owner, and nobody is
// behind a peer we merely found on the interface.
'kind' => $host !== null ? VpnPeer::KIND_HOST : VpnPeer::KIND_SYSTEM,
'name' => $host?->name ?? __('vpn.unknown_peer'),
'enabled' => true,
]);
continue;
}
// A sync that ran between addPeer() and the step storing wg_pubkey
// adopts the peer as "unknown". Once the link is known, name it
// after its host — but only if no operator ever named it
// (created_by is null exactly for sync-adopted rows).
if ($peer->host_id === null && $host !== null) {
$observed['host_id'] = $host->id;
if ($peer->kind === VpnPeer::KIND_SYSTEM) {
$observed['kind'] = VpnPeer::KIND_HOST;
}
if ($peer->created_by === null) {
$observed['name'] = $host->name;
}
}
// Never overwrite `enabled`: that is the operator's intent, and a
// peer being present only says the hub has not caught up yet.
$peer->update($observed);
}
$seen = array_keys($snapshots);
VpnPeer::query()
->whereNotIn('public_key', $seen)
->update(['present' => false, 'observed_at' => $now]);
// A tombstone whose peer is gone from the hub has done its job.
VpnPeer::onlyTrashed()->whereNotIn('public_key', $seen)->forceDelete();
}
}