From 2f15d1e7a41e885eec574da03239483847e44088 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 02:34:15 +0200 Subject: [PATCH] =?UTF-8?q?Release=20v1.3.84=20=E2=80=94=20VPN-Zug=C3=A4ng?= =?UTF-8?q?e=20nach=20Personen=20und=20Hosts=20getrennt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Personen und Hosts standen gemischt untereinander, nach Verbindungszustand sortiert — ein Host-Zugang konnte also zwischen zwei Mitarbeitern stehen. Beide sind Peers im selben Netz und haben dieselben Messwerte, aber es sind zwei verschiedene Fragen: "wer von uns ist im Netz" und "welche Maschinen hängen dran". Wer die eine stellt, liest die Antworten der anderen als Rauschen. Jetzt zwei Gruppen mit Überschrift und Anzahl. Eine Gruppe ohne Einträge wird gar nicht erst gezeichnet — auf einer frischen Installation stünde sonst "Hosts" über einer Lücke, bevor je einer angelegt wurde. Getrennt wird nach der geladenen host-Beziehung, nicht nach `kind`: die Plakette in der Zeile fragt dasselbe, und wer einen "Host-Zugang" liest, soll ihn auch unter den Hosts finden. Ein adoptierter Peer (kind=system) mit host_id gehört zu den Hosts, obwohl seine Art etwas anderes sagt. Die Zeile selbst ist nach resources/views/components/admin/vpn-peer-row.blade.php gewandert. Sie zweimal hinzuschreiben hätte geheissen, sie ab dem nächsten Knopf an zwei Stellen zu pflegen — und die zweite fällt erst auf, wenn jemand einen Host-Zugang sucht und ihn anders aussehen sieht als seinen eigenen. Die Plakette bleibt trotz der Überschrift daneben: eine Zeile wandert beim Suchen aus ihrer Überschrift heraus, und dann steht sie ohne sie da. Codex: keine Befunde. 2237 Tests grün. Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- app/Livewire/Admin/Vpn.php | 17 ++- lang/de/vpn.php | 5 + lang/en/vpn.php | 5 + .../components/admin/vpn-peer-row.blade.php | 122 ++++++++++++++++ resources/views/livewire/admin/vpn.blade.php | 136 ++++-------------- tests/Feature/Admin/VpnTest.php | 114 ++++++++++----- 7 files changed, 253 insertions(+), 148 deletions(-) create mode 100644 resources/views/components/admin/vpn-peer-row.blade.php diff --git a/VERSION b/VERSION index a295bf2..281fd4d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.83 +1.3.84 diff --git a/app/Livewire/Admin/Vpn.php b/app/Livewire/Admin/Vpn.php index dcf87c2..c97cc2a 100644 --- a/app/Livewire/Admin/Vpn.php +++ b/app/Livewire/Admin/Vpn.php @@ -385,10 +385,25 @@ class Vpn extends Component { $hub = app(WireguardHub::class); + // Einmal geladen, zweimal gelesen. Zwei Abfragen wären zwei Zeitpunkte: + // ein Zugang, der zwischen ihnen entsteht, stünde in keiner der beiden + // Gruppen — oder in beiden. + $peers = $this->visiblePeers()->with(['host', 'owner']) + ->orderByDesc('present')->orderBy('name')->get(); + + // Nach der geladenen Beziehung getrennt, nicht nach `kind`: die Plakette + // in der Zeile fragt dasselbe, und wer einen „Host-Zugang" liest, soll + // ihn auch unter den Hosts finden. Ein adoptierter Peer (kind=system) + // mit host_id gehört zu den Hosts, obwohl seine Art etwas anderes sagt. + $hostAccesses = $peers->filter(fn ($peer) => $peer->host !== null)->values(); + return view('livewire.admin.vpn', [ + 'peopleAccesses' => $peers->filter(fn ($peer) => $peer->host === null)->values(), + 'hostAccesses' => $hostAccesses, '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(), + // Bleibt: der Leerzustand und alles, was die Liste als Ganzes zählt. + 'peers' => $peers, 'operators' => Operator::query() ->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::operatorRoles())) ->orderBy('name')->get(['id', 'name', 'email']), diff --git a/lang/de/vpn.php b/lang/de/vpn.php index e00b40b..e993da6 100644 --- a/lang/de/vpn.php +++ b/lang/de/vpn.php @@ -18,6 +18,11 @@ return [ 'host_peer' => 'Host-Zugang', 'operator_peer' => 'Mitarbeiter-Zugang', + + // Die zwei Gruppen der Liste. Sie beantworten zwei verschiedene Fragen: + // wer von uns ist im Netz, und welche Maschinen hängen dran. + 'group_people' => 'Mitarbeiter', + 'group_hosts' => 'Hosts', 'unknown_peer' => 'Unbekannter Zugang', 'status_online' => 'Verbunden', diff --git a/lang/en/vpn.php b/lang/en/vpn.php index d3bcb13..f4adeec 100644 --- a/lang/en/vpn.php +++ b/lang/en/vpn.php @@ -18,6 +18,11 @@ return [ 'host_peer' => 'Host access', 'operator_peer' => 'Staff access', + + // The two groups in the list. They answer two different questions: who of + // us is on the network, and which machines are attached to it. + 'group_people' => 'Staff', + 'group_hosts' => 'Hosts', 'unknown_peer' => 'Unknown access', 'status_online' => 'Connected', diff --git a/resources/views/components/admin/vpn-peer-row.blade.php b/resources/views/components/admin/vpn-peer-row.blade.php new file mode 100644 index 0000000..fc71c6a --- /dev/null +++ b/resources/views/components/admin/vpn-peer-row.blade.php @@ -0,0 +1,122 @@ +@props(['peer']) + +{{-- + Eine Zeile der Zugangsliste. + + Ausgelagert, als die Liste in zwei Gruppen zerfiel (Personen und Hosts). + Sie zweimal hinzuschreiben hätte geheißen, sie ab dem nächsten Knopf an + zwei Stellen zu pflegen — und die zweite fällt erst auf, wenn jemand einen + Host-Zugang sucht und ihn anders aussehen sieht als seinen eigenen. + + Ursprünglicher Kopfkommentar der Liste, weil er die Form dieser Zeile + erklärt: Ein Register von Zugängen, keine Tabelle. Sieben Spalten ließen + jede davon zu schmal — Namen brachen um, „Letzter Kontakt" brach in seiner + eigenen Überschrift um, und die Zeile passte trotzdem nicht. Das sind + Eigenschaften EINES Zugangs, keine Größen, die jemand über eine Spalte + hinweg vergleicht: also je Zugang eine eigene Zeile, Identität oben, + Messwerte darunter in Mono, wo sie sich von selbst untereinander stellen. +--}} +@php + $status = $peer->status(); + $tone = [ + 'online' => 'border-success-border bg-success-bg text-success', + 'idle' => 'border-line-strong bg-surface-2 text-muted', + 'blocked' => 'border-danger-border bg-danger-bg text-danger', + 'pending' => 'border-warning-border bg-warning-bg text-warning', + ][$status]; + // Der Strich trägt den Zustand mit, damit ein gesperrter Zugang beim + // Überfliegen auffällt, ohne dass jemand eine Plakette liest. + $rule = [ + 'online' => 'bg-success-bright', + 'idle' => 'bg-line-strong', + 'blocked' => 'bg-danger', + 'pending' => 'bg-warning', + ][$status]; +@endphp +
  • + + +
    +
    + {{ $peer->name }} + + {{-- Die Plakette bleibt, obwohl die Gruppe daneben dasselbe sagt: + eine Zeile wandert beim Suchen aus ihrer Überschrift heraus, + und dann steht sie ohne sie da. --}} + + + {{ $peer->host ? __('vpn.host_peer') : __('vpn.operator_peer') }} + + + @if ($peer->owner) + {{ $peer->owner->name }} + @if ($peer->user_id === auth()->id()) + {{ __('vpn.you') }} + @endif + @elseif (! $peer->host) + {{ __('vpn.no_owner') }} + @endif +
    + + {{-- Die Messwerte, jeder mit eigener Beschriftung: ohne Kopfzeile + müssen sie sich selbst benennen. --}} +
    + {{ $peer->allowed_ip }} + @if ($peer->endpoint) + {{ $peer->endpoint }} + @endif + ↓ {{ \App\Support\Bytes::human($peer->rx_bytes) }}  ↑ {{ \App\Support\Bytes::human($peer->tx_bytes) }} + {{ __('vpn.handshake') }}: {{ $peer->last_handshake_at?->diffForHumans() ?? __('vpn.never') }} +
    +
    + +
    + + + {{ __('vpn.status_'.$status) }} + + +
    + {{-- Eine hinterlegte Konfiguration lässt sich erneut abholen; ohne + eine gibt es nichts auszugeben, und das zu sagen ist besser als + ein fehlender Knopf. --}} + @if (! $peer->hasStoredConfig() && $peer->kind === \App\Models\VpnPeer::KIND_STAFF && $peer->user_id === auth()->id()) + + + + @endif + @can('downloadConfig', $peer) + + @endcan + @can('block', $peer) + + @endcan + @can('update', $peer) + @if ($peer->kind === \App\Models\VpnPeer::KIND_STAFF) + + @endif + @endcan + @can('delete', $peer) + + @endcan +
    +
    +
  • diff --git a/resources/views/livewire/admin/vpn.blade.php b/resources/views/livewire/admin/vpn.blade.php index 9cc0a83..9db32f2 100644 --- a/resources/views/livewire/admin/vpn.blade.php +++ b/resources/views/livewire/admin/vpn.blade.php @@ -72,121 +72,35 @@ @if ($peers->isEmpty())

    {{ __('vpn.empty') }}

    @else - {{-- - A register of accesses, not a spreadsheet. + {{-- Zwei Gruppen, nicht eine Liste. - Seven columns in a fixed table left every one of them too - narrow: names broke across two lines, "Letzter Kontakt" - wrapped in its own heading, and the row still did not fit. - These are attributes of ONE access, not quantities anyone - compares down a column — so each access gets a row of its - own with the identity on the first line and the measurements - on the second, in mono where they line up by themselves. - --}} - + + @endforeach @endif diff --git a/tests/Feature/Admin/VpnTest.php b/tests/Feature/Admin/VpnTest.php index b0a6a00..c482b16 100644 --- a/tests/Feature/Admin/VpnTest.php +++ b/tests/Feature/Admin/VpnTest.php @@ -2,23 +2,31 @@ use App\Livewire\Admin\ConfirmDeleteVpnPeer; use App\Livewire\Admin\ConfirmReissueVpnPeer; +use App\Livewire\Admin\Settings; use App\Livewire\Admin\Vpn; use App\Livewire\Admin\VpnConfigAccess; use App\Models\Host; use App\Models\Operator; +use App\Models\User; use App\Models\VpnPeer; use App\Provisioning\Jobs\ApplyVpnPeer; +use App\Provisioning\Jobs\PurgeHost; +use App\Provisioning\Jobs\RemoveWireguardPeer; use App\Provisioning\Jobs\SyncVpnPeers; +use App\Services\Wireguard\ConfigHandoff; +use App\Services\Wireguard\ConfigVault; use App\Services\Wireguard\FakeWireguardHub; use App\Services\Wireguard\Keypair; use App\Services\Wireguard\LocalWireguardHub; use App\Services\Wireguard\PeerSnapshot; use App\Services\Wireguard\WireguardHub; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; +use Spatie\Permission\PermissionRegistrar; function vpnHub(): FakeWireguardHub { @@ -97,7 +105,7 @@ it('creates an access, hands out the config once and pushes it to the hub', func ->and($peer->enabled)->toBeTrue() ->and($peer->present)->toBeFalse(); // not on the hub until the job ran - $config = App\Services\Wireguard\ConfigHandoff::get($component->get('configToken')); + $config = ConfigHandoff::get($component->get('configToken')); expect($config)->toContain('[Interface]') ->toContain('Address = '.$peer->allowed_ip.'/32') ->toContain('PublicKey = HUBPUBLICKEY0000000000000000000000000000000=') @@ -197,7 +205,7 @@ it('stops mutations from a session whose capability was revoked mid-flight', fun // powers: every action re-checks, it does not trust the mount. And it must // refuse out loud, not quietly do nothing. $user->syncRoles(['Support']); - app(Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); $component->call('toggle', $peer->uuid)->assertForbidden(); @@ -343,7 +351,7 @@ it('narrows what an open page shows once the account is demoted', function () { $component = Livewire::actingAs($user, 'operator')->test(Vpn::class)->assertSee('Fremdes-Notebook'); $user->syncRoles(['Support']); - app(Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); // The five-second poll keeps working — the page is still theirs — but it // must stop serving other people's accesses to a demoted account. @@ -358,7 +366,7 @@ it('holds the same allocation lock the host pipeline uses', function () { // no unique index can catch the other side's insert — waiting is the only // thing that keeps a host and an access off the same tunnel IP. // Short TTL so the test measures the wait instead of sitting out a timeout. - expect(Illuminate\Support\Facades\Cache::lock('wireguard:allocate', 1)->get())->toBeTrue(); + expect(Cache::lock('wireguard:allocate', 1)->get())->toBeTrue(); $started = microtime(true); Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class) @@ -393,7 +401,7 @@ it('serializes hub reads against hub mutations', function () { // A removal in flight holds this lock. A sync must wait for it instead of // acting on a snapshot taken around the mutation. - expect(Illuminate\Support\Facades\Cache::lock('wireguard:hub', 1)->get())->toBeTrue(); + expect(Cache::lock('wireguard:hub', 1)->get())->toBeTrue(); $started = microtime(true); (new SyncVpnPeers)->handle($hub); @@ -442,10 +450,10 @@ it('keeps host peer removal behind the same hub lock', function () { $hub = vpnHub(); $hub->addPeer('HOSTKEY', '10.66.0.30'); - expect(Illuminate\Support\Facades\Cache::lock('wireguard:hub', 1)->get())->toBeTrue(); + expect(Cache::lock('wireguard:hub', 1)->get())->toBeTrue(); $started = microtime(true); - (new App\Provisioning\Jobs\RemoveWireguardPeer('HOSTKEY'))->handle($hub); + (new RemoveWireguardPeer('HOSTKEY'))->handle($hub); expect(microtime(true) - $started)->toBeGreaterThan(0.8) ->and($hub->peerIps())->toBe([]); @@ -509,7 +517,7 @@ it('does not leave a phantom access behind when a host is purged', function () { (new SyncVpnPeers)->handle($hub); // console adopts the host peer expect(VpnPeer::query()->count())->toBe(1); - (new App\Provisioning\Jobs\PurgeHost($host->uuid))->handle(); + (new PurgeHost($host->uuid))->handle(); // No enabled-but-absent leftover the operator could toggle back on. expect(VpnPeer::query()->count())->toBe(0) @@ -537,7 +545,7 @@ it('stores the config only when asked, and encrypted', function () { // Not readable without the key: no plaintext key material in the column. ->and($peer->config_secret)->not->toContain('PrivateKey') ->and($peer->config_secret)->toStartWith('v1:') - ->and(App\Services\Wireguard\ConfigVault::decrypt($peer->config_secret))->toContain('[Interface]'); + ->and(ConfigVault::decrypt($peer->config_secret))->toContain('[Interface]'); }); it('refuses to store when there is no key to store it under', function () { @@ -558,7 +566,7 @@ it('hands a stored config back only to its owner, and only with the password', f $support = operator('Support'); $support->forceFill(['password' => Hash::make('geheim-1234')])->save(); $peer = VpnPeer::factory()->ownedBy($support)->create([ - 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), + 'config_secret' => ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), ]); // Wrong password: nothing comes back. @@ -570,7 +578,7 @@ it('hands a stored config back only to its owner, and only with the password', f // Right password: the config is handed over, and the retrieval is recorded. $modal->set('password', 'geheim-1234')->call('reveal')->assertHasNoErrors(); - expect(App\Services\Wireguard\ConfigHandoff::get($modal->get('token')))->toContain('PrivateKey = XYZ') + expect(ConfigHandoff::get($modal->get('token')))->toContain('PrivateKey = XYZ') ->and($peer->fresh()->download_count)->toBe(1) ->and($peer->fresh()->last_downloaded_at)->not->toBeNull(); }); @@ -579,7 +587,7 @@ it('never hands someone else the private key, not even an Owner', function () { vpnHub(); $support = operator('Support'); $peer = VpnPeer::factory()->ownedBy($support)->create([ - 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), + 'config_secret' => ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), ]); // The boss can see the access and revoke it — but holding its key is a @@ -611,7 +619,7 @@ it('locks out password guessing', function () { vpnHub(); $support = operator('Support'); $peer = VpnPeer::factory()->ownedBy($support)->create([ - 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\n"), + 'config_secret' => ConfigVault::encrypt("[Interface]\n"), ]); $modal = Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]); @@ -637,7 +645,7 @@ it('sends the operator to sign in instead of a 500 when the session has ended', vpnHub(); $support = operator('Support'); $peer = VpnPeer::factory()->ownedBy($support)->create([ - 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), + 'config_secret' => ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), ]); $modal = Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]); @@ -654,7 +662,7 @@ it('destroys the stored config when the access is revoked', function () { Queue::fake(); $support = operator('Support'); $peer = VpnPeer::factory()->ownedBy($support)->create([ - 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), + 'config_secret' => ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), ]); Livewire::actingAs(operator('Owner'), 'operator') @@ -672,10 +680,10 @@ it('takes the tunnel away when a staff member is revoked', function () { Queue::fake(); $support = operator('Support'); $peer = VpnPeer::factory()->ownedBy($support)->create([ - 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\n"), + 'config_secret' => ConfigVault::encrypt("[Interface]\n"), ]); - Livewire::actingAs(operator('Owner'), 'operator')->test(App\Livewire\Admin\Settings::class) + Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class) ->call('revokeStaff', $support->id); expect($support->fresh()->isOperator())->toBeFalse() @@ -692,7 +700,7 @@ it('removes a revoked colleague from the hub only after the rows are committed', // Sync queue: the job runs the moment it is dispatched, so if that happened // inside the transaction it would still see a live peer and leave it up. - Livewire::actingAs(operator('Owner'), 'operator')->test(App\Livewire\Admin\Settings::class) + Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class) ->call('revokeStaff', $support->id); expect($hub->peerIps())->toBe([]) @@ -705,14 +713,14 @@ it('stops serving a revealed config once the access is revoked', function () { $support = operator('Support'); $support->forceFill(['password' => Hash::make('geheim-1234')])->save(); $peer = VpnPeer::factory()->ownedBy($support)->create([ - 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), + 'config_secret' => ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), ]); $modal = Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]) ->set('password', 'geheim-1234') ->call('reveal'); $token = $modal->get('token'); - expect(App\Services\Wireguard\ConfigHandoff::get($token))->toContain('PrivateKey = XYZ'); + expect(ConfigHandoff::get($token))->toContain('PrivateKey = XYZ'); // Revoked while the modal is still open on someone's screen. Livewire::actingAs(operator('Owner'), 'operator') @@ -721,7 +729,7 @@ it('stops serving a revealed config once the access is revoked', function () { // The open modal must not keep handing the key out of the cache. $modal->call('toggleQr')->assertForbidden(); - expect(App\Services\Wireguard\ConfigHandoff::get($token))->toBeNull(); + expect(ConfigHandoff::get($token))->toBeNull(); }); it('never leaves the previous config on screen after another access is created', function () { @@ -733,7 +741,7 @@ it('never leaves the previous config on screen after another access is created', ->set('name', 'Erster')->set('ownerId', $owner->id) ->call('create'); $firstToken = $component->get('configToken'); - expect(App\Services\Wireguard\ConfigHandoff::get($firstToken))->toContain('PrivateKey'); + expect(ConfigHandoff::get($firstToken))->toContain('PrivateKey'); // Second access brings its own key, so there is no config to show — the // first one must not stay up wearing the second one's name. @@ -743,7 +751,7 @@ it('never leaves the previous config on screen after another access is created', expect($component->get('configToken'))->toBeNull() ->and($component->get('newConfigName'))->toBeNull() - ->and(App\Services\Wireguard\ConfigHandoff::get($firstToken))->toBeNull(); + ->and(ConfigHandoff::get($firstToken))->toBeNull(); }); it('does not keep polling while a private config is on screen', function () { @@ -766,7 +774,7 @@ it('does not keep polling while a private config is on screen', function () { it('will not issue an access to someone who is no longer staff', function () { vpnHub(); $owner = operator('Owner'); - $former = App\Models\User::factory()->create(); // no operator role + $former = User::factory()->create(); // no operator role // Not $former->id directly: operators and users are independent id // sequences, and the in-memory test database restarts both near 1 after @@ -789,14 +797,14 @@ it('closes the owner doors as soon as the roles are gone', function () { vpnHub(); $support = operator('Support'); $peer = VpnPeer::factory()->ownedBy($support)->create([ - 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\n"), + 'config_secret' => ConfigVault::encrypt("[Interface]\n"), ]); expect($support->can('downloadConfig', $peer))->toBeTrue(); // Roles removed directly, not through revokeStaff() — ownership alone must // not keep the modals reachable. $support->syncRoles([]); - app(Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); $support = $support->fresh(); expect($support->can('downloadConfig', $peer))->toBeFalse() @@ -820,8 +828,8 @@ it('re-issues a key for an access whose config was never stored', function () { $peer->refresh(); expect($peer->public_key)->not->toBe($oldKey) ->and($peer->present)->toBeFalse() - ->and(App\Services\Wireguard\ConfigHandoff::get($component->get('configToken'))) - ->toContain('Address = '.$peer->allowed_ip); + ->and(ConfigHandoff::get($component->get('configToken'))) + ->toContain('Address = '.$peer->allowed_ip); // Old key off the hub, new key on — in that order, or two peers would // briefly claim the same tunnel address. @@ -888,14 +896,14 @@ it('keeps a stored config in step when the key is re-issued', function () { Queue::fake(); $owner = operator('Owner'); $peer = VpnPeer::factory()->ownedBy($owner)->create([ - 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = ALT\n"), + 'config_secret' => ConfigVault::encrypt("[Interface]\nPrivateKey = ALT\n"), ]); Livewire::actingAs($owner, 'operator')->test(Vpn::class)->call('reissue', $peer->uuid); // Otherwise the owner would download a configuration whose key the hub no // longer accepts. - $stored = App\Services\Wireguard\ConfigVault::decrypt($peer->fresh()->config_secret); + $stored = ConfigVault::decrypt($peer->fresh()->config_secret); expect($stored)->not->toContain('PrivateKey = ALT') ->and($stored)->toContain('Address = '.$peer->allowed_ip); }); @@ -962,7 +970,7 @@ it('re-issues even when the config vault is unavailable', function () { Queue::fake(); $owner = operator('Owner'); $peer = VpnPeer::factory()->ownedBy($owner)->create([ - 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\n"), + 'config_secret' => ConfigVault::encrypt("[Interface]\n"), ]); // The very situation the console tells people to fix by re-issuing. @@ -981,7 +989,7 @@ it('does not route the WireGuard endpoint through its own tunnel', function () { // at all. config()->set('provisioning.wireguard.subnet', '10.66.0.0/24'); - $method = new ReflectionMethod(App\Livewire\Admin\Vpn::class, 'allowedIps'); + $method = new ReflectionMethod(Vpn::class, 'allowedIps'); $method->setAccessible(true); foreach (['203.0.113.10:51820', '[2001:db8::10]:51820', 'vpn.example.test:51820'] as $endpoint) { @@ -994,11 +1002,11 @@ it('points a client at the tunnel resolver only when the console is reachable th // takes its name resolution down with it for as long as the tunnel is up. config()->set('provisioning.wireguard.hub_address', '10.66.0.1'); - $build = new ReflectionMethod(App\Livewire\Admin\Vpn::class, 'clientConfig'); + $build = new ReflectionMethod(Vpn::class, 'clientConfig'); $build->setAccessible(true); - $page = new App\Livewire\Admin\Vpn; + $page = new Vpn; - $keypair = new App\Services\Wireguard\Keypair('priv', 'pub'); + $keypair = new Keypair('priv', 'pub'); // Not ready: no resolver is running, so naming one would take the device's // whole name resolution down for as long as the tunnel is up. @@ -1008,3 +1016,39 @@ it('points a client at the tunnel resolver only when the console is reachable th config()->set('admin_access.vpn_ready', true); expect($build->invoke($page, $keypair, '10.66.0.9'))->toContain('DNS = 10.66.0.1'); }); + +/* +|-------------------------------------------------------------------------- +| Zwei Gruppen, nicht eine Liste +|-------------------------------------------------------------------------- +| +| Personen und Hosts standen gemischt untereinander, nach Verbindungszustand +| sortiert — ein Host-Zugang konnte also zwischen zwei Mitarbeitern stehen. +| Beide sind Peers im selben Netz, aber es sind zwei verschiedene Fragen: „wer +| von uns ist im Netz" und „welche Maschinen hängen dran". +*/ + +it('keeps host accesses in their own group, apart from the people', function () { + vpnHub(); + $host = Host::factory()->create(['name' => 'pve-fns-1']); + VpnPeer::factory()->create(['name' => 'pve-fns-1', 'host_id' => $host->id, 'kind' => VpnPeer::KIND_HOST]); + VpnPeer::factory()->create(['name' => 'Notebook Boban', 'host_id' => null, 'kind' => VpnPeer::KIND_STAFF]); + + Livewire::actingAs(admin(), 'operator')->test(Vpn::class) + ->assertViewHas('peopleAccesses', fn ($peers) => $peers->pluck('name')->all() === ['Notebook Boban']) + ->assertViewHas('hostAccesses', fn ($peers) => $peers->pluck('name')->all() === ['pve-fns-1']) + ->assertSee(__('vpn.group_people')) + ->assertSee(__('vpn.group_hosts')); +}); + +it('draws no heading for a group with nothing in it', function () { + // Eine leere Überschrift ist eine Aussage über nichts — und auf einer + // frischen Installation stünde „Hosts" über einer Lücke, bevor je einer + // angelegt wurde. + vpnHub(); + VpnPeer::factory()->create(['name' => 'Notebook Boban', 'host_id' => null, 'kind' => VpnPeer::KIND_STAFF]); + + Livewire::actingAs(admin(), 'operator')->test(Vpn::class) + ->assertSee(__('vpn.group_people')) + ->assertDontSee(__('vpn.group_hosts')); +});