153 lines
6.2 KiB
PHP
153 lines
6.2 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) {
|
|
// force: a key with no row of its own can only be revoked, never kept.
|
|
ApplyVpnPeer::dispatch($publicKey, null, false, force: true);
|
|
}
|
|
}
|
|
|
|
/** @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) {
|
|
// The address already belongs to a live access: this is a key
|
|
// that was just replaced and whose removal has not run yet.
|
|
// Adopting it would violate the unique index and abort the whole
|
|
// reconciliation — one stale key would stop every peer's state
|
|
// from being updated.
|
|
if (VpnPeer::query()->where('allowed_ip', $ip = strtok($snapshot->allowedIps, '/') ?: $snapshot->allowedIps)->exists()) {
|
|
$retryRemoval[] = $publicKey;
|
|
|
|
continue;
|
|
}
|
|
|
|
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' => $ip,
|
|
'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();
|
|
}
|
|
}
|