feat(wg): add/remove peers from the dashboard (show-once config + QR)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-21 00:36:26 +02:00
parent ed5506e558
commit ee8f2bac91
6 changed files with 247 additions and 4 deletions

View File

@ -2,29 +2,167 @@
namespace App\Livewire\Wireguard; namespace App\Livewire\Wireguard;
use App\Models\AuditEvent;
use App\Services\WgBridge;
use App\Services\WgStatus; use App\Services\WgStatus;
use App\Services\WgTraffic; use App\Services\WgTraffic;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component; use Livewire\Component;
/** /**
* WireGuard dashboard live status (P1) + traffic history (P2). Renders the host-collected * WireGuard dashboard live status (P1) + traffic (P2) + peer management (P3). Reads the
* status (WgStatus) and a bucketed throughput series (WgTraffic), wire:polls every 5s. Read-only. * host-collected status; mutations go through the host write-bridge (WgBridge), never a shell.
*/ */
#[Layout('layouts.app')] #[Layout('layouts.app')]
class Index extends Component class Index extends Component
{ {
/** Selected traffic window in seconds. One of WINDOWS. */
public int $window = 3600; public int $window = 3600;
/** Allowed windows (seconds): 1h / 24h / 7d. */
public const WINDOWS = [3600, 86400, 604800]; public const WINDOWS = [3600, 86400, 604800];
public string $newPeer = '';
/** id of an in-flight write-request we are polling for. */
public ?string $pendingId = null;
public ?string $pendingAction = null;
/** Show-once add-peer result (config text + QR svg). Never persisted. */
public ?string $resultConfig = null;
public ?string $resultQr = null;
public function setWindow(int $seconds): void public function setWindow(int $seconds): void
{ {
$this->window = in_array($seconds, self::WINDOWS, true) ? $seconds : 3600; $this->window = in_array($seconds, self::WINDOWS, true) ? $seconds : 3600;
} }
public function addPeer(WgBridge $bridge): void
{
$this->validate(['newPeer' => ['required', 'regex:/^[A-Za-z0-9._-]{1,64}$/']], [
'newPeer.regex' => __('wireguard.peer_name_invalid'),
'newPeer.required' => __('wireguard.peer_name_invalid'),
]);
if (! $this->throttle()) {
return;
}
$name = $this->newPeer;
$this->pendingId = $bridge->request('add-peer', ['name' => $name]);
$this->pendingAction = 'add-peer';
$this->newPeer = '';
$this->audit('wg.add-peer', $name);
}
/** Opens the wire-elements/modal confirm dialog for peer removal. */
public function confirmRemovePeer(string $name): void
{
if (preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1) {
return;
}
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('wireguard.remove_confirm_title'),
'body' => __('wireguard.remove_confirm_body'),
'confirmLabel' => __('wireguard.remove'),
'danger' => true,
'icon' => 'trash',
'notify' => '',
'token' => ConfirmToken::issue(
'wgPeerRemoved',
['name' => $name],
'wg.remove-peer',
$name,
),
],
);
}
/** Apply handler — called after the ConfirmAction modal confirms the removal. */
#[On('wgPeerRemoved')]
public function applyRemovePeer(string $confirmToken, WgBridge $bridge): void
{
try {
$payload = ConfirmToken::consume($confirmToken, 'wgPeerRemoved');
} catch (InvalidConfirmToken) {
return;
}
$name = $payload['params']['name'] ?? '';
if ($name === '' || preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1) {
return;
}
$this->removePeer($bridge, $name);
}
public function removePeer(WgBridge $bridge, string $name): void
{
if (preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1 || ! $this->throttle()) {
return;
}
$this->pendingId = $bridge->request('remove-peer', ['name' => $name]);
$this->pendingAction = 'remove-peer';
$this->audit('wg.remove-peer', $name);
}
public function pollResult(WgBridge $bridge): void
{
if ($this->pendingId === null) {
return;
}
$res = $bridge->result($this->pendingId);
if ($res === null) {
return;
}
$this->pendingId = null;
if ($res['ok'] && $this->pendingAction === 'add-peer' && $res['config'] !== null) {
$this->resultConfig = $res['config'];
$this->resultQr = $res['qr'];
} elseif (! $res['ok']) {
$this->dispatch('notify', message: __('wireguard.action_failed'), level: 'error');
} else {
$this->dispatch('notify', message: __('wireguard.action_done'));
}
$this->pendingAction = null;
}
public function dismissResult(): void
{
$this->resultConfig = null;
$this->resultQr = null;
}
private function throttle(): bool
{
$key = 'wg-request:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, 10)) {
$this->dispatch('notify', message: __('wireguard.throttled'), level: 'error');
return false;
}
RateLimiter::hit($key, 60);
return true;
}
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function render(WgStatus $wg, WgTraffic $traffic) public function render(WgStatus $wg, WgTraffic $traffic)
{ {
$window = in_array($this->window, self::WINDOWS, true) ? $this->window : 3600; $window = in_array($this->window, self::WINDOWS, true) ? $this->window : 3600;

View File

@ -56,6 +56,7 @@ class ConfirmToken
'twoFactorDisabled', 'twoFactorDisabled',
'banCleared', 'banCleared',
'bansClearedAll', 'bansClearedAll',
'wgPeerRemoved',
]; ];
/** How long an issued confirm stays valid (seconds) — generous for a human click. */ /** How long an issued confirm stays valid (seconds) — generous for a human click. */

View File

@ -33,4 +33,17 @@ return [
'total_down' => 'Empfangen', 'total_down' => 'Empfangen',
'total_up' => 'Gesendet', 'total_up' => 'Gesendet',
'no_traffic' => 'Noch keine Verlaufsdaten — sie sammeln sich, sobald Peers verbunden sind.', 'no_traffic' => 'Noch keine Verlaufsdaten — sie sammeln sich, sobald Peers verbunden sind.',
'add_peer' => 'Peer hinzufügen',
'peer_name_placeholder' => 'Name (z. B. laptop)',
'peer_name_invalid' => 'Nur AZ az 09 . _ - (max. 64).',
'remove' => 'Entfernen',
'remove_confirm_title' => 'Peer entfernen?',
'remove_confirm_body' => 'Der Client verliert sofort den Zugang. Das lässt sich nicht rückgängig machen.',
'pending' => 'Wird angewendet …',
'action_done' => 'Aktion angewendet.',
'action_failed' => 'Aktion fehlgeschlagen — auf dem Host prüfen.',
'throttled' => 'Zu viele Aktionen — kurz warten.',
'new_peer_title' => 'Neuer Peer — Konfiguration',
'new_peer_once' => 'Diese Konfiguration wird NUR EINMAL angezeigt und nicht gespeichert. Jetzt importieren (QR scannen oder Text kopieren).',
'close' => 'Schließen',
]; ];

View File

@ -33,4 +33,17 @@ return [
'total_down' => 'Received', 'total_down' => 'Received',
'total_up' => 'Sent', 'total_up' => 'Sent',
'no_traffic' => 'No history yet — it accrues once peers are connected.', 'no_traffic' => 'No history yet — it accrues once peers are connected.',
'add_peer' => 'Add peer',
'peer_name_placeholder' => 'Name (e.g. laptop)',
'peer_name_invalid' => 'Only AZ az 09 . _ - (max 64).',
'remove' => 'Remove',
'remove_confirm_title' => 'Remove peer?',
'remove_confirm_body' => 'The client loses access immediately. This cannot be undone.',
'pending' => 'Applying …',
'action_done' => 'Action applied.',
'action_failed' => 'Action failed — check on the host.',
'throttled' => 'Too many actions — wait a moment.',
'new_peer_title' => 'New peer — configuration',
'new_peer_once' => 'This configuration is shown ONCE and never stored. Import it now (scan the QR or copy the text).',
'close' => 'Close',
]; ];

View File

@ -36,6 +36,13 @@
</div> </div>
@endif @endif
@if ($pendingId)
<div class="flex items-center gap-2 rounded-lg border border-accent/30 bg-accent/5 px-4 py-3" wire:poll.2s="pollResult">
<svg class="h-4 w-4 shrink-0 animate-spin text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
<span class="font-mono text-[11px] text-ink-2">{{ __('wireguard.pending') }}</span>
</div>
@endif
@php @php
$win = collect($windows)->mapWithKeys(fn ($s) => [$s => match ($s) { $win = collect($windows)->mapWithKeys(fn ($s) => [$s => match ($s) {
3600 => __('wireguard.window_1h'), 86400 => __('wireguard.window_24h'), default => __('wireguard.window_7d'), 3600 => __('wireguard.window_1h'), 86400 => __('wireguard.window_24h'), default => __('wireguard.window_7d'),
@ -91,6 +98,14 @@
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_320px]"> <div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
<x-panel :title="__('wireguard.peers_title')" :subtitle="trans_choice('wireguard.peers_count', count($status['peers']), ['count' => count($status['peers'])])" :padded="false"> <x-panel :title="__('wireguard.peers_title')" :subtitle="trans_choice('wireguard.peers_count', count($status['peers']), ['count' => count($status['peers'])])" :padded="false">
<form wire:submit="addPeer" class="flex flex-wrap items-center gap-2 border-b border-line px-4 py-3 sm:px-5">
<input wire:model="newPeer" type="text" maxlength="64" placeholder="{{ __('wireguard.peer_name_placeholder') }}"
class="min-w-0 flex-1 rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
<x-btn variant="primary" type="submit" wire:loading.attr="disabled" wire:target="addPeer">
<x-icon name="plus" class="h-3.5 w-3.5" />{{ __('wireguard.add_peer') }}
</x-btn>
@error('newPeer') <span class="w-full font-mono text-[11px] text-offline">{{ $message }}</span> @enderror
</form>
<div class="divide-y divide-line"> <div class="divide-y divide-line">
@forelse ($status['peers'] as $peer) @forelse ($status['peers'] as $peer)
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3.5 sm:px-5" wire:key="peer-{{ $peer['pubkey'] }}"> <div class="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3.5 sm:px-5" wire:key="peer-{{ $peer['pubkey'] }}">
@ -104,6 +119,11 @@
{{ $peer['endpoint'] !== '' ? $peer['endpoint'] : '—' }} {{ $peer['endpoint'] !== '' ? $peer['endpoint'] : '—' }}
<span class="ml-2 text-ink-4">{{ __('wireguard.down_up', ['rx' => $fmtBytes($peer['rx']), 'tx' => $fmtBytes($peer['tx'])]) }}</span> <span class="ml-2 text-ink-4">{{ __('wireguard.down_up', ['rx' => $fmtBytes($peer['rx']), 'tx' => $fmtBytes($peer['tx'])]) }}</span>
</span> </span>
<div class="flex w-full justify-end">
<x-btn variant="danger-soft" size="sm" wire:click="confirmRemovePeer('{{ $peer['name'] !== '' ? $peer['name'] : $peer['pubkey'] }}')">
<x-icon name="trash" class="h-3.5 w-3.5" />{{ __('wireguard.remove') }}
</x-btn>
</div>
</div> </div>
@empty @empty
<p class="px-4 py-4 font-mono text-[11px] text-ink-3 sm:px-5">{{ __('wireguard.no_peers') }}</p> <p class="px-4 py-4 font-mono text-[11px] text-ink-3 sm:px-5">{{ __('wireguard.no_peers') }}</p>
@ -133,4 +153,20 @@
</div> </div>
</div> </div>
@endif @endif
@if ($resultConfig)
<div class="fixed inset-0 z-50 flex items-center justify-center bg-void/80 p-4" wire:key="wg-result-modal">
<div class="w-full max-w-lg rounded-xl border border-line bg-surface p-6 shadow-panel">
<h3 class="font-display text-lg font-semibold text-ink">{{ __('wireguard.new_peer_title') }}</h3>
<p class="mt-1 text-sm text-warning">{{ __('wireguard.new_peer_once') }}</p>
@if ($resultQr)
<div class="mx-auto mt-4 h-44 w-44 [&>svg]:h-full [&>svg]:w-full">{!! $resultQr !!}</div>
@endif
<pre class="mt-4 max-h-48 overflow-auto rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] leading-relaxed text-ink-2">{{ $resultConfig }}</pre>
<div class="mt-4 flex justify-end">
<x-btn variant="secondary" wire:click="dismissResult">{{ __('wireguard.close') }}</x-btn>
</div>
</div>
</div>
@endif
</div> </div>

View File

@ -72,4 +72,46 @@ class WireguardPageTest extends TestCase
->call('setWindow', 999) ->call('setWindow', 999)
->assertSet('window', 3600); ->assertSet('window', 3600);
} }
public function test_add_peer_writes_a_request_and_audits(): void
{
\Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)
->set('newPeer', 'laptop')
->call('addPeer')
->assertSet('pendingId', fn ($v) => is_string($v) && $v !== '');
$this->assertTrue(\App\Models\AuditEvent::where('action', 'wg.add-peer')->exists());
$this->assertFileExists(storage_path('app/restart-signal/wg-request.json'));
@unlink(storage_path('app/restart-signal/wg-request.json'));
}
public function test_add_peer_rejects_an_invalid_name(): void
{
\Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)
->set('newPeer', 'bad name')
->call('addPeer')
->assertHasErrors('newPeer');
$this->assertSame(0, \App\Models\AuditEvent::where('action', 'wg.add-peer')->count());
}
public function test_remove_peer_writes_a_request_and_audits(): void
{
\Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)
->call('removePeer', 'laptop')
->assertSet('pendingId', fn ($v) => is_string($v) && $v !== '');
$this->assertTrue(\App\Models\AuditEvent::where('action', 'wg.remove-peer')->exists());
@unlink(storage_path('app/restart-signal/wg-request.json'));
}
public function test_poll_result_surfaces_the_config_once(): void
{
$c = \Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)->set('newPeer', 'laptop')->call('addPeer');
$id = $c->get('pendingId');
file_put_contents(storage_path("app/restart-signal/wg-result-{$id}.json"), json_encode(['id' => $id, 'ok' => true, 'message' => 'ok', 'config' => "[Interface]\nPrivateKey = x", 'at' => time()]));
$c->call('pollResult')
->assertSet('pendingId', null)
->assertSet('resultConfig', fn ($v) => str_contains((string) $v, 'PrivateKey'));
@unlink(storage_path("app/restart-signal/wg-result-{$id}.json"));
}
} }