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:
@@ -1,13 +1,19 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
|
||||
@@ -21,11 +27,12 @@ import (
|
||||
// the account is auto-migrated into the DB (Upsert) so it shows up in
|
||||
// user management from that point on.
|
||||
type AuthHandler struct {
|
||||
Setup *setup.Store
|
||||
Signer *session.Signer
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Users *usersvc.Repo // optional — nil on first boot before DB is ready
|
||||
Setup *setup.Store
|
||||
Signer *session.Signer
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Users *usersvc.Repo // optional — nil on first boot before DB is ready
|
||||
ClusterTLS *clustertls.Store // optional — enables auth federation on cluster nodes
|
||||
}
|
||||
|
||||
func NewAuthHandler(s *setup.Store, sig *session.Signer) *AuthHandler {
|
||||
@@ -46,6 +53,13 @@ func (h *AuthHandler) WithUsers(u *usersvc.Repo) *AuthHandler {
|
||||
return h
|
||||
}
|
||||
|
||||
// WithClusterTLS enables auth federation: when local auth fails on a
|
||||
// cluster node, Login tries the primary via mTLS /agent/auth/check.
|
||||
func (h *AuthHandler) WithClusterTLS(store *clustertls.Store) *AuthHandler {
|
||||
h.ClusterTLS = store
|
||||
return h
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -115,24 +129,38 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 2. Fallback: setup-store admin (backwards compat for pre-DB installs).
|
||||
if actor == "" {
|
||||
if !strings.EqualFold(st.AdminEmail, email) || !st.VerifyAdminPassword(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)
|
||||
if actor == "" && st.AdminEmail != "" {
|
||||
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)
|
||||
}
|
||||
response.Unauthorized(c, errors.New("invalid_credentials"))
|
||||
return
|
||||
}
|
||||
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.
|
||||
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
|
||||
role = r
|
||||
} else {
|
||||
slog.Debug("auth: primary auth check failed", "primary", st.PrimaryFQDN, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if actor == "" {
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
|
||||
email, gin.H{"reason": "invalid_credentials", "remote": remote}, h.NodeID)
|
||||
}
|
||||
response.Unauthorized(c, errors.New("invalid_credentials"))
|
||||
return
|
||||
}
|
||||
|
||||
raw, tok, err := h.Signer.IssueWithRole(actor, role)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
@@ -278,6 +306,48 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// checkWithPrimary verifies credentials against the primary node via mTLS.
|
||||
// Returns actor+role on success, error on failure.
|
||||
func (h *AuthHandler) checkWithPrimary(ctx context.Context, primaryFQDN, email, password string) (string, string, error) {
|
||||
clientTLS, err := h.ClusterTLS.ClientTLSConfig()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
tr := &http.Transport{TLSClientConfig: clientTLS, TLSHandshakeTimeout: 5 * time.Second}
|
||||
client := &http.Client{Transport: tr, Timeout: 8 * time.Second}
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"email": email, "password": password})
|
||||
reqURL := "https://" + primaryFQDN + ":8443/agent/auth/check"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", errors.New("primary: " + strings.TrimSpace(string(raw)))
|
||||
}
|
||||
var env struct {
|
||||
Data struct {
|
||||
Actor string `json:"actor"`
|
||||
Role string `json:"role"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if env.Data.Actor == "" {
|
||||
return "", "", errors.New("primary returned empty actor")
|
||||
}
|
||||
return env.Data.Actor, env.Data.Role, nil
|
||||
}
|
||||
|
||||
func setSessionCookie(c *gin.Context, raw string, expUnix int64) {
|
||||
maxAge := int(time.Until(time.Unix(expUnix, 0)).Seconds())
|
||||
if maxAge < 0 {
|
||||
|
||||
Reference in New Issue
Block a user