window = $this->clampWindow($seconds); } public function setTab(string $tab): void { $this->tab = in_array($tab, ['peers', 'server'], true) ? $tab : 'peers'; } public function addPeer(WgBridge $bridge): void { $this->validate(['newPeer' => ['required', 'regex:'.self::PEER_NAME_RE]], [ '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->pendingName = $name; $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:'.self::PEER_NAME_RE], ], [ 'setupSubnet.regex' => __('wireguard.subnet_invalid'), 'setupPort.regex' => __('wireguard.port_invalid'), 'setupEndpoint.regex' => __('wireguard.endpoint_invalid'), 'setupPeer.regex' => __('wireguard.peer_name_invalid'), ]); if (! $this->portInRange($this->setupPort)) { $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->pendingName = $this->setupPeer; $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(self::PEER_NAME_RE, $name) !== 1) { return; } $this->openConfirm('wgPeerRemoved', ['name' => $name], __('wireguard.remove_confirm_title'), __('wireguard.remove_confirm_body'), __('wireguard.remove'), danger: true, icon: 'trash'); } /** 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(self::PEER_NAME_RE, $name) !== 1) { return; } $this->removePeer($bridge, $name); } public function removePeer(WgBridge $bridge, string $name): void { if (preg_match(self::PEER_NAME_RE, $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 = ''; } /** Set the DNS server(s) baked into future peer configs. Non-destructive (no confirm needed). */ public function setDns(WgBridge $bridge): void { $this->validate(['newDns' => ['required', 'regex:/^\d{1,3}(\.\d{1,3}){3}([,\s]+\d{1,3}(\.\d{1,3}){3})*$/']], [ 'newDns.regex' => __('wireguard.dns_invalid'), 'newDns.required' => __('wireguard.dns_invalid'), ]); if (! $this->throttle()) { return; } $this->pendingId = $bridge->request('set-dns', ['dns' => $this->newDns]); $this->pendingAction = 'set-dns'; $this->audit('wg.set-dns', $this->newDns); $this->newDns = ''; } public function confirmGate(bool $on): void { $this->openConfirm('wgGateToggle', ['on' => $on ? '1' : '0'], $on ? __('wireguard.gate_on_title') : __('wireguard.gate_off_title'), $on ? __('wireguard.gate_on_body') : __('wireguard.gate_off_body'), $on ? __('wireguard.gate_turn_on') : __('wireguard.gate_turn_off'), danger: ! $on, icon: 'shield'); } #[On('wgGateToggle')] public function applyGateToggle(string $confirmToken, WgBridge $bridge): void { try { $payload = ConfirmToken::consume($confirmToken, 'wgGateToggle'); } catch (InvalidConfirmToken) { return; } $this->runGate($this->onFlag($payload), $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'); } /** * The SSH lock (port 22). Turning it ON is the most dangerous action on this page: a user with no * surviving WG access locks themselves out of SSH entirely (only the server console / `clusev wg * down` recovers it). The confirm body spells that out and recommends a backup peer first. */ public function confirmSshGate(bool $on): void { $this->openConfirm('wgSshGate', ['on' => $on ? '1' : '0'], $on ? __('wireguard.ssh_lock_on_title') : __('wireguard.ssh_lock_off_title'), $on ? __('wireguard.ssh_lock_on_body') : __('wireguard.ssh_lock_off_body'), $on ? __('wireguard.ssh_lock_turn_on') : __('wireguard.ssh_lock_turn_off'), danger: $on, icon: 'alert'); } #[On('wgSshGate')] public function applySshGate(string $confirmToken, WgBridge $bridge): void { try { $payload = ConfirmToken::consume($confirmToken, 'wgSshGate'); } catch (InvalidConfirmToken) { return; } $this->runSshGate($this->onFlag($payload), $bridge); } public function runSshGate(bool $on, ?WgBridge $bridge = null): void { if (! $this->throttle()) { return; } $bridge ??= app(WgBridge::class); $this->pendingId = $bridge->request($on ? 'gate-ssh-on' : 'gate-ssh-off', []); $this->pendingAction = $on ? 'gate-ssh-on' : 'gate-ssh-off'; $this->audit($on ? 'wg.ssh-lock' : 'wg.ssh-unlock', $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 (! $this->portInRange($this->newPort)) { $this->addError('newPort', __('wireguard.port_invalid')); return; } $this->openConfirm('wgSetPort', ['port' => $this->newPort], __('wireguard.port_confirm_title'), __('wireguard.port_confirm_body'), __('wireguard.apply'), danger: true, icon: 'alert'); } #[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->openConfirm('wgSetSubnet', ['subnet' => $this->newSubnet], __('wireguard.subnet_confirm_title'), __('wireguard.subnet_confirm_body'), __('wireguard.apply'), danger: true, icon: 'alert'); } #[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']; $this->resultName = $this->pendingName; } elseif (! $res['ok']) { $msg = ($res['message'] ?? '') !== '' ? $res['message'] : __('wireguard.action_failed'); // Persist the failure so it's readable later in the audit log, not just a transient toast. $this->audit('wg.action-failed', trim(($this->pendingAction ?? '').': '.$msg)); $this->dispatch('notify', message: $msg, level: 'error'); } else { $this->dispatch('notify', message: __('wireguard.action_done')); } $this->pendingAction = null; $this->pendingName = null; } public function dismissResult(): void { $this->resultConfig = null; $this->resultQr = null; $this->resultName = null; } /** * Download the show-once client config as a .conf file. The filename becomes the tunnel name when * imported (WireGuard apps name a tunnel after the file), so build it from the endpoint host + peer * name — e.g. "203.0.113.10-laptop.conf". (A QR scan can't carry a name; the app prompts for one.) */ public function downloadConfig(): ?StreamedResponse { if ($this->resultConfig === null) { return null; } $cfg = $this->resultConfig; $host = preg_match('/^Endpoint\s*=\s*([^:\s]+)/m', $cfg, $m) === 1 ? $m[1] : 'clusev'; $parts = array_filter([$host, (string) $this->resultName]); $base = preg_replace('/[^A-Za-z0-9._-]+/', '-', implode('-', $parts)); $base = trim((string) $base, '-.') ?: 'clusev-wireguard'; return response()->streamDownload(function () use ($cfg) { echo $cfg; }, $base.'.conf', ['Content-Type' => 'text/plain; charset=UTF-8']); } /** Clamp a traffic window to one of the allowed values, falling back to the shortest. */ private function clampWindow(int $seconds): int { return in_array($seconds, self::WINDOWS, true) ? $seconds : self::WINDOWS[0]; } /** True when a numeric-string port is within the valid UDP range. */ private function portInRange(string $port): bool { return (int) $port >= 1 && (int) $port <= 65535; } /** Decode the sealed on/off flag a gate-toggle confirm token carries. */ private function onFlag(array $payload): bool { return ($payload['params']['on'] ?? '0') === '1'; } /** * Open the shared ConfirmAction modal (R5) for one WG action. The issued token carries NO audit * descriptor on purpose — each apply handler audits exactly once itself, so auditing here too * would double-count. Heading/body/label/danger/icon/params are the only per-action differences. * * @param array $params */ private function openConfirm(string $event, array $params, string $heading, string $body, string $confirmLabel, bool $danger, string $icon): void { $this->dispatch('openModal', component: 'modals.confirm-action', arguments: [ 'heading' => $heading, 'body' => $body, 'confirmLabel' => $confirmLabel, 'danger' => $danger, 'icon' => $icon, 'notify' => '', 'token' => ConfirmToken::issue($event, $params), ], ); } 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 = $this->clampWindow($this->window); return view('livewire.wireguard.index', [ 'status' => $wg->read(), 'traffic' => $traffic->series($window), 'windows' => self::WINDOWS, ])->title(__('wireguard.title')); } }