// Package wireguard renders /etc/edgeguard/wireguard/.conf // from the relational state in PG (wireguard_interfaces + // wireguard_peers) and brings the corresponding wg-quick@ // 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/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 } 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" } 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())) _ = stopWGQuick(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") // 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 { return fmt.Errorf("symlink: %w", err) } if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, body.Bytes()) { return startWGQuick(ifc.Name) } if err := os.WriteFile(path, body.Bytes(), 0o600); err != nil { return fmt.Errorf("write %s: %w", path, err) } 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) }