// 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)) // preemptDelaySeconds: wie lange der bevorzugte Node (PG-Primary, Prio 200) // nach seiner Erholung STABIL sein muss, bevor er die VIP zurückholt. Lang // genug, dass ein kurzer Deploy-/VM-Hick keinen sofortigen Flap-Back auslöst, // kurz genug, dass die VIP zeitnah zum Primary heimwandert. const preemptDelaySeconds = 120 // 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 // PreemptDelay > 0: dieser Node holt die VIP nach `preempt_delay` // Sekunden stabiler Erholung zurück (nur der bevorzugte PG-Primary, // Prio 200). 0 = nopreempt (Standby reißt die VIP nie an sich). PreemptDelay int // 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 (kein Node startet als MASTER — sonst würde // nopreempt/preempt_delay ignoriert). Die Priorität entscheidet die // Election: der PG-Primary (200) ist der bevorzugte VIP-Home-Node. // // Preemption-Strategie: // - Bevorzugter Node (Prio 200, PG-Primary): `preempt_delay` — holt die // VIP nach preemptDelaySeconds STABILER Erholung zurück. So „wandert" // die VIP nach einem Failover/Deploy-Blip von selbst wieder heim zum // Primary (gewünschtes Verhalten), aber der Delay + gw-check + // Heartbeat verhindern Flap-Back bei kurzen Hicks. // - Standby (Prio 100): `nopreempt` — reißt die VIP NIE an sich // (Split-Brain-Schutz; Promotion zum Primary bleibt manuell). // pg_role=standby ist das härtere Signal (Standby ist nie bevorzugt). v.State = "BACKUP" if local.PGRole == "standby" { v.Priority = 100 } else if local.PGRole == "primary" || local.Role == "primary" { v.Priority = 200 v.PreemptDelay = preemptDelaySeconds } 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 }