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:
@@ -60,15 +60,22 @@ func (h *AuthHandler) WithClusterTLS(store *clustertls.Store) *AuthHandler {
|
||||
return h
|
||||
}
|
||||
|
||||
const totpPendingCookie = "edgeguard_totp_pending"
|
||||
|
||||
// Register mounts /auth/login + /logout (public) and /auth/me
|
||||
// (gated by requireAuth, passed in as a per-route middleware).
|
||||
func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc) {
|
||||
g := rg.Group("/auth")
|
||||
g.POST("/login", h.Login)
|
||||
g.POST("/logout", h.Logout)
|
||||
g.POST("/totp-verify", h.TOTPVerify)
|
||||
g.GET("/me", requireAuth, h.Me)
|
||||
g.POST("/reset-password", h.ResetPassword)
|
||||
g.POST("/change-password", requireAuth, h.ChangePassword)
|
||||
// TOTP self-service (authenticated user manages own 2FA)
|
||||
g.POST("/totp/setup", requireAuth, h.TOTPSetup)
|
||||
g.POST("/totp/confirm", requireAuth, h.TOTPConfirm)
|
||||
g.DELETE("/totp", requireAuth, h.TOTPDisable)
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -77,9 +84,10 @@ type loginRequest struct {
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
Actor string `json:"actor"`
|
||||
Role string `json:"role"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Actor string `json:"actor"`
|
||||
Role string `json:"role"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
TOTPRequired bool `json:"totp_required,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
@@ -101,12 +109,13 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
email := strings.TrimSpace(req.Email)
|
||||
actor, role := "", "admin"
|
||||
remote := c.ClientIP()
|
||||
var totpEnabled bool
|
||||
|
||||
// 1. Try DB users table first.
|
||||
if h.Users != nil {
|
||||
u, hash, dbErr := h.Users.FindByEmail(c.Request.Context(), email)
|
||||
ai, dbErr := h.Users.FindForAuth(c.Request.Context(), email)
|
||||
if dbErr == nil {
|
||||
if !u.Active {
|
||||
if !ai.Active {
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
|
||||
email, gin.H{"reason": "account_disabled", "remote": remote}, h.NodeID)
|
||||
@@ -114,7 +123,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
response.Unauthorized(c, errors.New("account_disabled"))
|
||||
return
|
||||
}
|
||||
if !usersvc.VerifyPassword(hash, req.Password) {
|
||||
if !usersvc.VerifyPassword(ai.PasswordHash, req.Password) {
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
|
||||
email, gin.H{"reason": "invalid_credentials", "remote": remote}, h.NodeID)
|
||||
@@ -122,9 +131,10 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
response.Unauthorized(c, errors.New("invalid_credentials"))
|
||||
return
|
||||
}
|
||||
actor = u.Email
|
||||
role = u.Role
|
||||
h.Users.RecordLogin(c.Request.Context(), u.ID)
|
||||
actor = ai.Email
|
||||
role = ai.Role
|
||||
totpEnabled = ai.TOTPEnabled
|
||||
h.Users.RecordLogin(c.Request.Context(), ai.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,16 +143,13 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
if strings.EqualFold(st.AdminEmail, email) && st.VerifyAdminPassword(req.Password) {
|
||||
actor = st.AdminEmail
|
||||
role = "admin"
|
||||
// Auto-migrate: insert the setup-store admin into the DB so it
|
||||
// shows up in user management from this point on.
|
||||
if h.Users != nil {
|
||||
_, _ = h.Users.Upsert(c.Request.Context(), st.AdminEmail, req.Password, "admin", true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Auth federation: cluster nodes forward failed auth to the primary
|
||||
// via mTLS so users can log in with their primary credentials on any node.
|
||||
// 3. Auth federation: cluster nodes forward failed auth to the primary.
|
||||
if actor == "" && st.IsClusterNode && st.PrimaryFQDN != "" && h.ClusterTLS != nil {
|
||||
if a, r, err := h.checkWithPrimary(c.Request.Context(), st.PrimaryFQDN, email, req.Password); err == nil {
|
||||
actor = a
|
||||
@@ -161,6 +168,21 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// TOTP gate: password OK but 2FA required → issue a short-lived pending
|
||||
// cookie and tell the UI to show the TOTP input.
|
||||
if totpEnabled {
|
||||
pending, ptok, err := h.Signer.IssueWithRoleTTL(actor, "totp_pending", 2*time.Minute)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
c.SetSameSite(http.SameSiteStrictMode)
|
||||
c.SetCookie(totpPendingCookie, pending, int(2*time.Minute/time.Second), "/", "", true, true)
|
||||
_ = ptok
|
||||
response.OK(c, loginResponse{TOTPRequired: true})
|
||||
return
|
||||
}
|
||||
|
||||
raw, tok, err := h.Signer.IssueWithRole(actor, role)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
@@ -179,6 +201,146 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
type totpVerifyRequest struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
|
||||
// TOTPVerify completes the two-step login: verifies the TOTP code from the
|
||||
// pending cookie and, on success, issues a full session JWT.
|
||||
func (h *AuthHandler) TOTPVerify(c *gin.Context) {
|
||||
var req totpVerifyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
pendingRaw, err := c.Cookie(totpPendingCookie)
|
||||
if err != nil || pendingRaw == "" {
|
||||
response.Unauthorized(c, errors.New("no_pending_totp"))
|
||||
return
|
||||
}
|
||||
ptok, err := h.Signer.Verify(pendingRaw)
|
||||
if err != nil || ptok.Role != "totp_pending" {
|
||||
response.Unauthorized(c, errors.New("invalid_pending_token"))
|
||||
return
|
||||
}
|
||||
|
||||
if h.Users == nil {
|
||||
response.Internal(c, errors.New("users repo unavailable"))
|
||||
return
|
||||
}
|
||||
ai, err := h.Users.FindForAuth(c.Request.Context(), ptok.Actor)
|
||||
if err != nil || !ai.TOTPEnabled || ai.TOTPSecret == nil {
|
||||
response.Unauthorized(c, errors.New("totp_not_configured"))
|
||||
return
|
||||
}
|
||||
if !usersvc.VerifyTOTP(*ai.TOTPSecret, req.Code) {
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), ptok.Actor, "auth.totp.failed",
|
||||
ptok.Actor, gin.H{"remote": c.ClientIP()}, h.NodeID)
|
||||
}
|
||||
response.Unauthorized(c, errors.New("invalid_totp_code"))
|
||||
return
|
||||
}
|
||||
|
||||
// Clear pending cookie, issue full session.
|
||||
c.SetSameSite(http.SameSiteStrictMode)
|
||||
c.SetCookie(totpPendingCookie, "", -1, "/", "", true, true)
|
||||
|
||||
raw, tok, err := h.Signer.IssueWithRole(ptok.Actor, ai.Role)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
setSessionCookie(c, raw, tok.Exp)
|
||||
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), ptok.Actor, "auth.login.success",
|
||||
ptok.Actor, gin.H{"role": ai.Role, "remote": c.ClientIP(), "totp": true}, h.NodeID)
|
||||
}
|
||||
response.OK(c, loginResponse{
|
||||
Actor: tok.Actor,
|
||||
Role: tok.Role,
|
||||
ExpiresAt: time.Unix(tok.Exp, 0).UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
// TOTPSetup generates a new TOTP secret for the authenticated user and returns
|
||||
// the provisioning URI (renders as QR code in the UI). Secret is not saved yet.
|
||||
func (h *AuthHandler) TOTPSetup(c *gin.Context) {
|
||||
tok := CurrentToken(c)
|
||||
if tok == nil {
|
||||
response.Unauthorized(c, nil)
|
||||
return
|
||||
}
|
||||
secret, uri, err := usersvc.GenerateTOTPSecret(tok.Actor)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"secret": secret, "uri": uri})
|
||||
}
|
||||
|
||||
type totpConfirmRequest struct {
|
||||
Secret string `json:"secret" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
|
||||
// TOTPConfirm verifies the code against the provisioned secret and, on success,
|
||||
// enables TOTP for the user.
|
||||
func (h *AuthHandler) TOTPConfirm(c *gin.Context) {
|
||||
var req totpConfirmRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
tok := CurrentToken(c)
|
||||
if tok == nil || h.Users == nil {
|
||||
response.Unauthorized(c, nil)
|
||||
return
|
||||
}
|
||||
u, _, err := h.Users.FindByEmail(c.Request.Context(), tok.Actor)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.Users.ConfirmTOTP(c.Request.Context(), u.ID, req.Secret, req.Code); err != nil {
|
||||
if err.Error() == "invalid_totp_code" {
|
||||
response.Err(c, http.StatusUnprocessableEntity, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), tok.Actor, "auth.totp.enabled",
|
||||
tok.Actor, nil, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// TOTPDisable disables TOTP for the authenticated user.
|
||||
func (h *AuthHandler) TOTPDisable(c *gin.Context) {
|
||||
tok := CurrentToken(c)
|
||||
if tok == nil || h.Users == nil {
|
||||
response.Unauthorized(c, nil)
|
||||
return
|
||||
}
|
||||
u, _, err := h.Users.FindByEmail(c.Request.Context(), tok.Actor)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.Users.DisableTOTP(c.Request.Context(), u.ID); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), tok.Actor, "auth.totp.disabled",
|
||||
tok.Actor, nil, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
clearSessionCookie(c)
|
||||
response.OK(c, gin.H{"logged_out": true})
|
||||
|
||||
@@ -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
|
||||
|
||||
118
internal/handlers/cluster_certsync.go
Normal file
118
internal/handlers/cluster_certsync.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
const tlsCertDir = "/etc/edgeguard/tls"
|
||||
|
||||
// AgentTLSCerts liefert alle .pem-Dateien aus /etc/edgeguard/tls/ als
|
||||
// Base64-Map. Wird vom Secondary via mTLS aufgerufen um Zertifikate
|
||||
// des Primary zu spiegeln.
|
||||
func (h *ClusterHandler) AgentTLSCerts(c *gin.Context) {
|
||||
entries, err := os.ReadDir(tlsCertDir)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
certs := make(map[string]string, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".pem") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(tlsCertDir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
certs[e.Name()] = base64.StdEncoding.EncodeToString(data)
|
||||
}
|
||||
response.OK(c, gin.H{"certs": certs})
|
||||
}
|
||||
|
||||
// SyncTLSCertsFromPrimary holt alle TLS-Zertifikate vom Primary via mTLS
|
||||
// und schreibt geänderte Dateien nach /etc/edgeguard/tls/. Relädt HAProxy
|
||||
// wenn mindestens ein Zertifikat aktualisiert wurde.
|
||||
//
|
||||
// Läuft auf dem Secondary bei jedem runSecondaryConfigRender-Tick —
|
||||
// nicht hash-gated, da certbot-Renewals den config_hash nicht ändern.
|
||||
func SyncTLSCertsFromPrimary(ctx context.Context, pool *pgxpool.Pool, agg *aggregator.Aggregator, localID string) error {
|
||||
if agg == nil {
|
||||
return nil
|
||||
}
|
||||
// Primary-Peer aus ha_nodes ermitteln
|
||||
rows, err := pool.Query(ctx,
|
||||
`SELECT id, fqdn, api_url FROM ha_nodes WHERE id != $1 LIMIT 1`, localID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
var primary *models.HANode
|
||||
for rows.Next() {
|
||||
n := &models.HANode{}
|
||||
if err := rows.Scan(&n.ID, &n.FQDN, &n.APIURL); err != nil {
|
||||
continue
|
||||
}
|
||||
primary = n
|
||||
}
|
||||
if primary == nil {
|
||||
return nil // kein Peer → Single-Node
|
||||
}
|
||||
|
||||
results := agg.FanOut(ctx, []models.HANode{*primary}, "/agent/cluster/tls-certs", localID)
|
||||
if len(results) == 0 || !results[0].OK {
|
||||
return nil // Primary nicht erreichbar — nächster Tick
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Certs map[string]string `json:"certs"`
|
||||
}
|
||||
if err := json.Unmarshal(results[0].Data, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(tlsCertDir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
changed := false
|
||||
for name, b64 := range payload.Certs {
|
||||
data, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
slog.Warn("cert-sync: base64 decode failed", "file", name, "error", err)
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(tlsCertDir, name)
|
||||
existing, readErr := os.ReadFile(path)
|
||||
if readErr == nil && bytes.Equal(existing, data) {
|
||||
continue // unverändert
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o640); err != nil {
|
||||
slog.Warn("cert-sync: write failed", "file", name, "error", err)
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
slog.Info("cert-sync: updated", "file", name)
|
||||
}
|
||||
|
||||
if changed {
|
||||
if err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").Run(); err != nil {
|
||||
slog.Warn("cert-sync: haproxy reload failed", "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
319
internal/handlers/cluster_viptest.go
Normal file
319
internal/handlers/cluster_viptest.go
Normal file
@@ -0,0 +1,319 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
// vipInfo enthält die für einen VIP-Schwenk notwendigen Daten.
|
||||
type vipInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
Address string `json:"address"`
|
||||
Prefix int `json:"prefix"`
|
||||
Device string `json:"device"`
|
||||
}
|
||||
|
||||
// VIPStatusEntry kombiniert einen VIP mit den Nodes die ihn gerade halten.
|
||||
type VIPStatusEntry struct {
|
||||
VIP vipInfo `json:"vip"`
|
||||
ActiveOn []string `json:"active_on"` // FQDNs der Nodes mit diesem VIP
|
||||
}
|
||||
|
||||
// AgentActiveIPs gibt alle aktiven IPv4-Adressen dieses Nodes zurück.
|
||||
// Wird vom Primary genutzt um zu prüfen welcher Node welchen VIP hält.
|
||||
func (h *ClusterHandler) AgentActiveIPs(c *gin.Context) {
|
||||
ips, err := localActiveIPs()
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"ips": ips})
|
||||
}
|
||||
|
||||
// vipCmdRequest ist der Body für den AgentVIPCmd-Endpoint.
|
||||
type vipCmdRequest struct {
|
||||
Action string `json:"action"` // "add" | "del"
|
||||
Address string `json:"address"` // z.B. "10.0.5.1"
|
||||
Prefix int `json:"prefix"` // z.B. 24
|
||||
Device string `json:"device"` // z.B. "vlan100"
|
||||
}
|
||||
|
||||
// AgentVIPCmd führt `ip addr add/del` auf diesem Node aus.
|
||||
// Wird vom Primary via mTLS für VIP-Schwenk-Tests aufgerufen.
|
||||
func (h *ClusterHandler) AgentVIPCmd(c *gin.Context) {
|
||||
var req vipCmdRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if req.Action != "add" && req.Action != "del" {
|
||||
response.BadRequest(c, simpleError("action must be 'add' or 'del'"))
|
||||
return
|
||||
}
|
||||
if req.Address == "" || req.Device == "" || req.Prefix <= 0 || req.Prefix > 128 {
|
||||
response.BadRequest(c, simpleError("address, device, prefix required"))
|
||||
return
|
||||
}
|
||||
if err := runVIPCmd(req.Action, req.Address, req.Prefix, req.Device); err != nil {
|
||||
slog.Warn("cluster: agent vip-cmd failed",
|
||||
"action", req.Action, "addr", req.Address, "dev", req.Device, "error", err)
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
slog.Info("cluster: agent vip-cmd ok",
|
||||
"action", req.Action, "addr", req.Address, "prefix", req.Prefix,
|
||||
"dev", req.Device, "caller", c.ClientIP())
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// VIPStatus liest alle VIPs (is_vip=true) aus der DB und fragt alle Nodes
|
||||
// welche davon sie gerade aktiv haben. Nur sinnvoll im Cluster-Modus.
|
||||
func (h *ClusterHandler) VIPStatus(c *gin.Context) {
|
||||
vips, err := loadVIPs(c.Request.Context(), h.Store.Pool)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
nodeIPs := h.collectActiveIPs(c.Request.Context())
|
||||
result := make([]VIPStatusEntry, 0, len(vips))
|
||||
for _, v := range vips {
|
||||
entry := VIPStatusEntry{VIP: v}
|
||||
for fqdn, ips := range nodeIPs {
|
||||
for _, ip := range ips {
|
||||
if ip == v.Address {
|
||||
entry.ActiveOn = append(entry.ActiveOn, fqdn)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
response.OK(c, gin.H{"vips": result})
|
||||
}
|
||||
|
||||
// vipTestRequest steuert einen VIP-Schwenk.
|
||||
type vipTestRequest struct {
|
||||
IPAddressID int64 `json:"ip_address_id"`
|
||||
Action string `json:"action"` // "to_secondary" | "restore"
|
||||
}
|
||||
|
||||
// vipTestStep beschreibt einen Schritt des Schwenk-Prozesses.
|
||||
type vipTestStep struct {
|
||||
Step string `json:"step"`
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// VIPTest schwenkt einen VIP vom Primary auf den Secondary ("to_secondary")
|
||||
// oder zurück ("restore"). Nur vom Primary aufzurufen.
|
||||
func (h *ClusterHandler) VIPTest(c *gin.Context) {
|
||||
var req vipTestRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if req.Action != "to_secondary" && req.Action != "restore" {
|
||||
response.BadRequest(c, simpleError("action must be 'to_secondary' or 'restore'"))
|
||||
return
|
||||
}
|
||||
|
||||
vips, err := loadVIPs(c.Request.Context(), h.Store.Pool)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
var target *vipInfo
|
||||
for i := range vips {
|
||||
if vips[i].ID == req.IPAddressID {
|
||||
target = &vips[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
response.NotFound(c, simpleError("VIP not found or not marked as VIP"))
|
||||
return
|
||||
}
|
||||
|
||||
all, err := h.Store.List(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
var peer *models.HANode
|
||||
for i := range all {
|
||||
if all[i].ID != h.LocalID {
|
||||
peer = &all[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if peer == nil {
|
||||
response.BadRequest(c, simpleError("kein Secondary-Peer gefunden"))
|
||||
return
|
||||
}
|
||||
|
||||
var steps []vipTestStep
|
||||
addrPfx := fmt.Sprintf("%s/%d", target.Address, target.Prefix)
|
||||
|
||||
if req.Action == "to_secondary" {
|
||||
// 1. VIP auf Secondary via mTLS hinzufügen
|
||||
steps = append(steps, h.peerVIPCmd(c.Request.Context(), *peer, target, "add",
|
||||
fmt.Sprintf("add %s dev %s auf %s", addrPfx, target.Device, peer.FQDN)))
|
||||
// 2. VIP vom Primary entfernen (nur wenn Secondary-Add erfolgreich)
|
||||
if steps[0].OK {
|
||||
steps = append(steps, localVIPStep(target, "del",
|
||||
fmt.Sprintf("del %s dev %s lokal", addrPfx, target.Device)))
|
||||
}
|
||||
} else {
|
||||
// 1. VIP auf Primary zurückholen
|
||||
steps = append(steps, localVIPStep(target, "add",
|
||||
fmt.Sprintf("add %s dev %s lokal", addrPfx, target.Device)))
|
||||
// 2. VIP auf Secondary entfernen
|
||||
steps = append(steps, h.peerVIPCmd(c.Request.Context(), *peer, target, "del",
|
||||
fmt.Sprintf("del %s dev %s auf %s", addrPfx, target.Device, peer.FQDN)))
|
||||
}
|
||||
|
||||
slog.Info("cluster: vip-test", "action", req.Action, "vip", target.Address,
|
||||
"dev", target.Device, "peer", peer.FQDN, "actor", actorOf(c))
|
||||
response.OK(c, gin.H{"steps": steps})
|
||||
}
|
||||
|
||||
// ── Hilfsfunktionen ───────────────────────────────────────────────────────
|
||||
|
||||
func loadVIPs(ctx context.Context, pool *pgxpool.Pool) ([]vipInfo, error) {
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT ia.id, ia.address, ia.prefix, ni.name
|
||||
FROM ip_addresses ia
|
||||
JOIN network_interfaces ni ON ni.id = ia.interface_id
|
||||
WHERE ia.is_vip = true AND ia.active = true
|
||||
ORDER BY ni.name, ia.address`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []vipInfo
|
||||
for rows.Next() {
|
||||
var v vipInfo
|
||||
if err := rows.Scan(&v.ID, &v.Address, &v.Prefix, &v.Device); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// collectActiveIPs fragt alle Cluster-Nodes (lokal + Peers via mTLS) nach
|
||||
// ihren aktiven IPv4-Adressen und gibt eine Map[fqdn][]ip zurück.
|
||||
func (h *ClusterHandler) collectActiveIPs(ctx context.Context) map[string][]string {
|
||||
result := make(map[string][]string)
|
||||
if h.Store == nil {
|
||||
return result
|
||||
}
|
||||
all, err := h.Store.List(ctx)
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
// Lokaler Node
|
||||
if ips, err := localActiveIPs(); err == nil {
|
||||
for _, n := range all {
|
||||
if n.ID == h.LocalID {
|
||||
result[n.FQDN] = ips
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Peers via mTLS-Aggregator
|
||||
if h.Aggregator != nil {
|
||||
var peers []models.HANode
|
||||
for _, n := range all {
|
||||
if n.ID != h.LocalID {
|
||||
peers = append(peers, n)
|
||||
}
|
||||
}
|
||||
if len(peers) > 0 {
|
||||
peerResults := h.Aggregator.FanOut(ctx, peers, "/agent/cluster/active-ips", h.LocalID)
|
||||
for _, pr := range peerResults {
|
||||
if !pr.OK || len(pr.Data) == 0 {
|
||||
continue
|
||||
}
|
||||
var payload struct {
|
||||
IPs []string `json:"ips"`
|
||||
}
|
||||
if err := json.Unmarshal(pr.Data, &payload); err == nil {
|
||||
result[pr.FQDN] = payload.IPs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// localActiveIPs liest alle aktiven IPv4-Adressen des lokalen Nodes via `ip`.
|
||||
func localActiveIPs() ([]string, error) {
|
||||
out, err := exec.Command("ip", "-4", "-o", "addr", "show").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ips []string
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
parts := strings.Fields(line)
|
||||
for i, p := range parts {
|
||||
if p == "inet" && i+1 < len(parts) {
|
||||
addr := strings.SplitN(parts[i+1], "/", 2)[0]
|
||||
ips = append(ips, addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
// peerVIPCmd ruft AgentVIPCmd auf dem Peer via mTLS auf.
|
||||
func (h *ClusterHandler) peerVIPCmd(ctx context.Context, peer models.HANode, vip *vipInfo, action, stepLabel string) vipTestStep {
|
||||
step := vipTestStep{Step: stepLabel}
|
||||
if h.Aggregator == nil {
|
||||
step.Message = "aggregator nicht verfügbar"
|
||||
return step
|
||||
}
|
||||
body, _ := json.Marshal(vipCmdRequest{
|
||||
Action: action,
|
||||
Address: vip.Address,
|
||||
Prefix: vip.Prefix,
|
||||
Device: vip.Device,
|
||||
})
|
||||
res := h.Aggregator.PostPeerWithBody(ctx, peer, "/agent/cluster/vip-cmd", body)
|
||||
step.OK = res.OK
|
||||
if !res.OK {
|
||||
step.Message = res.Err
|
||||
}
|
||||
return step
|
||||
}
|
||||
|
||||
// localVIPStep führt ip addr add/del auf dem lokalen Node aus.
|
||||
func localVIPStep(vip *vipInfo, action, stepLabel string) vipTestStep {
|
||||
step := vipTestStep{Step: stepLabel}
|
||||
if err := runVIPCmd(action, vip.Address, vip.Prefix, vip.Device); err != nil {
|
||||
step.Message = err.Error()
|
||||
return step
|
||||
}
|
||||
step.OK = true
|
||||
return step
|
||||
}
|
||||
|
||||
// runVIPCmd führt `sudo /usr/lib/edgeguard/vip-cmd.sh {action} {addr/prefix} {dev}` aus.
|
||||
func runVIPCmd(action, address string, prefix int, device string) error {
|
||||
addrPfx := fmt.Sprintf("%s/%d", address, prefix)
|
||||
out, err := exec.Command("sudo", "-n", "/usr/lib/edgeguard/vip-cmd.sh", action, addrPfx, device).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("vip-cmd.sh %s %s %s: %s", action, addrPfx, device, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -306,7 +306,7 @@ func (h *DNSHandler) UpdateSettings(c *gin.Context) {
|
||||
// cached RRs from the resolver. Useful after DNS propagation or when
|
||||
// stale records need to be evicted immediately.
|
||||
func (h *DNSHandler) FlushCache(c *gin.Context) {
|
||||
out, err := exec.CommandContext(c.Request.Context(), "unbound-control", "flush_zone", ".").CombinedOutput()
|
||||
out, err := exec.CommandContext(c.Request.Context(), "/usr/sbin/unbound-control", "flush_zone", ".").CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("dns flush-cache failed", "err", err, "out", string(out))
|
||||
response.Internal(c, err)
|
||||
@@ -388,7 +388,7 @@ func validateZone(z *models.DNSZone) error {
|
||||
// stats_noreset liest die Zähler ohne sie zurückzusetzen — safe für
|
||||
// wiederholte Aufrufe aus dem UI.
|
||||
func (h *DNSHandler) Stats(c *gin.Context) {
|
||||
out, err := exec.Command("unbound-control", "stats_noreset").Output()
|
||||
out, err := exec.Command("/usr/sbin/unbound-control", "stats_noreset").Output()
|
||||
if err != nil {
|
||||
response.OK(c, gin.H{
|
||||
"error": "unbound-control nicht verfügbar: " + err.Error(),
|
||||
|
||||
@@ -39,6 +39,8 @@ func (h *ForwardProxyHandler) reload(ctx context.Context, op string) {
|
||||
func (h *ForwardProxyHandler) Register(rg *gin.RouterGroup) {
|
||||
base := rg.Group("/forward-proxy")
|
||||
base.GET("/stats", h.Stats)
|
||||
base.GET("/settings", h.GetSettings)
|
||||
base.PUT("/settings", h.UpdateSettings)
|
||||
|
||||
g := base.Group("/acls")
|
||||
g.GET("", h.List)
|
||||
@@ -48,6 +50,34 @@ func (h *ForwardProxyHandler) Register(rg *gin.RouterGroup) {
|
||||
g.DELETE("/:id", h.Delete)
|
||||
}
|
||||
|
||||
func (h *ForwardProxyHandler) GetSettings(c *gin.Context) {
|
||||
s, err := h.Repo.GetSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, s)
|
||||
}
|
||||
|
||||
func (h *ForwardProxyHandler) UpdateSettings(c *gin.Context) {
|
||||
var req models.ForwardProxySettings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if req.ListenPort <= 0 || req.ListenPort > 65535 {
|
||||
req.ListenPort = 3128
|
||||
}
|
||||
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "forward_proxy.settings.update", "settings", out, h.NodeID)
|
||||
response.OK(c, out)
|
||||
h.reload(c.Request.Context(), "settings.update")
|
||||
}
|
||||
|
||||
func (h *ForwardProxyHandler) List(c *gin.Context) {
|
||||
out, err := h.Repo.List(c.Request.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -122,6 +122,7 @@ func (h *SystemHandler) Register(rg *gin.RouterGroup) {
|
||||
g.GET("/ipv6", h.IPv6)
|
||||
g.POST("/ipv6", h.SetIPv6)
|
||||
g.GET("/config-preview", h.ConfigPreview)
|
||||
g.GET("/vip-status", h.VIPStatus)
|
||||
}
|
||||
|
||||
// RegisterAgent mountet die read-only System-Endpoints auf der mTLS-
|
||||
@@ -188,6 +189,7 @@ var servicesToCheck = []struct{ Label, Unit string }{
|
||||
{"edgeguard-scheduler", "edgeguard-scheduler"},
|
||||
{"haproxy", "haproxy"},
|
||||
{"nftables", "nftables"},
|
||||
{"keepalived", "keepalived"},
|
||||
{"unbound", "unbound"},
|
||||
{"chrony", "chrony"},
|
||||
{"squid", "squid"},
|
||||
@@ -1077,6 +1079,87 @@ func classifyLinkType(ifc net.Interface) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// VIPStatus returns the VRRP state and active VIPs for this node.
|
||||
// Uses net.Interfaces() (no shell-out) to check which VIPs from
|
||||
// ip_addresses WHERE is_vip=true are currently assigned locally.
|
||||
// MASTER = at least one VIP is locally present; BACKUP = none present.
|
||||
func (h *SystemHandler) VIPStatus(c *gin.Context) {
|
||||
type vipEntry struct {
|
||||
Address string `json:"address"`
|
||||
Prefix int `json:"prefix"`
|
||||
Device string `json:"device"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
type vipStatus struct {
|
||||
VRRPState string `json:"vrrp_state"`
|
||||
KeepalivedActive bool `json:"keepalived_active"`
|
||||
VIPs []vipEntry `json:"vips"`
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// keepalived service active?
|
||||
kaOut, _ := exec.CommandContext(ctx, "systemctl", "is-active", "keepalived").Output()
|
||||
kaActive := strings.TrimSpace(string(kaOut)) == "active"
|
||||
|
||||
// query VIPs from DB
|
||||
var dbVIPs []vipEntry
|
||||
if h.Pool != nil {
|
||||
rows, err := h.Pool.Query(ctx,
|
||||
`SELECT a.address, a.prefix, COALESCE(i.name,'') AS device
|
||||
FROM ip_addresses a
|
||||
LEFT JOIN network_interfaces i ON i.id = a.interface_id
|
||||
WHERE a.is_vip = true AND a.active = true
|
||||
ORDER BY a.address`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var e vipEntry
|
||||
if err2 := rows.Scan(&e.Address, &e.Prefix, &e.Device); err2 == nil {
|
||||
dbVIPs = append(dbVIPs, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// build set of locally assigned IPs
|
||||
localIPs := make(map[string]bool)
|
||||
if ifaces, err := net.Interfaces(); err == nil {
|
||||
for _, ifc := range ifaces {
|
||||
if addrs, err2 := ifc.Addrs(); err2 == nil {
|
||||
for _, a := range addrs {
|
||||
if ipnet, ok := a.(*net.IPNet); ok {
|
||||
localIPs[ipnet.IP.String()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anyActive := false
|
||||
for i := range dbVIPs {
|
||||
dbVIPs[i].Active = localIPs[dbVIPs[i].Address]
|
||||
if dbVIPs[i].Active {
|
||||
anyActive = true
|
||||
}
|
||||
}
|
||||
|
||||
state := "UNKNOWN"
|
||||
if kaActive {
|
||||
if anyActive {
|
||||
state = "MASTER"
|
||||
} else {
|
||||
state = "BACKUP"
|
||||
}
|
||||
}
|
||||
|
||||
response.OK(c, vipStatus{
|
||||
VRRPState: state,
|
||||
KeepalivedActive: kaActive,
|
||||
VIPs: dbVIPs,
|
||||
})
|
||||
}
|
||||
|
||||
func flagsToList(f net.Flags) []string {
|
||||
var out []string
|
||||
if f&net.FlagUp != 0 {
|
||||
|
||||
@@ -35,6 +35,7 @@ func (h *UsersHandler) Register(rg *gin.RouterGroup) {
|
||||
g.PUT("/:id", h.Update)
|
||||
g.POST("/:id/password", h.SetPassword)
|
||||
g.DELETE("/:id", h.Delete)
|
||||
g.DELETE("/:id/totp", h.DisableTOTP)
|
||||
}
|
||||
|
||||
func (h *UsersHandler) List(c *gin.Context) {
|
||||
@@ -164,3 +165,22 @@ func (h *UsersHandler) Delete(c *gin.Context) {
|
||||
c.Param("id"), nil, h.NodeID)
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// DisableTOTP allows an admin to disable 2FA for any user.
|
||||
func (h *UsersHandler) DisableTOTP(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.Repo.DisableTOTP(c.Request.Context(), id); err != nil {
|
||||
if errors.Is(err, users.ErrNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "user.totp.disabled",
|
||||
c.Param("id"), nil, h.NodeID)
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user