Files
edgeguard-native/internal/handlers/setup.go
noroot a54d367c24 fix(cluster): duplicate-key-Warnung bei jedem API-Start auf dem Standby
preRegisterPrimary legte unbedingt eine Platzhalter-Zeile fuer den
Primary an. Sobald der Primary sich aber selbst gemeldet hat, existiert
bereits eine Zeile mit seiner ECHTEN Node-ID und demselben FQDN — und
UpsertSelf nutzt ON CONFLICT (id), greift also nicht. Die Synthetik-ID
"prenode-<fqdn>" lief damit in den fqdn-Unique-Index:

  duplicate key value violates unique constraint "ha_nodes_fqdn_unique"

bei JEDEM API-Start auf dem Standby. Folgenlos — die echte Zeile ist ja
korrekt und der Firewall-Reload lief trotzdem — aber es sah nach einem
Defekt aus und verdeckte echte Warnungen im selben Log.

Jetzt wird zuerst die IP einer vorhandenen Zeile aufgefrischt (mehr will
die Funktion gar nicht) und nur bei RowsAffected()==0 der Platzhalter
angelegt — also genau dann, wenn der Primary sich noch nie gemeldet hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:36:30 +02:00

303 lines
9.5 KiB
Go

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-<fqdn>" 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)
}