feat(cluster): startup peer-sync + stable primary-ID in ha_nodes

On API restart, cluster nodes now re-register their primary in the local
ha_nodes and reload nftables so @peer_ipv4 is correct after a package
update or reboot without requiring a re-join.

Also fixes duplicate ha_nodes rows: preRegisterPrimary previously used
time.Now().UnixNano() as node ID, creating a fresh row each call.
Now uses a deterministic ID derived from the FQDN so repeated upserts
are idempotent.

PrimaryFQDN is now persisted in setup.json during CompleteAsNode so the
startup sync knows which primary to contact.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-29 17:45:26 +02:00
parent 8ac066d99a
commit de28523708
6 changed files with 42 additions and 9 deletions

View File

@@ -1 +1 @@
1.1.155
1.1.156

View File

@@ -60,7 +60,7 @@ import (
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
)
var version = "1.1.155"
var version = "1.1.156"
func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR")
@@ -279,6 +279,9 @@ func main() {
setupHdl.WithClusterSupport(clusterStore, func(ctx context.Context) error {
return firewallrender.New(pool).Render(ctx)
})
// Cluster-Node-Startup: Primary in lokalen ha_nodes eintragen damit
// nftables @peer_ipv4 korrekt ist — auch ohne erneuten Join.
go setupHdl.StartupPeerSync()
usersRepo := usersvc.New(pool)
authHdl.WithAudit(auditRepo, nodeID).WithUsers(usersRepo)

View File

@@ -11,7 +11,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
)
var version = "1.1.155"
var version = "1.1.156"
const usage = `edgeguard-ctl — EdgeGuard CLI

View File

@@ -41,7 +41,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
)
var version = "1.1.155"
var version = "1.1.156"
const (
// renewTickInterval — how often we re-evaluate expiring certs.

View File

@@ -191,8 +191,9 @@ func (h *SetupHandler) JoinCluster(c *gin.Context) {
}
st, err := h.Store.CompleteAsNode(setup.NodeRequest{
FQDN: fqdn,
ACMEEmail: body.ACMEEmail,
FQDN: fqdn,
ACMEEmail: body.ACMEEmail,
PrimaryFQDN: strings.ToLower(strings.TrimSpace(body.PrimaryFQDN)),
})
if err != nil {
response.Internal(c, err)
@@ -212,8 +213,24 @@ func (h *SetupHandler) JoinCluster(c *gin.Context) {
})
}
// 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()
@@ -228,7 +245,8 @@ func (h *SetupHandler) preRegisterPrimary(primaryFQDN string) {
}
ip := addrs[0]
nodeID := fmt.Sprintf("primary-%x", time.Now().UnixNano())
// Stable ID so repeated calls (join + startup) don't accumulate rows.
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(primaryFQDN, ".", "-"))
n := models.HANode{
ID: nodeID,
Name: primaryFQDN,

View File

@@ -65,6 +65,12 @@ type State struct {
// [::]:443 und [::]:3443. Default false weil nicht alle Deployments
// IPv6 haben. Nach Änderung wird HAProxy neu geladen.
IPv6Enabled bool `json:"ipv6_enabled,omitempty"`
// PrimaryFQDN: FQDN des Cluster-Primary, den dieser Node beim Join
// angegeben hat. Wird bei jedem API-Start genutzt um den Primary in
// der lokalen ha_nodes vorab zu registrieren (Firewall @peer_ipv4),
// ohne dass der Join erneut durchlaufen werden muss.
PrimaryFQDN string `json:"primary_fqdn,omitempty"`
}
// Request is the JSON body POST /api/v1/setup/complete accepts.
@@ -81,8 +87,9 @@ type Request struct {
// NodeRequest is the JSON body POST /api/v1/setup/complete-node accepts.
// No admin credentials — they are replicated from the primary via PG.
type NodeRequest struct {
FQDN string `json:"fqdn" binding:"required"`
ACMEEmail string `json:"acme_email" binding:"required,email"`
FQDN string `json:"fqdn" binding:"required"`
ACMEEmail string `json:"acme_email" binding:"required,email"`
PrimaryFQDN string `json:"primary_fqdn,omitempty"`
}
type Store struct {
@@ -188,12 +195,17 @@ func (s *Store) CompleteAsNode(req NodeRequest) (*State, error) {
if prev.CompletedAt != nil {
completedAt = prev.CompletedAt
}
primaryFQDN := strings.ToLower(strings.TrimSpace(req.PrimaryFQDN))
if primaryFQDN == "" {
primaryFQDN = prev.PrimaryFQDN // carry over if not re-supplied
}
st := &State{
FQDN: strings.TrimSpace(req.FQDN),
ACMEEmail: strings.ToLower(strings.TrimSpace(req.ACMEEmail)),
IsClusterNode: true,
Completed: true,
CompletedAt: completedAt,
PrimaryFQDN: primaryFQDN,
// Carry over non-auth fields from previous state.
LicenseKey: prev.LicenseKey,
IPv6Enabled: prev.IPv6Enabled,