From 91e51890dd0e35f38ebb7b0a9a8ee88d9a33b3b9 Mon Sep 17 00:00:00 2001 From: Debian Date: Sat, 6 Jun 2026 20:51:52 +0200 Subject: [PATCH] =?UTF-8?q?fix(wireguard):=20Tunnel=20rei=C3=9Ft=20nie=20a?= =?UTF-8?q?b=20+=20Client-Endpoint=20auto-bef=C3=BCllt=20=E2=80=94=20v1.2.?= =?UTF-8?q?100?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Bugs, die WireGuard-Verbindungen verhinderten/abrissen: 1) Client-Config-Endpoint war hartkodierter Platzhalter REPLACE_WITH_PUBLIC_HOST → neue Clients bauten nie einen Tunnel auf (Host löst nicht auf). Jetzt: WireguardHandler.PublicHost (aus setup.json FQDN, main.go) → Endpoint = :. Platzhalter nur noch als Fallback wenn FQDN unbekannt. 2) Renderer machte bei JEDER Config-Änderung 'systemctl restart wg-quick@' → voller Link-Flap, alle Peers droppen (verstößt gegen 'wireguard darf nie abbrechen'). Jetzt: laufendes Interface → 'wg-quick strip | wg syncconf' (Peers/Listen-Port live, KEIN Abbruch); nur erstmaliges Hochfahren via systemctl start; restart nur noch als Fallback mit WARN. interfaceExists() via 'ip link show'. Neue sudoers: wg syncconf *, wg-quick strip *. Ein edgeguard-api-Restart (Deploy) fasst wg-quick@ nicht an → Tunnel bleibt während Deploy bestehen. Co-Authored-By: Claude Opus 4.8 (1M context) --- VERSION | 2 +- cmd/edgeguard-api/main.go | 9 ++++- internal/handlers/wireguard.go | 22 +++++++++--- internal/wireguard/systemd.go | 35 ++++++++++++++++--- internal/wireguard/wireguard.go | 22 ++++++++++-- .../debian/edgeguard-api/DEBIAN/postinst | 3 ++ 6 files changed, 80 insertions(+), 13 deletions(-) diff --git a/VERSION b/VERSION index 97fc465..407695f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.99 \ No newline at end of file +1.2.100 \ No newline at end of file diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index e2c563a..50eaf7c 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -431,7 +431,14 @@ func main() { wgReloader := func(ctx context.Context) error { return wgrender.New(pool, secretsBox).Render(ctx) } - handlers.NewWireguardHandler(wgIfaces, wgPeers, secretsBox, auditRepo, nodeID, withFW(wgReloader)).Register(authed) + // Öffentlicher WG-Endpoint-Host für Peer-Configs = FQDN dieser Node + // (aus setup.json). Verhindert den REPLACE_WITH_PUBLIC_HOST-Platzhalter, + // an dem Clients sonst keinen Tunnel aufbauen können. + wgPublicHost := "" + if sst, serr := setupStore.Load(); serr == nil && sst != nil { + wgPublicHost = sst.FQDN + } + handlers.NewWireguardHandler(wgIfaces, wgPeers, secretsBox, auditRepo, nodeID, withFW(wgReloader)).WithPublicHost(wgPublicHost).Register(authed) // Squid forward-proxy reload — re-render squid.conf + reload // squid.service. sudoers im postinst whitelistet das. ACL-Count diff --git a/internal/handlers/wireguard.go b/internal/handlers/wireguard.go index 57a7e7d..0670af3 100644 --- a/internal/handlers/wireguard.go +++ b/internal/handlers/wireguard.go @@ -32,6 +32,10 @@ type WireguardHandler struct { Audit *audit.Repo NodeID string Reloader func(ctx context.Context) error + // PublicHost ist der öffentliche Host (FQDN/IP), den Clients als + // WireGuard-Endpoint anwählen. Wird in heruntergeladene Peer-Configs + // geschrieben (statt eines Platzhalters). Leer → Platzhalter (Fallback). + PublicHost string } func NewWireguardHandler( @@ -45,6 +49,12 @@ func NewWireguardHandler( return &WireguardHandler{Ifaces: ifaces, Peers: peers, Box: box, Audit: a, NodeID: nodeID, Reloader: reloader} } +// WithPublicHost setzt den öffentlichen Endpoint-Host für Peer-Configs. +func (h *WireguardHandler) WithPublicHost(host string) *WireguardHandler { + h.PublicHost = strings.TrimSpace(host) + return h +} + func (h *WireguardHandler) reload(ctx context.Context, op string) { if h.Reloader == nil { return @@ -702,11 +712,15 @@ func (h *WireguardHandler) peerConfigText(ctx context.Context, peerID int64) (st clientAllowedIPs += ", " + strings.TrimSpace(*ifc.ClientRoutes) } fmt.Fprintf(&b, "AllowedIPs = %s\n", clientAllowedIPs) - // Endpoint — the operator's public host:port that peers dial. - // We don't know this here (could be a CNAME or behind a load - // balancer); leave a placeholder the operator must fill in. + // Endpoint — der öffentliche Host:Port, den Clients anwählen. Standard + // ist der FQDN dieser Node (PublicHost, aus setup.json). Nur wenn der + // nicht ermittelbar ist, bleibt ein Platzhalter den der Operator füllt. if ifc.ListenPort != nil { - fmt.Fprintf(&b, "Endpoint = REPLACE_WITH_PUBLIC_HOST:%d\n", *ifc.ListenPort) + host := h.PublicHost + if host == "" { + host = "REPLACE_WITH_PUBLIC_HOST" + } + fmt.Fprintf(&b, "Endpoint = %s:%d\n", host, *ifc.ListenPort) } if p.Keepalive != nil && *p.Keepalive > 0 { fmt.Fprintf(&b, "PersistentKeepalive = %d\n", *p.Keepalive) diff --git a/internal/wireguard/systemd.go b/internal/wireguard/systemd.go index 7265b18..f2783b3 100644 --- a/internal/wireguard/systemd.go +++ b/internal/wireguard/systemd.go @@ -1,15 +1,42 @@ package wireguard import ( + "bytes" "fmt" "os/exec" ) // wg-quick is managed via systemd unit instances (wg-quick@). -// Reload-via-syncconf would be cheaper (no link flap) but needs more -// per-change diffing — for v1 we restart the unit, which takes ~1s -// and re-establishes peers cleanly. The sudoers entry shipped in -// postinst whitelists exactly these three commands. +// Für ein BEREITS laufendes Interface werden Config-Änderungen per +// `wg syncconf` LIVE angewendet (siehe syncWGQuick) — ohne Link-Flap, +// damit bestehende Tunnel nie abreißen. Nur das erstmalige Hochfahren +// (Interface noch nicht vorhanden) nutzt `systemctl start`. restart bleibt +// als Fallback, falls syncconf nicht erlaubt/möglich ist. Die sudoers- +// Einträge (postinst) whitelisten exakt diese Kommandos. + +// interfaceExists meldet ob das wg-Interface aktuell existiert (also von +// wg-quick bereits hochgefahren wurde). `ip link show` braucht kein root. +func interfaceExists(iface string) bool { + return exec.Command("/usr/bin/ip", "link", "show", iface).Run() == nil +} + +// syncWGQuick wendet Config-Änderungen LIVE auf ein laufendes Interface an +// (`wg syncconf`) — Peers werden hinzugefügt/entfernt/aktualisiert und der +// Listen-Port gesetzt, OHNE den Tunnel abzureißen. `wg-quick strip` liefert +// die reine wg-Config (ohne Address/MTU/Routes-Direktiven). Beides braucht +// root (Config ist root:root 700) → sudo. +func syncWGQuick(iface string) error { + stripped, err := exec.Command("sudo", "-n", "/usr/bin/wg-quick", "strip", iface).Output() + if err != nil { + return fmt.Errorf("wg-quick strip %s: %w", iface, err) + } + sync := exec.Command("sudo", "-n", "/usr/bin/wg", "syncconf", iface, "/dev/stdin") + sync.Stdin = bytes.NewReader(stripped) + if out, err := sync.CombinedOutput(); err != nil { + return fmt.Errorf("wg syncconf %s: %w: %s", iface, err, string(out)) + } + return nil +} func startWGQuick(iface string) error { cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "start", "wg-quick@"+iface+".service") diff --git a/internal/wireguard/wireguard.go b/internal/wireguard/wireguard.go index e7c47bb..bc7a5db 100644 --- a/internal/wireguard/wireguard.go +++ b/internal/wireguard/wireguard.go @@ -12,6 +12,7 @@ import ( "context" "errors" "fmt" + "log/slog" "os" "path/filepath" "sort" @@ -254,9 +255,24 @@ func (g *Generator) renderIface(ctx context.Context, ifc models.WireguardInterfa return fmt.Errorf("symlink: %w", err) } _ = enableWGQuick(ifc.Name) - if !changed { - return startWGQuick(ifc.Name) + + // Läuft das Interface schon, werden Änderungen LIVE per `wg syncconf` + // angewendet — KEIN Tunnel-Abbruch (WireGuard darf nie abreißen). Nur + // das erstmalige Hochfahren nutzt `systemctl start`. + if interfaceExists(ifc.Name) { + if !changed { + return nil // läuft + Config unverändert → nichts zu tun + } + if err := syncWGQuick(ifc.Name); err != nil { + // Fallback (z. B. sudoers noch ohne syncconf): voller Neustart. + // Bricht den Tunnel kurz ab — nur Notnagel. + slog.Warn("wireguard: wg syncconf fehlgeschlagen, Fallback auf restart (kurzer Tunnel-Flap)", + "iface", ifc.Name, "error", err) + return restartWGQuick(ifc.Name) + } + return nil } - return restartWGQuick(ifc.Name) + // Interface noch nicht oben → erstmalig hochfahren. + return startWGQuick(ifc.Name) } diff --git a/packaging/debian/edgeguard-api/DEBIAN/postinst b/packaging/debian/edgeguard-api/DEBIAN/postinst index db506fc..7f317be 100755 --- a/packaging/debian/edgeguard-api/DEBIAN/postinst +++ b/packaging/debian/edgeguard-api/DEBIAN/postinst @@ -111,6 +111,9 @@ edgeguard ALL=(root) NOPASSWD: /bin/systemctl enable wg-quick@*.service edgeguard ALL=(root) NOPASSWD: /bin/systemctl disable wg-quick@*.service edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show all dump edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show * +# WireGuard Live-Reload ohne Tunnel-Abbruch: wg syncconf + wg-quick strip +edgeguard ALL=(root) NOPASSWD: /usr/bin/wg syncconf * +edgeguard ALL=(root) NOPASSWD: /usr/bin/wg-quick strip * # WireGuard symlink: /etc/wireguard/ ist root:root 700; edgeguard-api # legt Symlinks an damit wg-quick@ die Configs findet. edgeguard ALL=(root) NOPASSWD: /bin/ln -sf /etc/edgeguard/wireguard/* /etc/wireguard/*