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
nexxo 2026-07-26 01:20:04 +02:00
parent e5aea84539
commit 556a560506
6 changed files with 130 additions and 0 deletions

View File

@ -222,6 +222,50 @@ class Vpn extends Component
$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
{
ConfigHandoff::forget($this->configToken);

View File

@ -76,6 +76,12 @@ return [
'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.',
'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',
'endpoint' => 'Endpunkt',
'hub_key' => 'Public Key',

View File

@ -76,6 +76,12 @@ return [
'too_many_attempts' => 'Too many attempts. Try again in :seconds seconds.',
'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',
'endpoint' => 'Endpoint',
'hub_key' => 'Public key',

View File

@ -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"/>',
'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"/>',
'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"/>',
'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"/>',

View File

@ -138,6 +138,14 @@
</td>
<td class="px-4 py-3 text-right">
<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)
<button type="button" aria-label="{{ __('vpn.get_config') }}"
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" />
</button>
@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)
<button type="button" aria-label="{{ __('vpn.delete') }}"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-delete-vpn-peer', arguments: { uuid: '{{ $peer->uuid }}' } })"

View File

@ -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();
});
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);
});