fix(wg+fw): Peer-Sync-Bug via sudo-Symlink + Site-to-Site-Masquerade
- WireGuard-Peer-Änderungen landeten nicht im laufenden Interface: /etc/wireguard/ ist root:root 700, os.Readlink schlug fehl → ensureWGQuickSymlink fiel immer in den Error-Pfad. Fix: Symlink via sudo /bin/ln -sf (sudoers-Entry in postinst ergänzt). - Site-to-Site-Masquerade: Roadwarrior-Clients (z. B. 192.168.99.3) konnten LANs hinter anderen Peers nicht erreichen, weil das remote Gateway die VPN-Client-IP nicht als Tunnel-Route kannte. Fix: auto masquerade in nftables postrouting_nat pro WireGuard-Server-Interface (oifname "wg7" ip saddr 192.168.99.0/24 masquerade). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -111,6 +111,21 @@ type View struct {
|
|||||||
// interface) can be forwarded by the box. Without this, the
|
// interface) can be forwarded by the box. Without this, the
|
||||||
// forward policy=drop silently kills all inter-peer packets.
|
// forward policy=drop silently kills all inter-peer packets.
|
||||||
WGServerIfaces []string
|
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
|
// 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 ──
|
// ── Auto-Rules aus laufender Service-Config ──
|
||||||
view.AutoRules = g.loadAutoRules(ctx)
|
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,
|
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 {
|
if err == nil {
|
||||||
defer wgRows.Close()
|
defer wgRows.Close()
|
||||||
for wgRows.Next() {
|
for wgRows.Next() {
|
||||||
var name string
|
var name, cidr string
|
||||||
if wgRows.Scan(&name) == nil {
|
if wgRows.Scan(&name, &cidr) == nil {
|
||||||
view.WGServerIfaces = append(view.WGServerIfaces, name)
|
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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,16 @@ table inet edgeguard {
|
|||||||
# client-IP (für Logging / Geo-Block: später optional via
|
# client-IP (für Logging / Geo-Block: später optional via
|
||||||
# NAT-Rule-Flag preserve_client_ip).
|
# NAT-Rule-Flag preserve_client_ip).
|
||||||
ct status dnat masquerade
|
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"}}
|
{{range .NATRules}}{{if eq .Kind "snat"}}
|
||||||
# NAT {{.ID}} (snat{{if .Comment}} — {{.Comment}}{{end}})
|
# NAT {{.ID}} (snat{{if .Comment}} — {{.Comment}}{{end}})
|
||||||
{{""}}
|
{{""}}
|
||||||
|
|||||||
@@ -33,3 +33,16 @@ func stopWGQuick(iface string) error {
|
|||||||
_ = cmd.Run()
|
_ = cmd.Run()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// symlinkWGQuickConf creates (or atomically replaces) the symlink
|
||||||
|
// /etc/wireguard/<iface>.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
|
||||||
|
}
|
||||||
|
|||||||
@@ -157,11 +157,10 @@ func (g *Generator) renderIface(ctx context.Context, ifc models.WireguardInterfa
|
|||||||
path := filepath.Join(ConfDir, ifc.Name+".conf")
|
path := filepath.Join(ConfDir, ifc.Name+".conf")
|
||||||
// wg-quick@<iface>.service liest /etc/wireguard/<iface>.conf (Distro-
|
// wg-quick@<iface>.service liest /etc/wireguard/<iface>.conf (Distro-
|
||||||
// Default), nicht unseren ConfDir. Wir lassen die Quelle of truth in
|
// Default), nicht unseren ConfDir. Wir lassen die Quelle of truth in
|
||||||
// /etc/edgeguard/wireguard/ und symlinken einmalig — sonst lesen
|
// /etc/edgeguard/wireguard/ und symlinken via sudo — /etc/wireguard/
|
||||||
// wg-quick und unser Renderer aus zwei verschiedenen Files und
|
// ist root:root 700, daher braucht es sudo /bin/ln. Das sudoers-Entry
|
||||||
// driften auseinander (gefangen 2026-05-10 als wg-quick beim restart
|
// wird von postinst angelegt.
|
||||||
// noch alte AllowedIPs aus /etc/wireguard/wg7.conf gelesen hat).
|
if err := symlinkWGQuickConf(ifc.Name, path); err != nil {
|
||||||
if err := ensureWGQuickSymlink(ifc.Name, path); err != nil {
|
|
||||||
return fmt.Errorf("symlink: %w", err)
|
return fmt.Errorf("symlink: %w", err)
|
||||||
}
|
}
|
||||||
if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, body.Bytes()) {
|
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)
|
return restartWGQuick(ifc.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureWGQuickSymlink puts /etc/wireguard/<iface>.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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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: /bin/systemctl stop 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 symlink: /etc/wireguard/ ist root:root 700; edgeguard-api
|
||||||
|
# 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: /usr/bin/systemctl reload squid.service
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload squid.service
|
||||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload squid.service
|
edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload squid.service
|
||||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload unbound.service
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload unbound.service
|
||||||
|
|||||||
Reference in New Issue
Block a user