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. --}}
+
@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.
- --}}
-
- @foreach ($peers as $peer)
- @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];
- // The rule carries the status too, so a blocked access is
- // visible while scanning without reading a single pill.
- $rule = [
- 'online' => 'bg-success-bright',
- 'idle' => 'bg-line-strong',
- 'blocked' => 'bg-danger',
- 'pending' => 'bg-warning',
- ][$status];
- @endphp
-
-
+ 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.
-
-
- {{ $peer->name }}
+ Eine Gruppe ohne Einträge wird gar nicht erst gezeichnet:
+ eine leere Überschrift ist eine Aussage über nichts. --}}
+ @foreach ([
+ ['title' => __('vpn.group_people'), 'peers' => $peopleAccesses],
+ ['title' => __('vpn.group_hosts'), 'peers' => $hostAccesses],
+ ] as $group)
+ @continue($group['peers']->isEmpty())
-
-
- {{ $peer->host ? __('vpn.host_peer') : __('vpn.operator_peer') }}
-
+
- {{-- A stored config can be fetched again; without one there is
- nothing to hand out, and saying so beats an absent button. --}}
- @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
-
-
-
- @endforeach
-
+
+ @foreach ($group['peers'] as $peer)
+
+ @endforeach
+
+ @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'));
+});