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

@@ -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,