package handlers import ( "context" "fmt" "log/slog" "net" "strings" "time" "github.com/gin-gonic/gin" "git.netcell-it.de/projekte/edgeguard-native/internal/cluster" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" "git.netcell-it.de/projekte/edgeguard-native/internal/models" "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" "git.netcell-it.de/projekte/edgeguard-native/internal/services/clusterjoin" "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" ) // SetupHandler exposes the first-run wizard endpoints. Both endpoints // are mounted before SetupGate so they remain reachable while the API // is in setup mode. type SetupHandler struct { Store *setup.Store Audit *audit.Repo NodeID string Version string ClusterStore *cluster.Store PeerReloader PeerReloader } func NewSetupHandler(store *setup.Store) *SetupHandler { return &SetupHandler{Store: store} } // WithAudit injiziert Audit-Repo + Node-ID damit Mutationen (contact-emails) // in audit_log landen. Optional — wenn Audit nil bleibt, läuft die // Mutation, aber ohne Log-Eintrag. func (h *SetupHandler) WithAudit(a *audit.Repo, nodeID string) *SetupHandler { h.Audit = a h.NodeID = nodeID return h } // WithVersion macht die laufende Version für auto-register verfügbar. func (h *SetupHandler) WithVersion(v string) *SetupHandler { h.Version = v return h } // WithClusterSupport erlaubt dem JoinCluster-Handler nach dem Join den // Primary in der lokalen ha_nodes zu registrieren + nftables neu zu laden, // damit Port 8443 bidirektional offen ist. func (h *SetupHandler) WithClusterSupport(store *cluster.Store, reloader PeerReloader) *SetupHandler { h.ClusterStore = store h.PeerReloader = reloader return h } func (h *SetupHandler) Register(rg *gin.RouterGroup) { g := rg.Group("/setup") g.GET("/status", h.Status) g.POST("/complete", h.Complete) g.POST("/complete-node", h.CompleteAsNode) g.POST("/join-cluster", h.JoinCluster) g.GET("/replication-status", h.ReplicationStatus) } // RegisterAuthed mountet die Endpoints die nach abgeschlossenem Setup // den Admin-Modus brauchen — Aufrufer hat requireAuth schon dran. func (h *SetupHandler) RegisterAuthed(rg *gin.RouterGroup) { g := rg.Group("/setup") g.POST("/contact-emails", h.SetContactEmails) } // Status returns just the public bits of the setup state: whether // it's done and (if so) the configured admin_email + acme_email + // fqdn. Never exposes the password hash. func (h *SetupHandler) Status(c *gin.Context) { st, err := h.Store.Load() if err != nil { response.Internal(c, err) return } response.OK(c, gin.H{ "completed": st.Completed, "admin_email": st.AdminEmail, "acme_email": st.ACMEEmail, "fqdn": st.FQDN, }) } // SetContactEmails: Admin-only Update der zwei E-Mail-Felder. // Sessions bleiben aktiv (Cookie referenziert den alten Actor); auf // nächstem Login zählt der neue Wert. func (h *SetupHandler) SetContactEmails(c *gin.Context) { var req struct { AdminEmail string `json:"admin_email" binding:"required,email"` ACMEEmail string `json:"acme_email" binding:"required,email"` } if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } if err := h.Store.SetContactEmails(req.AdminEmail, req.ACMEEmail); err != nil { response.BadRequest(c, err) return } st, err := h.Store.Load() if err != nil { response.Internal(c, err) return } if h.Audit != nil { _ = h.Audit.Log(c.Request.Context(), actorOf(c), "setup.contact_emails", st.AdminEmail, gin.H{ "admin_email": st.AdminEmail, "acme_email": st.ACMEEmail, }, h.NodeID) } response.OK(c, gin.H{ "admin_email": st.AdminEmail, "acme_email": st.ACMEEmail, }) } func (h *SetupHandler) Complete(c *gin.Context) { var req setup.Request if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } st, err := h.Store.Complete(req) if err != nil { response.BadRequest(c, err) return } response.OK(c, gin.H{ "completed": st.Completed, "admin_email": st.AdminEmail, "fqdn": st.FQDN, }) } func (h *SetupHandler) CompleteAsNode(c *gin.Context) { var req setup.NodeRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } st, err := h.Store.CompleteAsNode(req) if err != nil { response.BadRequest(c, err) return } response.OK(c, gin.H{ "completed": st.Completed, "is_cluster_node": st.IsClusterNode, "fqdn": st.FQDN, }) } // JoinCluster performs the full cluster-join flow from the setup wizard: // fetches certs from the primary, writes them to disk, then marks setup // as completed. No CLI required. func (h *SetupHandler) JoinCluster(c *gin.Context) { var body struct { FQDN string `json:"fqdn" binding:"required"` ACMEEmail string `json:"acme_email" binding:"required,email"` PrimaryFQDN string `json:"primary_fqdn" binding:"required"` Token string `json:"token" binding:"required"` } if err := c.ShouldBindJSON(&body); err != nil { response.BadRequest(c, err) return } fqdn := strings.ToLower(strings.TrimSpace(body.FQDN)) if err := clusterjoin.Join(clusterjoin.Request{ PrimaryFQDN: body.PrimaryFQDN, Token: strings.TrimSpace(body.Token), CommonName: fqdn, Insecure: true, // security comes from the HMAC token, not TLS cert trust Force: true, // bootstrap self-signed cert must be replaced Version: h.Version, NodeID: h.NodeID, }); err != nil { response.BadRequest(c, err) return } st, err := h.Store.CompleteAsNode(setup.NodeRequest{ FQDN: fqdn, ACMEEmail: body.ACMEEmail, PrimaryFQDN: strings.ToLower(strings.TrimSpace(body.PrimaryFQDN)), }) if err != nil { response.Internal(c, err) return } // Pre-register the primary in our local ha_nodes so its IP lands in // @peer_ipv4 and port 8443 is open bidirectionally. if h.ClusterStore != nil && h.PeerReloader != nil { go h.preRegisterPrimary(body.PrimaryFQDN) } // Logical Replication automatisch einrichten. Ohne diesen Schritt waere // der Node zwar im Cluster registriert, wuerde aber keinerlei geteilte // Config (Domains, Backends, Firewall-Rules, WireGuard, …) bekommen — // was frueher erst beim Failover auffiel. Laeuft detached, der Wizard // pollt /setup/replication-status. h.startReplicationSetup(body.PrimaryFQDN) response.OK(c, gin.H{ "completed": st.Completed, "is_cluster_node": st.IsClusterNode, "fqdn": st.FQDN, }) } // StartupPeerSync is called once after the DB pool and ClusterStore are // ready. On cluster nodes it re-registers the primary in the local ha_nodes // and reloads nftables so @peer_ipv4 is correct after a package update or // reboot — without requiring a new join. func (h *SetupHandler) StartupPeerSync() { if h.ClusterStore == nil || h.PeerReloader == nil { return } st, err := h.Store.Load() if err != nil || st == nil || !st.IsClusterNode || st.PrimaryFQDN == "" { return } h.preRegisterPrimary(st.PrimaryFQDN) } // preRegisterPrimary inserts the primary node into the local ha_nodes with // its resolved IP so nftables @peer_ipv4 allows port 8443 from the primary. // Uses a stable ID derived from the FQDN so repeated calls are idempotent. func (h *SetupHandler) preRegisterPrimary(primaryFQDN string) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() primaryFQDN = strings.ToLower(strings.TrimSpace(primaryFQDN)) addrs, err := net.DefaultResolver.LookupHost(ctx, primaryFQDN) if err != nil || len(addrs) == 0 { slog.Warn("setup: could not resolve primary FQDN for pre-registration", "fqdn", primaryFQDN, "error", err) return } ip := addrs[0] // Sobald der Primary sich selbst gemeldet hat (Heartbeat-Push), steht // hier bereits eine Zeile mit seiner ECHTEN Node-ID. Dann ist nur die // IP frisch zu halten — mehr will diese Funktion gar nicht. // // Vorher lief in dem Fall unbedingt der Platzhalter-Upsert unten, der // am fqdn-Unique-Index scheiterte: UpsertSelf nutzt ON CONFLICT (id), // und die Synthetik-ID "prenode-" trifft die echte Zeile nicht. // Ergebnis war ein "duplicate key value violates unique constraint // ha_nodes_fqdn_unique" bei JEDEM API-Start auf dem Standby — folgenlos // (die echte Zeile stimmt ja), aber es sah nach einem Defekt aus und // verdeckte echte Warnungen. tag, err := h.ClusterStore.Pool.Exec(ctx, `UPDATE ha_nodes SET public_ip = $1, updated_at = NOW() WHERE fqdn = $2`, ip, primaryFQDN) if err != nil { slog.Warn("setup: refreshing primary IP in ha_nodes failed", "fqdn", primaryFQDN, "error", err) return } if tag.RowsAffected() == 0 { // Noch keine Zeile: Platzhalter anlegen, damit @peer_ipv4 den // Primary schon kennt, bevor er sich das erste Mal meldet. // Stabile ID, damit wiederholte Aufrufe (Join + Start) keine // Zeilen anhaeufen. nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(primaryFQDN, ".", "-")) n := models.HANode{ ID: nodeID, Name: primaryFQDN, FQDN: primaryFQDN, APIURL: "https://" + primaryFQDN + ":3443", Role: "primary", Status: "online", } n.PublicIP = &ip if _, err := h.ClusterStore.UpsertSelf(ctx, n); err != nil { slog.Warn("setup: pre-register primary in ha_nodes failed", "fqdn", primaryFQDN, "error", err) return } } if err := h.PeerReloader(ctx); err != nil { slog.Warn("setup: PeerReloader failed after primary pre-register", "error", err) return } slog.Info("setup: primary pre-registered locally, firewall updated", "fqdn", primaryFQDN, "ip", ip) }