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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user