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'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"/>',
|
||||
|
|
|
|||
|
|
@ -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 }}' } })"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue