feat(wg): configurable DNS + Server/Peers tabs + faster offline (v0.9.45)
Three things the operator asked for on the WireGuard page:
- DNS is no longer hardcoded to 1.1.1.1. New host action set-dns (_set_dns +
cmd_set_dns + write-bridge branch + WgBridge validation, IPv4 list only),
stored as WG_DNS in wg.env (appended for pre-existing tunnels), surfaced via
the collector + WgStatus, baked into new peer configs (DNS = ${WG_DNS:-1.1.1.1}).
Editable in the Server tab; e.g. point clients at your own gateway.
- The page is split into two independent tabs: "Peers" (list / add / remove /
traffic) and "Server configuration" (endpoint / DNS / port / subnet / gate /
SSH lock + server identity). #[Url] $tab makes it deep-linkable (?tab=server).
- Offline detection: ONLINE_WITHIN 180s -> 150s (PersistentKeepalive=25 keeps
active peers re-handshaking well within it, so no flapping; a disconnect now
shows offline ~2.5 min sooner). Endpoint hint clarified: the port is optional
(auto-appended), no need to repeat the listen port.
Tests: set-dns (write+audit, reject), tab switch/clamp, dns in status; the two
configured-render tests now switch to the server tab for server content. 353
pass, shellcheck clean, Pint clean, both tabs load 200 with no console errors,
lang parity 89/89.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
v0.9.45
parent
920dfb02bf
commit
aaa64c3df4
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -13,7 +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._
|
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
|
||||||
|
|
||||||
## [0.9.44] - 2026-06-21
|
## [0.9.45] - 2026-06-21
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- **DNS individuell einstellbar.** Der DNS-Server in den Client-Configs war fest auf `1.1.1.1`. Jetzt
|
||||||
|
in der Server-Konfiguration frei wählbar (z. B. dein eigenes Gateway `10.0.0.1`, mehrere
|
||||||
|
kommagetrennt) — gilt für künftige Peer-Configs, host-seitig validiert (nur IPv4). Auch per CLI:
|
||||||
|
`clusev wg set-dns <ip[,ip]>`. Bestehende Tunnel bekommen den Wert beim ersten Setzen ergänzt.
|
||||||
|
- **WireGuard-Seite in Tabs aufgeteilt.** Zwei unabhängige Reiter: **Peers** (Liste, Anlegen,
|
||||||
|
Entfernen, Traffic) und **Server-Konfiguration** (Endpoint, DNS, Port, Subnetz, Gate, SSH-Sperre +
|
||||||
|
Server-Identität). Der aktive Tab ist deep-linkbar (`?tab=server`). So lassen sich Server-Einstellungen
|
||||||
|
getrennt von der Peer-Verwaltung ändern.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- **Offline-Erkennung schneller.** Das Handshake-Fenster für „online" wurde von 180 s auf 150 s
|
||||||
|
gesenkt — ein abgeschalteter Peer wird ~2,5 statt ~3 min nach dem Trennen offline (WireGuard ist
|
||||||
|
verbindungslos, schneller geht es ohne Flackern aktiver Peers nicht).
|
||||||
|
- **Endpoint-Hinweis klargestellt:** der Port ist optional (fehlt er, wird der Listen-Port ergänzt) —
|
||||||
|
man muss ihn nicht doppelt zum Listen-Port eintragen.
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
- **Peer-QR-Code kompakter.** Nach der Vergrößerung in 0.9.42 war der QR zu groß; er ist jetzt auf
|
- **Peer-QR-Code kompakter.** Nach der Vergrößerung in 0.9.42 war der QR zu groß; er ist jetzt auf
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Attributes\On;
|
use Livewire\Attributes\On;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
|
@ -29,6 +30,10 @@ class Index extends Component
|
||||||
|
|
||||||
public int $window = self::WINDOWS[0];
|
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 $newPeer = '';
|
||||||
|
|
||||||
public string $newEndpoint = '';
|
public string $newEndpoint = '';
|
||||||
|
|
@ -37,6 +42,8 @@ class Index extends Component
|
||||||
|
|
||||||
public string $newSubnet = '';
|
public string $newSubnet = '';
|
||||||
|
|
||||||
|
public string $newDns = '';
|
||||||
|
|
||||||
// first-time setup form
|
// first-time setup form
|
||||||
public string $setupSubnet = '10.99.0.0/24';
|
public string $setupSubnet = '10.99.0.0/24';
|
||||||
|
|
||||||
|
|
@ -69,6 +76,11 @@ class Index extends Component
|
||||||
$this->window = $this->clampWindow($seconds);
|
$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
|
public function addPeer(WgBridge $bridge): void
|
||||||
{
|
{
|
||||||
$this->validate(['newPeer' => ['required', 'regex:'.self::PEER_NAME_RE]], [
|
$this->validate(['newPeer' => ['required', 'regex:'.self::PEER_NAME_RE]], [
|
||||||
|
|
@ -171,6 +183,22 @@ class Index extends Component
|
||||||
$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
|
public function confirmGate(bool $on): void
|
||||||
{
|
{
|
||||||
$this->openConfirm('wgGateToggle', ['on' => $on ? '1' : '0'],
|
$this->openConfirm('wgGateToggle', ['on' => $on ? '1' : '0'],
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ use Illuminate\Support\Str;
|
||||||
class WgBridge
|
class WgBridge
|
||||||
{
|
{
|
||||||
/** Actions the UI may request; the host whitelists the same set. */
|
/** Actions the UI may request; the host whitelists the same set. */
|
||||||
public const ACTIONS = ['add-peer', 'remove-peer', 'gate-up', 'gate-down', 'gate-ssh-on', 'gate-ssh-off', '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', 'set-dns', 'setup'];
|
||||||
|
|
||||||
private function dir(): string
|
private function dir(): string
|
||||||
{
|
{
|
||||||
|
|
@ -112,6 +112,14 @@ class WgBridge
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['subnet' => $subnet];
|
return ['subnet' => $subnet];
|
||||||
|
case 'set-dns':
|
||||||
|
$dns = (string) ($args['dns'] ?? '');
|
||||||
|
// one or more IPv4 addresses, comma/space separated
|
||||||
|
if (preg_match('/^\d{1,3}(\.\d{1,3}){3}([,\s]+\d{1,3}(\.\d{1,3}){3})*$/', $dns) !== 1) {
|
||||||
|
throw new \InvalidArgumentException('invalid dns');
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['dns' => $dns];
|
||||||
case 'setup':
|
case 'setup':
|
||||||
$subnet = (string) ($args['subnet'] ?? '');
|
$subnet = (string) ($args['subnet'] ?? '');
|
||||||
$port = (string) ($args['port'] ?? '');
|
$port = (string) ($args['port'] ?? '');
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,13 @@ class WgStatus
|
||||||
/** Status older than this (seconds) means the host collector is offline. */
|
/** Status older than this (seconds) means the host collector is offline. */
|
||||||
private const STALE_AFTER = 20;
|
private const STALE_AFTER = 20;
|
||||||
|
|
||||||
/** A peer with a handshake within this many seconds is considered online. */
|
/**
|
||||||
private const ONLINE_WITHIN = 180;
|
* A peer with a handshake within this many seconds is considered online. WireGuard is
|
||||||
|
* connectionless, so "offline" can only be inferred from the handshake ageing out. Client configs
|
||||||
|
* use PersistentKeepalive = 25, so an active peer re-handshakes well within this window; 150s is
|
||||||
|
* the floor that detects a disconnect ~2.5 min after it happens without flapping live peers.
|
||||||
|
*/
|
||||||
|
private const ONLINE_WITHIN = 150;
|
||||||
|
|
||||||
private function path(): string
|
private function path(): string
|
||||||
{
|
{
|
||||||
|
|
@ -71,6 +76,7 @@ class WgStatus
|
||||||
'server_ip' => (string) ($data['server']['server_ip'] ?? ''),
|
'server_ip' => (string) ($data['server']['server_ip'] ?? ''),
|
||||||
'port' => (int) ($data['server']['port'] ?? 0),
|
'port' => (int) ($data['server']['port'] ?? 0),
|
||||||
'endpoint' => (string) ($data['server']['endpoint'] ?? ''),
|
'endpoint' => (string) ($data['server']['endpoint'] ?? ''),
|
||||||
|
'dns' => (string) ($data['server']['dns'] ?? ''),
|
||||||
// Not rendered by the page, but part of the /wg-status.json response contract (API consumers).
|
// Not rendered by the page, but part of the /wg-status.json response contract (API consumers).
|
||||||
'pubkey' => (string) ($data['server']['pubkey'] ?? ''),
|
'pubkey' => (string) ($data['server']['pubkey'] ?? ''),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// First tagged release is v0.1.0 (semantic, not -dev).
|
// First tagged release is v0.1.0 (semantic, not -dev).
|
||||||
'version' => '0.9.44',
|
'version' => '0.9.45',
|
||||||
|
|
||||||
// Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod
|
// 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
|
// image ships no .git); the Versions page prefers them and falls back to a live .git read in
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@ EOF
|
||||||
[Interface]
|
[Interface]
|
||||||
PrivateKey = ${cpriv}
|
PrivateKey = ${cpriv}
|
||||||
Address = ${ip}/32
|
Address = ${ip}/32
|
||||||
DNS = 1.1.1.1
|
DNS = ${WG_DNS:-1.1.1.1}
|
||||||
|
|
||||||
[Peer]
|
[Peer]
|
||||||
PublicKey = ${WG_SERVER_PUBKEY}
|
PublicKey = ${WG_SERVER_PUBKEY}
|
||||||
|
|
@ -337,6 +337,7 @@ WG_SUBNET=${subnet}
|
||||||
WG_SERVER_IP=${server_ip}
|
WG_SERVER_IP=${server_ip}
|
||||||
WG_PORT=${port}
|
WG_PORT=${port}
|
||||||
WG_ENDPOINT=${endpoint}
|
WG_ENDPOINT=${endpoint}
|
||||||
|
WG_DNS=1.1.1.1
|
||||||
WG_SERVER_PUBKEY=${srv_pub}
|
WG_SERVER_PUBKEY=${srv_pub}
|
||||||
EOF
|
EOF
|
||||||
chmod 600 "$WG_ENV"
|
chmod 600 "$WG_ENV"
|
||||||
|
|
@ -432,10 +433,11 @@ cmd_serve_request() {
|
||||||
[ -n "$id" ] || return 0
|
[ -n "$id" ] || return 0
|
||||||
action="$(printf '%s' "$json" | grep -oE '"action":"[a-z-]{1,24}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
|
action="$(printf '%s' "$json" | grep -oE '"action":"[a-z-]{1,24}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
|
||||||
name="$(printf '%s' "$json" | grep -oE '"name":"[A-Za-z0-9._-]{1,64}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
|
name="$(printf '%s' "$json" | grep -oE '"name":"[A-Za-z0-9._-]{1,64}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
|
||||||
local endpoint port subnet
|
local endpoint port subnet dns
|
||||||
endpoint="$(printf '%s' "$json" | grep -oE '"endpoint":"[A-Za-z0-9.:_-]{1,128}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
|
endpoint="$(printf '%s' "$json" | grep -oE '"endpoint":"[A-Za-z0-9.:_-]{1,128}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
|
||||||
port="$(printf '%s' "$json" | grep -oE '"port":"[0-9]{1,5}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
|
port="$(printf '%s' "$json" | grep -oE '"port":"[0-9]{1,5}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
|
||||||
subnet="$(printf '%s' "$json" | grep -oE '"subnet":"[0-9./]{1,18}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
|
subnet="$(printf '%s' "$json" | grep -oE '"subnet":"[0-9./]{1,18}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
|
||||||
|
dns="$(printf '%s' "$json" | grep -oE '"dns":"[0-9., ]{1,128}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
|
||||||
|
|
||||||
local ok=false msg='ok' config=''
|
local ok=false msg='ok' config=''
|
||||||
case "$action" in
|
case "$action" in
|
||||||
|
|
@ -458,6 +460,7 @@ cmd_serve_request() {
|
||||||
set-endpoint) if [ -n "$endpoint" ] && ( _set_endpoint "$endpoint" ) >/dev/null 2>&1; then ok=true; else msg='set-endpoint-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-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 ;;
|
set-subnet) if [ -n "$subnet" ] && ( _set_subnet "$subnet" ) >/dev/null 2>&1; then ok=true; else msg='set-subnet-failed'; fi ;;
|
||||||
|
set-dns) if [ -n "$dns" ] && ( _set_dns "$dns" ) >/dev/null 2>&1; then ok=true; else msg='set-dns-failed'; fi ;;
|
||||||
setup)
|
setup)
|
||||||
local sip ep pn
|
local sip ep pn
|
||||||
sip="$(subnet_first_ip "$subnet" 2>/dev/null || true)"
|
sip="$(subnet_first_ip "$subnet" 2>/dev/null || true)"
|
||||||
|
|
@ -504,6 +507,19 @@ _set_endpoint() {
|
||||||
sed -i "s#^WG_ENDPOINT=.*#WG_ENDPOINT=${ep}#" "$WG_ENV"
|
sed -i "s#^WG_ENDPOINT=.*#WG_ENDPOINT=${ep}#" "$WG_ENV"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_set_dns() {
|
||||||
|
local dns="$1"; require_setup
|
||||||
|
# One or more IPv4 addresses, comma/space separated (e.g. "10.0.0.1" or "1.1.1.1, 1.0.0.1").
|
||||||
|
# Only affects NEW peer configs (the DNS line is baked into each client config at creation).
|
||||||
|
case "$dns" in *[!0-9.,\ ]*|'') echo "bad dns" >&2; return 1 ;; esac
|
||||||
|
printf '%s' "$dns" | grep -qE '^[0-9]{1,3}(\.[0-9]{1,3}){3}([,[:space:]]+[0-9]{1,3}(\.[0-9]{1,3}){3})*$' || { echo "bad dns" >&2; return 1; }
|
||||||
|
if grep -q '^WG_DNS=' "$WG_ENV" 2>/dev/null; then
|
||||||
|
sed -i "s#^WG_DNS=.*#WG_DNS=${dns}#" "$WG_ENV"
|
||||||
|
else
|
||||||
|
printf 'WG_DNS=%s\n' "$dns" >> "$WG_ENV" # older wg.env (pre-DNS) has no line yet
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
_set_port() {
|
_set_port() {
|
||||||
local port="$1"; require_setup
|
local port="$1"; require_setup
|
||||||
# Re-validated here: cmd_set_port passes $1 unchecked; also adds the 1-65535 range the bridge regex omits.
|
# Re-validated here: cmd_set_port passes $1 unchecked; also adds the 1-65535 range the bridge regex omits.
|
||||||
|
|
@ -532,6 +548,7 @@ _set_subnet() {
|
||||||
cmd_set_endpoint() { need_root set-endpoint; local v="${1:-}"; [ -n "$v" ] || die "Endpoint fehlt."; _set_endpoint "$v" || die "Endpoint setzen fehlgeschlagen."; bold "Endpoint gesetzt: ${v}"; }
|
cmd_set_endpoint() { need_root set-endpoint; local v="${1:-}"; [ -n "$v" ] || die "Endpoint fehlt."; _set_endpoint "$v" || die "Endpoint setzen fehlgeschlagen."; bold "Endpoint gesetzt: ${v}"; }
|
||||||
cmd_set_port() { need_root set-port; local v="${1:-}"; [ -n "$v" ] || die "Port fehlt."; _set_port "$v" || die "Port setzen fehlgeschlagen."; bold "Port gesetzt: ${v} (Clients muessen den Port aktualisieren)."; }
|
cmd_set_port() { need_root set-port; local v="${1:-}"; [ -n "$v" ] || die "Port fehlt."; _set_port "$v" || die "Port setzen fehlgeschlagen."; bold "Port gesetzt: ${v} (Clients muessen den Port aktualisieren)."; }
|
||||||
cmd_set_subnet() { need_root set-subnet; local v="${1:-}"; [ -n "$v" ] || die "Subnetz fehlt."; _set_subnet "$v" || die "Subnetz setzen fehlgeschlagen."; bold "Subnetz gesetzt: ${v} (bestehende Peers muessen neu angelegt werden)."; }
|
cmd_set_subnet() { need_root set-subnet; local v="${1:-}"; [ -n "$v" ] || die "Subnetz fehlt."; _set_subnet "$v" || die "Subnetz setzen fehlgeschlagen."; bold "Subnetz gesetzt: ${v} (bestehende Peers muessen neu angelegt werden)."; }
|
||||||
|
cmd_set_dns() { need_root set-dns; local v="${1:-}"; [ -n "$v" ] || die "DNS fehlt."; _set_dns "$v" || die "DNS ungueltig — nur IPv4, kommagetrennt."; bold "DNS gesetzt: ${v} (gilt fuer kuenftige Peer-Configs)."; }
|
||||||
|
|
||||||
cmd_collect() {
|
cmd_collect() {
|
||||||
mkdir -p "$RUN_DIR" 2>/dev/null || true
|
mkdir -p "$RUN_DIR" 2>/dev/null || true
|
||||||
|
|
@ -581,9 +598,9 @@ cmd_collect() {
|
||||||
')"
|
')"
|
||||||
[ -n "$peers_json" ] || peers_json="[]"
|
[ -n "$peers_json" ] || peers_json="[]"
|
||||||
|
|
||||||
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' \
|
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","dns":"%s","pubkey":"%s"},"peers":%s}\n' \
|
||||||
"$now" "$gate_marker" "$gate_chain" "$ssh_marker" "$wgq" \
|
"$now" "$gate_marker" "$gate_chain" "$ssh_marker" "$wgq" \
|
||||||
"${WG_SUBNET:-}" "${WG_SERVER_IP:-}" "${WG_PORT:-0}" "${WG_ENDPOINT:-}" "${WG_SERVER_PUBKEY:-}" \
|
"${WG_SUBNET:-}" "${WG_SERVER_IP:-}" "${WG_PORT:-0}" "${WG_ENDPOINT:-}" "${WG_DNS:-}" "${WG_SERVER_PUBKEY:-}" \
|
||||||
"$peers_json" > "$tmp"
|
"$peers_json" > "$tmp"
|
||||||
mv -f "$tmp" "$dest" 2>/dev/null || { rm -f "$tmp"; return 0; }
|
mv -f "$tmp" "$dest" 2>/dev/null || { rm -f "$tmp"; return 0; }
|
||||||
chmod 644 "$dest" 2>/dev/null || true
|
chmod 644 "$dest" 2>/dev/null || true
|
||||||
|
|
@ -602,6 +619,7 @@ clusev wg — WireGuard-Zugang (Host)
|
||||||
clusev wg set-endpoint <ip:port> oeffentlichen Endpoint aendern
|
clusev wg set-endpoint <ip:port> oeffentlichen Endpoint aendern
|
||||||
clusev wg set-port <udp> Listen-Port aendern (Clients neu)
|
clusev wg set-port <udp> Listen-Port aendern (Clients neu)
|
||||||
clusev wg set-subnet <cidr> Subnetz aendern (Peers neu anlegen)
|
clusev wg set-subnet <cidr> Subnetz aendern (Peers neu anlegen)
|
||||||
|
clusev wg set-dns <ip[,ip]> DNS fuer kuenftige Peer-Configs setzen
|
||||||
clusev wg ssh-lock SSH (22) sperren — nur noch ueber den Tunnel (Vorsicht: Aussperr-Gefahr!)
|
clusev wg ssh-lock SSH (22) sperren — nur noch ueber den Tunnel (Vorsicht: Aussperr-Gefahr!)
|
||||||
clusev wg ssh-unlock SSH-Sperre wieder aufheben
|
clusev wg ssh-unlock SSH-Sperre wieder aufheben
|
||||||
|
|
||||||
|
|
@ -621,6 +639,7 @@ case "$cmd" in
|
||||||
set-endpoint) cmd_set_endpoint "$@" ;;
|
set-endpoint) cmd_set_endpoint "$@" ;;
|
||||||
set-port) cmd_set_port "$@" ;;
|
set-port) cmd_set_port "$@" ;;
|
||||||
set-subnet) cmd_set_subnet "$@" ;;
|
set-subnet) cmd_set_subnet "$@" ;;
|
||||||
|
set-dns) cmd_set_dns "$@" ;;
|
||||||
ssh-lock) cmd_ssh_lock "$@" ;;
|
ssh-lock) cmd_ssh_lock "$@" ;;
|
||||||
ssh-unlock) cmd_ssh_unlock "$@" ;;
|
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)
|
gate-apply) need_root gate-apply; gate_apply; ssh_gate_apply ;; # called by clusev-wg-gate.service (both gates re-applied on boot)
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ return [
|
||||||
'server_ip' => 'Server-IP',
|
'server_ip' => 'Server-IP',
|
||||||
'port' => 'Port',
|
'port' => 'Port',
|
||||||
'endpoint' => 'Endpoint',
|
'endpoint' => 'Endpoint',
|
||||||
|
'dns' => 'DNS',
|
||||||
'service' => 'Dienst',
|
'service' => 'Dienst',
|
||||||
|
'tab_peers' => 'Peers',
|
||||||
|
'tab_server' => 'Server-Konfiguration',
|
||||||
'peers_title' => 'Peers',
|
'peers_title' => 'Peers',
|
||||||
'peers_count' => ':count Peer|:count Peers',
|
'peers_count' => ':count Peer|:count Peers',
|
||||||
'no_peers' => 'Noch keine Peers — per SSH "clusev wg add-peer <name>".',
|
'no_peers' => 'Noch keine Peers — per SSH "clusev wg add-peer <name>".',
|
||||||
|
|
@ -66,7 +69,10 @@ return [
|
||||||
'ssh_lock_off_title' => 'SSH-Sperre aufheben?',
|
'ssh_lock_off_title' => 'SSH-Sperre aufheben?',
|
||||||
'ssh_lock_off_body' => 'Port 22 wird danach wieder normal erreichbar (vorbehaltlich einer Cloud-/Host-Firewall).',
|
'ssh_lock_off_body' => 'Port 22 wird danach wieder normal erreichbar (vorbehaltlich einer Cloud-/Host-Firewall).',
|
||||||
'endpoint_label' => 'Öffentlicher Endpoint',
|
'endpoint_label' => 'Öffentlicher Endpoint',
|
||||||
'endpoint_hint' => 'IP oder DNS:Port — betrifft nur künftige Peer-Configs.',
|
'endpoint_hint' => 'IP oder Host (Port optional — sonst Listen-Port). Betrifft nur künftige Peer-Configs.',
|
||||||
|
'dns_label' => 'DNS-Server',
|
||||||
|
'dns_hint' => 'DNS für neue Peer-Configs (z. B. dein eigenes Gateway 10.0.0.1). Mehrere durch Komma. Betrifft nur künftige Clients.',
|
||||||
|
'dns_invalid' => 'Ungültige DNS-Adresse(n) — nur IPv4, kommagetrennt.',
|
||||||
'endpoint_invalid' => 'Ungültiger Endpoint.',
|
'endpoint_invalid' => 'Ungültiger Endpoint.',
|
||||||
'port_label' => 'Listen-Port (UDP)',
|
'port_label' => 'Listen-Port (UDP)',
|
||||||
'port_hint' => 'Ändern startet den Tunnel neu — alle Clients müssen den Port aktualisieren.',
|
'port_hint' => 'Ändern startet den Tunnel neu — alle Clients müssen den Port aktualisieren.',
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ return [
|
||||||
'server_ip' => 'Server IP',
|
'server_ip' => 'Server IP',
|
||||||
'port' => 'Port',
|
'port' => 'Port',
|
||||||
'endpoint' => 'Endpoint',
|
'endpoint' => 'Endpoint',
|
||||||
|
'dns' => 'DNS',
|
||||||
'service' => 'Service',
|
'service' => 'Service',
|
||||||
|
'tab_peers' => 'Peers',
|
||||||
|
'tab_server' => 'Server configuration',
|
||||||
'peers_title' => 'Peers',
|
'peers_title' => 'Peers',
|
||||||
'peers_count' => ':count peer|:count peers',
|
'peers_count' => ':count peer|:count peers',
|
||||||
'no_peers' => 'No peers yet — over SSH run "clusev wg add-peer <name>".',
|
'no_peers' => 'No peers yet — over SSH run "clusev wg add-peer <name>".',
|
||||||
|
|
@ -66,7 +69,10 @@ return [
|
||||||
'ssh_lock_off_title' => 'Unlock SSH?',
|
'ssh_lock_off_title' => 'Unlock SSH?',
|
||||||
'ssh_lock_off_body' => 'Port 22 becomes reachable normally again (subject to any cloud/host firewall).',
|
'ssh_lock_off_body' => 'Port 22 becomes reachable normally again (subject to any cloud/host firewall).',
|
||||||
'endpoint_label' => 'Public endpoint',
|
'endpoint_label' => 'Public endpoint',
|
||||||
'endpoint_hint' => 'IP or DNS:port — affects only future peer configs.',
|
'endpoint_hint' => 'IP or host (port optional — otherwise the listen port). Affects only future peer configs.',
|
||||||
|
'dns_label' => 'DNS server',
|
||||||
|
'dns_hint' => 'DNS for new peer configs (e.g. your own gateway 10.0.0.1). Multiple comma-separated. Only affects future clients.',
|
||||||
|
'dns_invalid' => 'Invalid DNS address(es) — IPv4 only, comma-separated.',
|
||||||
'endpoint_invalid' => 'Invalid endpoint.',
|
'endpoint_invalid' => 'Invalid endpoint.',
|
||||||
'port_label' => 'Listen port (UDP)',
|
'port_label' => 'Listen port (UDP)',
|
||||||
'port_hint' => 'Changing this restarts the tunnel — all clients must update the port.',
|
'port_hint' => 'Changing this restarts the tunnel — all clients must update the port.',
|
||||||
|
|
|
||||||
|
|
@ -75,109 +75,21 @@
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@php
|
{{-- Tabs: Peers (list + traffic) and Server (independent server configuration) --}}
|
||||||
$win = collect($windows)->mapWithKeys(fn ($s) => [$s => match ($s) {
|
<div class="flex items-center gap-1 border-b border-line">
|
||||||
3600 => __('wireguard.window_1h'), 86400 => __('wireguard.window_24h'), default => __('wireguard.window_7d'),
|
@foreach ([['peers', __('wireguard.tab_peers')], ['server', __('wireguard.tab_server')]] as [$tabKey, $tabLabel])
|
||||||
}])->all();
|
<button type="button" wire:click="setTab('{{ $tabKey }}')"
|
||||||
$pts = $traffic['points'];
|
@class([
|
||||||
$n = max(1, count($pts) - 1);
|
'-mb-px border-b-2 px-4 py-2.5 font-mono text-[12px] uppercase tracking-wider transition-colors',
|
||||||
$peak = max(1, $traffic['peak_down'], $traffic['peak_up']);
|
'border-accent text-accent-text' => $tab === $tabKey,
|
||||||
$line = function (string $key) use ($pts, $n, $peak) {
|
'border-transparent text-ink-3 hover:text-ink' => $tab !== $tabKey,
|
||||||
$out = [];
|
])>{{ $tabLabel }}</button>
|
||||||
foreach ($pts as $i => $p) {
|
@endforeach
|
||||||
$x = round($i / $n * 600, 1);
|
</div>
|
||||||
$y = round(140 - ($p[$key] / $peak) * 132, 1);
|
|
||||||
$out[] = $x.','.$y;
|
|
||||||
}
|
|
||||||
return implode(' ', $out);
|
|
||||||
};
|
|
||||||
$hasTraffic = $traffic['total_down'] > 0 || $traffic['total_up'] > 0;
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<x-panel :title="__('wireguard.traffic_title')" :padded="false">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-line px-4 py-3 sm:px-5">
|
|
||||||
<div class="flex items-center gap-4 font-mono text-[11px]">
|
|
||||||
<span class="text-accent-text">{{ __('wireguard.total_down') }}: <span class="text-ink-2">{{ $fmtBytes($traffic['total_down']) }}</span></span>
|
|
||||||
<span class="text-cyan">{{ __('wireguard.total_up') }}: <span class="text-ink-2">{{ $fmtBytes($traffic['total_up']) }}</span></span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
@foreach ($windows as $w)
|
|
||||||
<button type="button" wire:click="setWindow({{ $w }})"
|
|
||||||
@class([
|
|
||||||
'rounded-md border px-2.5 py-1 font-mono text-[11px] transition-colors',
|
|
||||||
'border-accent/40 bg-accent/15 text-accent-text' => $window === $w,
|
|
||||||
'border-line bg-raised text-ink-3 hover:border-accent/30 hover:text-ink' => $window !== $w,
|
|
||||||
])>{{ $win[$w] }}</button>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="px-4 py-4 sm:px-5">
|
|
||||||
@if ($hasTraffic)
|
|
||||||
<svg viewBox="0 0 600 140" preserveAspectRatio="none" class="h-32 w-full" role="img" aria-label="{{ __('wireguard.traffic_title') }}">
|
|
||||||
<g class="text-cyan"><polyline points="{{ $line('up') }}" fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" /></g>
|
|
||||||
<g class="text-accent"><polyline points="{{ $line('down') }}" fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" /></g>
|
|
||||||
</svg>
|
|
||||||
@else
|
|
||||||
<p class="font-mono text-[11px] text-ink-3">{{ __('wireguard.no_traffic') }}</p>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</x-panel>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
|
|
||||||
<x-panel :title="__('wireguard.peers_title')" :subtitle="trans_choice('wireguard.peers_count', count($status['peers']), ['count' => count($status['peers'])])" :padded="false">
|
|
||||||
<form wire:submit="addPeer" class="flex flex-wrap items-center gap-2 border-b border-line px-4 py-3 sm:px-5">
|
|
||||||
<input wire:model="newPeer" type="text" maxlength="64" placeholder="{{ __('wireguard.peer_name_placeholder') }}"
|
|
||||||
class="min-w-0 flex-1 rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
|
||||||
<x-btn variant="primary" type="submit" wire:loading.attr="disabled" wire:target="addPeer">
|
|
||||||
<x-icon name="plus" class="h-3.5 w-3.5" />{{ __('wireguard.add_peer') }}
|
|
||||||
</x-btn>
|
|
||||||
@error('newPeer') <span class="w-full font-mono text-[11px] text-offline">{{ $message }}</span> @enderror
|
|
||||||
</form>
|
|
||||||
<div class="divide-y divide-line">
|
|
||||||
@forelse ($status['peers'] as $peer)
|
|
||||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3.5 sm:px-5" wire:key="peer-{{ $peer['pubkey'] }}">
|
|
||||||
<span class="inline-flex items-center gap-2">
|
|
||||||
<x-status-dot :status="$peer['online'] ? 'online' : 'offline'" />
|
|
||||||
<span class="font-mono text-sm font-semibold text-ink">{{ $peer['name'] !== '' ? $peer['name'] : \Illuminate\Support\Str::limit($peer['pubkey'], 10, '…') }}</span>
|
|
||||||
</span>
|
|
||||||
<span class="font-mono text-[11px] text-ink-4">{{ $peer['online'] ? __('wireguard.online') : __('wireguard.offline') }}</span>
|
|
||||||
<span class="ml-auto font-mono text-[11px] text-ink-3">{{ __('wireguard.last_handshake') }}: {{ $ago($peer['latest_handshake']) }}</span>
|
|
||||||
<span class="w-full font-mono text-[11px] text-ink-3">
|
|
||||||
{{ $peer['endpoint'] !== '' ? $peer['endpoint'] : '—' }}
|
|
||||||
<span class="ml-2 text-ink-4">{{ __('wireguard.down_up', ['rx' => $fmtBytes($peer['rx']), 'tx' => $fmtBytes($peer['tx'])]) }}</span>
|
|
||||||
</span>
|
|
||||||
<div class="flex w-full justify-end">
|
|
||||||
<x-btn variant="danger-soft" size="sm" wire:click="confirmRemovePeer('{{ $peer['name'] !== '' ? $peer['name'] : $peer['pubkey'] }}')">
|
|
||||||
<x-icon name="trash" class="h-3.5 w-3.5" />{{ __('wireguard.remove') }}
|
|
||||||
</x-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@empty
|
|
||||||
<p class="px-4 py-4 font-mono text-[11px] text-ink-3 sm:px-5">{{ __('wireguard.no_peers') }}</p>
|
|
||||||
@endforelse
|
|
||||||
</div>
|
|
||||||
</x-panel>
|
|
||||||
|
|
||||||
<div class="space-y-5">
|
|
||||||
<x-panel :title="__('wireguard.gate')">
|
|
||||||
@if ($status['gate']['marker'] && $status['gate']['chain'])
|
|
||||||
<x-badge tone="cyan">{{ __('wireguard.gate_on') }}</x-badge>
|
|
||||||
@else
|
|
||||||
<x-badge tone="neutral">{{ __('wireguard.gate_off') }}</x-badge>
|
|
||||||
@endif
|
|
||||||
<p class="mt-2 font-mono text-[11px] text-ink-4">{{ __('wireguard.gate_escape') }}</p>
|
|
||||||
</x-panel>
|
|
||||||
|
|
||||||
<x-panel :title="__('wireguard.server_title')" :padded="false">
|
|
||||||
<dl class="space-y-2 px-4 py-4 font-mono text-[11px] sm:px-5">
|
|
||||||
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.subnet') }}</dt><dd class="text-ink-2">{{ $status['server']['subnet'] ?: '—' }}</dd></div>
|
|
||||||
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.server_ip') }}</dt><dd class="text-ink-2">{{ $status['server']['server_ip'] ?: '—' }}</dd></div>
|
|
||||||
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.port') }}</dt><dd class="text-ink-2">{{ $status['server']['port'] ?: '—' }}</dd></div>
|
|
||||||
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.endpoint') }}</dt><dd class="truncate text-ink-2">{{ $status['server']['endpoint'] ?: '—' }}</dd></div>
|
|
||||||
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.service') }}</dt><dd class="text-ink-2">{{ $status['wg_quick'] }}</dd></div>
|
|
||||||
</dl>
|
|
||||||
</x-panel>
|
|
||||||
|
|
||||||
|
@if ($tab === 'server')
|
||||||
|
{{-- ── Server configuration tab ──────────────────────────────────────────────── --}}
|
||||||
|
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||||
<x-panel :title="__('wireguard.settings_title')" :padded="false">
|
<x-panel :title="__('wireguard.settings_title')" :padded="false">
|
||||||
<div class="divide-y divide-line">
|
<div class="divide-y divide-line">
|
||||||
{{-- Gate toggle --}}
|
{{-- Gate toggle --}}
|
||||||
|
|
@ -231,6 +143,21 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- DNS --}}
|
||||||
|
<div class="px-4 py-3.5 sm:px-5">
|
||||||
|
<form wire:submit="setDns" class="space-y-2">
|
||||||
|
<label class="block font-mono text-[11px] text-ink-3">{{ __('wireguard.dns_label') }}</label>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<input wire:model="newDns" type="text" maxlength="128"
|
||||||
|
placeholder="{{ $status['server']['dns'] ?: '1.1.1.1' }}"
|
||||||
|
class="min-w-0 flex-1 rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||||
|
<x-btn variant="primary" type="submit" wire:loading.attr="disabled" wire:target="setDns">{{ __('wireguard.apply') }}</x-btn>
|
||||||
|
</div>
|
||||||
|
<p class="font-mono text-[11px] text-ink-4">{{ __('wireguard.dns_hint') }}</p>
|
||||||
|
@error('newDns') <span class="font-mono text-[11px] text-offline">{{ $message }}</span> @enderror
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{-- Port --}}
|
{{-- Port --}}
|
||||||
<div class="px-4 py-3.5 sm:px-5">
|
<div class="px-4 py-3.5 sm:px-5">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|
@ -262,8 +189,113 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-panel>
|
</x-panel>
|
||||||
|
|
||||||
|
<div class="space-y-5">
|
||||||
|
<x-panel :title="__('wireguard.server_title')" :padded="false">
|
||||||
|
<dl class="space-y-2 px-4 py-4 font-mono text-[11px] sm:px-5">
|
||||||
|
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.subnet') }}</dt><dd class="text-ink-2">{{ $status['server']['subnet'] ?: '—' }}</dd></div>
|
||||||
|
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.server_ip') }}</dt><dd class="text-ink-2">{{ $status['server']['server_ip'] ?: '—' }}</dd></div>
|
||||||
|
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.port') }}</dt><dd class="text-ink-2">{{ $status['server']['port'] ?: '—' }}</dd></div>
|
||||||
|
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.endpoint') }}</dt><dd class="truncate text-ink-2">{{ $status['server']['endpoint'] ?: '—' }}</dd></div>
|
||||||
|
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.dns') }}</dt><dd class="text-ink-2">{{ $status['server']['dns'] ?: '1.1.1.1' }}</dd></div>
|
||||||
|
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.service') }}</dt><dd class="text-ink-2">{{ $status['wg_quick'] }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</x-panel>
|
||||||
|
|
||||||
|
<x-panel :title="__('wireguard.gate')">
|
||||||
|
@if ($status['gate']['marker'] && $status['gate']['chain'])
|
||||||
|
<x-badge tone="cyan">{{ __('wireguard.gate_on') }}</x-badge>
|
||||||
|
@else
|
||||||
|
<x-badge tone="neutral">{{ __('wireguard.gate_off') }}</x-badge>
|
||||||
|
@endif
|
||||||
|
<p class="mt-2 font-mono text-[11px] text-ink-4">{{ __('wireguard.gate_escape') }}</p>
|
||||||
|
</x-panel>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
@else
|
||||||
|
{{-- ── Peers tab (traffic + peer management) ──────────────────────────────────── --}}
|
||||||
|
@php
|
||||||
|
$win = collect($windows)->mapWithKeys(fn ($s) => [$s => match ($s) {
|
||||||
|
3600 => __('wireguard.window_1h'), 86400 => __('wireguard.window_24h'), default => __('wireguard.window_7d'),
|
||||||
|
}])->all();
|
||||||
|
$pts = $traffic['points'];
|
||||||
|
$n = max(1, count($pts) - 1);
|
||||||
|
$peak = max(1, $traffic['peak_down'], $traffic['peak_up']);
|
||||||
|
$line = function (string $key) use ($pts, $n, $peak) {
|
||||||
|
$out = [];
|
||||||
|
foreach ($pts as $i => $p) {
|
||||||
|
$x = round($i / $n * 600, 1);
|
||||||
|
$y = round(140 - ($p[$key] / $peak) * 132, 1);
|
||||||
|
$out[] = $x.','.$y;
|
||||||
|
}
|
||||||
|
return implode(' ', $out);
|
||||||
|
};
|
||||||
|
$hasTraffic = $traffic['total_down'] > 0 || $traffic['total_up'] > 0;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<x-panel :title="__('wireguard.traffic_title')" :padded="false">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-line px-4 py-3 sm:px-5">
|
||||||
|
<div class="flex items-center gap-4 font-mono text-[11px]">
|
||||||
|
<span class="text-accent-text">{{ __('wireguard.total_down') }}: <span class="text-ink-2">{{ $fmtBytes($traffic['total_down']) }}</span></span>
|
||||||
|
<span class="text-cyan">{{ __('wireguard.total_up') }}: <span class="text-ink-2">{{ $fmtBytes($traffic['total_up']) }}</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
@foreach ($windows as $w)
|
||||||
|
<button type="button" wire:click="setWindow({{ $w }})"
|
||||||
|
@class([
|
||||||
|
'rounded-md border px-2.5 py-1 font-mono text-[11px] transition-colors',
|
||||||
|
'border-accent/40 bg-accent/15 text-accent-text' => $window === $w,
|
||||||
|
'border-line bg-raised text-ink-3 hover:border-accent/30 hover:text-ink' => $window !== $w,
|
||||||
|
])>{{ $win[$w] }}</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-4 sm:px-5">
|
||||||
|
@if ($hasTraffic)
|
||||||
|
<svg viewBox="0 0 600 140" preserveAspectRatio="none" class="h-32 w-full" role="img" aria-label="{{ __('wireguard.traffic_title') }}">
|
||||||
|
<g class="text-cyan"><polyline points="{{ $line('up') }}" fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" /></g>
|
||||||
|
<g class="text-accent"><polyline points="{{ $line('down') }}" fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" /></g>
|
||||||
|
</svg>
|
||||||
|
@else
|
||||||
|
<p class="font-mono text-[11px] text-ink-3">{{ __('wireguard.no_traffic') }}</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-panel>
|
||||||
|
|
||||||
|
<x-panel :title="__('wireguard.peers_title')" :subtitle="trans_choice('wireguard.peers_count', count($status['peers']), ['count' => count($status['peers'])])" :padded="false">
|
||||||
|
<form wire:submit="addPeer" class="flex flex-wrap items-center gap-2 border-b border-line px-4 py-3 sm:px-5">
|
||||||
|
<input wire:model="newPeer" type="text" maxlength="64" placeholder="{{ __('wireguard.peer_name_placeholder') }}"
|
||||||
|
class="min-w-0 flex-1 rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||||
|
<x-btn variant="primary" type="submit" wire:loading.attr="disabled" wire:target="addPeer">
|
||||||
|
<x-icon name="plus" class="h-3.5 w-3.5" />{{ __('wireguard.add_peer') }}
|
||||||
|
</x-btn>
|
||||||
|
@error('newPeer') <span class="w-full font-mono text-[11px] text-offline">{{ $message }}</span> @enderror
|
||||||
|
</form>
|
||||||
|
<div class="divide-y divide-line">
|
||||||
|
@forelse ($status['peers'] as $peer)
|
||||||
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3.5 sm:px-5" wire:key="peer-{{ $peer['pubkey'] }}">
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<x-status-dot :status="$peer['online'] ? 'online' : 'offline'" />
|
||||||
|
<span class="font-mono text-sm font-semibold text-ink">{{ $peer['name'] !== '' ? $peer['name'] : \Illuminate\Support\Str::limit($peer['pubkey'], 10, '…') }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="font-mono text-[11px] text-ink-4">{{ $peer['online'] ? __('wireguard.online') : __('wireguard.offline') }}</span>
|
||||||
|
<span class="ml-auto font-mono text-[11px] text-ink-3">{{ __('wireguard.last_handshake') }}: {{ $ago($peer['latest_handshake']) }}</span>
|
||||||
|
<span class="w-full font-mono text-[11px] text-ink-3">
|
||||||
|
{{ $peer['endpoint'] !== '' ? $peer['endpoint'] : '—' }}
|
||||||
|
<span class="ml-2 text-ink-4">{{ __('wireguard.down_up', ['rx' => $fmtBytes($peer['rx']), 'tx' => $fmtBytes($peer['tx'])]) }}</span>
|
||||||
|
</span>
|
||||||
|
<div class="flex w-full justify-end">
|
||||||
|
<x-btn variant="danger-soft" size="sm" wire:click="confirmRemovePeer('{{ $peer['name'] !== '' ? $peer['name'] : $peer['pubkey'] }}')">
|
||||||
|
<x-icon name="trash" class="h-3.5 w-3.5" />{{ __('wireguard.remove') }}
|
||||||
|
</x-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<p class="px-4 py-4 font-mono text-[11px] text-ink-3 sm:px-5">{{ __('wireguard.no_peers') }}</p>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</x-panel>
|
||||||
|
@endif
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($resultConfig)
|
@if ($resultConfig)
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,8 @@ class WireguardPageTest extends TestCase
|
||||||
Livewire::test(Index::class)
|
Livewire::test(Index::class)
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertViewHas('status', fn ($s) => $s['configured'] === true && count($s['peers']) === 1)
|
->assertViewHas('status', fn ($s) => $s['configured'] === true && count($s['peers']) === 1)
|
||||||
->assertSee('laptop')
|
->assertSee('laptop') // peers tab (default)
|
||||||
->assertSee('10.99.0.0/24');
|
->set('tab', 'server')->assertSee('10.99.0.0/24'); // server tab shows the subnet
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_shows_ssh_lock_active_state_when_the_host_reports_it(): void
|
public function test_shows_ssh_lock_active_state_when_the_host_reports_it(): void
|
||||||
|
|
@ -67,6 +67,7 @@ class WireguardPageTest extends TestCase
|
||||||
|
|
||||||
Livewire::test(Index::class)
|
Livewire::test(Index::class)
|
||||||
->assertViewHas('status', fn ($s) => $s['gate']['ssh'] === true)
|
->assertViewHas('status', fn ($s) => $s['gate']['ssh'] === true)
|
||||||
|
->set('tab', 'server') // SSH lock lives in the server-configuration tab
|
||||||
->assertSee(__('wireguard.ssh_lock'))
|
->assertSee(__('wireguard.ssh_lock'))
|
||||||
->assertSee(__('wireguard.ssh_lock_turn_off'));
|
->assertSee(__('wireguard.ssh_lock_turn_off'));
|
||||||
}
|
}
|
||||||
|
|
@ -187,6 +188,49 @@ class WireguardPageTest extends TestCase
|
||||||
@unlink(storage_path("app/restart-signal/wg-result-{$id}.json"));
|
@unlink(storage_path("app/restart-signal/wg-result-{$id}.json"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_set_dns_writes_a_request_and_audits(): void
|
||||||
|
{
|
||||||
|
Livewire::test(Index::class)
|
||||||
|
->set('newDns', '10.0.0.1, 1.1.1.1')
|
||||||
|
->call('setDns')
|
||||||
|
->assertSet('pendingAction', 'set-dns')
|
||||||
|
->assertSet('pendingId', fn ($v) => is_string($v) && $v !== '');
|
||||||
|
$this->assertTrue(AuditEvent::where('action', 'wg.set-dns')->exists());
|
||||||
|
@unlink(storage_path('app/restart-signal/wg-request.json'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_set_dns_rejects_a_bad_value(): void
|
||||||
|
{
|
||||||
|
Livewire::test(Index::class)
|
||||||
|
->set('newDns', 'not-an-ip')
|
||||||
|
->call('setDns')
|
||||||
|
->assertHasErrors('newDns');
|
||||||
|
$this->assertSame(0, AuditEvent::where('action', 'wg.set-dns')->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_set_tab_switches_and_clamps_unknown_values(): void
|
||||||
|
{
|
||||||
|
Livewire::test(Index::class)
|
||||||
|
->assertSet('tab', 'peers')
|
||||||
|
->call('setTab', 'server')->assertSet('tab', 'server')
|
||||||
|
->call('setTab', 'bogus')->assertSet('tab', 'peers');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_status_exposes_the_server_dns(): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->file, json_encode([
|
||||||
|
'at' => time(), 'configured' => true,
|
||||||
|
'gate' => ['marker' => true, 'chain' => true, 'ssh' => false], 'wg_quick' => 'active',
|
||||||
|
'server' => ['subnet' => '10.99.0.0/24', 'server_ip' => '10.99.0.1', 'port' => 51820, 'endpoint' => '1.2.3.4:51820', 'dns' => '10.0.0.1', 'pubkey' => 'srvpub'],
|
||||||
|
'peers' => [],
|
||||||
|
]));
|
||||||
|
|
||||||
|
Livewire::test(Index::class)
|
||||||
|
->set('tab', 'server')
|
||||||
|
->assertViewHas('status', fn ($s) => $s['server']['dns'] === '10.0.0.1')
|
||||||
|
->assertSee('10.0.0.1');
|
||||||
|
}
|
||||||
|
|
||||||
public function test_setup_writes_a_request_and_audits(): void
|
public function test_setup_writes_a_request_and_audits(): void
|
||||||
{
|
{
|
||||||
Livewire::test(Index::class)
|
Livewire::test(Index::class)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue