feat(wireguard): SSH lock + peer config download (v0.9.40)
Two dashboard additions to the WireGuard page: - Peer config download: the show-once new-client view gets a "Download" button next to the QR code (streams clusev-wireguard.conf) plus an inline hint explaining QR-on-phone vs download-on-PC import. - SSH lock (port 22): an optional toggle in WireGuard settings that walls off host SSH to the tunnel via a dedicated CLUSEV-WG-SSH chain on the host INPUT chain. Fail-open by construction: ESTABLISHED,RELATED + lo + wg0 + WG-subnet RETURN before the DROP (live sessions survive, SSH over the tunnel stays open); _ssh_gate_on refuses while wg0 is down; the boot unit's ConditionPathExists=wg0 and an in-function self-guard keep a broken tunnel from ever applying the DROP; ssh_gate_apply re-validates the subnet shape and fails open on a corrupt wg.env. Strong lock-out warning shown inline AND in the confirm modal (recommend a backup peer). New CLI escapes: clusev wg ssh-lock / ssh-unlock; clusev wg down clears both gates. Panel gate and SSH gate are independent toggles. Write-bridge: new gate-ssh-on/gate-ssh-off actions (no args, host whitelist + ConfirmToken wgSshGate, single-use uid-bound). Status collector now reports gate.ssh. Adversarial lock-out review: all 8 failure modes refuted. 348 tests pass, shellcheck clean, Pint clean, R12 verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation v0.9.40
parent
94425f93e8
commit
2b245c3d3a
18
CHANGELOG.md
18
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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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'] ?? '');
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ class ConfirmToken
|
|||
'bansClearedAll',
|
||||
'wgPeerRemoved',
|
||||
'wgGateToggle',
|
||||
'wgSshGate',
|
||||
'wgSetPort',
|
||||
'wgSetSubnet',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <ip:port> oeffentlichen Endpoint aendern
|
||||
clusev wg set-port <udp> Listen-Port aendern (Clients neu)
|
||||
clusev wg set-subnet <cidr> 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 ;;
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
<span {{ $attributes->merge(['class' => "inline-flex items-center gap-1 rounded-sm border px-2 py-0.5 font-mono text-[11px] $c"]) }}>{{ $slot }}</span>
|
||||
|
|
|
|||
|
|
@ -197,6 +197,30 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{{-- SSH lock (port 22) — lock-out risk, warning shown inline (not just in the modal) --}}
|
||||
<div class="px-4 py-3.5 sm:px-5">
|
||||
<div class="space-y-2">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="font-mono text-[11px] text-ink-3">{{ __('wireguard.ssh_lock') }}</span>
|
||||
@if ($status['gate']['ssh'])
|
||||
<div class="flex items-center gap-2">
|
||||
<x-badge tone="warning">{{ __('wireguard.ssh_lock_on_state') }}</x-badge>
|
||||
<x-btn variant="accent" size="sm" wire:click="confirmSshGate(false)">{{ __('wireguard.ssh_lock_turn_off') }}</x-btn>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex items-center gap-2">
|
||||
<x-badge tone="neutral">{{ __('wireguard.ssh_lock_off_state') }}</x-badge>
|
||||
<x-btn variant="danger-soft" size="sm" wire:click="confirmSshGate(true)">{{ __('wireguard.ssh_lock_turn_on') }}</x-btn>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<p class="flex gap-1.5 rounded-md border border-warning/25 bg-warning/5 px-2.5 py-2 font-mono text-[11px] leading-relaxed text-ink-3">
|
||||
<x-icon name="alert" class="mt-px size-3.5 shrink-0 text-warning" />
|
||||
<span>{{ __('wireguard.ssh_lock_hint') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Endpoint --}}
|
||||
<div class="px-4 py-3.5 sm:px-5">
|
||||
<form wire:submit="setEndpoint" class="space-y-2">
|
||||
|
|
@ -252,11 +276,15 @@
|
|||
<div class="w-full max-w-lg rounded-xl border border-line bg-surface p-6 shadow-panel">
|
||||
<h3 class="font-display text-lg font-semibold text-ink">{{ __('wireguard.new_peer_title') }}</h3>
|
||||
<p class="mt-1 text-sm text-warning">{{ __('wireguard.new_peer_once') }}</p>
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('wireguard.import_hint') }}</p>
|
||||
@if ($resultQr)
|
||||
<div class="mx-auto mt-4 h-44 w-44 [&>svg]:h-full [&>svg]:w-full">{!! $resultQr !!}</div>
|
||||
@endif
|
||||
<pre class="mt-4 max-h-48 overflow-auto rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] leading-relaxed text-ink-2">{{ $resultConfig }}</pre>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<x-btn variant="primary" wire:click="downloadConfig">
|
||||
<x-icon name="save" class="h-3.5 w-3.5" />{{ __('wireguard.download') }}
|
||||
</x-btn>
|
||||
<x-btn variant="secondary" wire:click="dismissResult">{{ __('wireguard.close') }}</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue