feat(cluster): Fix "joining" status + cross-node auth federation

1. preRegisterJoiner now runs SYNCHRONOUSLY before IssueCert responds,
   so nftables @peer_ipv4 is updated before the joiner calls autoRegister.
   Previously it was a goroutine → race → autoRegister failed → "joining"
   forever.

2. Stable node ID for pre-registered placeholder (prenode-{fqdn}) instead
   of time-based ID — re-joins are now idempotent.

3. AgentRegisterPeer sets status="online" immediately (peer proved it is
   online by connecting via mTLS) and deletes the prenode-{fqdn} placeholder.

4. autoRegister retries 3× with 2s delay in case of transient nftables lag.

5. Auth federation: cluster nodes forward failed logins to the primary via
   mTLS /agent/auth/check so users can log in on any node with primary
   credentials (no PG replication needed).
   - SystemHandler.AgentAuthCheck: new endpoint on :8443
   - AuthHandler.checkWithPrimary: mTLS call to primary when local auth fails
   - AuthHandler.WithClusterTLS: inject cluster TLS store
   - startAgentListener now uses the wired systemHdl with Users repo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-29 18:04:34 +02:00
parent 6bb1c5c6d3
commit 8d83de6b0f
8 changed files with 187 additions and 36 deletions

View File

@@ -3,6 +3,7 @@ package handlers
import (
"bufio"
stdcontext "context"
"errors"
"log/slog"
"net"
"net/http"
@@ -22,6 +23,7 @@ import (
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
)
// SystemHandler covers /system/health, /system/package-versions,
@@ -44,6 +46,9 @@ type SystemHandler struct {
// ExtraReloaders: additional service renderers triggered by
// RenderConfigs. Keyed by service name (nftables, wireguard, etc.).
ExtraReloaders map[string]func(stdcontext.Context) error
// Users: optional — wired after DB pool opens. Used by AgentAuthCheck
// so cluster peers can verify credentials against this node's DB.
Users *usersvc.Repo
}
func NewSystemHandler(version string) *SystemHandler {
@@ -87,6 +92,12 @@ func (h *SystemHandler) WithAllReloaders(extras map[string]func(stdcontext.Conte
return h
}
// WithUsers injectet das Users-Repo für AgentAuthCheck.
func (h *SystemHandler) WithUsers(u *usersvc.Repo) *SystemHandler {
h.Users = u
return h
}
func (h *SystemHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/system")
g.GET("/health", h.Health)
@@ -124,8 +135,50 @@ func (h *SystemHandler) RegisterAgent(rg *gin.RouterGroup) {
g := rg.Group("/agent/system")
g.GET("/health", h.Health)
g.GET("/resources", h.Resources)
// Auth-Federation: Cluster-Nodes verifizieren Credentials gegen diesen
// Node via mTLS. Nur über den Agent-Listener (:8443) erreichbar.
rg.POST("/agent/auth/check", h.AgentAuthCheck)
}
// AgentAuthCheck verifies email+password against the local users table
// and setup.json. Called by cluster nodes over mTLS when local auth fails
// so users can log in on any cluster node with the primary's credentials.
func (h *SystemHandler) AgentAuthCheck(c *gin.Context) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
email := strings.TrimSpace(strings.ToLower(req.Email))
if email == "" || req.Password == "" {
response.Unauthorized(c, errInvalidCreds)
return
}
// Check local users table first.
if h.Users != nil {
u, hash, err := h.Users.FindByEmail(c.Request.Context(), email)
if err == nil && u.Active && usersvc.VerifyPassword(hash, req.Password) {
response.OK(c, gin.H{"actor": u.Email, "role": u.Role})
return
}
}
// Fallback: setup.json admin.
if h.Setup != nil {
st, _ := h.Setup.Load()
if st != nil && strings.EqualFold(st.AdminEmail, email) && st.VerifyAdminPassword(req.Password) {
response.OK(c, gin.H{"actor": st.AdminEmail, "role": "admin"})
return
}
}
response.Unauthorized(c, errInvalidCreds)
}
var errInvalidCreds = errors.New("invalid_credentials")
// servicesToCheck is the curated list shown on the dashboard
// service-health-grid. Order matters (UI renders in this sequence).
// Each entry is a (label, systemd-unit) pair — label is what the