feat(cluster): Logical Replication wird beim Join automatisch eingerichtet

Bisher war der zweite Node nach dem Join zwar im Cluster registriert und
in der UI sichtbar, replizierte aber keine einzige geteilte Tabelle —
dafuer musste jemand manuell `edgeguard-ctl cluster-setup-standby`
ausfuehren. Wer das uebersah, merkte es erst beim Failover: der neue
Primary stand ohne Domains, Backends, Firewall-Regeln und WireGuard-Keys
da. Das ist jetzt Teil des Join-Vorgangs.

Beide Seiten muessen dafuer vorbereitet sein:

1) Primary, beim Erzeugen des Join-Tokens: ein frisch installierter
   Single-Node hat weder Replikations-Rolle noch PUBLICATION noch
   wal_level=logical. Ohne das liefe das spaetere CREATE SUBSCRIPTION in
   ein 404. Der Token wird deshalb erst ausgegeben, nachdem die
   Publisher-Seite steht — inklusive des einmaligen PG-Restarts
   (wal_level ist ein postmaster-Parameter), der bewusst hier passiert,
   solange der Admin danebensteht und noch kein Peer Traffic erwartet.

   WICHTIG dabei: setupReplicationPrimary rotiert bei jedem Lauf das
   Replikations-Passwort (ALTER ROLE … PASSWORD). Auf einem Cluster mit
   bereits angebundenem Subscriber wuerde ein zweiter Token-Klick dessen
   Connection-String ungueltig machen und die Replikation still
   anhalten. Deshalb laeuft die Initialisierung nur, wenn PUBLICATION
   und Secret nicht bereits existieren.

2) Neuer Node, nach erfolgreichem Join: cluster-setup-standby laeuft
   detached (die Initialkopie dauert je nach Datenmenge Minuten), der
   Wizard pollt GET /setup/replication-status und zeigt running/done/
   failed an. Schlaegt es fehl, steht das manuelle Kommando inkl.
   Primary-Host direkt daneben statt nur einer Fehlermeldung.

Beides braucht root (psql als postgres, pg_hba, PG-Restart), die API
laeuft als unprivilegierter edgeguard → Aufruf via sudo mit gepinnten
Regeln. Das einzige variable Argument (Primary-Host) wird vorher gegen
Hostname/IP-Syntax geprueft; der Aufruf laeuft ohne Shell. Test dafuer
liegt bei.

Ausserdem zwei Doku-Korrekturen: architecture.md behauptete,
cluster-join richte die Replikation gleich mit ein (tut es nicht,
clusterjoin.Join macht nur Cert + Registrierung), und der Hinweistext
von cluster-join verwies noch auf "PG-Basebackup + KeyDB, Phase 3.5" —
beides laut Doku laengst verworfen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-09-11 11:40:44 +02:00
parent bb19562bc1
commit 808f6fc055
10 changed files with 429 additions and 11 deletions

View File

@@ -53,6 +53,14 @@ type ClusterHandler struct {
NodeID string
}
const (
// pgPublicationName + pgReplicationSecretPath spiegeln die Werte aus
// cmd/edgeguard-ctl (egPubName / egReplSecret) — beide Seiten muessen
// dasselbe meinen.
pgPublicationName = "edgeguard_shared"
pgReplicationSecretPath = "/var/lib/edgeguard/pg-replication-secret"
)
func NewClusterHandler(store *cluster.Store, localID string) *ClusterHandler {
return &ClusterHandler{Store: store, LocalID: localID}
}
@@ -284,8 +292,7 @@ func (h *ClusterHandler) AgentIdentity(c *gin.Context) {
// aus /var/lib/edgeguard/pg-replication-secret. Gibt 404 zurück wenn die
// Datei fehlt (cluster-init-replication noch nicht ausgeführt).
func (h *ClusterHandler) AgentPGReplicationInfo(c *gin.Context) {
const secretPath = "/var/lib/edgeguard/pg-replication-secret"
pass, err := readFileString(secretPath)
pass, err := readFileString(pgReplicationSecretPath)
if err != nil {
response.NotFound(c, simpleError("pg-replication-secret nicht gefunden — cluster-init-replication auf dem Primary ausführen"))
return
@@ -518,6 +525,20 @@ func (h *ClusterHandler) GenerateJoinToken(c *gin.Context) {
// Body optional — wenn leer, läuft der Flow ohne Pre-Register.
_ = c.ShouldBindJSON(&req)
// Publisher-Seite sicherstellen, BEVOR ein Token rausgeht. Ein frisch
// installierter Single-Node hat weder Replikations-Rolle noch
// PUBLICATION noch wal_level=logical — der beitretende Node bekaeme
// beim CREATE SUBSCRIPTION nur ein 404 ("pg-replication-secret nicht
// gefunden") und stuende ohne replizierte Config da. Idempotent; der
// PG-Restart (nur beim allerersten Mal noetig, wal_level ist ein
// postmaster-Parameter) passiert hier bewusst, solange der Admin
// danebensteht und noch kein zweiter Node Traffic erwartet.
if err := h.ensureReplicationPublisher(c.Request.Context()); err != nil {
slog.Error("cluster: publisher setup before join-token failed", "error", err)
response.Internal(c, err)
return
}
token, exp, err := h.Tokens.Generate()
if err != nil {
response.Internal(c, err)
@@ -1128,3 +1149,43 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
"client_cn", cn, "remote", c.ClientIP())
response.OK(c, out)
}
// ensureReplicationPublisher richtet die lokale PG-Instanz als Logical-
// Replication-Publisher ein (Rolle + Secret, wal_level=logical, pg_hba,
// Grants, PUBLICATION). Idempotent — auf einem bereits eingerichteten
// Primary ist es ein No-Op.
//
// Braucht root (psql als postgres, pg_hba schreiben, ggf. PG-Restart), die
// API laeuft als unprivilegierter `edgeguard` → Aufruf via sudo mit
// gepinnter Regel, wie bei den uebrigen privilegierten Operationen.
func (h *ClusterHandler) ensureReplicationPublisher(ctx context.Context) error {
// WICHTIG: nur ausfuehren wenn die Publisher-Seite noch NICHT steht.
// setupReplicationPrimary generiert bei JEDEM Lauf ein neues
// Replikations-Passwort (ALTER ROLE … PASSWORD). Auf einem Cluster mit
// bereits angebundenem Subscriber wuerde dessen gespeicherter
// Connection-String damit ungueltig und die Replikation bliebe still
// stehen — ein zweiter Token-Klick duerfte das niemals ausloesen.
// Das Passwort laesst sich nicht wiederverwenden (in PG nur gehasht),
// deshalb ist "schon eingerichtet" hier ein hartes Abbruchkriterium.
if h.Store != nil {
var hasPub bool
if err := h.Store.Pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname = $1)`,
pgPublicationName).Scan(&hasPub); err == nil && hasPub {
if _, err := os.Stat(pgReplicationSecretPath); err == nil {
slog.Info("cluster: replication publisher already set up — skipping init")
return nil
}
}
}
cmd := exec.Command("sudo", "-n", "/usr/bin/edgeguard-ctl", //nolint:noctx // System-Setup, darf nicht am Request-Context haengen
"cluster-init-replication")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("cluster-init-replication: %w: %s",
err, strings.TrimSpace(string(out)))
}
slog.Info("cluster: replication publisher ensured")
return nil
}