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

@@ -75,6 +75,8 @@ func (h *ClusterHandler) Register(rg *gin.RouterGroup) {
g.PUT("/vip-settings", h.UpdateVIPSettings)
g.POST("/rolling-update", h.RollingUpdate)
g.GET("/rolling-update/status", h.RollingUpdateStatus)
g.GET("/vip-status", h.VIPStatus)
g.POST("/vip-test", h.VIPTest)
if h.TLSStore != nil {
g.GET("/cert-status", h.CertStatus)
g.POST("/renew-self", h.RenewSelf)
@@ -130,9 +132,12 @@ func (h *ClusterHandler) GetVIPSettings(c *gin.Context) {
return
}
var cs vipSettingsRow
row := h.Store.Pool.QueryRow(c.Request.Context(),
`SELECT vip_address, vip_interface, vip_auth_pass, vrrp_router_id FROM cluster_settings WHERE id = 1`)
if err := row.Scan(&cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID); err != nil {
row := h.Store.Pool.QueryRow(c.Request.Context(), `
SELECT 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.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID,
&cs.HBInterface, &cs.HBSrcIP, &cs.HBPeerIP, &cs.HBRouterID, &cs.GWCheckIP); err != nil {
response.Internal(c, err)
return
}
@@ -154,10 +159,14 @@ func (h *ClusterHandler) UpdateVIPSettings(c *gin.Context) {
}
_, err := h.Store.Pool.Exec(c.Request.Context(), `
UPDATE cluster_settings
SET vip_address=$1, vip_interface=$2, vip_auth_pass=$3, vrrp_router_id=$4, updated_at=NOW()
SET vip_address=$1, vip_interface=$2, vip_auth_pass=$3, vrrp_router_id=$4,
hb_interface=$5, hb_src_ip=$6, hb_peer_ip=$7, hb_router_id=$8, gw_check_ip=$9,
updated_at=NOW()
WHERE id=1`,
nullIfEmpty(req.VIPAddress), nullIfEmpty(req.VIPInterface),
nullIfEmpty(req.VIPAuthPass), req.VRRPRouterID)
nullIfEmpty(req.VIPAuthPass), req.VRRPRouterID,
nullIfEmpty(req.HBInterface), nullIfEmpty(req.HBSrcIP),
nullIfEmpty(req.HBPeerIP), req.HBRouterID, nullIfEmpty(req.GWCheckIP))
if err != nil {
response.Internal(c, err)
return
@@ -181,6 +190,11 @@ type vipSettingsRow struct {
VIPInterface *string `json:"vip_interface"`
VIPAuthPass *string `json:"vip_auth_pass"`
VRRPRouterID int `json:"vrrp_router_id"`
HBInterface *string `json:"hb_interface"`
HBSrcIP *string `json:"hb_src_ip"`
HBPeerIP *string `json:"hb_peer_ip"`
HBRouterID int `json:"hb_router_id"`
GWCheckIP *string `json:"gw_check_ip"`
}
func nullIfEmpty(s *string) *string {
@@ -218,6 +232,9 @@ func (h *ClusterHandler) RegisterAgent(rg *gin.RouterGroup) {
g.GET("/master-key", h.AgentMasterKey)
g.GET("/version", h.AgentVersion)
g.POST("/trigger-update", h.AgentTriggerUpdate)
g.GET("/active-ips", h.AgentActiveIPs)
g.POST("/vip-cmd", h.AgentVIPCmd)
g.GET("/tls-certs", h.AgentTLSCerts)
}
// AgentIdentity gibt die eigene ha_nodes-Row zurück. Wird vom Primary