// 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 // PreemptDelay > 0 → Node holt die VIP nach Erholung zurück (nach N Sekunden // Stabilität). 0 → nopreempt (bleibt Backup). Siehe buildView. PreemptDelay int } // preemptDelaySeconds: der bevorzugte Node (höhere Priorität = PG-Primary) holt // die VIP erst nach dieser Wartezeit zurück — lange genug, dass ein frisch // gebooteter/deployter Node erst wirklich bereit ist (Boot + Service-Start), // bevor er überhaupt preempten darf. Zusammen mit dem gehärteten Health-Check // (haproxy aktiv + :443 gebunden, keepalived-check.sh) verhindert das den // Incident 2026-08-03 (halb-kaputter Node riss die VIP an sich). const preemptDelaySeconds = 120 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; die Priorität entscheidet, welcher Node die VIP // bevorzugt hält (PG-Primary=200 > Standby=100). Mit preempt_delay holt // der bevorzugte Node die VIP nach Erholung zurück (VIP-Affinität zum // PG-Primary), aber erst nach preemptDelaySeconds Stabilität. // // Incident 2026-08-03 & Fix: eine frühere preempt_delay-Variante ließ den // Prio-200-Node die VIP zurückholen, sobald der Health-Check ihn für // „gesund" hielt — der prüfte aber NUR die edgeguard-api, nicht ob der Node // wirklich Traffic bedient. Ein halb-kaputter Primary (api up, haproxy/Netz // down) riss so die VIP an sich → Ausfall. Preempt ist wieder aktiv, WEIL // keepalived-check.sh jetzt zusätzlich haproxy-aktiv + :443-gebunden fordert: // ein nicht-bedienender Node geht in FAULT und kann NICHT preempten. // Promotion/PG-Failover bleibt manuell (edgeguard-ctl promote). v.State = "BACKUP" v.PreemptDelay = preemptDelaySeconds 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 }