Ursache der „WireGuard reißt immer wieder ab"-Abrisse war NICHT die UniFi, sondern keepalived-Flapping im HA-Cluster: die VIP 89.163.205.100 (an der die UniFi-Site-to-Site hängt) wanderte bei ~17 VRRP-Wahlen/Tag zwischen utm-1/utm-2 → Tunnel-Abriss bei jeder Wahl. Drei Bugs: 1) Firewall ließ VRRP (IP-Proto 112) zwischen den Cluster-Peers NICHT zu (policy drop). Adverts überlebten nur via conntrack-Reverse-Matching → bei conntrack-Ablauf gedroppt → Peer promotet sich → Split-Brain. Fix: ruleset.nft.tpl erlaubt `ip/ip6 ... vrrp saddr @peer_ipv4/6`; firewall.go nimmt zusätzlich hb_src_ip/hb_peer_ip aus cluster_settings ins Peer-Set (deckt den Heartbeat-Pfad 169.254.0.x ab). 2) Kein nopreempt → erholter Node riss die VIP sofort zurück (Flap-Back); aggressiver gw-Check (fall 2 → 10s-Blip = Failover). Fix: keepalived.conf.tpl mit `nopreempt` in VI_1+VI_HB, chk_gateway fall 2→5; keepalived.go setzt State immer BACKUP (nopreempt wirkt nur in BACKUP), Priorität 200/100 aus pg_role bleibt → deckt sich mit „manuelles Promote". 3) keepalived-Boot-Race: Unit startete vor vlan500 (nur After=network-online) → „interface vlan500 doesn't exist" → permanenter CONFIG-Crash ohne Recovery (keepalived nach Reboot tot). Fix: postinst legt Drop-in mit After=/Wants=edgeguard-interfaces.service + Restart=on-failure an. Neuer Test internal/keepalived/keepalived_test.go (nopreempt/BACKUP/fall). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
213 lines
6.2 KiB
Go
213 lines
6.2 KiB
Go
// Package keepalived rendert /etc/keepalived/keepalived.conf aus
|
|
// cluster_settings (VIP/VRRP-Config) und ha_nodes (local vs. peer).
|
|
//
|
|
// Split-Brain-Strategie: kein Auto-Promote. notify_master loggt nur
|
|
// und sendet einen internen Alert. Promotion ist immer manuell via
|
|
// "edgeguard-ctl promote" — das ist die einzig sichere Option ohne
|
|
// externes Quorum in einem 2-Node-Cluster.
|
|
package keepalived
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
_ "embed"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
|
)
|
|
|
|
const ConfPath = "/etc/keepalived/keepalived.conf"
|
|
|
|
//go:embed keepalived.conf.tpl
|
|
var cfgTpl string
|
|
|
|
var tpl = template.Must(template.New("keepalived").Parse(cfgTpl))
|
|
|
|
// VIPEntry ist eine einzelne VIP-Adresse die keepalived verwaltet.
|
|
type VIPEntry struct {
|
|
Address string // z.B. 89.163.205.100
|
|
Prefix int // z.B. 24
|
|
Device string // z.B. eth0
|
|
}
|
|
|
|
// View ist der Template-Kontext.
|
|
type View struct {
|
|
State string // MASTER | BACKUP
|
|
Interface string // Interface für VRRP-Advertisements (VI_1)
|
|
RouterID int
|
|
Priority int // MASTER=200, BACKUP=100
|
|
SrcIP string // eigene Public-IP (unicast_src_ip)
|
|
PeerIP string // Peer-Public-IP (unicast_peer)
|
|
AuthPass string
|
|
VIPs []VIPEntry // alle is_vip=true Einträge aus ip_addresses
|
|
// Dual-path VRRP (Split-Brain-Schutz, Migration 0033)
|
|
HBInterface string
|
|
HBSrcIP string
|
|
HBPeerIP string
|
|
HBRouterID int
|
|
// GW-Tracking
|
|
GWCheckIP string
|
|
}
|
|
|
|
type generator struct {
|
|
pool *pgxpool.Pool
|
|
localID string
|
|
}
|
|
|
|
func New(pool *pgxpool.Pool, localID string) configgen.Generator {
|
|
return &generator{pool: pool, localID: localID}
|
|
}
|
|
|
|
func (g *generator) Name() string { return "keepalived" }
|
|
|
|
func (g *generator) Render(ctx context.Context) error {
|
|
cs, vips, local, peer, err := g.loadData(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("keepalived: load: %w", err)
|
|
}
|
|
if len(vips) == 0 {
|
|
// Keine VIPs konfiguriert → keepalived.conf nicht schreiben.
|
|
return nil
|
|
}
|
|
v := g.buildView(cs, vips, local, peer)
|
|
var buf bytes.Buffer
|
|
if err := tpl.Execute(&buf, v); err != nil {
|
|
return fmt.Errorf("keepalived: template: %w", err)
|
|
}
|
|
if err := configgen.AtomicWrite(ConfPath, buf.Bytes(), 0o640); err != nil {
|
|
return fmt.Errorf("keepalived: write: %w", err)
|
|
}
|
|
if err := reloadKeepalived(); err != nil {
|
|
return fmt.Errorf("keepalived: reload: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (g *generator) loadData(ctx context.Context) (*models.ClusterSettings, []VIPEntry, *models.HANode, *models.HANode, error) {
|
|
var cs models.ClusterSettings
|
|
row := g.pool.QueryRow(ctx, `
|
|
SELECT id, vip_address, vip_interface, vip_auth_pass, vrrp_router_id,
|
|
hb_interface, hb_src_ip, hb_peer_ip, hb_router_id, gw_check_ip
|
|
FROM cluster_settings WHERE id = 1`)
|
|
if err := row.Scan(&cs.ID, &cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID,
|
|
&cs.HBInterface, &cs.HBSrcIP, &cs.HBPeerIP, &cs.HBRouterID, &cs.GWCheckIP); err != nil {
|
|
return nil, nil, nil, nil, fmt.Errorf("cluster_settings: %w", err)
|
|
}
|
|
|
|
// Alle VIPs aus ip_addresses (is_vip=true, active=true) inkl. Interface-Name.
|
|
vipRows, err := g.pool.Query(ctx, `
|
|
SELECT ia.address, ia.prefix, ni.name
|
|
FROM ip_addresses ia
|
|
JOIN network_interfaces ni ON ia.interface_id = ni.id
|
|
WHERE ia.is_vip = true AND ia.active = true
|
|
ORDER BY ni.name, ia.address`)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, fmt.Errorf("ip_addresses: %w", err)
|
|
}
|
|
defer vipRows.Close()
|
|
var vips []VIPEntry
|
|
for vipRows.Next() {
|
|
var v VIPEntry
|
|
if err := vipRows.Scan(&v.Address, &v.Prefix, &v.Device); err != nil {
|
|
continue
|
|
}
|
|
vips = append(vips, v)
|
|
}
|
|
|
|
nodeRows, err := g.pool.Query(ctx, `SELECT id, fqdn, role, pg_role, public_ip, status FROM ha_nodes ORDER BY joined_at`)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, fmt.Errorf("ha_nodes: %w", err)
|
|
}
|
|
defer nodeRows.Close()
|
|
|
|
var local, peer *models.HANode
|
|
for nodeRows.Next() {
|
|
n := &models.HANode{}
|
|
if err := nodeRows.Scan(&n.ID, &n.FQDN, &n.Role, &n.PGRole, &n.PublicIP, &n.Status); err != nil {
|
|
continue
|
|
}
|
|
if n.ID == g.localID {
|
|
local = n
|
|
} else {
|
|
peer = n
|
|
}
|
|
}
|
|
if local == nil {
|
|
return nil, nil, nil, nil, fmt.Errorf("local node %s not in ha_nodes", g.localID)
|
|
}
|
|
return &cs, vips, local, peer, nil
|
|
}
|
|
|
|
func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local, peer *models.HANode) View {
|
|
v := View{
|
|
RouterID: cs.VRRPRouterID,
|
|
VIPs: vips,
|
|
Interface: deref(cs.VIPInterface),
|
|
AuthPass: deref(cs.VIPAuthPass),
|
|
HBInterface: deref(cs.HBInterface),
|
|
HBSrcIP: deref(cs.HBSrcIP),
|
|
HBPeerIP: deref(cs.HBPeerIP),
|
|
HBRouterID: cs.HBRouterID,
|
|
GWCheckIP: deref(cs.GWCheckIP),
|
|
}
|
|
if v.Interface == "" {
|
|
v.Interface = "eth0"
|
|
}
|
|
if v.AuthPass == "" {
|
|
v.AuthPass = "edgeguard"
|
|
}
|
|
if v.HBRouterID == 0 {
|
|
v.HBRouterID = 52
|
|
}
|
|
|
|
// State IMMER BACKUP: das Template setzt `nopreempt`, und nopreempt wirkt
|
|
// in keepalived NUR, wenn die Instanz im BACKUP-Zustand startet (bei state
|
|
// MASTER wird nopreempt ignoriert). Die Priorität entscheidet weiterhin die
|
|
// Initial-Election (primary=200 gewinnt), aber ein erholter Node reißt die
|
|
// VIP NICHT mehr zurück → kein Flap-Back / Split-Brain. Deckt sich mit der
|
|
// "kein Auto-Promote"-Philosophie: Promotion bleibt manuell.
|
|
// pg_role=standby ist das härtere Signal (Standby ist nie bevorzugter Node).
|
|
v.State = "BACKUP"
|
|
if local.PGRole == "standby" {
|
|
v.Priority = 100
|
|
} else if local.PGRole == "primary" || local.Role == "primary" {
|
|
v.Priority = 200
|
|
} else {
|
|
v.Priority = 100
|
|
}
|
|
|
|
if local.PublicIP != nil {
|
|
v.SrcIP = *local.PublicIP
|
|
}
|
|
if peer != nil && peer.PublicIP != nil {
|
|
v.PeerIP = *peer.PublicIP
|
|
}
|
|
return v
|
|
}
|
|
|
|
func reloadKeepalived() error {
|
|
if _, err := os.Stat("/run/keepalived.pid"); os.IsNotExist(err) {
|
|
// keepalived läuft noch nicht — erster Render beim Start.
|
|
return nil
|
|
}
|
|
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload-or-restart", "keepalived.service")
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("sudo systemctl reload-or-restart keepalived.service: %w (output: %s)", err, strings.TrimSpace(string(out)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func deref(s *string) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return *s
|
|
}
|