fix(wireguard): Tunnel reißt nie ab + Client-Endpoint auto-befüllt — v1.2.100
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 = <fqdn>:<port>. Platzhalter nur noch als Fallback wenn FQDN unbekannt. 2) Renderer machte bei JEDER Config-Änderung 'systemctl restart wg-quick@<iface>' → 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@<iface> nicht an → Tunnel bleibt während Deploy bestehen. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -431,7 +431,14 @@ func main() {
|
|||||||
wgReloader := func(ctx context.Context) error {
|
wgReloader := func(ctx context.Context) error {
|
||||||
return wgrender.New(pool, secretsBox).Render(ctx)
|
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 forward-proxy reload — re-render squid.conf + reload
|
||||||
// squid.service. sudoers im postinst whitelistet das. ACL-Count
|
// squid.service. sudoers im postinst whitelistet das. ACL-Count
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ type WireguardHandler struct {
|
|||||||
Audit *audit.Repo
|
Audit *audit.Repo
|
||||||
NodeID string
|
NodeID string
|
||||||
Reloader func(ctx context.Context) error
|
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(
|
func NewWireguardHandler(
|
||||||
@@ -45,6 +49,12 @@ func NewWireguardHandler(
|
|||||||
return &WireguardHandler{Ifaces: ifaces, Peers: peers, Box: box, Audit: a, NodeID: nodeID, Reloader: reloader}
|
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) {
|
func (h *WireguardHandler) reload(ctx context.Context, op string) {
|
||||||
if h.Reloader == nil {
|
if h.Reloader == nil {
|
||||||
return
|
return
|
||||||
@@ -702,11 +712,15 @@ func (h *WireguardHandler) peerConfigText(ctx context.Context, peerID int64) (st
|
|||||||
clientAllowedIPs += ", " + strings.TrimSpace(*ifc.ClientRoutes)
|
clientAllowedIPs += ", " + strings.TrimSpace(*ifc.ClientRoutes)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&b, "AllowedIPs = %s\n", clientAllowedIPs)
|
fmt.Fprintf(&b, "AllowedIPs = %s\n", clientAllowedIPs)
|
||||||
// Endpoint — the operator's public host:port that peers dial.
|
// Endpoint — der öffentliche Host:Port, den Clients anwählen. Standard
|
||||||
// We don't know this here (could be a CNAME or behind a load
|
// ist der FQDN dieser Node (PublicHost, aus setup.json). Nur wenn der
|
||||||
// balancer); leave a placeholder the operator must fill in.
|
// nicht ermittelbar ist, bleibt ein Platzhalter den der Operator füllt.
|
||||||
if ifc.ListenPort != nil {
|
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 {
|
if p.Keepalive != nil && *p.Keepalive > 0 {
|
||||||
fmt.Fprintf(&b, "PersistentKeepalive = %d\n", *p.Keepalive)
|
fmt.Fprintf(&b, "PersistentKeepalive = %d\n", *p.Keepalive)
|
||||||
|
|||||||
@@ -1,15 +1,42 @@
|
|||||||
package wireguard
|
package wireguard
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
)
|
)
|
||||||
|
|
||||||
// wg-quick is managed via systemd unit instances (wg-quick@<iface>).
|
// wg-quick is managed via systemd unit instances (wg-quick@<iface>).
|
||||||
// Reload-via-syncconf would be cheaper (no link flap) but needs more
|
// Für ein BEREITS laufendes Interface werden Config-Änderungen per
|
||||||
// per-change diffing — for v1 we restart the unit, which takes ~1s
|
// `wg syncconf` LIVE angewendet (siehe syncWGQuick) — ohne Link-Flap,
|
||||||
// and re-establishes peers cleanly. The sudoers entry shipped in
|
// damit bestehende Tunnel nie abreißen. Nur das erstmalige Hochfahren
|
||||||
// postinst whitelists exactly these three commands.
|
// (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 {
|
func startWGQuick(iface string) error {
|
||||||
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "start", "wg-quick@"+iface+".service")
|
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "start", "wg-quick@"+iface+".service")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -254,9 +255,24 @@ func (g *Generator) renderIface(ctx context.Context, ifc models.WireguardInterfa
|
|||||||
return fmt.Errorf("symlink: %w", err)
|
return fmt.Errorf("symlink: %w", err)
|
||||||
}
|
}
|
||||||
_ = enableWGQuick(ifc.Name)
|
_ = enableWGQuick(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 {
|
if !changed {
|
||||||
return startWGQuick(ifc.Name)
|
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 restartWGQuick(ifc.Name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Interface noch nicht oben → erstmalig hochfahren.
|
||||||
|
return startWGQuick(ifc.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: /bin/systemctl disable wg-quick@*.service
|
||||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show all dump
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show all dump
|
||||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show *
|
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
|
# WireGuard symlink: /etc/wireguard/ ist root:root 700; edgeguard-api
|
||||||
# legt Symlinks an damit wg-quick@<iface> die Configs findet.
|
# legt Symlinks an damit wg-quick@<iface> die Configs findet.
|
||||||
edgeguard ALL=(root) NOPASSWD: /bin/ln -sf /etc/edgeguard/wireguard/* /etc/wireguard/*
|
edgeguard ALL=(root) NOPASSWD: /bin/ln -sf /etc/edgeguard/wireguard/* /etc/wireguard/*
|
||||||
|
|||||||
Reference in New Issue
Block a user