diff --git a/app/Livewire/Admin/Vpn.php b/app/Livewire/Admin/Vpn.php index 0f0d4c7..7b72547 100644 --- a/app/Livewire/Admin/Vpn.php +++ b/app/Livewire/Admin/Vpn.php @@ -2,6 +2,7 @@ namespace App\Livewire\Admin; +use App\Models\Host; use App\Models\VpnPeer; use App\Provisioning\Jobs\ApplyVpnPeer; use App\Provisioning\Jobs\SyncVpnPeers; @@ -83,6 +84,14 @@ class Vpn extends Component 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'; + } + return VpnPeer::create([ 'name' => trim($this->name), 'public_key' => $publicKey, diff --git a/app/Provisioning/Jobs/SyncVpnPeers.php b/app/Provisioning/Jobs/SyncVpnPeers.php index 97d72ad..29480e4 100644 --- a/app/Provisioning/Jobs/SyncVpnPeers.php +++ b/app/Provisioning/Jobs/SyncVpnPeers.php @@ -68,10 +68,12 @@ class SyncVpnPeers implements ShouldBeUnique, ShouldQueue // under their host name instead of as an unknown key. $hostsByKey = Host::query() ->whereNotNull('wg_pubkey') - ->pluck('id', '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 @@ -81,8 +83,6 @@ class SyncVpnPeers implements ShouldBeUnique, ShouldQueue continue; } - $hostId = $hostsByKey[$publicKey] ?? null; - $observed = [ 'present' => true, 'endpoint' => $snapshot->endpoint, @@ -98,19 +98,28 @@ class SyncVpnPeers implements ShouldBeUnique, ShouldQueue // 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'), + 'host_id' => $host?->id, + '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->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 + ['host_id' => $peer->host_id ?? $hostId]); + $peer->update($observed); } $seen = array_keys($snapshots); diff --git a/lang/de/vpn.php b/lang/de/vpn.php index c450d78..caae889 100644 --- a/lang/de/vpn.php +++ b/lang/de/vpn.php @@ -33,6 +33,7 @@ return [ 'invalid_key' => 'Das ist kein gültiger WireGuard-Schlüssel (32 Byte, Base64).', 'duplicate_key' => 'Für diesen Schlüssel gibt es bereits einen Zugang.', 'pending_removal' => 'Dieser Zugang wird gerade entfernt. Bitte kurz warten.', + 'host_key' => 'Dieser Schlüssel gehört bereits zu einem Host. Ein zweiter Zugang darauf würde dessen Tunnel kappen.', 'created' => 'Zugang angelegt.', 'blocked' => 'Zugang gesperrt.', 'unblocked' => 'Zugang wieder freigegeben.', diff --git a/lang/en/vpn.php b/lang/en/vpn.php index b365a90..756f490 100644 --- a/lang/en/vpn.php +++ b/lang/en/vpn.php @@ -33,6 +33,7 @@ return [ 'invalid_key' => 'That is not a valid WireGuard key (32 bytes, base64).', 'duplicate_key' => 'An access already exists for this key.', 'pending_removal' => 'This access is currently being removed. Please wait a moment.', + 'host_key' => 'This key already belongs to a host. A second access on it would cut that host\'s tunnel.', 'created' => 'Access created.', 'blocked' => 'Access blocked.', 'unblocked' => 'Access re-enabled.', diff --git a/tests/Feature/Admin/VpnTest.php b/tests/Feature/Admin/VpnTest.php index 9b57361..99a741d 100644 --- a/tests/Feature/Admin/VpnTest.php +++ b/tests/Feature/Admin/VpnTest.php @@ -384,3 +384,49 @@ it('keeps host peer removal behind the same hub lock', function () { expect(microtime(true) - $started)->toBeGreaterThan(0.8) ->and($hub->peerIps())->toBe([]); }); + +it('refuses a public key that already belongs to a host', function () { + vpnHub(); + $key = Keypair::generate()->publicKey; + // The host exists but the scheduled sync has not adopted its peer yet. + Host::factory()->active()->create(['name' => 'pve-fsn1', 'wg_pubkey' => $key]); + + Livewire::actingAs(operator('Owner'))->test(Vpn::class) + ->set('name', 'entführt') + ->set('publicKey', $key) + ->call('create') + ->assertHasErrors('publicKey'); + + expect(VpnPeer::withTrashed()->count())->toBe(0); +}); + +it('renames a peer it adopted before the host link was known', function () { + $hub = vpnHub(); + $key = Keypair::generate()->publicKey; + $hub->addPeer($key, '10.66.0.7'); + + // First sync: the step has not stored wg_pubkey yet → unknown peer. + (new SyncVpnPeers)->handle($hub); + $peer = VpnPeer::query()->where('public_key', $key)->firstOrFail(); + expect($peer->name)->toBe(__('vpn.unknown_peer')) + ->and($peer->host_id)->toBeNull(); + + Host::factory()->active()->create(['name' => 'pve-fsn1', 'wg_pubkey' => $key]); + (new SyncVpnPeers)->handle($hub); + + expect($peer->fresh()->name)->toBe('pve-fsn1') + ->and($peer->fresh()->host_id)->not->toBeNull(); +}); + +it('does not rename an access an operator named themselves', function () { + $hub = vpnHub(); + $user = operator('Owner'); + $key = Keypair::generate()->publicKey; + $peer = VpnPeer::factory()->create(['public_key' => $key, 'name' => 'Notebook Boban', 'created_by' => $user->id]); + $hub->addPeer($key, $peer->allowed_ip); + + Host::factory()->active()->create(['name' => 'pve-fsn1', 'wg_pubkey' => $key]); + (new SyncVpnPeers)->handle($hub); + + expect($peer->fresh()->name)->toBe('Notebook Boban'); +});