- 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>
175 lines
5.1 KiB
Go
175 lines
5.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/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@<iface>.service liest /etc/wireguard/<iface>.conf (Distro-
|
|
// Default), nicht unseren ConfDir. Wir lassen die Quelle of truth in
|
|
// /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()) {
|
|
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)
|
|
}
|
|
|