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:
@@ -60,7 +60,7 @@ import (
|
|||||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.1.157"
|
var version = "1.1.158"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||||
@@ -283,7 +283,8 @@ func main() {
|
|||||||
// nftables @peer_ipv4 korrekt ist — auch ohne erneuten Join.
|
// nftables @peer_ipv4 korrekt ist — auch ohne erneuten Join.
|
||||||
go setupHdl.StartupPeerSync()
|
go setupHdl.StartupPeerSync()
|
||||||
usersRepo := usersvc.New(pool)
|
usersRepo := usersvc.New(pool)
|
||||||
authHdl.WithAudit(auditRepo, nodeID).WithUsers(usersRepo)
|
authHdl.WithAudit(auditRepo, nodeID).WithUsers(usersRepo).WithClusterTLS(clusterTLSStore)
|
||||||
|
systemHdl.WithUsers(usersRepo)
|
||||||
|
|
||||||
haproxyReloader := func(ctx context.Context) error {
|
haproxyReloader := func(ctx context.Context) error {
|
||||||
return haproxy.New(pool).Render(ctx)
|
return haproxy.New(pool).Render(ctx)
|
||||||
@@ -454,7 +455,7 @@ func main() {
|
|||||||
// Listener wird nur gestartet wenn Cert-Material vorhanden ist;
|
// Listener wird nur gestartet wenn Cert-Material vorhanden ist;
|
||||||
// auf einer frisch installierten Box hat die Init-Phase oben das
|
// auf einer frisch installierten Box hat die Init-Phase oben das
|
||||||
// schon erledigt.
|
// schon erledigt.
|
||||||
startAgentListener(version, agentHdl)
|
startAgentListener(version, agentHdl, systemHdl)
|
||||||
|
|
||||||
log.Printf("edgeguard-api %s listening on %s", version, addr)
|
log.Printf("edgeguard-api %s listening on %s", version, addr)
|
||||||
srv := &http.Server{Addr: addr, Handler: r}
|
srv := &http.Server{Addr: addr, Handler: r}
|
||||||
@@ -468,7 +469,7 @@ func main() {
|
|||||||
// RegisterAgent — health + resources). Fehler im Cert-Load = no-op
|
// RegisterAgent — health + resources). Fehler im Cert-Load = no-op
|
||||||
// + log; Fehler beim Listen.Serve loggen wir aber lassen die API
|
// + log; Fehler beim Listen.Serve loggen wir aber lassen die API
|
||||||
// weiterlaufen.
|
// weiterlaufen.
|
||||||
func startAgentListener(version string, clusterHdl *handlers.ClusterHandler) {
|
func startAgentListener(version string, clusterHdl *handlers.ClusterHandler, sysHdl *handlers.SystemHandler) {
|
||||||
store := clustertls.New("")
|
store := clustertls.New("")
|
||||||
serverTLS, err := store.ServerTLSConfig()
|
serverTLS, err := store.ServerTLSConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -488,7 +489,13 @@ func startAgentListener(version string, clusterHdl *handlers.ClusterHandler) {
|
|||||||
// hier implizit aus dem Binary (Peer-Roundtrip ist immer same-major).
|
// hier implizit aus dem Binary (Peer-Roundtrip ist immer same-major).
|
||||||
// Aggregator-Aufrufer sehen /agent/... direkt.
|
// Aggregator-Aufrufer sehen /agent/... direkt.
|
||||||
root := r.Group("")
|
root := r.Group("")
|
||||||
handlers.NewSystemHandler(version).RegisterAgent(root)
|
// Nutze den gewiredeten systemHdl (mit Users + Setup) damit
|
||||||
|
// AgentAuthCheck Credentials gegen die echte DB prüfen kann.
|
||||||
|
if sysHdl != nil {
|
||||||
|
sysHdl.RegisterAgent(root)
|
||||||
|
} else {
|
||||||
|
handlers.NewSystemHandler(version).RegisterAgent(root)
|
||||||
|
}
|
||||||
if clusterHdl != nil {
|
if clusterHdl != nil {
|
||||||
// Phase 3.5: /agent/cluster/peers (Auto-Register).
|
// Phase 3.5: /agent/cluster/peers (Auto-Register).
|
||||||
clusterHdl.RegisterAgent(root)
|
clusterHdl.RegisterAgent(root)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.1.157"
|
var version = "1.1.158"
|
||||||
|
|
||||||
const usage = `edgeguard-ctl — EdgeGuard CLI
|
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.1.157"
|
var version = "1.1.158"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// renewTickInterval — how often we re-evaluate expiring certs.
|
// renewTickInterval — how often we re-evaluate expiring certs.
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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/handlers/response"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
|
"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
|
// the account is auto-migrated into the DB (Upsert) so it shows up in
|
||||||
// user management from that point on.
|
// user management from that point on.
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
Setup *setup.Store
|
Setup *setup.Store
|
||||||
Signer *session.Signer
|
Signer *session.Signer
|
||||||
Audit *audit.Repo
|
Audit *audit.Repo
|
||||||
NodeID string
|
NodeID string
|
||||||
Users *usersvc.Repo // optional — nil on first boot before DB is ready
|
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 {
|
func NewAuthHandler(s *setup.Store, sig *session.Signer) *AuthHandler {
|
||||||
@@ -46,6 +53,13 @@ func (h *AuthHandler) WithUsers(u *usersvc.Repo) *AuthHandler {
|
|||||||
return h
|
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
|
// Register mounts /auth/login + /logout (public) and /auth/me
|
||||||
// (gated by requireAuth, passed in as a per-route middleware).
|
// (gated by requireAuth, passed in as a per-route middleware).
|
||||||
func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc) {
|
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).
|
// 2. Fallback: setup-store admin (backwards compat for pre-DB installs).
|
||||||
if actor == "" {
|
if actor == "" && st.AdminEmail != "" {
|
||||||
if !strings.EqualFold(st.AdminEmail, email) || !st.VerifyAdminPassword(req.Password) {
|
if strings.EqualFold(st.AdminEmail, email) && st.VerifyAdminPassword(req.Password) {
|
||||||
if h.Audit != nil {
|
actor = st.AdminEmail
|
||||||
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
|
role = "admin"
|
||||||
email, gin.H{"reason": "invalid_credentials", "remote": remote}, h.NodeID)
|
// 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
|
// 3. Auth federation: cluster nodes forward failed auth to the primary
|
||||||
// shows up in user management from this point on.
|
// via mTLS so users can log in with their primary credentials on any node.
|
||||||
if h.Users != nil {
|
if actor == "" && st.IsClusterNode && st.PrimaryFQDN != "" && h.ClusterTLS != nil {
|
||||||
_, _ = h.Users.Upsert(c.Request.Context(), st.AdminEmail, req.Password, "admin", true)
|
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)
|
raw, tok, err := h.Signer.IssueWithRole(actor, role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Internal(c, err)
|
response.Internal(c, err)
|
||||||
@@ -278,6 +306,48 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{"ok": true})
|
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) {
|
func setSessionCookie(c *gin.Context, raw string, expUnix int64) {
|
||||||
maxAge := int(time.Until(time.Unix(expUnix, 0)).Seconds())
|
maxAge := int(time.Until(time.Unix(expUnix, 0)).Seconds())
|
||||||
if maxAge < 0 {
|
if maxAge < 0 {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -392,11 +393,13 @@ func (h *ClusterHandler) IssueCert(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-register the joining node so its IP lands in @peer_ipv4
|
// Pre-register the joining node SYNCHRONOUSLY before returning the
|
||||||
// immediately — otherwise port 8443 stays blocked and auto-register
|
// cert so that nftables @peer_ipv4 already contains the joiner's IP
|
||||||
// via mTLS can never succeed (chicken-and-egg).
|
// by the time they call autoRegister on port 8443. A goroutine here
|
||||||
|
// caused a race: cert returned → joiner calls autoRegister → nftables
|
||||||
|
// not updated yet → connection refused → status stays "joining".
|
||||||
if h.Store != nil && h.PeerReloader != nil {
|
if h.Store != nil && h.PeerReloader != nil {
|
||||||
go h.preRegisterJoiner(clientIP, req.CSR)
|
h.preRegisterJoiner(c.Request.Context(), clientIP, req.CSR)
|
||||||
}
|
}
|
||||||
|
|
||||||
response.OK(c, issueCertResponse{
|
response.OK(c, issueCertResponse{
|
||||||
@@ -409,15 +412,16 @@ func (h *ClusterHandler) IssueCert(c *gin.Context) {
|
|||||||
// (using the CSR CN as FQDN and the HTTP client IP as public_ip), then
|
// (using the CSR CN as FQDN and the HTTP client IP as public_ip), then
|
||||||
// triggers a firewall reload so @peer_ipv4 contains the new IP before
|
// triggers a firewall reload so @peer_ipv4 contains the new IP before
|
||||||
// the peer tries to call /agent/cluster/peers on port 8443.
|
// the peer tries to call /agent/cluster/peers on port 8443.
|
||||||
func (h *ClusterHandler) preRegisterJoiner(clientIP, csrPEM string) {
|
// Uses a stable deterministic ID so re-joins are idempotent.
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
func (h *ClusterHandler) preRegisterJoiner(parent context.Context, clientIP, csrPEM string) {
|
||||||
|
ctx, cancel := context.WithTimeout(parent, 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
fqdn := cnFromCSR(csrPEM)
|
fqdn := cnFromCSR(csrPEM)
|
||||||
if fqdn == "" {
|
if fqdn == "" {
|
||||||
fqdn = "joining-" + clientIP
|
fqdn = "joining-" + clientIP
|
||||||
}
|
}
|
||||||
nodeID := fmt.Sprintf("pre-%x", time.Now().UnixNano())
|
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(fqdn, ".", "-"))
|
||||||
|
|
||||||
n := models.HANode{
|
n := models.HANode{
|
||||||
ID: nodeID,
|
ID: nodeID,
|
||||||
@@ -574,7 +578,7 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
|
|||||||
FQDN: req.FQDN,
|
FQDN: req.FQDN,
|
||||||
APIURL: req.APIURL,
|
APIURL: req.APIURL,
|
||||||
Role: "peer",
|
Role: "peer",
|
||||||
Status: "joining",
|
Status: "online", // peer IS online — it just connected via mTLS
|
||||||
}
|
}
|
||||||
if req.PublicIP != "" {
|
if req.PublicIP != "" {
|
||||||
v := req.PublicIP
|
v := req.PublicIP
|
||||||
@@ -598,6 +602,13 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up the prenode-{fqdn} placeholder that preRegisterJoiner
|
||||||
|
// created during cert issuance — the real row just took its place.
|
||||||
|
placeholderID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(req.FQDN, ".", "-"))
|
||||||
|
if placeholderID != req.ID {
|
||||||
|
_ = h.Store.Delete(c.Request.Context(), placeholderID)
|
||||||
|
}
|
||||||
|
|
||||||
// Firewall-Reload damit peer_ipv4-Set die neue IP aufnimmt. Best-
|
// Firewall-Reload damit peer_ipv4-Set die neue IP aufnimmt. Best-
|
||||||
// effort: Fehler loggen, Response weiter durchreichen — der Peer
|
// effort: Fehler loggen, Response weiter durchreichen — der Peer
|
||||||
// hat seine Identity erfolgreich registriert, Operator kann manuell
|
// hat seine Identity erfolgreich registriert, Operator kann manuell
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
stdcontext "context"
|
stdcontext "context"
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -22,6 +23,7 @@ import (
|
|||||||
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
|
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/audit"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
"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,
|
// SystemHandler covers /system/health, /system/package-versions,
|
||||||
@@ -44,6 +46,9 @@ type SystemHandler struct {
|
|||||||
// ExtraReloaders: additional service renderers triggered by
|
// ExtraReloaders: additional service renderers triggered by
|
||||||
// RenderConfigs. Keyed by service name (nftables, wireguard, etc.).
|
// RenderConfigs. Keyed by service name (nftables, wireguard, etc.).
|
||||||
ExtraReloaders map[string]func(stdcontext.Context) error
|
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 {
|
func NewSystemHandler(version string) *SystemHandler {
|
||||||
@@ -87,6 +92,12 @@ func (h *SystemHandler) WithAllReloaders(extras map[string]func(stdcontext.Conte
|
|||||||
return h
|
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) {
|
func (h *SystemHandler) Register(rg *gin.RouterGroup) {
|
||||||
g := rg.Group("/system")
|
g := rg.Group("/system")
|
||||||
g.GET("/health", h.Health)
|
g.GET("/health", h.Health)
|
||||||
@@ -124,8 +135,50 @@ func (h *SystemHandler) RegisterAgent(rg *gin.RouterGroup) {
|
|||||||
g := rg.Group("/agent/system")
|
g := rg.Group("/agent/system")
|
||||||
g.GET("/health", h.Health)
|
g.GET("/health", h.Health)
|
||||||
g.GET("/resources", h.Resources)
|
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
|
// servicesToCheck is the curated list shown on the dashboard
|
||||||
// service-health-grid. Order matters (UI renders in this sequence).
|
// service-health-grid. Order matters (UI renders in this sequence).
|
||||||
// Each entry is a (label, systemd-unit) pair — label is what the
|
// Each entry is a (label, systemd-unit) pair — label is what the
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -122,8 +123,17 @@ func Join(req Request) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-register: best-effort — cert material is already written.
|
// Auto-register: retry a few times because the primary's nftables may
|
||||||
_ = autoRegister(primary, tlsDir, req.CommonName, req.Version, req.NodeID)
|
// need a moment to reload even though preRegisterJoiner is now
|
||||||
|
// synchronous on the primary side.
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := autoRegister(primary, tlsDir, req.CommonName, req.Version, req.NodeID); err == nil {
|
||||||
|
break
|
||||||
|
} else if i < 2 {
|
||||||
|
slog.Warn("clusterjoin: autoRegister failed, retrying", "attempt", i+1, "error", err)
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user