517 lines
19 KiB
PHP
517 lines
19 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\Attributes\Url;
|
|
use Livewire\Component;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
/**
|
|
* 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 const WINDOWS = [3600, 86400, 604800];
|
|
|
|
/** Peer-name charset, shared by every app-side validation site (the host re-validates too). */
|
|
private const PEER_NAME_RE = '/^[A-Za-z0-9._-]{1,64}$/';
|
|
|
|
public int $window = self::WINDOWS[0];
|
|
|
|
/** Active tab when configured: 'peers' (list + traffic) or 'server' (settings). Deep-linkable. */
|
|
#[Url]
|
|
public string $tab = 'peers';
|
|
|
|
public string $newPeer = '';
|
|
|
|
public string $newEndpoint = '';
|
|
|
|
public string $newPort = '';
|
|
|
|
public string $newSubnet = '';
|
|
|
|
public string $newDns = '';
|
|
|
|
// 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;
|
|
|
|
/** Peer name of the in-flight request, carried to the result so the download gets a meaningful filename. */
|
|
public ?string $pendingName = null;
|
|
|
|
public ?string $resultName = null;
|
|
|
|
/**
|
|
* Page guard (RBAC): WireGuard is a manage-network capability. The route carries a
|
|
* `can:manage-network` middleware for the initial GET, but a Livewire component update
|
|
* (POST /livewire/update) is NOT re-run through the route middleware — so this mount()
|
|
* check is the real per-request guard for the page itself.
|
|
*/
|
|
public function mount(): void
|
|
{
|
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
|
}
|
|
|
|
public function setWindow(int $seconds): void
|
|
{
|
|
$this->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
|
|
{
|
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
|
|
|
$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
|
|
{
|
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
|
|
|
$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
|
|
{
|
|
// Re-gate on consume: a token issued while the user still had rights must be
|
|
// refused if the role was demoted before it was confirmed (tokens are uid-bound).
|
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
|
|
|
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
|
|
{
|
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
|
|
|
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
|
|
{
|
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
|
|
|
$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
|
|
{
|
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
|
|
|
$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
|
|
{
|
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
|
|
|
try {
|
|
$payload = ConfirmToken::consume($confirmToken, 'wgGateToggle');
|
|
} catch (InvalidConfirmToken) {
|
|
return;
|
|
}
|
|
|
|
$this->runGate($this->onFlag($payload), $bridge);
|
|
}
|
|
|
|
public function runGate(bool $on, ?WgBridge $bridge = null): void
|
|
{
|
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
|
|
|
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
|
|
{
|
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
|
|
|
try {
|
|
$payload = ConfirmToken::consume($confirmToken, 'wgSshGate');
|
|
} catch (InvalidConfirmToken) {
|
|
return;
|
|
}
|
|
|
|
$this->runSshGate($this->onFlag($payload), $bridge);
|
|
}
|
|
|
|
public function runSshGate(bool $on, ?WgBridge $bridge = null): void
|
|
{
|
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
|
|
|
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
|
|
{
|
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
|
|
|
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
|
|
{
|
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
|
|
|
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<string, string> $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'));
|
|
}
|
|
}
|