// 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" "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)) // View ist der Template-Kontext. type View struct { State string // MASTER | BACKUP Interface string RouterID int Priority int // MASTER=200, BACKUP=100 SrcIP string // eigene Public-IP (für unicast_src_ip) PeerIP string // Peer-Public-IP (für unicast_peer) AuthPass string VIP 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, local, peer, err := g.loadData(ctx) if err != nil { return fmt.Errorf("keepalived: load: %w", err) } if cs.VIPAddress == nil || *cs.VIPAddress == "" { // Kein VIP konfiguriert → keepalived.conf nicht schreiben. return nil } v := g.buildView(cs, 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, *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 FROM cluster_settings WHERE id = 1`) if err := row.Scan(&cs.ID, &cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID); err != nil { return nil, nil, nil, fmt.Errorf("cluster_settings: %w", err) } rows, 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, fmt.Errorf("ha_nodes: %w", err) } defer rows.Close() var local, peer *models.HANode for rows.Next() { n := &models.HANode{} if err := rows.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, fmt.Errorf("local node %s not in ha_nodes", g.localID) } return &cs, local, peer, nil } func (g *generator) buildView(cs *models.ClusterSettings, local, peer *models.HANode) View { v := View{ RouterID: cs.VRRPRouterID, VIP: deref(cs.VIPAddress), Interface: deref(cs.VIPInterface), AuthPass: deref(cs.VIPAuthPass), } if v.Interface == "" { v.Interface = "eth0" } if v.AuthPass == "" { v.AuthPass = "edgeguard" } // Primary-Node bekommt höhere Priorität und startet als MASTER. 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 } return exec.Command("systemctl", "reload-or-restart", "keepalived").Run() } func deref(s *string) string { if s == nil { return "" } return *s }