diff --git a/VERSION b/VERSION index 546dc6a..87b00c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.157 +1.1.158 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index f3d1a65..9ebaee0 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -60,7 +60,7 @@ import ( usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" ) -var version = "1.1.157" +var version = "1.1.158" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") @@ -283,7 +283,8 @@ func main() { // nftables @peer_ipv4 korrekt ist — auch ohne erneuten Join. go setupHdl.StartupPeerSync() 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 { return haproxy.New(pool).Render(ctx) @@ -454,7 +455,7 @@ func main() { // Listener wird nur gestartet wenn Cert-Material vorhanden ist; // auf einer frisch installierten Box hat die Init-Phase oben das // schon erledigt. - startAgentListener(version, agentHdl) + startAgentListener(version, agentHdl, systemHdl) log.Printf("edgeguard-api %s listening on %s", version, addr) srv := &http.Server{Addr: addr, Handler: r} @@ -468,7 +469,7 @@ func main() { // RegisterAgent — health + resources). Fehler im Cert-Load = no-op // + log; Fehler beim Listen.Serve loggen wir aber lassen die API // weiterlaufen. -func startAgentListener(version string, clusterHdl *handlers.ClusterHandler) { +func startAgentListener(version string, clusterHdl *handlers.ClusterHandler, sysHdl *handlers.SystemHandler) { store := clustertls.New("") serverTLS, err := store.ServerTLSConfig() 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). // Aggregator-Aufrufer sehen /agent/... direkt. 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 { // Phase 3.5: /agent/cluster/peers (Auto-Register). clusterHdl.RegisterAgent(root) diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index 6da5443..43e544e 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -11,7 +11,7 @@ import ( "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 diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index 786d93d..93dd437 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -41,7 +41,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.1.157" +var version = "1.1.158" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 8699629..15ad81b 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -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 { diff --git a/internal/handlers/cluster.go b/internal/handlers/cluster.go index 297cef0..4e2eef1 100644 --- a/internal/handlers/cluster.go +++ b/internal/handlers/cluster.go @@ -7,6 +7,7 @@ import ( "encoding/pem" "fmt" "log/slog" + "strings" "time" "github.com/gin-gonic/gin" @@ -392,11 +393,13 @@ func (h *ClusterHandler) IssueCert(c *gin.Context) { return } - // Pre-register the joining node so its IP lands in @peer_ipv4 - // immediately — otherwise port 8443 stays blocked and auto-register - // via mTLS can never succeed (chicken-and-egg). + // Pre-register the joining node SYNCHRONOUSLY before returning the + // cert so that nftables @peer_ipv4 already contains the joiner's IP + // 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 { - go h.preRegisterJoiner(clientIP, req.CSR) + h.preRegisterJoiner(c.Request.Context(), clientIP, req.CSR) } 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 // triggers a firewall reload so @peer_ipv4 contains the new IP before // the peer tries to call /agent/cluster/peers on port 8443. -func (h *ClusterHandler) preRegisterJoiner(clientIP, csrPEM string) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) +// Uses a stable deterministic ID so re-joins are idempotent. +func (h *ClusterHandler) preRegisterJoiner(parent context.Context, clientIP, csrPEM string) { + ctx, cancel := context.WithTimeout(parent, 10*time.Second) defer cancel() fqdn := cnFromCSR(csrPEM) if fqdn == "" { fqdn = "joining-" + clientIP } - nodeID := fmt.Sprintf("pre-%x", time.Now().UnixNano()) + nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(fqdn, ".", "-")) n := models.HANode{ ID: nodeID, @@ -574,7 +578,7 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) { FQDN: req.FQDN, APIURL: req.APIURL, Role: "peer", - Status: "joining", + Status: "online", // peer IS online — it just connected via mTLS } if req.PublicIP != "" { v := req.PublicIP @@ -598,6 +602,13 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) { 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- // effort: Fehler loggen, Response weiter durchreichen — der Peer // hat seine Identity erfolgreich registriert, Operator kann manuell diff --git a/internal/handlers/system.go b/internal/handlers/system.go index ac2ded4..c2b9fcd 100644 --- a/internal/handlers/system.go +++ b/internal/handlers/system.go @@ -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 diff --git a/internal/services/clusterjoin/join.go b/internal/services/clusterjoin/join.go index 404edb9..70bbdaa 100644 --- a/internal/services/clusterjoin/join.go +++ b/internal/services/clusterjoin/join.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "io" + "log/slog" "net" "net/http" "net/url" @@ -122,8 +123,17 @@ func Join(req Request) error { } } - // Auto-register: best-effort — cert material is already written. - _ = autoRegister(primary, tlsDir, req.CommonName, req.Version, req.NodeID) + // Auto-register: retry a few times because the primary's nftables may + // 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 }