Files
edgeguard-native/internal/keepalived/keepalived.go
Debian 0846eaa05b feat(ha): VIP-Preempt zurück zum PG-Primary — mit gehärtetem Health-Gate — v1.3.16
Preempt-Rückkehr (preempt_delay 120) wieder aktiv: der bevorzugte Node
(PG-Primary, Prio 200) holt die VIP nach Erholung zurück. Der Incident
2026-08-03 (halb-kaputter Node riss die VIP an sich) wird verhindert, weil
keepalived-check.sh jetzt zusätzlich fordert:
  - haproxy-Prozess aktiv
  - :443 gebunden (bedient wirklich Traffic)
Ein nicht-bedienender Node geht damit in FAULT und kann NICHT (mehr) preempten.

Außerdem: CrowdSec-Management-Whitelist (Backend api_backend) fest ins postinst
gebacken (Admin-SPA-Traffic wird nie mehr als http-crawl gebannt, IP-unabhängig,
Incident-Root-Fix) + Altlast netcell-mgmt-whitelist.yaml wird aufgeräumt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 13:39:15 +02:00

231 lines
7.3 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
// 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
}