feat: HA-Cluster v1.2.x — Split-Brain, TOTP, Enterprise-FW, Drift-Fix, VIP-Recovery

- keepalived: pg_role='standby' hat Vorrang vor role für BACKUP-Bestimmung
- keepalived-master.sh: gecrasht Dienste beim MASTER-Übergang starten (nicht nur reload)
- confighash: ip_addresses per Interface-Name hashen statt per FK (Cross-Node-Drift-Fix)
- TOTP/2FA: RFC 6238 — Setup-Flow, QR-Code, Admin-Disable; two-step Login
- Firewall-UI: Enterprise-Design — auto-Beschreibung, icon-only Actions, zero-hit Indikator
- fe80-Filter: Link-local IPv6 aus NTP/DNS Listen-Dropdowns entfernen
- VIP-Dashboard, Dual-Path VRRP, GW-Tracking (Migrations 0033/0034)
- Forward Proxy + DNS erweiterte Einstellungen (Migrations 0031/0032)
- unbound-control: edgeguard in unbound-Gruppe via postinst

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-31 18:18:31 +02:00
parent 49899e984c
commit 1d06b28064
55 changed files with 3132 additions and 672 deletions

View File

@@ -14,6 +14,7 @@ import (
"fmt"
"os"
"os/exec"
"strings"
"text/template"
"github.com/jackc/pgx/v5/pgxpool"
@@ -29,16 +30,30 @@ 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
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
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 {
@@ -53,15 +68,15 @@ func New(pool *pgxpool.Pool, localID string) configgen.Generator {
func (g *generator) Name() string { return "keepalived" }
func (g *generator) Render(ctx context.Context) error {
cs, local, peer, err := g.loadData(ctx)
cs, vips, 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.
if len(vips) == 0 {
// Keine VIPs konfiguriert → keepalived.conf nicht schreiben.
return nil
}
v := g.buildView(cs, local, peer)
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)
@@ -75,23 +90,47 @@ func (g *generator) Render(ctx context.Context) error {
return nil
}
func (g *generator) loadData(ctx context.Context) (*models.ClusterSettings, *models.HANode, *models.HANode, error) {
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 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)
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)
}
rows, err := g.pool.Query(ctx, `SELECT id, fqdn, role, pg_role, public_ip, status FROM ha_nodes ORDER BY joined_at`)
// 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, fmt.Errorf("ha_nodes: %w", err)
return nil, nil, nil, nil, fmt.Errorf("ip_addresses: %w", err)
}
defer rows.Close()
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 rows.Next() {
for nodeRows.Next() {
n := &models.HANode{}
if err := rows.Scan(&n.ID, &n.FQDN, &n.Role, &n.PGRole, &n.PublicIP, &n.Status); err != nil {
if err := nodeRows.Scan(&n.ID, &n.FQDN, &n.Role, &n.PGRole, &n.PublicIP, &n.Status); err != nil {
continue
}
if n.ID == g.localID {
@@ -101,17 +140,22 @@ func (g *generator) loadData(ctx context.Context) (*models.ClusterSettings, *mod
}
}
if local == nil {
return nil, nil, nil, fmt.Errorf("local node %s not in ha_nodes", g.localID)
return nil, nil, nil, nil, fmt.Errorf("local node %s not in ha_nodes", g.localID)
}
return &cs, local, peer, nil
return &cs, vips, local, peer, nil
}
func (g *generator) buildView(cs *models.ClusterSettings, local, peer *models.HANode) View {
func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local, peer *models.HANode) View {
v := View{
RouterID: cs.VRRPRouterID,
VIP: deref(cs.VIPAddress),
Interface: deref(cs.VIPInterface),
AuthPass: deref(cs.VIPAuthPass),
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"
@@ -119,9 +163,17 @@ func (g *generator) buildView(cs *models.ClusterSettings, local, peer *models.HA
if v.AuthPass == "" {
v.AuthPass = "edgeguard"
}
if v.HBRouterID == 0 {
v.HBRouterID = 52
}
// Primary-Node bekommt höhere Priorität und startet als MASTER.
if local.PGRole == "primary" || local.Role == "primary" {
// 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 {
@@ -143,7 +195,11 @@ func reloadKeepalived() error {
// keepalived läuft noch nicht — erster Render beim Start.
return nil
}
return exec.Command("systemctl", "reload-or-restart", "keepalived").Run()
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 {