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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user