// 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 } // pg_role=standby ist das härtere Signal — ein Standby-Node ist niemals // MASTER, auch wenn role='primary' noch aus dem Join-Prozess stammt. // Reihenfolge: standby → BACKUP; sonst primary-Check. if local.PGRole == "standby" { v.State = "BACKUP" v.Priority = 100 } else if local.PGRole == "primary" || local.Role == "primary" { v.State = "MASTER" v.Priority = 200 } else { v.State = "BACKUP" 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 }