feat(vpn): explain a missing download, and offer a way out
An access created without storing its config had no download button and no explanation — it looked broken. It now shows a dimmed button that says why (the private key only exists on your device), and every staff access gets "Re-issue": a new keypair, old key off the hub before the new one goes on, so two peers never claim the same tunnel address at once. A stored config is re-encrypted in step, or the owner would download a key the hub no longer accepts. Host peers are excluded: their key belongs to the machine's own wg0, and swapping it here would cut the host off with nothing left to repair it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
e5aea84539
commit
556a560506
|
|
@ -222,6 +222,50 @@ class Vpn extends Component
|
||||||
$this->dispatch('notify', message: __('vpn.deleted'));
|
$this->dispatch('notify', message: __('vpn.deleted'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace an access's keypair.
|
||||||
|
*
|
||||||
|
* The way back for an access whose config was never stored: nobody can hand
|
||||||
|
* out a private key that no longer exists, so the honest option is a new
|
||||||
|
* one. The old key stops working the moment the hub is updated, which is
|
||||||
|
* also what makes this the right tool for a lost or leaked device.
|
||||||
|
*/
|
||||||
|
public function reissue(string $uuid): void
|
||||||
|
{
|
||||||
|
$peer = VpnPeer::query()->where('uuid', $uuid)->first();
|
||||||
|
if ($peer === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->authorize('update', $peer);
|
||||||
|
|
||||||
|
if ($peer->kind !== VpnPeer::KIND_STAFF) {
|
||||||
|
$this->dispatch('notify', message: __('vpn.reissue_staff_only'));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keypair = Keypair::generate();
|
||||||
|
$oldKey = $peer->public_key;
|
||||||
|
$config = $this->clientConfig($keypair, $peer->allowed_ip);
|
||||||
|
|
||||||
|
$peer->forceFill([
|
||||||
|
'public_key' => $keypair->publicKey,
|
||||||
|
'present' => false,
|
||||||
|
'config_secret' => $peer->hasStoredConfig() ? ConfigVault::encrypt($config) : null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
// Old key off the hub first, then the new one on: the other order would
|
||||||
|
// briefly leave two peers claiming the same tunnel address.
|
||||||
|
ApplyVpnPeer::dispatch($oldKey, null, false);
|
||||||
|
ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, true);
|
||||||
|
|
||||||
|
$this->dismissConfig();
|
||||||
|
$this->configToken = ConfigHandoff::put($config);
|
||||||
|
$this->newConfigName = $peer->name;
|
||||||
|
|
||||||
|
$this->dispatch('notify', message: __('vpn.reissued'));
|
||||||
|
}
|
||||||
|
|
||||||
public function dismissConfig(): void
|
public function dismissConfig(): void
|
||||||
{
|
{
|
||||||
ConfigHandoff::forget($this->configToken);
|
ConfigHandoff::forget($this->configToken);
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,12 @@ return [
|
||||||
'too_many_attempts' => 'Zu viele Versuche. Bitte in :seconds Sekunden erneut versuchen.',
|
'too_many_attempts' => 'Zu viele Versuche. Bitte in :seconds Sekunden erneut versuchen.',
|
||||||
'config_unreadable' => 'Die gespeicherte Konfiguration lässt sich nicht entschlüsseln. Bitte den Zugang neu ausstellen.',
|
'config_unreadable' => 'Die gespeicherte Konfiguration lässt sich nicht entschlüsseln. Bitte den Zugang neu ausstellen.',
|
||||||
|
|
||||||
|
'no_stored_config' => 'Für diesen Zugang wurde die Konfiguration nicht gespeichert — der private Schlüssel existiert nur noch auf Ihrem Gerät. Über „Neu ausstellen" bekommen Sie eine neue.',
|
||||||
|
'reissue' => 'Neu ausstellen',
|
||||||
|
'reissue_confirm' => 'Für :name ein neues Schlüsselpaar erzeugen? Die bisherige Konfiguration wird sofort ungültig.',
|
||||||
|
'reissued' => 'Neuer Schlüssel erzeugt — die alte Konfiguration gilt nicht mehr.',
|
||||||
|
'reissue_staff_only' => 'Host-Zugänge werden über die Host-Verwaltung erneuert, nicht hier.',
|
||||||
|
|
||||||
'hub' => 'Hub',
|
'hub' => 'Hub',
|
||||||
'endpoint' => 'Endpunkt',
|
'endpoint' => 'Endpunkt',
|
||||||
'hub_key' => 'Public Key',
|
'hub_key' => 'Public Key',
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,12 @@ return [
|
||||||
'too_many_attempts' => 'Too many attempts. Try again in :seconds seconds.',
|
'too_many_attempts' => 'Too many attempts. Try again in :seconds seconds.',
|
||||||
'config_unreadable' => 'The stored configuration cannot be decrypted. Please re-issue the access.',
|
'config_unreadable' => 'The stored configuration cannot be decrypted. Please re-issue the access.',
|
||||||
|
|
||||||
|
'no_stored_config' => 'This access was created without storing its configuration — the private key only exists on your device. Use "Re-issue" to get a new one.',
|
||||||
|
'reissue' => 'Re-issue',
|
||||||
|
'reissue_confirm' => 'Generate a new keypair for :name? The current configuration stops working immediately.',
|
||||||
|
'reissued' => 'New key issued — the old configuration no longer works.',
|
||||||
|
'reissue_staff_only' => 'Host accesses are renewed through host management, not here.',
|
||||||
|
|
||||||
'hub' => 'Hub',
|
'hub' => 'Hub',
|
||||||
'endpoint' => 'Endpoint',
|
'endpoint' => 'Endpoint',
|
||||||
'hub_key' => 'Public key',
|
'hub_key' => 'Public key',
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
'download' => '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
|
'download' => '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
|
||||||
'alert-triangle' => '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/>',
|
'alert-triangle' => '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/>',
|
||||||
'check' => '<polyline points="20 6 9 17 4 12"/>',
|
'check' => '<polyline points="20 6 9 17 4 12"/>',
|
||||||
|
'refresh' => '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
|
||||||
'bell' => '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
|
'bell' => '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
|
||||||
'calendar' => '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
|
'calendar' => '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
|
||||||
'qr-code' => '<rect width="5" height="5" x="3" y="3" rx="1"/><rect width="5" height="5" x="16" y="3" rx="1"/><rect width="5" height="5" x="3" y="16" rx="1"/><path d="M21 16h-3a2 2 0 0 0-2 2v3"/><path d="M21 21v.01"/><path d="M12 7v3a2 2 0 0 1-2 2H7"/><path d="M3 12h.01"/><path d="M12 3h.01"/><path d="M12 16v.01"/><path d="M16 12h1"/><path d="M21 12v.01"/><path d="M12 21v-1"/>',
|
'qr-code' => '<rect width="5" height="5" x="3" y="3" rx="1"/><rect width="5" height="5" x="16" y="3" rx="1"/><rect width="5" height="5" x="3" y="16" rx="1"/><path d="M21 16h-3a2 2 0 0 0-2 2v3"/><path d="M21 21v.01"/><path d="M12 7v3a2 2 0 0 1-2 2H7"/><path d="M3 12h.01"/><path d="M12 3h.01"/><path d="M12 16v.01"/><path d="M16 12h1"/><path d="M21 12v.01"/><path d="M12 21v-1"/>',
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,14 @@
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="px-4 py-3 text-right">
|
||||||
<div class="inline-flex gap-1">
|
<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-faint">
|
||||||
|
<x-ui.icon name="download" class="size-4" />
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
@can('downloadConfig', $peer)
|
@can('downloadConfig', $peer)
|
||||||
<button type="button" aria-label="{{ __('vpn.get_config') }}"
|
<button type="button" aria-label="{{ __('vpn.get_config') }}"
|
||||||
x-on:click="$dispatch('openModal', { component: 'admin.vpn-config-access', arguments: { uuid: '{{ $peer->uuid }}' } })"
|
x-on:click="$dispatch('openModal', { component: 'admin.vpn-config-access', arguments: { uuid: '{{ $peer->uuid }}' } })"
|
||||||
|
|
@ -152,6 +160,16 @@
|
||||||
<x-ui.icon :name="$peer->enabled ? 'lock' : 'unlock'" class="size-4" />
|
<x-ui.icon :name="$peer->enabled ? 'lock' : 'unlock'" class="size-4" />
|
||||||
</button>
|
</button>
|
||||||
@endcan
|
@endcan
|
||||||
|
@can('update', $peer)
|
||||||
|
@if ($peer->kind === \App\Models\VpnPeer::KIND_STAFF)
|
||||||
|
<button type="button" wire:click="reissue('{{ $peer->uuid }}')"
|
||||||
|
wire:confirm="{{ __('vpn.reissue_confirm', ['name' => $peer->name]) }}"
|
||||||
|
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)
|
@can('delete', $peer)
|
||||||
<button type="button" aria-label="{{ __('vpn.delete') }}"
|
<button type="button" aria-label="{{ __('vpn.delete') }}"
|
||||||
x-on:click="$dispatch('openModal', { component: 'admin.confirm-delete-vpn-peer', arguments: { uuid: '{{ $peer->uuid }}' } })"
|
x-on:click="$dispatch('openModal', { component: 'admin.confirm-delete-vpn-peer', arguments: { uuid: '{{ $peer->uuid }}' } })"
|
||||||
|
|
|
||||||
|
|
@ -766,3 +766,58 @@ it('closes the owner doors as soon as the roles are gone', function () {
|
||||||
|
|
||||||
Livewire::actingAs($support)->test(VpnConfigAccess::class, ['uuid' => $peer->uuid])->assertForbidden();
|
Livewire::actingAs($support)->test(VpnConfigAccess::class, ['uuid' => $peer->uuid])->assertForbidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('re-issues a key for an access whose config was never stored', function () {
|
||||||
|
vpnHub();
|
||||||
|
Queue::fake();
|
||||||
|
$owner = operator('Owner');
|
||||||
|
$peer = VpnPeer::factory()->ownedBy($owner)->create();
|
||||||
|
$oldKey = $peer->public_key;
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($owner)->test(Vpn::class)->call('reissue', $peer->uuid);
|
||||||
|
|
||||||
|
// A new key, and the config handed over once — the only honest answer when
|
||||||
|
// the private half of the old one exists nowhere.
|
||||||
|
$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);
|
||||||
|
|
||||||
|
// Old key off the hub, new key on — in that order, or two peers would
|
||||||
|
// briefly claim the same tunnel address.
|
||||||
|
Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $oldKey && ! $job->enabled);
|
||||||
|
Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $peer->public_key && $job->enabled);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a stored config in step when the key is re-issued', function () {
|
||||||
|
vpnHub();
|
||||||
|
Queue::fake();
|
||||||
|
$owner = operator('Owner');
|
||||||
|
$peer = VpnPeer::factory()->ownedBy($owner)->create([
|
||||||
|
'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = ALT\n"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($owner)->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);
|
||||||
|
expect($stored)->not->toContain('PrivateKey = ALT')
|
||||||
|
->and($stored)->toContain('Address = '.$peer->allowed_ip);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not re-issue a host peer from the VPN page', function () {
|
||||||
|
vpnHub();
|
||||||
|
Queue::fake();
|
||||||
|
$host = Host::factory()->active()->create();
|
||||||
|
$peer = VpnPeer::factory()->forHost()->create(['host_id' => $host->id]);
|
||||||
|
$oldKey = $peer->public_key;
|
||||||
|
|
||||||
|
Livewire::actingAs(operator('Owner'))->test(Vpn::class)->call('reissue', $peer->uuid);
|
||||||
|
|
||||||
|
// Its key belongs to the host's own wg0 — swapping it here would cut the
|
||||||
|
// machine off with nothing to repair it.
|
||||||
|
expect($peer->fresh()->public_key)->toBe($oldKey);
|
||||||
|
Queue::assertNotPushed(ApplyVpnPeer::class);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue