diff --git a/VERSION b/VERSION index 36a0393..baeb5d0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.53 +1.1.54 diff --git a/internal/firewall/firewall.go b/internal/firewall/firewall.go index 0969043..7a82eb3 100644 --- a/internal/firewall/firewall.go +++ b/internal/firewall/firewall.go @@ -111,6 +111,21 @@ type View struct { // interface) can be forwarded by the box. Without this, the // forward policy=drop silently kills all inter-peer packets. WGServerIfaces []string + + // WGSiteMasq drives masquerade rules in postrouting_nat: one entry + // per active WireGuard server interface. Without masquerade, traffic + // from VPN roadwarrior clients (e.g. 192.168.99.3) forwarded to a + // site-to-site LAN (10.0.10.0/24) comes back with the client's tunnel + // IP as destination. The remote gateway (Unify Home) doesn't know + // that IP and drops the reply. Masquerade rewrites the source to the + // server's own tunnel IP so return traffic follows the same path back. + WGSiteMasq []WGSiteMasqEntry +} + +// WGSiteMasqEntry is one WireGuard server interface's masquerade config. +type WGSiteMasqEntry struct { + Iface string // wg interface name, e.g. "wg7" + VPNNet string // network CIDR of the VPN subnet, e.g. "192.168.99.0/24" } // AutoFWRule is one auto-emitted inbound rule. Proto is "tcp" or @@ -286,15 +301,21 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) { // ── Auto-Rules aus laufender Service-Config ── view.AutoRules = g.loadAutoRules(ctx) - // ── WireGuard server-iface names (für forward-chain) ── + // ── WireGuard server-iface names (für forward-chain + site-to-site masquerade) ── wgRows, err := g.Pool.Query(ctx, - `SELECT name FROM wireguard_interfaces WHERE active AND mode = 'server'`) + `SELECT name, address_cidr FROM wireguard_interfaces WHERE active AND mode = 'server'`) if err == nil { defer wgRows.Close() for wgRows.Next() { - var name string - if wgRows.Scan(&name) == nil { + var name, cidr string + if wgRows.Scan(&name, &cidr) == nil { view.WGServerIfaces = append(view.WGServerIfaces, name) + if _, ipNet, err := net.ParseCIDR(cidr); err == nil { + view.WGSiteMasq = append(view.WGSiteMasq, WGSiteMasqEntry{ + Iface: name, + VPNNet: ipNet.String(), + }) + } } } } diff --git a/internal/firewall/ruleset.nft.tpl b/internal/firewall/ruleset.nft.tpl index 4d35bb8..719ddc0 100644 --- a/internal/firewall/ruleset.nft.tpl +++ b/internal/firewall/ruleset.nft.tpl @@ -138,6 +138,16 @@ table inet edgeguard { # client-IP (für Logging / Geo-Block: später optional via # NAT-Rule-Flag preserve_client_ip). ct status dnat masquerade + + # Auto-Masquerade für WireGuard site-to-site: VPN-Clients (z. B. Roadwarrior + # mit 192.168.99.3) greifen auf LANs hinter anderen Peers zu (z. B. 10.0.10.0/24). + # Das entfernte Gateway (z. B. Unify Home) sieht als Return-Destination die + # VPN-Client-IP — die es nicht in seiner Routing-Table hat → Reply wird gedroppt. + # Masquerade schreibt die Source auf die lokale Tunnel-IP um; Return-Traffic + # findet so den Weg zurück durch den Tunnel. +{{range .WGSiteMasq}} + oifname "{{.Iface}}" ip saddr {{.VPNNet}} masquerade comment "auto: WireGuard site-to-site masquerade {{.Iface}}" +{{end}} {{range .NATRules}}{{if eq .Kind "snat"}} # NAT {{.ID}} (snat{{if .Comment}} — {{.Comment}}{{end}}) {{""}} diff --git a/internal/wireguard/systemd.go b/internal/wireguard/systemd.go index dcda61d..1761222 100644 --- a/internal/wireguard/systemd.go +++ b/internal/wireguard/systemd.go @@ -33,3 +33,16 @@ func stopWGQuick(iface string) error { _ = cmd.Run() return nil } + +// symlinkWGQuickConf creates (or atomically replaces) the symlink +// /etc/wireguard/.conf → target via sudo. /etc/wireguard/ is +// owned root:root 700 so the edgeguard user cannot write to it directly; +// the sudoers entry in postinst whitelists exactly this ln command. +func symlinkWGQuickConf(iface, target string) error { + link := "/etc/wireguard/" + iface + ".conf" + cmd := exec.Command("sudo", "-n", "/bin/ln", "-sf", target, link) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("ln -sf %s %s: %w: %s", target, link, err, string(out)) + } + return nil +} diff --git a/internal/wireguard/wireguard.go b/internal/wireguard/wireguard.go index 76d8cc8..4e86e6b 100644 --- a/internal/wireguard/wireguard.go +++ b/internal/wireguard/wireguard.go @@ -157,11 +157,10 @@ func (g *Generator) renderIface(ctx context.Context, ifc models.WireguardInterfa path := filepath.Join(ConfDir, ifc.Name+".conf") // wg-quick@.service liest /etc/wireguard/.conf (Distro- // Default), nicht unseren ConfDir. Wir lassen die Quelle of truth in - // /etc/edgeguard/wireguard/ und symlinken einmalig — sonst lesen - // wg-quick und unser Renderer aus zwei verschiedenen Files und - // driften auseinander (gefangen 2026-05-10 als wg-quick beim restart - // noch alte AllowedIPs aus /etc/wireguard/wg7.conf gelesen hat). - if err := ensureWGQuickSymlink(ifc.Name, path); err != nil { + // /etc/edgeguard/wireguard/ und symlinken via sudo — /etc/wireguard/ + // ist root:root 700, daher braucht es sudo /bin/ln. Das sudoers-Entry + // wird von postinst angelegt. + if err := symlinkWGQuickConf(ifc.Name, path); err != nil { return fmt.Errorf("symlink: %w", err) } if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, body.Bytes()) { @@ -173,19 +172,3 @@ func (g *Generator) renderIface(ctx context.Context, ifc models.WireguardInterfa return restartWGQuick(ifc.Name) } -// ensureWGQuickSymlink puts /etc/wireguard/.conf as a symlink -// pointing at our managed file in /etc/edgeguard/wireguard/. Idempotent -// — if the symlink already targets the right path we no-op; if the -// distro path holds a real (legacy) file we replace it. -func ensureWGQuickSymlink(iface, target string) error { - wgDir := "/etc/wireguard" - if err := os.MkdirAll(wgDir, 0o700); err != nil { - return err - } - link := filepath.Join(wgDir, iface+".conf") - if cur, err := os.Readlink(link); err == nil && cur == target { - return nil - } - _ = os.Remove(link) - return os.Symlink(target, link) -} diff --git a/packaging/debian/edgeguard-api/DEBIAN/postinst b/packaging/debian/edgeguard-api/DEBIAN/postinst index 58cc735..6a5817d 100755 --- a/packaging/debian/edgeguard-api/DEBIAN/postinst +++ b/packaging/debian/edgeguard-api/DEBIAN/postinst @@ -95,6 +95,9 @@ edgeguard ALL=(root) NOPASSWD: /bin/systemctl restart wg-quick@*.service edgeguard ALL=(root) NOPASSWD: /bin/systemctl stop wg-quick@*.service edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show all dump edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show * +# 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/* edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload squid.service edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload squid.service edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload unbound.service