feat(wg): gate toggle + endpoint/port/subnet settings from the dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>feat/v1-foundation
parent
7ff02c4e68
commit
7ccefa5890
|
|
@ -27,6 +27,12 @@ class Index extends Component
|
|||
|
||||
public string $newPeer = '';
|
||||
|
||||
public string $newEndpoint = '';
|
||||
|
||||
public string $newPort = '';
|
||||
|
||||
public string $newSubnet = '';
|
||||
|
||||
/** id of an in-flight write-request we are polling for. */
|
||||
public ?string $pendingId = null;
|
||||
|
||||
|
|
@ -112,6 +118,156 @@ class Index extends Component
|
|||
$this->audit('wg.remove-peer', $name);
|
||||
}
|
||||
|
||||
public function setEndpoint(WgBridge $bridge): void
|
||||
{
|
||||
$this->validate(['newEndpoint' => ['required', 'regex:/^[A-Za-z0-9.:_-]{1,128}$/']], [
|
||||
'newEndpoint.regex' => __('wireguard.endpoint_invalid'),
|
||||
'newEndpoint.required' => __('wireguard.endpoint_invalid'),
|
||||
]);
|
||||
if (! $this->throttle()) {
|
||||
return;
|
||||
}
|
||||
$this->pendingId = $bridge->request('set-endpoint', ['endpoint' => $this->newEndpoint]);
|
||||
$this->pendingAction = 'set-endpoint';
|
||||
$this->audit('wg.set-endpoint', $this->newEndpoint);
|
||||
$this->newEndpoint = '';
|
||||
}
|
||||
|
||||
public function confirmGate(bool $on): void
|
||||
{
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
'heading' => $on ? __('wireguard.gate_on_title') : __('wireguard.gate_off_title'),
|
||||
'body' => $on ? __('wireguard.gate_on_body') : __('wireguard.gate_off_body'),
|
||||
'confirmLabel' => $on ? __('wireguard.gate_turn_on') : __('wireguard.gate_turn_off'),
|
||||
'danger' => ! $on,
|
||||
'icon' => 'shield',
|
||||
'notify' => '',
|
||||
'token' => ConfirmToken::issue(
|
||||
'wgGateToggle',
|
||||
['on' => $on ? '1' : '0'],
|
||||
$on ? 'wg.gate-up' : 'wg.gate-down',
|
||||
$on ? 'on' : 'off',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[On('wgGateToggle')]
|
||||
public function applyGateToggle(string $confirmToken, WgBridge $bridge): void
|
||||
{
|
||||
try {
|
||||
$payload = ConfirmToken::consume($confirmToken, 'wgGateToggle');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->runGate(($payload['params']['on'] ?? '0') === '1', $bridge);
|
||||
}
|
||||
|
||||
public function runGate(bool $on, ?WgBridge $bridge = null): void
|
||||
{
|
||||
if (! $this->throttle()) {
|
||||
return;
|
||||
}
|
||||
$bridge ??= app(WgBridge::class);
|
||||
$this->pendingId = $bridge->request($on ? 'gate-up' : 'gate-down', []);
|
||||
$this->pendingAction = $on ? 'gate-up' : 'gate-down';
|
||||
$this->audit($on ? 'wg.gate-up' : 'wg.gate-down', $on ? 'on' : 'off');
|
||||
}
|
||||
|
||||
public function confirmSetPort(): void
|
||||
{
|
||||
$this->validate(
|
||||
['newPort' => ['required', 'regex:/^\d{1,5}$/']],
|
||||
['newPort.regex' => __('wireguard.port_invalid'), 'newPort.required' => __('wireguard.port_invalid')],
|
||||
);
|
||||
if ((int) $this->newPort < 1 || (int) $this->newPort > 65535) {
|
||||
$this->addError('newPort', __('wireguard.port_invalid'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
'heading' => __('wireguard.port_confirm_title'),
|
||||
'body' => __('wireguard.port_confirm_body'),
|
||||
'confirmLabel' => __('wireguard.apply'),
|
||||
'danger' => true,
|
||||
'icon' => 'alert',
|
||||
'notify' => '',
|
||||
'token' => ConfirmToken::issue(
|
||||
'wgSetPort',
|
||||
['port' => $this->newPort],
|
||||
'wg.set-port',
|
||||
$this->newPort,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[On('wgSetPort')]
|
||||
public function applySetPort(string $confirmToken, WgBridge $bridge): void
|
||||
{
|
||||
try {
|
||||
$payload = ConfirmToken::consume($confirmToken, 'wgSetPort');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->throttle()) {
|
||||
return;
|
||||
}
|
||||
$this->pendingId = $bridge->request('set-port', ['port' => (string) ($payload['params']['port'] ?? '')]);
|
||||
$this->pendingAction = 'set-port';
|
||||
$this->newPort = '';
|
||||
}
|
||||
|
||||
public function confirmSetSubnet(): void
|
||||
{
|
||||
$this->validate(
|
||||
['newSubnet' => ['required', 'regex:#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#']],
|
||||
['newSubnet.regex' => __('wireguard.subnet_invalid'), 'newSubnet.required' => __('wireguard.subnet_invalid')],
|
||||
);
|
||||
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
'heading' => __('wireguard.subnet_confirm_title'),
|
||||
'body' => __('wireguard.subnet_confirm_body'),
|
||||
'confirmLabel' => __('wireguard.apply'),
|
||||
'danger' => true,
|
||||
'icon' => 'alert',
|
||||
'notify' => '',
|
||||
'token' => ConfirmToken::issue(
|
||||
'wgSetSubnet',
|
||||
['subnet' => $this->newSubnet],
|
||||
'wg.set-subnet',
|
||||
$this->newSubnet,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[On('wgSetSubnet')]
|
||||
public function applySetSubnet(string $confirmToken, WgBridge $bridge): void
|
||||
{
|
||||
try {
|
||||
$payload = ConfirmToken::consume($confirmToken, 'wgSetSubnet');
|
||||
} catch (InvalidConfirmToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->throttle()) {
|
||||
return;
|
||||
}
|
||||
$this->pendingId = $bridge->request('set-subnet', ['subnet' => (string) ($payload['params']['subnet'] ?? '')]);
|
||||
$this->pendingAction = 'set-subnet';
|
||||
$this->newSubnet = '';
|
||||
}
|
||||
|
||||
public function pollResult(WgBridge $bridge): void
|
||||
{
|
||||
if ($this->pendingId === null) {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,9 @@ class ConfirmToken
|
|||
'banCleared',
|
||||
'bansClearedAll',
|
||||
'wgPeerRemoved',
|
||||
'wgGateToggle',
|
||||
'wgSetPort',
|
||||
'wgSetSubnet',
|
||||
];
|
||||
|
||||
/** How long an issued confirm stays valid (seconds) — generous for a human click. */
|
||||
|
|
|
|||
|
|
@ -46,4 +46,26 @@ return [
|
|||
'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',
|
||||
'settings_title' => 'Einstellungen',
|
||||
'apply' => 'Anwenden',
|
||||
'gate_toggle' => 'Gate',
|
||||
'gate_turn_on' => 'Einschalten',
|
||||
'gate_turn_off' => 'Ausschalten',
|
||||
'gate_on_title' => 'Gate einschalten?',
|
||||
'gate_on_body' => 'Das Panel ist danach nur noch über den Tunnel erreichbar. Vorher sicherstellen, dass der Tunnel das Panel erreicht — sonst nur per SSH ("clusev wg down") zurück.',
|
||||
'gate_off_title' => 'Gate ausschalten?',
|
||||
'gate_off_body' => 'Das Panel wird wieder öffentlich erreichbar (nur 2FA + Anmeldeschutz schützen dann).',
|
||||
'endpoint_label' => 'Öffentlicher Endpoint',
|
||||
'endpoint_hint' => 'IP oder DNS:Port — betrifft nur künftige Peer-Configs.',
|
||||
'endpoint_invalid' => 'Ungültiger Endpoint.',
|
||||
'port_label' => 'Listen-Port (UDP)',
|
||||
'port_hint' => 'Ändern startet den Tunnel neu — alle Clients müssen den Port aktualisieren.',
|
||||
'port_invalid' => 'Port 1–65535.',
|
||||
'port_confirm_title' => 'Port ändern?',
|
||||
'port_confirm_body' => 'Der Tunnel wird neu gestartet. Alle Clients müssen den neuen Port in ihrer Config eintragen, sonst verlieren sie die Verbindung.',
|
||||
'subnet_label' => 'Subnetz',
|
||||
'subnet_hint' => 'Stark destruktiv — re-adressiert den Server; bestehende Peers müssen neu angelegt werden.',
|
||||
'subnet_invalid' => 'Ungültiges Subnetz (CIDR).',
|
||||
'subnet_confirm_title' => 'Subnetz ändern?',
|
||||
'subnet_confirm_body' => 'Der Server bekommt eine neue Adresse und der Tunnel wird neu gestartet. ALLE bestehenden Peers verlieren den Zugang und müssen neu angelegt werden.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -46,4 +46,26 @@ return [
|
|||
'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',
|
||||
'settings_title' => 'Settings',
|
||||
'apply' => 'Apply',
|
||||
'gate_toggle' => 'Gate',
|
||||
'gate_turn_on' => 'Turn on',
|
||||
'gate_turn_off' => 'Turn off',
|
||||
'gate_on_title' => 'Turn gate on?',
|
||||
'gate_on_body' => 'The panel will then only be reachable through the tunnel. Make sure the tunnel reaches the panel first — otherwise only SSH ("clusev wg down") gets you back.',
|
||||
'gate_off_title' => 'Turn gate off?',
|
||||
'gate_off_body' => 'The panel becomes publicly reachable again (only 2FA + login protection then guard it).',
|
||||
'endpoint_label' => 'Public endpoint',
|
||||
'endpoint_hint' => 'IP or DNS:port — affects only future peer configs.',
|
||||
'endpoint_invalid' => 'Invalid endpoint.',
|
||||
'port_label' => 'Listen port (UDP)',
|
||||
'port_hint' => 'Changing this restarts the tunnel — all clients must update the port.',
|
||||
'port_invalid' => 'Port 1–65535.',
|
||||
'port_confirm_title' => 'Change port?',
|
||||
'port_confirm_body' => 'The tunnel restarts. All clients must set the new port in their config or they lose the connection.',
|
||||
'subnet_label' => 'Subnet',
|
||||
'subnet_hint' => 'Highly destructive — re-addresses the server; existing peers must be re-created.',
|
||||
'subnet_invalid' => 'Invalid subnet (CIDR).',
|
||||
'subnet_confirm_title' => 'Change subnet?',
|
||||
'subnet_confirm_body' => 'The server gets a new address and the tunnel restarts. ALL existing peers lose access and must be re-created.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -150,6 +150,67 @@
|
|||
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.service') }}</dt><dd class="text-ink-2">{{ $status['wg_quick'] }}</dd></div>
|
||||
</dl>
|
||||
</x-panel>
|
||||
|
||||
<x-panel :title="__('wireguard.settings_title')" :padded="false">
|
||||
<div class="divide-y divide-line">
|
||||
{{-- Gate toggle --}}
|
||||
<div class="px-4 py-3.5 sm:px-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="font-mono text-[11px] text-ink-3">{{ __('wireguard.gate_toggle') }}</span>
|
||||
@if ($status['gate']['marker'] && $status['gate']['chain'])
|
||||
<x-btn variant="danger-soft" size="sm" wire:click="confirmGate(false)">{{ __('wireguard.gate_turn_off') }}</x-btn>
|
||||
@else
|
||||
<x-btn variant="accent" size="sm" wire:click="confirmGate(true)">{{ __('wireguard.gate_turn_on') }}</x-btn>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Endpoint --}}
|
||||
<div class="px-4 py-3.5 sm:px-5">
|
||||
<form wire:submit="setEndpoint" class="space-y-2">
|
||||
<label class="block font-mono text-[11px] text-ink-3">{{ __('wireguard.endpoint_label') }}</label>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input wire:model="newEndpoint" type="text" maxlength="128"
|
||||
placeholder="{{ $status['server']['endpoint'] ?: 'host:51820' }}"
|
||||
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="setEndpoint">{{ __('wireguard.apply') }}</x-btn>
|
||||
</div>
|
||||
<p class="font-mono text-[11px] text-ink-4">{{ __('wireguard.endpoint_hint') }}</p>
|
||||
@error('newEndpoint') <span class="font-mono text-[11px] text-offline">{{ $message }}</span> @enderror
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Port --}}
|
||||
<div class="px-4 py-3.5 sm:px-5">
|
||||
<div class="space-y-2">
|
||||
<label class="block font-mono text-[11px] text-ink-3">{{ __('wireguard.port_label') }}</label>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input wire:model="newPort" type="text" maxlength="5"
|
||||
placeholder="{{ $status['server']['port'] ?: '51820' }}"
|
||||
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="danger-soft" size="sm" wire:click="confirmSetPort">{{ __('wireguard.apply') }}</x-btn>
|
||||
</div>
|
||||
<p class="font-mono text-[11px] text-ink-4">{{ __('wireguard.port_hint') }}</p>
|
||||
@error('newPort') <span class="font-mono text-[11px] text-offline">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Subnet --}}
|
||||
<div class="px-4 py-3.5 sm:px-5">
|
||||
<div class="space-y-2">
|
||||
<label class="block font-mono text-[11px] text-ink-3">{{ __('wireguard.subnet_label') }}</label>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input wire:model="newSubnet" type="text" maxlength="18"
|
||||
placeholder="{{ $status['server']['subnet'] ?: '10.99.0.0/24' }}"
|
||||
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="danger-soft" size="sm" wire:click="confirmSetSubnet">{{ __('wireguard.apply') }}</x-btn>
|
||||
</div>
|
||||
<p class="font-mono text-[11px] text-ink-4">{{ __('wireguard.subnet_hint') }}</p>
|
||||
@error('newSubnet') <span class="font-mono text-[11px] text-offline">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-panel>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Wireguard\Index;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\User;
|
||||
use App\Models\WgTrafficSample;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
|
@ -56,10 +58,10 @@ class WireguardPageTest extends TestCase
|
|||
|
||||
public function test_window_selector_switches_and_passes_traffic(): void
|
||||
{
|
||||
\App\Models\WgTrafficSample::create(['peer_pubkey' => 'p1', 'peer_name' => 'a', 'rx' => 0, 'tx' => 0, 'sampled_at' => now()->subMinutes(10)]);
|
||||
\App\Models\WgTrafficSample::create(['peer_pubkey' => 'p1', 'peer_name' => 'a', 'rx' => 500, 'tx' => 100, 'sampled_at' => now()->subMinutes(1)]);
|
||||
WgTrafficSample::create(['peer_pubkey' => 'p1', 'peer_name' => 'a', 'rx' => 0, 'tx' => 0, 'sampled_at' => now()->subMinutes(10)]);
|
||||
WgTrafficSample::create(['peer_pubkey' => 'p1', 'peer_name' => 'a', 'rx' => 500, 'tx' => 100, 'sampled_at' => now()->subMinutes(1)]);
|
||||
|
||||
\Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)
|
||||
Livewire::test(Index::class)
|
||||
->assertViewHas('traffic', fn ($t) => $t['total_down'] === 500)
|
||||
->call('setWindow', 86400)
|
||||
->assertSet('window', 86400)
|
||||
|
|
@ -68,44 +70,71 @@ class WireguardPageTest extends TestCase
|
|||
|
||||
public function test_window_clamps_to_allowed_values(): void
|
||||
{
|
||||
\Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)
|
||||
Livewire::test(Index::class)
|
||||
->call('setWindow', 999)
|
||||
->assertSet('window', 3600);
|
||||
}
|
||||
|
||||
public function test_add_peer_writes_a_request_and_audits(): void
|
||||
{
|
||||
\Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)
|
||||
Livewire::test(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->assertTrue(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)
|
||||
Livewire::test(Index::class)
|
||||
->set('newPeer', 'bad name')
|
||||
->call('addPeer')
|
||||
->assertHasErrors('newPeer');
|
||||
$this->assertSame(0, \App\Models\AuditEvent::where('action', 'wg.add-peer')->count());
|
||||
$this->assertSame(0, 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)
|
||||
Livewire::test(Index::class)
|
||||
->call('removePeer', 'laptop')
|
||||
->assertSet('pendingId', fn ($v) => is_string($v) && $v !== '');
|
||||
$this->assertTrue(\App\Models\AuditEvent::where('action', 'wg.remove-peer')->exists());
|
||||
$this->assertTrue(AuditEvent::where('action', 'wg.remove-peer')->exists());
|
||||
@unlink(storage_path('app/restart-signal/wg-request.json'));
|
||||
}
|
||||
|
||||
public function test_set_endpoint_writes_a_request_and_audits(): void
|
||||
{
|
||||
Livewire::test(Index::class)
|
||||
->set('newEndpoint', 'vpn.example.com:51820')
|
||||
->call('setEndpoint')
|
||||
->assertSet('pendingId', fn ($v) => is_string($v) && $v !== '');
|
||||
$this->assertTrue(AuditEvent::where('action', 'wg.set-endpoint')->exists());
|
||||
@unlink(storage_path('app/restart-signal/wg-request.json'));
|
||||
}
|
||||
|
||||
public function test_set_endpoint_rejects_a_bad_value(): void
|
||||
{
|
||||
Livewire::test(Index::class)
|
||||
->set('newEndpoint', 'has spaces!!')
|
||||
->call('setEndpoint')
|
||||
->assertHasErrors('newEndpoint');
|
||||
}
|
||||
|
||||
public function test_run_gate_writes_the_right_action_and_audits(): void
|
||||
{
|
||||
Livewire::test(Index::class)
|
||||
->call('runGate', false)
|
||||
->assertSet('pendingId', fn ($v) => is_string($v) && $v !== '');
|
||||
$this->assertTrue(AuditEvent::where('action', 'wg.gate-down')->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');
|
||||
$c = Livewire::test(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()]));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue