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"
|
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.1.155"
|
var version = "1.1.156"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||||
@@ -279,6 +279,9 @@ func main() {
|
|||||||
setupHdl.WithClusterSupport(clusterStore, func(ctx context.Context) error {
|
setupHdl.WithClusterSupport(clusterStore, func(ctx context.Context) error {
|
||||||
return firewallrender.New(pool).Render(ctx)
|
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)
|
usersRepo := usersvc.New(pool)
|
||||||
authHdl.WithAudit(auditRepo, nodeID).WithUsers(usersRepo)
|
authHdl.WithAudit(auditRepo, nodeID).WithUsers(usersRepo)
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
"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
|
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.1.155"
|
var version = "1.1.156"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// renewTickInterval — how often we re-evaluate expiring certs.
|
// 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{
|
st, err := h.Store.CompleteAsNode(setup.NodeRequest{
|
||||||
FQDN: fqdn,
|
FQDN: fqdn,
|
||||||
ACMEEmail: body.ACMEEmail,
|
ACMEEmail: body.ACMEEmail,
|
||||||
|
PrimaryFQDN: strings.ToLower(strings.TrimSpace(body.PrimaryFQDN)),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Internal(c, err)
|
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
|
// preRegisterPrimary inserts the primary node into the local ha_nodes with
|
||||||
// its resolved IP so nftables @peer_ipv4 allows port 8443 from the primary.
|
// 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) {
|
func (h *SetupHandler) preRegisterPrimary(primaryFQDN string) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -228,7 +245,8 @@ func (h *SetupHandler) preRegisterPrimary(primaryFQDN string) {
|
|||||||
}
|
}
|
||||||
ip := addrs[0]
|
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{
|
n := models.HANode{
|
||||||
ID: nodeID,
|
ID: nodeID,
|
||||||
Name: primaryFQDN,
|
Name: primaryFQDN,
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ type State struct {
|
|||||||
// [::]:443 und [::]:3443. Default false weil nicht alle Deployments
|
// [::]:443 und [::]:3443. Default false weil nicht alle Deployments
|
||||||
// IPv6 haben. Nach Änderung wird HAProxy neu geladen.
|
// IPv6 haben. Nach Änderung wird HAProxy neu geladen.
|
||||||
IPv6Enabled bool `json:"ipv6_enabled,omitempty"`
|
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.
|
// 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.
|
// NodeRequest is the JSON body POST /api/v1/setup/complete-node accepts.
|
||||||
// No admin credentials — they are replicated from the primary via PG.
|
// No admin credentials — they are replicated from the primary via PG.
|
||||||
type NodeRequest struct {
|
type NodeRequest struct {
|
||||||
FQDN string `json:"fqdn" binding:"required"`
|
FQDN string `json:"fqdn" binding:"required"`
|
||||||
ACMEEmail string `json:"acme_email" binding:"required,email"`
|
ACMEEmail string `json:"acme_email" binding:"required,email"`
|
||||||
|
PrimaryFQDN string `json:"primary_fqdn,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
@@ -188,12 +195,17 @@ func (s *Store) CompleteAsNode(req NodeRequest) (*State, error) {
|
|||||||
if prev.CompletedAt != nil {
|
if prev.CompletedAt != nil {
|
||||||
completedAt = prev.CompletedAt
|
completedAt = prev.CompletedAt
|
||||||
}
|
}
|
||||||
|
primaryFQDN := strings.ToLower(strings.TrimSpace(req.PrimaryFQDN))
|
||||||
|
if primaryFQDN == "" {
|
||||||
|
primaryFQDN = prev.PrimaryFQDN // carry over if not re-supplied
|
||||||
|
}
|
||||||
st := &State{
|
st := &State{
|
||||||
FQDN: strings.TrimSpace(req.FQDN),
|
FQDN: strings.TrimSpace(req.FQDN),
|
||||||
ACMEEmail: strings.ToLower(strings.TrimSpace(req.ACMEEmail)),
|
ACMEEmail: strings.ToLower(strings.TrimSpace(req.ACMEEmail)),
|
||||||
IsClusterNode: true,
|
IsClusterNode: true,
|
||||||
Completed: true,
|
Completed: true,
|
||||||
CompletedAt: completedAt,
|
CompletedAt: completedAt,
|
||||||
|
PrimaryFQDN: primaryFQDN,
|
||||||
// Carry over non-auth fields from previous state.
|
// Carry over non-auth fields from previous state.
|
||||||
LicenseKey: prev.LicenseKey,
|
LicenseKey: prev.LicenseKey,
|
||||||
IPv6Enabled: prev.IPv6Enabled,
|
IPv6Enabled: prev.IPv6Enabled,
|
||||||
|
|||||||
Reference in New Issue
Block a user