diff --git a/app/Livewire/Wireguard/Index.php b/app/Livewire/Wireguard/Index.php index 4d778fc..cbf1f89 100644 --- a/app/Livewire/Wireguard/Index.php +++ b/app/Livewire/Wireguard/Index.php @@ -22,10 +22,13 @@ use Symfony\Component\HttpFoundation\StreamedResponse; #[Layout('layouts.app')] class Index extends Component { - public int $window = 3600; - 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]; + public string $newPeer = ''; public string $newEndpoint = ''; @@ -58,12 +61,12 @@ class Index extends Component public function setWindow(int $seconds): void { - $this->window = in_array($seconds, self::WINDOWS, true) ? $seconds : 3600; + $this->window = $this->clampWindow($seconds); } public function addPeer(WgBridge $bridge): void { - $this->validate(['newPeer' => ['required', 'regex:/^[A-Za-z0-9._-]{1,64}$/']], [ + $this->validate(['newPeer' => ['required', 'regex:'.self::PEER_NAME_RE]], [ 'newPeer.regex' => __('wireguard.peer_name_invalid'), 'newPeer.required' => __('wireguard.peer_name_invalid'), ]); @@ -83,14 +86,14 @@ class Index extends Component '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}$/'], + '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 ((int) $this->setupPort < 1 || (int) $this->setupPort > 65535) { + if (! $this->portInRange($this->setupPort)) { $this->addError('setupPort', __('wireguard.port_invalid')); return; @@ -109,24 +112,13 @@ class Index extends Component /** 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) { + if (preg_match(self::PEER_NAME_RE, $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]), - ], - ); + $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. */ @@ -140,7 +132,7 @@ class Index extends Component } $name = $payload['params']['name'] ?? ''; - if ($name === '' || preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1) { + if ($name === '' || preg_match(self::PEER_NAME_RE, $name) !== 1) { return; } @@ -149,7 +141,7 @@ class Index extends Component public function removePeer(WgBridge $bridge, string $name): void { - if (preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1 || ! $this->throttle()) { + if (preg_match(self::PEER_NAME_RE, $name) !== 1 || ! $this->throttle()) { return; } $this->pendingId = $bridge->request('remove-peer', ['name' => $name]); @@ -174,18 +166,11 @@ class Index extends Component 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']), - ], - ); + $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')] @@ -197,7 +182,7 @@ class Index extends Component return; } - $this->runGate(($payload['params']['on'] ?? '0') === '1', $bridge); + $this->runGate($this->onFlag($payload), $bridge); } public function runGate(bool $on, ?WgBridge $bridge = null): void @@ -218,18 +203,11 @@ class Index extends Component */ public function confirmSshGate(bool $on): void { - $this->dispatch('openModal', - component: 'modals.confirm-action', - arguments: [ - 'heading' => $on ? __('wireguard.ssh_lock_on_title') : __('wireguard.ssh_lock_off_title'), - 'body' => $on ? __('wireguard.ssh_lock_on_body') : __('wireguard.ssh_lock_off_body'), - 'confirmLabel' => $on ? __('wireguard.ssh_lock_turn_on') : __('wireguard.ssh_lock_turn_off'), - 'danger' => $on, - 'icon' => 'alert', - 'notify' => '', - 'token' => ConfirmToken::issue('wgSshGate', ['on' => $on ? '1' : '0']), - ], - ); + $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')] @@ -241,7 +219,7 @@ class Index extends Component return; } - $this->runSshGate(($payload['params']['on'] ?? '0') === '1', $bridge); + $this->runSshGate($this->onFlag($payload), $bridge); } public function runSshGate(bool $on, ?WgBridge $bridge = null): void @@ -261,24 +239,15 @@ class Index extends Component ['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) { + if (! $this->portInRange($this->newPort)) { $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]), - ], - ); + $this->openConfirm('wgSetPort', ['port' => $this->newPort], + __('wireguard.port_confirm_title'), __('wireguard.port_confirm_body'), + __('wireguard.apply'), danger: true, icon: 'alert'); } #[On('wgSetPort')] @@ -307,18 +276,9 @@ class Index extends Component ['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]), - ], - ); + $this->openConfirm('wgSetSubnet', ['subnet' => $this->newSubnet], + __('wireguard.subnet_confirm_title'), __('wireguard.subnet_confirm_body'), + __('wireguard.apply'), danger: true, icon: 'alert'); } #[On('wgSetSubnet')] @@ -392,6 +352,47 @@ class Index extends Component }, 'clusev-wireguard.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(); @@ -418,7 +419,7 @@ class Index extends Component public function render(WgStatus $wg, WgTraffic $traffic) { - $window = in_array($this->window, self::WINDOWS, true) ? $this->window : 3600; + $window = $this->clampWindow($this->window); return view('livewire.wireguard.index', [ 'status' => $wg->read(), diff --git a/app/Services/WgBridge.php b/app/Services/WgBridge.php index d9ec778..8299089 100644 --- a/app/Services/WgBridge.php +++ b/app/Services/WgBridge.php @@ -100,7 +100,7 @@ class WgBridge return ['endpoint' => $ep]; case 'set-port': $port = (string) ($args['port'] ?? ''); - if (preg_match('/^\d{1,5}$/', $port) !== 1 || (int) $port < 1 || (int) $port > 65535) { + if (! $this->validPort($port)) { throw new \InvalidArgumentException('invalid port'); } @@ -120,7 +120,7 @@ class WgBridge if (preg_match('#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#', $subnet) !== 1) { throw new \InvalidArgumentException('invalid subnet'); } - if (preg_match('/^\d{1,5}$/', $port) !== 1 || (int) $port < 1 || (int) $port > 65535) { + if (! $this->validPort($port)) { throw new \InvalidArgumentException('invalid port'); } if ($endpoint !== '' && preg_match('/^[A-Za-z0-9.:_-]{1,128}$/', $endpoint) !== 1) { @@ -135,4 +135,10 @@ class WgBridge return []; } + + /** A valid 1–65535 UDP port given as a numeric string. */ + private function validPort(string $port): bool + { + return preg_match('/^\d{1,5}$/', $port) === 1 && (int) $port >= 1 && (int) $port <= 65535; + } } diff --git a/app/Services/WgStatus.php b/app/Services/WgStatus.php index 43dd273..4e8945f 100644 --- a/app/Services/WgStatus.php +++ b/app/Services/WgStatus.php @@ -71,6 +71,7 @@ class WgStatus 'server_ip' => (string) ($data['server']['server_ip'] ?? ''), 'port' => (int) ($data['server']['port'] ?? 0), 'endpoint' => (string) ($data['server']['endpoint'] ?? ''), + // Not rendered by the page, but part of the /wg-status.json response contract (API consumers). 'pubkey' => (string) ($data['server']['pubkey'] ?? ''), ], 'peers' => $peers, diff --git a/app/Services/WgTraffic.php b/app/Services/WgTraffic.php index 08c9892..665d081 100644 --- a/app/Services/WgTraffic.php +++ b/app/Services/WgTraffic.php @@ -12,7 +12,7 @@ use App\Models\WgTrafficSample; class WgTraffic { /** - * @return array{window:int, buckets:int, points:array, + * @return array{window:int, points:array, * total_down:int, total_up:int, peak_down:int, peak_up:int} */ public function series(int $windowSeconds, int $buckets = 60): array @@ -59,7 +59,6 @@ class WgTraffic return [ 'window' => $windowSeconds, - 'buckets' => $buckets, 'points' => $points, 'total_down' => array_sum($down), 'total_up' => array_sum($up), diff --git a/docker/wg/clusev-wg.sh b/docker/wg/clusev-wg.sh index 5027cee..b2b2cce 100755 --- a/docker/wg/clusev-wg.sh +++ b/docker/wg/clusev-wg.sh @@ -2,8 +2,9 @@ # Clusev WireGuard gate + peer CLI (HOST-side, root). Invoked via `clusev wg ` (the host # wrapper execs this from the deployed repo tree). Puts the panel behind a WG tunnel: the VM is the # WG server, operator devices peer in, and TCP 80/443 is filtered to the WG subnet by a dedicated -# iptables chain hung off DOCKER-USER. SSH (22) and the WG UDP port are NEVER matched — you can -# always SSH in and run `clusev wg down`. The gate is OFF until `clusev wg up`. +# iptables chain hung off DOCKER-USER. The WG UDP port is NEVER matched. SSH (22) is only filtered if +# the operator opts into the SEPARATE ssh-lock (default off); the server console + `clusev wg down` +# are always a way back in. The panel gate is OFF until `clusev wg up`. set -euo pipefail WG_IF="wg0" @@ -20,6 +21,8 @@ PANEL_PORTS="80,443" # see spec §3 post-DNAT note: matches the published cont # current SSH session, only NEW non-tunnel SSH. Off unless the operator explicitly enables it. SSH_MARKER="${STATE_DIR}/wg-ssh-gate.enabled" SSH_CHAIN="CLUSEV-WG-SSH" +# Shape-check for a /N CIDR (single source for the subnet validators below). Single-quoted = literal. +readonly CIDR_RE='^[0-9]{1,3}(\.[0-9]{1,3}){3}/[0-9]{1,2}$' # Project root (for the run/ bridge dir). The collect timer sets CLUSEV_DIR; otherwise derive it # from this script's location (docker/wg/clusev-wg.sh -> ../..). @@ -102,7 +105,7 @@ ssh_gate_apply() { [ -n "${WG_SUBNET:-}" ] || return 0 # Re-validate the subnet shape before it reaches `iptables -s`; a malformed value fails open # (no DROP) rather than aborting mid-chain and leaving SSH half-filtered. - printf '%s' "$WG_SUBNET" | grep -qE '^[0-9]{1,3}(\.[0-9]{1,3}){3}/[0-9]{1,2}$' || return 0 + printf '%s' "$WG_SUBNET" | grep -qE "$CIDR_RE" || return 0 iptables -N "$SSH_CHAIN" 2>/dev/null || iptables -F "$SSH_CHAIN" iptables -A "$SSH_CHAIN" -i lo -j RETURN iptables -A "$SSH_CHAIN" -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN # never cut a live session @@ -297,7 +300,7 @@ _setup_noninteractive() { local subnet="$1" server_ip="$2" port="$3" endpoint="$4" peer1="$5" have wg || { echo "wireguard-tools fehlt" >&2; return 1; } [ -f "$WG_CONF" ] && { echo "WireGuard ist bereits eingerichtet" >&2; return 1; } - printf '%s' "$subnet" | grep -qE '^[0-9]{1,3}(\.[0-9]{1,3}){3}/[0-9]{1,2}$' || { echo "Ungueltiges Subnetz" >&2; return 1; } + printf '%s' "$subnet" | grep -qE "$CIDR_RE" || { echo "Ungueltiges Subnetz" >&2; return 1; } case "$server_ip" in ''|*[!0-9.]*) echo "Ungueltige Server-IP" >&2; return 1 ;; esac case "$port" in ''|*[!0-9]*) echo "Ungueltiger Port" >&2; return 1 ;; esac [ "$port" -ge 1 ] && [ "$port" -le 65535 ] || { echo "Port ausserhalb 1-65535" >&2; return 1; } @@ -305,6 +308,16 @@ _setup_noninteractive() { valid_name "$peer1" || { echo "Ungueltiger Peer-Name" >&2; return 1; } subnet_collides "$subnet" && { echo "Subnetz kollidiert mit einer bestehenden Route/Adresse" >&2; return 1; } + _write_server_conf "$subnet" "$server_ip" "$port" "$endpoint" + systemctl enable --now "wg-quick@${WG_IF}" || { echo "wg-quick@${WG_IF} konnte nicht gestartet werden" >&2; return 1; } + _peer_add "$peer1" +} + +# Generate the server keypair and write wg0.conf + wg.env (both 0600). Shared by both setup paths +# (dashboard _setup_noninteractive + interactive cmd_setup); the caller does `systemctl enable --now` +# and the first-peer add, since those differ (error style return-vs-die, _peer_add vs cmd_add_peer). +_write_server_conf() { + local subnet="$1" server_ip="$2" port="$3" endpoint="$4" umask 077; mkdir -p "$WG_DIR" "$STATE_DIR" local srv_priv srv_pub prefix srv_priv="$(wg genkey)"; srv_pub="$(printf '%s' "$srv_priv" | wg pubkey)" @@ -324,8 +337,6 @@ WG_ENDPOINT=${endpoint} WG_SERVER_PUBKEY=${srv_pub} EOF chmod 600 "$WG_ENV" - systemctl enable --now "wg-quick@${WG_IF}" || { echo "wg-quick@${WG_IF} konnte nicht gestartet werden" >&2; return 1; } - _peer_add "$peer1" } cmd_setup() { @@ -368,28 +379,7 @@ cmd_setup() { [ -n "$endpoint" ] || die "Endpoint erforderlich." read -rp " Name des ersten Peers [client-1]: " peer1 || true; peer1="${peer1:-client-1}" - umask 077; mkdir -p "$WG_DIR" "$STATE_DIR" - local srv_priv srv_pub prefix - srv_priv="$(wg genkey)"; srv_pub="$(printf '%s' "$srv_priv" | wg pubkey)" - prefix="${subnet##*/}" - - cat > "$WG_CONF" < "$WG_ENV" <&2; return 1 ;; esac sed -i "s#^WG_ENDPOINT=.*#WG_ENDPOINT=${ep}#" "$WG_ENV" } _set_port() { local port="$1"; require_setup + # Re-validated here: cmd_set_port passes $1 unchecked; also adds the 1-65535 range the bridge regex omits. case "$port" in ''|*[!0-9]*) echo "bad port" >&2; return 1 ;; esac [ "$port" -ge 1 ] && [ "$port" -le 65535 ] || { echo "port out of range" >&2; return 1; } sed -i "s#^ListenPort = .*#ListenPort = ${port}#" "$WG_CONF" @@ -506,8 +498,9 @@ _set_port() { _set_subnet() { local subnet="$1"; require_setup + # Re-validated here (defence-in-depth): the bare CLI path cmd_set_subnet passes $1 unchecked. case "$subnet" in *[!0-9./]*|'') echo "bad subnet" >&2; return 1 ;; esac - printf '%s' "$subnet" | grep -qE '^[0-9]{1,3}(\.[0-9]{1,3}){3}/[0-9]{1,2}$' || { echo "bad subnet" >&2; return 1; } + printf '%s' "$subnet" | grep -qE "$CIDR_RE" || { echo "bad subnet" >&2; return 1; } subnet_collides "$subnet" && { echo "subnet collides with an existing route/address" >&2; return 1; } local prefix server_ip prefix="${subnet##*/}" diff --git a/lang/de/wireguard.php b/lang/de/wireguard.php index b59be62..c7cc6d3 100644 --- a/lang/de/wireguard.php +++ b/lang/de/wireguard.php @@ -5,8 +5,6 @@ return [ 'eyebrow' => 'Netzwerk', 'heading' => 'WireGuard', 'subtitle' => 'Tunnel-Zugang zum Panel — Live-Status.', - 'not_configured_title' => 'WireGuard ist nicht eingerichtet', - 'not_configured_body' => 'Auf dem Host einrichten: per SSH "sudo clusev wg setup". Danach erscheint hier der Live-Status.', 'stale' => 'Status veraltet — der Host-Collector antwortet nicht. Läuft der Stack?', 'gate' => 'Gate (Panel-Sperre)', 'gate_on' => 'aktiv — Panel nur über den Tunnel', diff --git a/lang/en/wireguard.php b/lang/en/wireguard.php index 2e2ad41..e664936 100644 --- a/lang/en/wireguard.php +++ b/lang/en/wireguard.php @@ -5,8 +5,6 @@ return [ 'eyebrow' => 'Network', 'heading' => 'WireGuard', 'subtitle' => 'Tunnel access to the panel — live status.', - 'not_configured_title' => 'WireGuard is not set up', - 'not_configured_body' => 'Set it up on the host: over SSH run "sudo clusev wg setup". The live status appears here afterwards.', 'stale' => 'Status is stale — the host collector is not responding. Is the stack running?', 'gate' => 'Gate (panel lock)', 'gate_on' => 'on — panel only via the tunnel', diff --git a/resources/views/livewire/wireguard/index.blade.php b/resources/views/livewire/wireguard/index.blade.php index d526802..09950d7 100644 --- a/resources/views/livewire/wireguard/index.blade.php +++ b/resources/views/livewire/wireguard/index.blade.php @@ -79,11 +79,6 @@ $win = collect($windows)->mapWithKeys(fn ($s) => [$s => match ($s) { 3600 => __('wireguard.window_1h'), 86400 => __('wireguard.window_24h'), default => __('wireguard.window_7d'), }])->all(); - $fmtB = function (int $n): string { - $u = ['B', 'KB', 'MB', 'GB', 'TB']; $i = 0; $v = (float) $n; - while ($v >= 1024 && $i < count($u) - 1) { $v /= 1024; $i++; } - return ($i === 0 ? (string) $n : number_format($v, 1)).' '.$u[$i]; - }; $pts = $traffic['points']; $n = max(1, count($pts) - 1); $peak = max(1, $traffic['peak_down'], $traffic['peak_up']); @@ -102,8 +97,8 @@
- {{ __('wireguard.total_down') }}: {{ $fmtB($traffic['total_down']) }} - {{ __('wireguard.total_up') }}: {{ $fmtB($traffic['total_up']) }} + {{ __('wireguard.total_down') }}: {{ $fmtBytes($traffic['total_down']) }} + {{ __('wireguard.total_up') }}: {{ $fmtBytes($traffic['total_up']) }}
@foreach ($windows as $w)