clusev/app/Livewire/Wireguard/Index.php

372 lines
13 KiB
PHP

<?php
namespace App\Livewire\Wireguard;
use App\Models\AuditEvent;
use App\Services\WgBridge;
use App\Services\WgStatus;
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\On;
use Livewire\Component;
/**
* WireGuard dashboard — live status (P1) + traffic (P2) + peer management (P3). Reads the
* host-collected status; mutations go through the host write-bridge (WgBridge), never a shell.
*/
#[Layout('layouts.app')]
class Index extends Component
{
public int $window = 3600;
public const WINDOWS = [3600, 86400, 604800];
public string $newPeer = '';
public string $newEndpoint = '';
public string $newPort = '';
public string $newSubnet = '';
// first-time setup form
public string $setupSubnet = '10.99.0.0/24';
public string $setupPort = '51820';
public string $setupEndpoint = '';
public string $setupPeer = 'client-1';
/** id of an in-flight write-request we are polling for. */
public ?string $pendingId = null;
public ?string $pendingAction = null;
/** Unix time the current request started polling — used to time out a non-responding host. */
public ?int $pendingSince = 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
{
$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);
}
public function setupWg(WgBridge $bridge): void
{
$this->validate([
'setupSubnet' => ['required', 'regex:#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#'],
'setupPort' => ['required', 'regex:/^\d{1,5}$/'],
'setupEndpoint' => ['nullable', 'regex:/^[A-Za-z0-9.:_-]{1,128}$/'],
'setupPeer' => ['required', 'regex:/^[A-Za-z0-9._-]{1,64}$/'],
], [
'setupSubnet.regex' => __('wireguard.subnet_invalid'),
'setupPort.regex' => __('wireguard.port_invalid'),
'setupEndpoint.regex' => __('wireguard.endpoint_invalid'),
'setupPeer.regex' => __('wireguard.peer_name_invalid'),
]);
if ((int) $this->setupPort < 1 || (int) $this->setupPort > 65535) {
$this->addError('setupPort', __('wireguard.port_invalid'));
return;
}
if (! $this->throttle()) {
return;
}
$this->pendingId = $bridge->request('setup', [
'subnet' => $this->setupSubnet, 'port' => $this->setupPort,
'endpoint' => $this->setupEndpoint, 'name' => $this->setupPeer,
]);
$this->pendingAction = 'setup';
$this->audit('wg.setup', $this->setupSubnet);
}
/** 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' => '',
// No audit descriptor — the apply path (removePeer) audits, so the confirm modal
// must NOT also audit (avoids a double audit row for one action).
'token' => ConfirmToken::issue('wgPeerRemoved', ['name' => $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 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('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]),
],
);
}
#[On('wgSetPort')]
public function applySetPort(string $confirmToken, WgBridge $bridge): void
{
try {
$payload = ConfirmToken::consume($confirmToken, 'wgSetPort');
} catch (InvalidConfirmToken) {
return;
}
if (! $this->throttle()) {
return;
}
$port = (string) ($payload['params']['port'] ?? '');
$this->pendingId = $bridge->request('set-port', ['port' => $port]);
$this->pendingAction = 'set-port';
$this->audit('wg.set-port', $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]),
],
);
}
#[On('wgSetSubnet')]
public function applySetSubnet(string $confirmToken, WgBridge $bridge): void
{
try {
$payload = ConfirmToken::consume($confirmToken, 'wgSetSubnet');
} catch (InvalidConfirmToken) {
return;
}
if (! $this->throttle()) {
return;
}
$subnet = (string) ($payload['params']['subnet'] ?? '');
$this->pendingId = $bridge->request('set-subnet', ['subnet' => $subnet]);
$this->pendingAction = 'set-subnet';
$this->audit('wg.set-subnet', $subnet);
$this->newSubnet = '';
}
public function pollResult(WgBridge $bridge): void
{
if ($this->pendingId === null) {
return;
}
$this->pendingSince ??= time(); // first poll after a request — start the clock
$res = $bridge->result($this->pendingId);
if ($res === null) {
// No host response after a while → the host watcher (systemd) likely isn't running yet.
// Stop spinning forever and tell the operator instead of leaving "Wird angewendet …".
if (time() - $this->pendingSince > 30) {
$this->pendingId = null;
$this->pendingAction = null;
$this->pendingSince = null;
$this->dispatch('notify', message: __('wireguard.no_host_response'), level: 'error');
}
return;
}
$this->pendingId = null;
$this->pendingSince = null;
if ($res['ok'] && in_array($this->pendingAction, ['add-peer', 'setup'], true) && $res['config'] !== null) {
$this->resultConfig = $res['config'];
$this->resultQr = $res['qr'];
} elseif (! $res['ok']) {
$msg = ($res['message'] ?? '') !== '' ? $res['message'] : __('wireguard.action_failed');
$this->dispatch('notify', message: $msg, 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)
{
$window = in_array($this->window, self::WINDOWS, true) ? $this->window : 3600;
return view('livewire.wireguard.index', [
'status' => $wg->read(),
'traffic' => $traffic->series($window),
'windows' => self::WINDOWS,
])->title(__('wireguard.title'));
}
}