Release v1.3.84 — VPN-Zugänge nach Personen und Hosts getrennt
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 <noreply@anthropic.com>main v1.3.84
parent
e0c0bb2c29
commit
2f15d1e7a4
|
|
@ -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']),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
<li wire:key="peer-{{ $peer->uuid }}" class="relative flex flex-wrap items-start gap-x-4 gap-y-3 py-4 pl-6 pr-4 transition hover:bg-surface-hover">
|
||||
<span class="absolute left-0 top-4 bottom-4 w-0.5 rounded-pill {{ $rule }}" aria-hidden="true"></span>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
<span class="font-medium text-ink">{{ $peer->name }}</span>
|
||||
|
||||
{{-- 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. --}}
|
||||
<span class="inline-flex items-center gap-1 rounded-pill border border-line px-2 py-0.5 text-[11px] text-muted">
|
||||
<x-ui.icon :name="$peer->host ? 'server' : 'users'" class="size-3" />
|
||||
{{ $peer->host ? __('vpn.host_peer') : __('vpn.operator_peer') }}
|
||||
</span>
|
||||
|
||||
@if ($peer->owner)
|
||||
<span class="text-sm text-muted">{{ $peer->owner->name }}</span>
|
||||
@if ($peer->user_id === auth()->id())
|
||||
<span class="rounded-pill border border-accent-border bg-accent-subtle px-1.5 py-0.5 text-[10px] font-medium text-accent-text">{{ __('vpn.you') }}</span>
|
||||
@endif
|
||||
@elseif (! $peer->host)
|
||||
<span class="text-sm text-muted">{{ __('vpn.no_owner') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Die Messwerte, jeder mit eigener Beschriftung: ohne Kopfzeile
|
||||
müssen sie sich selbst benennen. --}}
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-x-5 gap-y-1 font-mono text-xs text-muted">
|
||||
<span class="text-body">{{ $peer->allowed_ip }}</span>
|
||||
@if ($peer->endpoint)
|
||||
<span class="text-muted">{{ $peer->endpoint }}</span>
|
||||
@endif
|
||||
<span class="whitespace-nowrap">↓ {{ \App\Support\Bytes::human($peer->rx_bytes) }} ↑ {{ \App\Support\Bytes::human($peer->tx_bytes) }}</span>
|
||||
<span class="whitespace-nowrap">{{ __('vpn.handshake') }}: {{ $peer->last_handshake_at?->diffForHumans() ?? __('vpn.never') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<span class="inline-flex items-center gap-1.5 whitespace-nowrap rounded-pill border px-2.5 py-0.5 text-xs font-medium {{ $tone }}">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ __('vpn.status_'.$status) }}
|
||||
</span>
|
||||
|
||||
<div class="inline-flex gap-1">
|
||||
{{-- 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())
|
||||
<span title="{{ __('vpn.no_stored_config') }}"
|
||||
class="grid size-8 cursor-help place-items-center rounded-md border border-dashed border-line text-muted">
|
||||
<x-ui.icon name="download" class="size-4" />
|
||||
</span>
|
||||
@endif
|
||||
@can('downloadConfig', $peer)
|
||||
<button type="button" aria-label="{{ __('vpn.get_config') }}" title="{{ __('vpn.get_config') }}"
|
||||
x-on:click="$dispatch('openModal', { component: 'admin.vpn-config-access', arguments: { uuid: '{{ $peer->uuid }}' } })"
|
||||
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-accent-border hover:text-accent-text">
|
||||
<x-ui.icon name="download" class="size-4" />
|
||||
</button>
|
||||
@endcan
|
||||
@can('block', $peer)
|
||||
<button type="button" wire:click="toggle('{{ $peer->uuid }}')"
|
||||
aria-label="{{ $peer->enabled ? __('vpn.block') : __('vpn.unblock') }}" title="{{ $peer->enabled ? __('vpn.block') : __('vpn.unblock') }}"
|
||||
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-warning-border hover:text-warning">
|
||||
<x-ui.icon :name="$peer->enabled ? 'lock' : 'unlock'" class="size-4" />
|
||||
</button>
|
||||
@endcan
|
||||
@can('update', $peer)
|
||||
@if ($peer->kind === \App\Models\VpnPeer::KIND_STAFF)
|
||||
<button type="button"
|
||||
x-on:click="$dispatch('openModal', { component: 'admin.confirm-reissue-vpn-peer', arguments: { uuid: '{{ $peer->uuid }}' } })"
|
||||
title="{{ __('vpn.reissue') }}" aria-label="{{ __('vpn.reissue') }}"
|
||||
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-accent-border hover:text-accent-text">
|
||||
<x-ui.icon name="refresh" class="size-4" />
|
||||
</button>
|
||||
@endif
|
||||
@endcan
|
||||
@can('delete', $peer)
|
||||
<button type="button" aria-label="{{ __('vpn.delete') }}" title="{{ __('vpn.delete') }}"
|
||||
x-on:click="$dispatch('openModal', { component: 'admin.confirm-delete-vpn-peer', arguments: { uuid: '{{ $peer->uuid }}' } })"
|
||||
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-danger hover:text-danger">
|
||||
<x-ui.icon name="trash-2" class="size-4" />
|
||||
</button>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
|
@ -72,121 +72,35 @@
|
|||
@if ($peers->isEmpty())
|
||||
<p class="p-8 text-center text-sm text-muted">{{ __('vpn.empty') }}</p>
|
||||
@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.
|
||||
--}}
|
||||
<ul class="divide-y divide-line">
|
||||
@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
|
||||
<li wire:key="peer-{{ $peer->uuid }}" class="relative flex flex-wrap items-start gap-x-4 gap-y-3 py-4 pl-6 pr-4 transition hover:bg-surface-hover">
|
||||
<span class="absolute left-0 top-4 bottom-4 w-0.5 rounded-pill {{ $rule }}" aria-hidden="true"></span>
|
||||
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.
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
<span class="font-medium text-ink">{{ $peer->name }}</span>
|
||||
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())
|
||||
|
||||
<span class="inline-flex items-center gap-1 rounded-pill border border-line px-2 py-0.5 text-[11px] text-muted">
|
||||
<x-ui.icon :name="$peer->host ? 'server' : 'users'" class="size-3" />
|
||||
{{ $peer->host ? __('vpn.host_peer') : __('vpn.operator_peer') }}
|
||||
</span>
|
||||
<div class="flex items-baseline justify-between gap-3 border-b border-line bg-surface-2 px-4 py-2">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted">{{ $group['title'] }}</h3>
|
||||
<span class="font-mono text-xs text-faint">{{ $group['peers']->count() }}</span>
|
||||
</div>
|
||||
|
||||
@if ($peer->owner)
|
||||
<span class="text-sm text-muted">{{ $peer->owner->name }}</span>
|
||||
@if ($peer->user_id === auth()->id())
|
||||
<span class="rounded-pill border border-accent-border bg-accent-subtle px-1.5 py-0.5 text-[10px] font-medium text-accent-text">{{ __('vpn.you') }}</span>
|
||||
@endif
|
||||
@elseif (! $peer->host)
|
||||
<span class="text-sm text-muted">{{ __('vpn.no_owner') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- The measurements, each with its own label: without a
|
||||
header row they have to name themselves. --}}
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-x-5 gap-y-1 font-mono text-xs text-muted">
|
||||
<span class="text-body">{{ $peer->allowed_ip }}</span>
|
||||
@if ($peer->endpoint)
|
||||
<span class="text-muted">{{ $peer->endpoint }}</span>
|
||||
@endif
|
||||
<span class="whitespace-nowrap">↓ {{ \App\Support\Bytes::human($peer->rx_bytes) }} ↑ {{ \App\Support\Bytes::human($peer->tx_bytes) }}</span>
|
||||
<span class="whitespace-nowrap">{{ __('vpn.handshake') }}: {{ $peer->last_handshake_at?->diffForHumans() ?? __('vpn.never') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<span class="inline-flex items-center gap-1.5 whitespace-nowrap rounded-pill border px-2.5 py-0.5 text-xs font-medium {{ $tone }}">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ __('vpn.status_'.$status) }}
|
||||
</span>
|
||||
|
||||
<div class="inline-flex gap-1">
|
||||
{{-- 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())
|
||||
<span title="{{ __('vpn.no_stored_config') }}"
|
||||
class="grid size-8 cursor-help place-items-center rounded-md border border-dashed border-line text-muted">
|
||||
<x-ui.icon name="download" class="size-4" />
|
||||
</span>
|
||||
@endif
|
||||
@can('downloadConfig', $peer)
|
||||
<button type="button" aria-label="{{ __('vpn.get_config') }}" title="{{ __('vpn.get_config') }}"
|
||||
x-on:click="$dispatch('openModal', { component: 'admin.vpn-config-access', arguments: { uuid: '{{ $peer->uuid }}' } })"
|
||||
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-accent-border hover:text-accent-text">
|
||||
<x-ui.icon name="download" class="size-4" />
|
||||
</button>
|
||||
@endcan
|
||||
@can('block', $peer)
|
||||
<button type="button" wire:click="toggle('{{ $peer->uuid }}')"
|
||||
aria-label="{{ $peer->enabled ? __('vpn.block') : __('vpn.unblock') }}" title="{{ $peer->enabled ? __('vpn.block') : __('vpn.unblock') }}"
|
||||
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-warning-border hover:text-warning">
|
||||
<x-ui.icon :name="$peer->enabled ? 'lock' : 'unlock'" class="size-4" />
|
||||
</button>
|
||||
@endcan
|
||||
@can('update', $peer)
|
||||
@if ($peer->kind === \App\Models\VpnPeer::KIND_STAFF)
|
||||
<button type="button"
|
||||
x-on:click="$dispatch('openModal', { component: 'admin.confirm-reissue-vpn-peer', arguments: { uuid: '{{ $peer->uuid }}' } })"
|
||||
title="{{ __('vpn.reissue') }}" aria-label="{{ __('vpn.reissue') }}"
|
||||
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-accent-border hover:text-accent-text">
|
||||
<x-ui.icon name="refresh" class="size-4" />
|
||||
</button>
|
||||
@endif
|
||||
@endcan
|
||||
@can('delete', $peer)
|
||||
<button type="button" aria-label="{{ __('vpn.delete') }}" title="{{ __('vpn.delete') }}"
|
||||
x-on:click="$dispatch('openModal', { component: 'admin.confirm-delete-vpn-peer', arguments: { uuid: '{{ $peer->uuid }}' } })"
|
||||
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-danger hover:text-danger">
|
||||
<x-ui.icon name="trash-2" class="size-4" />
|
||||
</button>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<ul class="divide-y divide-line">
|
||||
@foreach ($group['peers'] as $peer)
|
||||
<x-admin.vpn-peer-row :peer="$peer" />
|
||||
@endforeach
|
||||
</ul>
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue