Files
edgeguard-native/internal/wireguard/wireguard.go
Debian df31bfa720 fix: Audit-Bugfixes (Auth/WAF/Firewall/Cluster/Renderer) — v1.2.94
Verifizierte Bugs aus dem Code-Audit behoben (je mit Test/Build/nft -c geprüft):
- session: IssueWithRoleTTL mutierte geteiltes s.TTL (Data-Race + falsche TTL) → interne issue(); -race-Test.
- auth: Fallback/Federation leiteten role/TOTP nicht aus DB ab (2FA-Bypass auf Secondary, Rolle aus Remote) → viaDB-Flag + DB-Re-Lookup.
- waf: TrustedProxies waren No-op (bogus-Direktive) → XFF-Auflösung im SPOE-Agent (rightmostXFF/ipMatchesAny); RuleExclusions/TrustedProxies validiert (Direktiven-Injection); GetForHost via net.SplitHostPort.
- firewall: Auto-Rule mit IPv6-DstIP erzeugte 'ip daddr <v6>' → bricht ganzes nft-Ruleset; jetzt familienbewusst (ip/ip6, ungültige raus).
- kea: 'interfaces': null bei 0 Subnets → leeres Array.
- cluster_repair: nodeHasPublication schluckte DB-Fehler (Resync auf falschem Node) → (bool,error) fail-closed; IPv6-Primary-URL via net.JoinHostPort.
- cluster_replication: Replikations-Passwort via stdin statt psql -c (nicht mehr in argv/Logs).
- wireguard: Config (Private Key) jetzt configgen.AtomicWrite VOR Symlink/enable; SkipReload-Feld.
- render.go: --no-reload jetzt für alle Renderer (squid/unbound/chrony/wireguard).
- radius: leeres Secret/Passwort + Newlines abgelehnt; freeradius confEscape strippt CR/LF.
- configorch: continue-on-error + errors.Join statt Abbruch mitten in der Sequenz.
- i18n: fehlender Key common.status (de/en).
Verworfen als kein Bug: WAF detection-'blocked' (DetectionOnly liefert keine Interruption), render secrets.New('') (nutzt Default-Masterkey), FanOut-Sort (nur Kommentar), pg_hba (durch nft abgesichert).
Offen/bewusst zurückgestellt (low/risk): AlertWriter-Close (langlebiger Worker, vernachlässigbar), Rolling-Update-Kleinkram (sudoers-gebundener Script-Pfad / GET-State).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:55:21 +02:00

263 lines
8.1 KiB
Go

// Package wireguard renders /etc/edgeguard/wireguard/<iface>.conf
// from the relational state in PG (wireguard_interfaces +
// wireguard_peers) and brings the corresponding wg-quick@<iface>
// service up. Each iface gets its own conf file; the renderer is
// idempotent — running it twice produces the same files and only
// reloads wg if the contents actually changed (mtime + content
// compare).
package wireguard
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
)
const ConfDir = "/etc/edgeguard/wireguard"
type Generator struct {
Pool *pgxpool.Pool
Box *secrets.Box
Ifaces *wgsvc.InterfacesRepo
Peers *wgsvc.PeersRepo
SkipReload bool // nur Configs schreiben, keine wg-quick@-Service-Aktionen
}
func New(pool *pgxpool.Pool, box *secrets.Box) *Generator {
return &Generator{
Pool: pool,
Box: box,
Ifaces: wgsvc.NewInterfacesRepo(pool),
Peers: wgsvc.NewPeersRepo(pool),
}
}
func (g *Generator) Name() string { return "wireguard" }
// RenderToString renders all active interface configs to a combined
// string for the config-preview endpoint. Private keys are redacted
// so the output is safe to display in the management UI.
func (g *Generator) RenderToString(ctx context.Context) (string, error) {
ifs, err := g.Ifaces.List(ctx)
if err != nil {
return "", fmt.Errorf("list ifaces: %w", err)
}
var combined strings.Builder
for _, ifc := range ifs {
if !ifc.Active {
continue
}
fmt.Fprintf(&combined, "# ── %s (%s) ────────────────────────────────\n", ifc.Name, ifc.Mode)
combined.WriteString("[Interface]\n")
fmt.Fprintf(&combined, "Address = %s\n", ifc.AddressCIDR)
combined.WriteString("PrivateKey = <redacted>\n")
if ifc.ListenPort != nil {
fmt.Fprintf(&combined, "ListenPort = %d\n", *ifc.ListenPort)
}
if ifc.MTU != nil {
fmt.Fprintf(&combined, "MTU = %d\n", *ifc.MTU)
}
combined.WriteString("\n")
switch ifc.Mode {
case "client":
if ifc.PeerPublicKey != nil && ifc.PeerEndpoint != nil {
combined.WriteString("[Peer]\n")
fmt.Fprintf(&combined, "PublicKey = %s\n", *ifc.PeerPublicKey)
fmt.Fprintf(&combined, "Endpoint = %s\n", *ifc.PeerEndpoint)
if ifc.AllowedIPs != nil && *ifc.AllowedIPs != "" {
fmt.Fprintf(&combined, "AllowedIPs = %s\n", *ifc.AllowedIPs)
} else {
combined.WriteString("AllowedIPs = 0.0.0.0/0,::/0\n")
}
if ifc.PersistentKeepalive != nil {
fmt.Fprintf(&combined, "PersistentKeepalive = %d\n", *ifc.PersistentKeepalive)
}
if len(ifc.PeerPSKEnc) > 0 {
combined.WriteString("PresharedKey = <redacted>\n")
}
}
case "server":
peers, err := g.Peers.ListForInterface(ctx, ifc.ID)
if err == nil {
sort.Slice(peers, func(i, j int) bool { return peers[i].Name < peers[j].Name })
for _, p := range peers {
if !p.Enabled {
continue
}
combined.WriteString("[Peer]\n")
fmt.Fprintf(&combined, "# %s\n", p.Name)
fmt.Fprintf(&combined, "PublicKey = %s\n", p.PublicKey)
fmt.Fprintf(&combined, "AllowedIPs = %s\n", p.AllowedIPs)
if p.Keepalive != nil {
fmt.Fprintf(&combined, "PersistentKeepalive = %d\n", *p.Keepalive)
}
if len(p.PSKEnc) > 0 {
combined.WriteString("PresharedKey = <redacted>\n")
}
combined.WriteString("\n")
}
}
}
combined.WriteString("\n")
}
if combined.Len() == 0 {
return "# No active WireGuard interfaces configured.\n", nil
}
return combined.String(), nil
}
func (g *Generator) Render(ctx context.Context) error {
if err := os.MkdirAll(ConfDir, 0o700); err != nil {
return fmt.Errorf("mkdir %s: %w", ConfDir, err)
}
ifs, err := g.Ifaces.List(ctx)
if err != nil {
return fmt.Errorf("list ifaces: %w", err)
}
wantNames := map[string]bool{}
for _, ifc := range ifs {
if !ifc.Active {
continue
}
wantNames[ifc.Name] = true
if err := g.renderIface(ctx, ifc); err != nil {
return fmt.Errorf("iface %s: %w", ifc.Name, err)
}
}
// Tidy up: any .conf in ConfDir that doesn't correspond to an
// active iface gets removed and its wg-quick@ stopped — keeps
// kernel state in sync after a delete.
entries, err := os.ReadDir(ConfDir)
if err == nil {
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".conf") {
continue
}
ifaceName := strings.TrimSuffix(e.Name(), ".conf")
if wantNames[ifaceName] {
continue
}
_ = os.Remove(filepath.Join(ConfDir, e.Name()))
if !g.SkipReload {
_ = stopWGQuick(ifaceName)
_ = disableWGQuick(ifaceName)
}
}
}
return nil
}
func (g *Generator) renderIface(ctx context.Context, ifc models.WireguardInterface) error {
priv, err := g.Box.Open(ifc.PrivateKeyEnc)
if err != nil {
return fmt.Errorf("decrypt private key: %w", err)
}
var body bytes.Buffer
body.WriteString("# Generated by edgeguard — do not edit by hand.\n")
body.WriteString("[Interface]\n")
fmt.Fprintf(&body, "Address = %s\n", ifc.AddressCIDR)
fmt.Fprintf(&body, "PrivateKey = %s\n", string(priv))
if ifc.ListenPort != nil {
fmt.Fprintf(&body, "ListenPort = %d\n", *ifc.ListenPort)
}
if ifc.MTU != nil {
fmt.Fprintf(&body, "MTU = %d\n", *ifc.MTU)
}
body.WriteString("\n")
switch ifc.Mode {
case "client":
if ifc.PeerPublicKey == nil || ifc.PeerEndpoint == nil {
return errors.New("client iface missing peer_endpoint or peer_public_key")
}
body.WriteString("[Peer]\n")
fmt.Fprintf(&body, "PublicKey = %s\n", *ifc.PeerPublicKey)
fmt.Fprintf(&body, "Endpoint = %s\n", *ifc.PeerEndpoint)
if ifc.AllowedIPs != nil && *ifc.AllowedIPs != "" {
fmt.Fprintf(&body, "AllowedIPs = %s\n", *ifc.AllowedIPs)
} else {
body.WriteString("AllowedIPs = 0.0.0.0/0,::/0\n")
}
if ifc.PersistentKeepalive != nil {
fmt.Fprintf(&body, "PersistentKeepalive = %d\n", *ifc.PersistentKeepalive)
}
if len(ifc.PeerPSKEnc) > 0 {
psk, err := g.Box.Open(ifc.PeerPSKEnc)
if err != nil {
return fmt.Errorf("decrypt peer psk: %w", err)
}
fmt.Fprintf(&body, "PresharedKey = %s\n", string(psk))
}
case "server":
peers, err := g.Peers.ListForInterface(ctx, ifc.ID)
if err != nil {
return fmt.Errorf("list peers: %w", err)
}
sort.Slice(peers, func(i, j int) bool { return peers[i].Name < peers[j].Name })
for _, p := range peers {
if !p.Enabled {
continue
}
body.WriteString("[Peer]\n")
fmt.Fprintf(&body, "# %s\n", p.Name)
fmt.Fprintf(&body, "PublicKey = %s\n", p.PublicKey)
fmt.Fprintf(&body, "AllowedIPs = %s\n", p.AllowedIPs)
if p.Keepalive != nil {
fmt.Fprintf(&body, "PersistentKeepalive = %d\n", *p.Keepalive)
}
if len(p.PSKEnc) > 0 {
psk, err := g.Box.Open(p.PSKEnc)
if err != nil {
return fmt.Errorf("decrypt peer %s psk: %w", p.Name, err)
}
fmt.Fprintf(&body, "PresharedKey = %s\n", string(psk))
}
body.WriteString("\n")
}
}
path := filepath.Join(ConfDir, ifc.Name+".conf")
// Config (enthält den Private Key) ZUERST atomar schreiben — vorher
// keinen Symlink/Service auf eine evtl. fehlende/abgeschnittene Datei
// zeigen lassen. AtomicWrite = temp+fsync+rename, 0600.
changed := true
if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, body.Bytes()) {
changed = false
}
if changed {
if err := configgen.AtomicWrite(path, body.Bytes(), 0o600); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
}
if g.SkipReload {
return nil
}
// wg-quick@<iface>.service liest /etc/wireguard/<iface>.conf (Distro-
// Default), nicht unseren ConfDir. Symlink via sudo (/etc/wireguard/
// ist root:root 700). Das sudoers-Entry wird von postinst angelegt.
if err := symlinkWGQuickConf(ifc.Name, path); err != nil {
return fmt.Errorf("symlink: %w", err)
}
_ = enableWGQuick(ifc.Name)
if !changed {
return startWGQuick(ifc.Name)
}
return restartWGQuick(ifc.Name)
}