package handlers import ( "encoding/json" "log/slog" "net" "os" "os/exec" "strings" "sync" "time" "github.com/gin-gonic/gin" "git.netcell-it.de/projekte/edgeguard-native/internal/configgen" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" ) // Automatische Logical-Replication-Einrichtung beim Cluster-Join. // // Früher war das ein manueller Schritt: nach dem Join musste der Operator // auf dem neuen Node `edgeguard-ctl cluster-setup-standby ` // ausführen. Wer das übersah, hatte einen Node, der im Cluster sichtbar // war, aber KEINE geteilte Config replizierte — und merkte es erst beim // Failover. Deshalb läuft es jetzt direkt aus dem Join heraus. // // Der eigentliche Ablauf bleibt im CLI (`cluster-setup-standby`): er // braucht root (psql als postgres-User, pg_hba, render-config), die API // läuft als unprivilegierter `edgeguard`. Aufruf daher via sudo mit // gepinnter Regel — gleiches Muster wie bei apt-get/systemctl/tee. // // Weil die Initialkopie der geteilten Tabellen Minuten dauern kann, läuft // das detached; der Setup-Wizard pollt GET /setup/replication-status. const replicationStateFile = "/var/lib/edgeguard/replication-setup-state.json" const ( replPhaseIdle = "idle" replPhaseRunning = "running" replPhaseDone = "done" replPhaseFailed = "failed" ) // replStateMu serialisiert Lesen/Schreiben der State-Datei (HTTP-Handler // + Hintergrund-Goroutine greifen gleichzeitig zu). var replStateMu sync.Mutex // ReplicationSetupState hält den Fortschritt der Standby-Einrichtung. // Persistiert, damit der Status einen API-Neustart übersteht — der ist // der letzte Schritt des Setups und würde den Zustand sonst verlieren. type ReplicationSetupState struct { Phase string `json:"phase"` Primary string `json:"primary,omitempty"` Error string `json:"error,omitempty"` Log string `json:"log,omitempty"` StartedAt time.Time `json:"started_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"` } func readReplicationState() ReplicationSetupState { replStateMu.Lock() defer replStateMu.Unlock() raw, err := os.ReadFile(replicationStateFile) if err != nil { return ReplicationSetupState{Phase: replPhaseIdle} } var st ReplicationSetupState if err := json.Unmarshal(raw, &st); err != nil { return ReplicationSetupState{Phase: replPhaseIdle} } if st.Phase == "" { st.Phase = replPhaseIdle } // Ein "running", das älter als das CLI-Timeout ist, kann nur von einem // gestorbenen Prozess stammen (z. B. OOM-Kill). Sonst haengt der Wizard // ewig im Spinner. if st.Phase == replPhaseRunning && !st.StartedAt.IsZero() && time.Since(st.StartedAt) > 15*time.Minute { st.Phase = replPhaseFailed st.Error = "Zeitüberschreitung — Einrichtung lief länger als 15 Minuten. " + "Manuell nachholen: sudo edgeguard-ctl cluster-setup-standby " + st.Primary } return st } func writeReplicationState(st ReplicationSetupState) { st.UpdatedAt = time.Now() raw, err := json.Marshal(st) if err != nil { return } replStateMu.Lock() defer replStateMu.Unlock() if err := configgen.AtomicWrite(replicationStateFile, raw, 0o640); err != nil { slog.Warn("setup: replication state write failed", "error", err) } } // validPrimaryHost laesst nur das durch, was ein Hostname oder eine IP // sein kann. exec.Command startet keine Shell, Metazeichen koennen also // ohnehin nichts ausloesen — die Pruefung haelt aber Unsinn von der // sudo-Regel fern und liefert dem Operator einen klaren Fehler statt // eines kryptischen CLI-Abbruchs. func validPrimaryHost(h string) bool { h = strings.TrimSpace(h) if h == "" || len(h) > 253 { return false } if net.ParseIP(h) != nil { return true } for _, label := range strings.Split(h, ".") { if label == "" { return false } for _, r := range label { isAlnum := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') if !isAlnum && r != '-' { return false } } } return true } // startReplicationSetup richtet diesen Node im Hintergrund als Logical- // Replication-Subscriber ein. Nicht-blockierend: der Join-Request // antwortet sofort, der Wizard pollt den Status. func (h *SetupHandler) startReplicationSetup(primary string) { primary = strings.ToLower(strings.TrimSpace(primary)) if !validPrimaryHost(primary) { writeReplicationState(ReplicationSetupState{ Phase: replPhaseFailed, Error: "ungültiger Primary-Host: " + primary, }) return } writeReplicationState(ReplicationSetupState{ Phase: replPhaseRunning, Primary: primary, StartedAt: time.Now(), }) go func() { defer func() { if r := recover(); r != nil { slog.Error("setup: replication setup panic", "panic", r) writeReplicationState(ReplicationSetupState{ Phase: replPhaseFailed, Primary: primary, Error: "interner Fehler bei der Replikations-Einrichtung", }) } }() slog.Info("setup: starting logical replication setup", "primary", primary) // Kein Request-Context: der Join-Request ist längst beantwortet, // und ein Abbruch mitten im CREATE SUBSCRIPTION wäre schlimmer // als ein Weiterlaufen. cmd := exec.Command("sudo", "-n", "/usr/bin/edgeguard-ctl", //nolint:noctx // detached by design — darf nicht am Request haengen "cluster-setup-standby", primary) out, err := cmd.CombinedOutput() logTail := tailString(string(out), 4000) if err != nil { slog.Warn("setup: logical replication setup failed", "primary", primary, "error", err, "output", logTail) writeReplicationState(ReplicationSetupState{ Phase: replPhaseFailed, Primary: primary, Error: err.Error(), Log: logTail, }) return } slog.Info("setup: logical replication setup finished", "primary", primary) writeReplicationState(ReplicationSetupState{ Phase: replPhaseDone, Primary: primary, Log: logTail, }) }() } // tailString kuerzt lange CLI-Ausgaben auf die letzten n Bytes — der // interessante Teil (Fehler, Abschlussmeldung) steht am Ende. func tailString(s string, n int) string { if len(s) <= n { return s } return "…" + s[len(s)-n:] } // ReplicationStatus liefert den Fortschritt der automatischen Standby- // Einrichtung. Liegt bewusst auf der Setup-Gruppe (pre-auth): der Wizard // pollt es, bevor auf dem neuen Node ueberhaupt ein Login moeglich ist. func (h *SetupHandler) ReplicationStatus(c *gin.Context) { response.OK(c, readReplicationState()) }