diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e1d3b9..e272cea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,24 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._ +## [0.9.40] - 2026-06-21 + +### Hinzugefügt +- **Peer-Konfiguration herunterladen.** Die Einmal-Anzeige eines neuen Clients hat neben dem + QR-Code jetzt einen **Herunterladen**-Knopf: Die Konfiguration lässt sich als + `clusev-wireguard.conf` speichern und am PC in die WireGuard-App importieren — alternativ zum + Scannen des QR-Codes am Handy. Ein Hinweis erklärt beide Wege direkt in der Anzeige. +- **SSH-Sperre aus dem Dashboard (Port 22).** In den WireGuard-Einstellungen lässt sich SSH + optional **zusätzlich** auf den Tunnel sperren — danach ist Port 22 nur noch über WireGuard bzw. + die Server-Konsole erreichbar. Die Aktion ist mit einer deutlichen **Aussperr-Warnung** versehen + (Inline-Hinweis **und** Bestätigungsdialog): Wer seinen WG-Zugang verliert, kommt per SSH nicht + mehr auf den Host — Empfehlung, vorher einen zweiten WG-Peer als Backup-Zugang anzulegen. Auf das + Dashboard kommt man auch ohne SSH weiter über den Tunnel. Bestehende SSH-Sitzungen bleiben + verbunden; ein nicht laufender Tunnel verhindert das Aktivieren. Neue CLI-Notausgänge: + `clusev wg ssh-lock` / `clusev wg ssh-unlock`, und `clusev wg down` (Server-Konsole) hebt alle + Sperren auf. Die Sperre übersteht Neustarts nur bei aktivem Tunnel — kommt wg0 nicht hoch, bleibt + SSH offen (kein Aussperren durch einen kaputten Boot). + ## [0.9.39] - 2026-06-21 ### Geändert diff --git a/app/Livewire/Wireguard/Index.php b/app/Livewire/Wireguard/Index.php index 5826be2..4d778fc 100644 --- a/app/Livewire/Wireguard/Index.php +++ b/app/Livewire/Wireguard/Index.php @@ -13,6 +13,7 @@ use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Layout; use Livewire\Attributes\On; use Livewire\Component; +use Symfony\Component\HttpFoundation\StreamedResponse; /** * WireGuard dashboard — live status (P1) + traffic (P2) + peer management (P3). Reads the @@ -210,6 +211,50 @@ class Index extends Component $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->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']), + ], + ); + } + + #[On('wgSshGate')] + public function applySshGate(string $confirmToken, WgBridge $bridge): void + { + try { + $payload = ConfirmToken::consume($confirmToken, 'wgSshGate'); + } catch (InvalidConfirmToken) { + return; + } + + $this->runSshGate(($payload['params']['on'] ?? '0') === '1', $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( @@ -334,6 +379,19 @@ class Index extends Component $this->resultQr = null; } + /** Download the show-once client config as a .conf file (for importing on a PC). */ + public function downloadConfig(): ?StreamedResponse + { + if ($this->resultConfig === null) { + return null; + } + $cfg = $this->resultConfig; + + return response()->streamDownload(function () use ($cfg) { + echo $cfg; + }, 'clusev-wireguard.conf', ['Content-Type' => 'text/plain; charset=UTF-8']); + } + private function throttle(): bool { $key = 'wg-request:'.Auth::id(); diff --git a/app/Services/WgBridge.php b/app/Services/WgBridge.php index 09af78a..d9ec778 100644 --- a/app/Services/WgBridge.php +++ b/app/Services/WgBridge.php @@ -12,7 +12,7 @@ use Illuminate\Support\Str; class WgBridge { /** Actions the UI may request; the host whitelists the same set. */ - public const ACTIONS = ['add-peer', 'remove-peer', 'gate-up', 'gate-down', 'set-endpoint', 'set-port', 'set-subnet', 'setup']; + public const ACTIONS = ['add-peer', 'remove-peer', 'gate-up', 'gate-down', 'gate-ssh-on', 'gate-ssh-off', 'set-endpoint', 'set-port', 'set-subnet', 'setup']; private function dir(): string { @@ -88,6 +88,8 @@ class WgBridge return ['name' => $name]; case 'gate-up': case 'gate-down': + case 'gate-ssh-on': + case 'gate-ssh-off': return []; case 'set-endpoint': $ep = (string) ($args['endpoint'] ?? ''); diff --git a/app/Services/WgStatus.php b/app/Services/WgStatus.php index 73e12d1..43dd273 100644 --- a/app/Services/WgStatus.php +++ b/app/Services/WgStatus.php @@ -63,6 +63,7 @@ class WgStatus 'gate' => [ 'marker' => (bool) ($data['gate']['marker'] ?? false), 'chain' => (bool) ($data['gate']['chain'] ?? false), + 'ssh' => (bool) ($data['gate']['ssh'] ?? false), ], 'wg_quick' => (string) ($data['wg_quick'] ?? 'inactive'), 'server' => [ diff --git a/app/Support/Confirm/ConfirmToken.php b/app/Support/Confirm/ConfirmToken.php index f3aac14..78bfcae 100644 --- a/app/Support/Confirm/ConfirmToken.php +++ b/app/Support/Confirm/ConfirmToken.php @@ -58,6 +58,7 @@ class ConfirmToken 'bansClearedAll', 'wgPeerRemoved', 'wgGateToggle', + 'wgSshGate', 'wgSetPort', 'wgSetSubnet', ]; diff --git a/config/clusev.php b/config/clusev.php index cb82ff1..aa906f9 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -2,7 +2,7 @@ return [ // First tagged release is v0.1.0 (semantic, not -dev). - 'version' => '0.9.39', + 'version' => '0.9.40', // Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod // image ships no .git); the Versions page prefers them and falls back to a live .git read in diff --git a/docker/wg/clusev-wg.sh b/docker/wg/clusev-wg.sh index 5a975f9..5027cee 100755 --- a/docker/wg/clusev-wg.sh +++ b/docker/wg/clusev-wg.sh @@ -14,6 +14,12 @@ STATE_DIR="/etc/clusev" WG_ENV="${STATE_DIR}/wg.env" GATE_MARKER="${STATE_DIR}/wg-gate.enabled" PANEL_PORTS="80,443" # see spec §3 post-DNAT note: matches the published container ports (Caddy 80/443) +# OPTIONAL SSH gate (separate, dangerous): SSH (22) is a HOST service — not Docker-published — so it +# can't be filtered via DOCKER-USER; it needs a dedicated chain on the host INPUT chain. ESTABLISHED +# sessions + the wg0 interface + the WG subnet are always allowed, so enabling it never cuts the +# 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" # 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 -> ../..). @@ -81,6 +87,53 @@ gate_remove() { iptables -X "$GATE_CHAIN" 2>/dev/null || true } +# ── the SSH gate (optional, host INPUT chain) ─────────────────────────────────────────────────── +ssh_gate_apply() { + # No-op unless the operator enabled it. Applied alongside the panel gate by the boot unit + `up`. + # Every guard below is FAIL-OPEN (return 0 = no DROP installed = SSH stays reachable): a missing + # marker, no iptables, a tunnel that is down, or a corrupt subnet must NEVER wall off SSH. + [ -f "$SSH_MARKER" ] || return 0 + have iptables || return 0 + # Self-guard (defence-in-depth; the boot unit also has ConditionPathExists=wg0): never install the + # DROP while the tunnel interface is down — that is exactly when it would lock the operator out. + [ -e "/sys/class/net/${WG_IF}" ] || return 0 + # shellcheck disable=SC1090 + [ -f "$WG_ENV" ] && . "$WG_ENV" + [ -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 + 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 + iptables -A "$SSH_CHAIN" -i "$WG_IF" -j RETURN # SSH over the tunnel + iptables -A "$SSH_CHAIN" -s "$WG_SUBNET" -j RETURN # belt + suspenders + iptables -A "$SSH_CHAIN" -p tcp --dport 22 -j DROP # everyone else + iptables -D INPUT -p tcp --dport 22 -j "$SSH_CHAIN" 2>/dev/null || true + iptables -I INPUT 1 -p tcp --dport 22 -j "$SSH_CHAIN" +} + +ssh_gate_remove() { + have iptables || return 0 + iptables -D INPUT -p tcp --dport 22 -j "$SSH_CHAIN" 2>/dev/null || true + iptables -F "$SSH_CHAIN" 2>/dev/null || true + iptables -X "$SSH_CHAIN" 2>/dev/null || true +} + +# Cores: enable/disable the SSH gate (marker + apply/remove). Refuse to enable if wg0 is down (so a +# broken tunnel can't be the moment you also wall off SSH). Errors to stderr. +_ssh_gate_on() { + require_setup + [ -e "/sys/class/net/${WG_IF}" ] || { echo "wg0 nicht aktiv" >&2; return 1; } + mkdir -p "$STATE_DIR" + printf 'enabled\n' > "$SSH_MARKER" + ssh_gate_apply +} +_ssh_gate_off() { + rm -f "$SSH_MARKER" + ssh_gate_remove +} + # ── commands ────────────────────────────────────────────────────────────────────────────────── # Core: create a peer + print ONLY the client config to stdout (errors to stderr, non-zero on fail). @@ -182,9 +235,30 @@ cmd_up() { cmd_down() { need_root down - rm -f "$GATE_MARKER" + # Full escape: removes BOTH gates (panel + the optional SSH gate) + their markers. + rm -f "$GATE_MARKER" "$SSH_MARKER" gate_remove + ssh_gate_remove bold "WireGuard-Gate entfernt — Panel (80/443) wieder oeffentlich erreichbar (vorbehaltlich Cloud-/Host-Firewall)." + info "SSH-Sperre (falls aktiv) ebenfalls entfernt." +} + +cmd_ssh_lock() { + need_root ssh-lock + warn "SSH (22) wird gesperrt — danach erreichst du den Host nur noch UEBER DEN TUNNEL oder die Server-Konsole." + warn "Verlierst du deinen WG-Zugang, kommst du per SSH NICHT mehr rein. Lege dir vorher einen zweiten WG-Peer als Backup an." + if _ssh_gate_on; then + bold "SSH-Sperre aktiv — Port 22 nur noch ueber wg0/${WG_IF} bzw. das WG-Subnetz erreichbar." + info "Aufheben: 'clusev wg ssh-unlock' (oder 'clusev wg down' fuer alle Gates) — von der Server-Konsole aus immer moeglich." + else + die "SSH-Sperre fehlgeschlagen (wg0 aktiv? Setup vorhanden?)." + fi +} + +cmd_ssh_unlock() { + need_root ssh-unlock + _ssh_gate_off + bold "SSH-Sperre entfernt — Port 22 wieder normal erreichbar (vorbehaltlich Cloud-/Host-Firewall)." } next_free_ip() { @@ -208,8 +282,9 @@ cmd_status() { if [ -e "/sys/class/net/${WG_IF}" ]; then wg show "$WG_IF" 2>/dev/null || true; else info "wg0 nicht aktiv (Setup/Tunnel noch nicht gestartet)."; fi echo bold "Gate" - if [ -f "$GATE_MARKER" ]; then info "Marker: aktiv (${GATE_MARKER})"; else info "Marker: aus"; fi + if [ -f "$GATE_MARKER" ]; then info "Panel-Marker: aktiv (${GATE_MARKER})"; else info "Panel-Marker: aus"; fi if iptables -nL "$GATE_CHAIN" >/dev/null 2>&1; then info "Chain ${GATE_CHAIN}: vorhanden"; else info "Chain ${GATE_CHAIN}: nicht vorhanden"; fi + if [ -f "$SSH_MARKER" ]; then info "SSH-Sperre: AKTIV (${SSH_MARKER}) — Notausgang nur ueber den Tunnel/Konsole"; else info "SSH-Sperre: aus"; fi echo bold "Dienst wg-quick@${WG_IF}" systemctl is-active "wg-quick@${WG_IF}" 2>/dev/null || true @@ -375,8 +450,10 @@ cmd_serve_request() { if valid_name "$name"; then if _peer_remove "$name" >/dev/null 2>&1; then ok=true; else msg='remove-failed'; fi else msg='invalid-name'; fi ;; - gate-up) if _gate_up_now >/dev/null 2>&1; then ok=true; else msg='gate-up-failed'; fi ;; - gate-down) if cmd_down >/dev/null 2>&1; then ok=true; else msg='gate-down-failed'; fi ;; + gate-up) if _gate_up_now >/dev/null 2>&1; then ok=true; else msg='gate-up-failed'; fi ;; + gate-down) { rm -f "$GATE_MARKER"; gate_remove; } >/dev/null 2>&1 && ok=true || msg='gate-down-failed' ;; # panel only; SSH gate is its own toggle + gate-ssh-on) if _ssh_gate_on >/dev/null 2>&1; then ok=true; else msg='ssh-gate-on-failed'; fi ;; + gate-ssh-off) if _ssh_gate_off >/dev/null 2>&1; then ok=true; else msg='ssh-gate-off-failed'; fi ;; set-endpoint) if [ -n "$endpoint" ] && _set_endpoint "$endpoint" >/dev/null 2>&1; then ok=true; else msg='set-endpoint-failed'; fi ;; set-port) if [ -n "$port" ] && _set_port "$port" >/dev/null 2>&1; then ok=true; else msg='set-port-failed'; fi ;; set-subnet) if [ -n "$subnet" ] && _set_subnet "$subnet" >/dev/null 2>&1; then ok=true; else msg='set-subnet-failed'; fi ;; @@ -463,9 +540,10 @@ cmd_collect() { # shellcheck disable=SC1090 [ -f "$WG_ENV" ] && . "$WG_ENV" - local gate_marker=false gate_chain=false wgq + local gate_marker=false gate_chain=false ssh_marker=false wgq [ -f "$GATE_MARKER" ] && gate_marker=true iptables -nL "$GATE_CHAIN" >/dev/null 2>&1 && gate_chain=true + [ -f "$SSH_MARKER" ] && ssh_marker=true wgq="$(systemctl is-active "wg-quick@${WG_IF}" 2>/dev/null || echo inactive)" # Peers from `wg show wg0 dump`, named via the wg0.conf `# clusev-peer:` comments. Peer names are @@ -492,8 +570,8 @@ cmd_collect() { ')" [ -n "$peers_json" ] || peers_json="[]" - printf '{"at":%s,"configured":true,"gate":{"marker":%s,"chain":%s},"wg_quick":"%s","server":{"subnet":"%s","server_ip":"%s","port":%s,"endpoint":"%s","pubkey":"%s"},"peers":%s}\n' \ - "$now" "$gate_marker" "$gate_chain" "$wgq" \ + printf '{"at":%s,"configured":true,"gate":{"marker":%s,"chain":%s,"ssh":%s},"wg_quick":"%s","server":{"subnet":"%s","server_ip":"%s","port":%s,"endpoint":"%s","pubkey":"%s"},"peers":%s}\n' \ + "$now" "$gate_marker" "$gate_chain" "$ssh_marker" "$wgq" \ "${WG_SUBNET:-}" "${WG_SERVER_IP:-}" "${WG_PORT:-0}" "${WG_ENDPOINT:-}" "${WG_SERVER_PUBKEY:-}" \ "$peers_json" > "$tmp" mv -f "$tmp" "$dest" 2>/dev/null || { rm -f "$tmp"; return 0; } @@ -513,8 +591,11 @@ clusev wg — WireGuard-Zugang (Host) clusev wg set-endpoint oeffentlichen Endpoint aendern clusev wg set-port Listen-Port aendern (Clients neu) clusev wg set-subnet Subnetz aendern (Peers neu anlegen) + clusev wg ssh-lock SSH (22) sperren — nur noch ueber den Tunnel (Vorsicht: Aussperr-Gefahr!) + clusev wg ssh-unlock SSH-Sperre wieder aufheben - SSH (22) und der WG-Port bleiben IMMER offen. 'clusev wg down' per SSH ist der Notausgang. + Der WG-Port bleibt IMMER offen. 'clusev wg down' (Server-Konsole) ist der Notausgang fuer ALLE Gates. + ssh-lock kann dich aussperren — vorher einen zweiten WG-Peer als Backup-Zugang anlegen. EOF } @@ -529,7 +610,9 @@ case "$cmd" in set-endpoint) cmd_set_endpoint "$@" ;; set-port) cmd_set_port "$@" ;; set-subnet) cmd_set_subnet "$@" ;; - gate-apply) need_root gate-apply; gate_apply ;; # called by clusev-wg-gate.service + ssh-lock) cmd_ssh_lock "$@" ;; + ssh-unlock) cmd_ssh_unlock "$@" ;; + gate-apply) need_root gate-apply; gate_apply; ssh_gate_apply ;; # called by clusev-wg-gate.service (both gates re-applied on boot) collect) cmd_collect ;; # called by clusev-wg-collect.timer (read-only; no root needed) serve-request) cmd_serve_request ;; # called by clusev-wg-request.service help|-h|--help) usage ;; diff --git a/lang/de/wireguard.php b/lang/de/wireguard.php index 24ecc90..b59be62 100644 --- a/lang/de/wireguard.php +++ b/lang/de/wireguard.php @@ -46,6 +46,8 @@ 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', + 'download' => 'Herunterladen', + 'import_hint' => 'QR-Code mit der WireGuard-App scannen (Handy) oder Konfiguration herunterladen und importieren (PC).', 'settings_title' => 'Einstellungen', 'apply' => 'Anwenden', 'gate_toggle' => 'Gate', @@ -55,6 +57,16 @@ return [ '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).', + 'ssh_lock' => 'SSH-Sperre (Port 22)', + 'ssh_lock_on_state' => 'aktiv — SSH nur über den Tunnel', + 'ssh_lock_off_state' => 'aus — SSH normal erreichbar', + 'ssh_lock_turn_on' => 'SSH sperren', + 'ssh_lock_turn_off' => 'Sperre aufheben', + 'ssh_lock_hint' => 'Sperrt zusätzlich SSH (Port 22) auf den Tunnel. WARNUNG: Verlierst du deinen WG-Zugang, kommst du per SSH NICHT mehr auf den Host — nur noch über die Server-Konsole ("clusev wg down"). Lege dir vorher einen zweiten WG-Peer als Backup-Zugang an. Auf das Dashboard kommst du auch ohne SSH weiter über den Tunnel.', + 'ssh_lock_on_title' => 'SSH wirklich sperren?', + 'ssh_lock_on_body' => 'Danach ist Port 22 nur noch über den Tunnel erreichbar. Geht dein WG-Zugang verloren, sperrst du dich selbst per SSH aus — Rettung nur über die Server-Konsole. Empfehlung: vorher einen zweiten WG-Peer als Backup anlegen. Bestehende SSH-Sitzungen bleiben verbunden.', + 'ssh_lock_off_title' => 'SSH-Sperre aufheben?', + 'ssh_lock_off_body' => 'Port 22 wird danach wieder normal erreichbar (vorbehaltlich einer Cloud-/Host-Firewall).', 'endpoint_label' => 'Öffentlicher Endpoint', 'endpoint_hint' => 'IP oder DNS:Port — betrifft nur künftige Peer-Configs.', 'endpoint_invalid' => 'Ungültiger Endpoint.', diff --git a/lang/en/wireguard.php b/lang/en/wireguard.php index 0683568..2e2ad41 100644 --- a/lang/en/wireguard.php +++ b/lang/en/wireguard.php @@ -46,6 +46,8 @@ 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', + 'download' => 'Download', + 'import_hint' => 'Scan the QR code with the WireGuard app (phone) or download the configuration and import it (PC).', 'settings_title' => 'Settings', 'apply' => 'Apply', 'gate_toggle' => 'Gate', @@ -55,6 +57,16 @@ return [ '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).', + 'ssh_lock' => 'SSH lock (port 22)', + 'ssh_lock_on_state' => 'on — SSH only through the tunnel', + 'ssh_lock_off_state' => 'off — SSH reachable normally', + 'ssh_lock_turn_on' => 'Lock SSH', + 'ssh_lock_turn_off' => 'Unlock', + 'ssh_lock_hint' => 'Also locks SSH (port 22) to the tunnel. WARNING: if you lose your WG access you can NO LONGER reach the host over SSH — only through the server console ("clusev wg down"). Create a second WG peer as a backup first. You can still reach the dashboard through the tunnel without SSH.', + 'ssh_lock_on_title' => 'Really lock SSH?', + 'ssh_lock_on_body' => 'Port 22 will then only be reachable through the tunnel. If you lose your WG access you lock yourself out of SSH — recovery only via the server console. Recommended: create a second WG peer as a backup first. Existing SSH sessions stay connected.', + 'ssh_lock_off_title' => 'Unlock SSH?', + 'ssh_lock_off_body' => 'Port 22 becomes reachable normally again (subject to any cloud/host firewall).', 'endpoint_label' => 'Public endpoint', 'endpoint_hint' => 'IP or DNS:port — affects only future peer configs.', 'endpoint_invalid' => 'Invalid endpoint.', diff --git a/resources/views/components/badge.blade.php b/resources/views/components/badge.blade.php index ad765b3..0f91072 100644 --- a/resources/views/components/badge.blade.php +++ b/resources/views/components/badge.blade.php @@ -4,6 +4,7 @@ 'neutral' => 'text-ink-2 border-line bg-line', 'accent' => 'text-accent-text border-accent/25 bg-accent/10', 'cyan' => 'text-cyan-bright border-cyan/20 bg-cyan/10', + 'warning' => 'text-warning border-warning/25 bg-warning/10', ][$tone] ?? 'text-ink-2 border-line bg-line'; @endphp merge(['class' => "inline-flex items-center gap-1 rounded-sm border px-2 py-0.5 font-mono text-[11px] $c"]) }}>{{ $slot }} diff --git a/resources/views/livewire/wireguard/index.blade.php b/resources/views/livewire/wireguard/index.blade.php index 73de003..d526802 100644 --- a/resources/views/livewire/wireguard/index.blade.php +++ b/resources/views/livewire/wireguard/index.blade.php @@ -197,6 +197,30 @@ + {{-- SSH lock (port 22) — lock-out risk, warning shown inline (not just in the modal) --}} +
+
+
+ {{ __('wireguard.ssh_lock') }} + @if ($status['gate']['ssh']) +
+ {{ __('wireguard.ssh_lock_on_state') }} + {{ __('wireguard.ssh_lock_turn_off') }} +
+ @else +
+ {{ __('wireguard.ssh_lock_off_state') }} + {{ __('wireguard.ssh_lock_turn_on') }} +
+ @endif +
+

+ + {{ __('wireguard.ssh_lock_hint') }} +

+
+
+ {{-- Endpoint --}}
@@ -252,11 +276,15 @@

{{ __('wireguard.new_peer_title') }}

{{ __('wireguard.new_peer_once') }}

+

{{ __('wireguard.import_hint') }}

@if ($resultQr)
{!! $resultQr !!}
@endif
{{ $resultConfig }}
-
+
+ + {{ __('wireguard.download') }} + {{ __('wireguard.close') }}
diff --git a/tests/Feature/WireguardPageTest.php b/tests/Feature/WireguardPageTest.php index 7e8c239..5b89f9d 100644 --- a/tests/Feature/WireguardPageTest.php +++ b/tests/Feature/WireguardPageTest.php @@ -56,6 +56,21 @@ class WireguardPageTest extends TestCase ->assertSee('10.99.0.0/24'); } + public function test_shows_ssh_lock_active_state_when_the_host_reports_it(): void + { + file_put_contents($this->file, json_encode([ + 'at' => time(), 'configured' => true, + 'gate' => ['marker' => true, 'chain' => true, 'ssh' => true], 'wg_quick' => 'active', + 'server' => ['subnet' => '10.99.0.0/24', 'server_ip' => '10.99.0.1', 'port' => 51820, 'endpoint' => '1.2.3.4:51820', 'pubkey' => 'srvpub'], + 'peers' => [], + ])); + + Livewire::test(Index::class) + ->assertViewHas('status', fn ($s) => $s['gate']['ssh'] === true) + ->assertSee(__('wireguard.ssh_lock')) + ->assertSee(__('wireguard.ssh_lock_turn_off')); + } + public function test_window_selector_switches_and_passes_traffic(): void { WgTrafficSample::create(['peer_pubkey' => 'p1', 'peer_name' => 'a', 'rx' => 0, 'tx' => 0, 'sampled_at' => now()->subMinutes(10)]); @@ -132,6 +147,34 @@ class WireguardPageTest extends TestCase @unlink(storage_path('app/restart-signal/wg-request.json')); } + public function test_run_ssh_gate_lock_writes_the_right_action_and_audits(): void + { + Livewire::test(Index::class) + ->call('runSshGate', true) + ->assertSet('pendingId', fn ($v) => is_string($v) && $v !== '') + ->assertSet('pendingAction', 'gate-ssh-on'); + $this->assertTrue(AuditEvent::where('action', 'wg.ssh-lock')->exists()); + @unlink(storage_path('app/restart-signal/wg-request.json')); + } + + public function test_run_ssh_gate_unlock_writes_the_right_action_and_audits(): void + { + Livewire::test(Index::class) + ->call('runSshGate', false) + ->assertSet('pendingAction', 'gate-ssh-off'); + $this->assertTrue(AuditEvent::where('action', 'wg.ssh-unlock')->exists()); + @unlink(storage_path('app/restart-signal/wg-request.json')); + } + + public function test_confirm_ssh_gate_opens_the_modal_without_auditing(): void + { + Livewire::test(Index::class) + ->call('confirmSshGate', true) + ->assertDispatched('openModal'); + // The confirm step must NOT audit — only the apply path (runSshGate) does. + $this->assertSame(0, AuditEvent::where('action', 'wg.ssh-lock')->count()); + } + public function test_poll_result_surfaces_the_config_once(): void { $c = Livewire::test(Index::class)->set('newPeer', 'laptop')->call('addPeer'); @@ -162,6 +205,14 @@ class WireguardPageTest extends TestCase ->assertHasErrors('setupSubnet'); } + public function test_download_config_streams_a_conf_file(): void + { + Livewire::test(Index::class) + ->set('resultConfig', "[Interface]\nPrivateKey = x") + ->call('downloadConfig') + ->assertFileDownloaded('clusev-wireguard.conf'); + } + public function test_poll_result_times_out_when_the_host_does_not_respond(): void { // A pending request with no host result for >30s must stop spinning, not poll forever.