CluPilotCloud/app/Provisioning/Jobs/SyncVpnPeers.php

126 lines
4.8 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')
->pluck('id', 'wg_pubkey');
foreach ($snapshots as $publicKey => $snapshot) {
$peer = VpnPeer::withTrashed()->where('public_key', $publicKey)->first();
// 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;
}
$hostId = $hostsByKey[$publicKey] ?? null;
$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' => $hostId,
'name' => $hostId !== null
? (Host::find($hostId)?->name ?? __('vpn.unknown_peer'))
: __('vpn.unknown_peer'),
'enabled' => true,
]);
continue;
}
// 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 + ['host_id' => $peer->host_id ?? $hostId]);
}
$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();
}
}