v1.3.9 hatte auf dem PG-Primary (Prio 200) nopreempt durch preempt_delay ersetzt, damit die VIP zum Primary heimwandert. Das reaktivierte GENAU den Fehlermodus, den nopreempt verhindert: der Prio-200-Node holt die VIP zurück, sobald keepalived ihn für gesund hält — aber die Track-Scripts können "gesund" melden, während der Dienst kaputt ist. Am 2026-08-03 entriss so ein halb-kaputtes utm-1 dem funktionierenden utm-2 die VIP (Log: "Master received advert from .6 with higher priority 200 → Entering BACKUP") und hielt sie fest → Ausfall, bis utm-1 hart abgeschaltet wurde. Zurück auf nopreempt (beide Nodes, wie vor v1.3.9). VIP-Affinität zum Primary erst wieder, wenn der Health-Check "Prozess up aber Dienst kaputt" als FAULT erkennt. Bis dahin: Stabilität > Affinität. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
220 lines
6.7 KiB
Go
220 lines
6.7 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 + `nopreempt` auf BEIDEN Nodes. Die Priorität
|
|
// entscheidet die INITIAL-Election (primary=200 gewinnt), aber ein
|
|
// erholter Node reißt die VIP NIEMALS zurück.
|
|
//
|
|
// WARUM kein Preempt (Incident 2026-08-03): eine frühere `preempt_delay`-
|
|
// Variante ließ den Prio-200-Node die VIP zurückholen, sobald keepalived
|
|
// ihn für „gesund" hielt. Die Track-Scripts (chk_edgeguard/chk_gateway)
|
|
// können aber „gesund" melden, während der eigentliche Dienst noch kaputt
|
|
// ist → der halb-kaputte Primary entriss dem funktionierenden Standby die
|
|
// VIP und hielt sie fest (Ausfall, bis der Primary hart abgeschaltet
|
|
// wurde). nopreempt verhindert genau das. VIP-Affinität zum Primary ist
|
|
// erst wieder vertretbar, wenn der Health-Check „Prozess up aber Dienst
|
|
// kaputt" zuverlässig als FAULT erkennt. Bis dahin gilt: Stabilität >
|
|
// Affinität. Promotion/PG-Failover bleibt manuell (edgeguard-ctl promote).
|
|
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
|
|
}
|