diff --git a/VERSION b/VERSION index 101bb3a..3bacf4a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.84 +1.2.86 \ No newline at end of file diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 61f85d1..e8fcfce 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -347,6 +347,7 @@ func main() { WithAggregator(clusterAggregator). WithJoinFlow(clusterTLSStore, joinTokens). WithPeerReloader(peerReloader). + WithAudit(auditRepo, nodeID). WithVersion(version) clusterHdl.Register(authed) // /cluster/issue-cert läuft PUBLIC — joining Peer hat noch diff --git a/internal/handlers/cluster.go b/internal/handlers/cluster.go index 0214a37..2679595 100644 --- a/internal/handlers/cluster.go +++ b/internal/handlers/cluster.go @@ -21,6 +21,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" "git.netcell-it.de/projekte/edgeguard-native/internal/models" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" ) // ClusterHandler exposes cluster-state endpoints. /status ist die @@ -44,6 +45,11 @@ type ClusterHandler struct { // PeerReloader: optional, gesetzt bei Phase 3.5. Nach Auto-Register // triggert das den firewall-Render damit peer_ipv4 frisch ist. PeerReloader PeerReloader + + // Audit + NodeID: optional, gesetzt via WithAudit. Nötig für + // protokollierte, mutierende Aktionen wie den Replication-Repair. + Audit *audit.Repo + NodeID string } func NewClusterHandler(store *cluster.Store, localID string) *ClusterHandler { @@ -65,6 +71,13 @@ func (h *ClusterHandler) WithJoinFlow(store *clustertls.Store, tokens *jointoken return h } +// WithAudit setzt den Audit-Repo + NodeID für protokollierte Aktionen. +func (h *ClusterHandler) WithAudit(a *audit.Repo, nodeID string) *ClusterHandler { + h.Audit = a + h.NodeID = nodeID + return h +} + func (h *ClusterHandler) Register(rg *gin.RouterGroup) { g := rg.Group("/cluster") g.GET("/nodes", h.ListNodes) @@ -75,6 +88,8 @@ func (h *ClusterHandler) Register(rg *gin.RouterGroup) { g.PUT("/vip-settings", h.UpdateVIPSettings) g.POST("/rolling-update", h.RollingUpdate) g.GET("/rolling-update/status", h.RollingUpdateStatus) + g.POST("/repair-replication", h.RepairReplication) + g.GET("/repair-replication/status", h.RepairReplicationStatus) g.GET("/vip-status", h.VIPStatus) g.POST("/vip-test", h.VIPTest) if h.TLSStore != nil { @@ -235,6 +250,8 @@ func (h *ClusterHandler) RegisterAgent(rg *gin.RouterGroup) { g.GET("/active-ips", h.AgentActiveIPs) g.POST("/vip-cmd", h.AgentVIPCmd) g.GET("/tls-certs", h.AgentTLSCerts) + g.POST("/repair-replication", h.AgentRepairReplication) + g.GET("/repair-replication/status", h.AgentRepairReplicationStatus) } // AgentIdentity gibt die eigene ha_nodes-Row zurück. Wird vom Primary diff --git a/internal/handlers/cluster_repair.go b/internal/handlers/cluster_repair.go new file mode 100644 index 0000000..94cbc73 --- /dev/null +++ b/internal/handlers/cluster_repair.go @@ -0,0 +1,344 @@ +package handlers + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" + "git.netcell-it.de/projekte/edgeguard-native/internal/models" +) + +// Replication-Repair ("Resync erzwingen") für das Config-Drift-Banner. +// +// Drift entsteht, wenn ein Peer einen anderen config_hash hat als dieser +// Node — entweder weil die Logical-Replication-Subscription gestört ist +// oder weil direkt in die DB des Subscribers geschrieben wurde. Die +// Reparatur baut die Subscription neu auf und kopiert alle geteilten +// Tabellen frisch vom Primary (einseitig: Primary = Source of Truth). +// +// Der Resync MUSS auf dem Standby/Subscriber laufen (nur der hat eine +// Subscription). Operatoren erreichen die UI aber über die VIP, die immer +// auf den Primary zeigt. Deshalb: +// +// - Auf dem Primary geklickt → Dispatch via mTLS an den Standby +// (POST /agent/cluster/repair-replication), der dort lokal läuft. +// - Auf dem Standby direkt geklickt → läuft lokal. +// +// Die eigentliche Arbeit läuft — analog zum Rolling-Update — in einer +// transienten systemd-Unit, die das bereits getestete +// `edgeguard-ctl cluster-setup-standby ` ausführt. + +const ( + repairUnitName = "edgeguard-repair-replication.service" + repairScriptPath = "/var/lib/edgeguard/repair-replication.sh" + repairAgentPath = "/agent/cluster/repair-replication" +) + +// validRepairHost erlaubt nur IPv4/IPv6/Hostnamen — der Wert landet in +// einem Bash-Script das als root läuft, also strikt validieren (defense +// in depth, auch wenn er aus ha_nodes stammt). +var validRepairHost = regexp.MustCompile(`^[A-Za-z0-9._:-]{1,253}$`) + +// RepairReplication ist der UI-Endpoint. Läuft der lokale Node als +// Primary, wird der Resync an den Standby-Peer delegiert; auf dem Standby +// selbst läuft er lokal. +func (h *ClusterHandler) RepairReplication(c *gin.Context) { + if h.Store == nil { + response.Internal(c, errors.New("cluster store unavailable")) + return + } + all, err := h.Store.List(c.Request.Context()) + if err != nil { + response.Internal(c, err) + return + } + local := findNode(all, h.LocalID) + standby := findByPGRole(all, "standby") + + // Primary → an den Standby delegieren. + if local != nil && local.PGRole == "primary" { + if h.Aggregator == nil { + response.BadRequest(c, errors.New("kein mTLS-Aggregator verfügbar — Resync nicht delegierbar")) + return + } + if standby == nil { + response.BadRequest(c, errors.New("kein Standby-Node gefunden, an den der Resync delegiert werden könnte")) + return + } + res := h.Aggregator.PostPeer(c.Request.Context(), *standby, repairAgentPath) + if !res.OK { + response.Internal(c, fmt.Errorf("Resync auf %s anstoßen: %s", standby.FQDN, res.Err)) + return + } + slog.Info("cluster: replication repair delegated to standby", "standby", standby.FQDN) + if h.Audit != nil { + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "cluster.repair-replication", + standby.FQDN, gin.H{"target": "standby", "standby": standby.FQDN}, h.NodeID) + } + response.Accepted(c, gin.H{"dispatched": true, "target": "standby", "standby_fqdn": standby.FQDN}) + return + } + + // Standby (oder Direktzugriff) → lokal ausführen. + host, err := h.runLocalRepair(c.Request.Context(), all) + if err != nil { + response.BadRequest(c, err) + return + } + if h.Audit != nil { + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "cluster.repair-replication", + host, gin.H{"target": "local", "primary": host}, h.NodeID) + } + response.Accepted(c, gin.H{"dispatched": true, "target": "local", "primary": host}) +} + +// AgentRepairReplication wird vom Primary via mTLS auf dem Standby +// aufgerufen und startet dort den lokalen Resync. +func (h *ClusterHandler) AgentRepairReplication(c *gin.Context) { + if h.Store == nil { + response.Internal(c, errors.New("cluster store unavailable")) + return + } + all, err := h.Store.List(c.Request.Context()) + if err != nil { + response.Internal(c, err) + return + } + host, err := h.runLocalRepair(c.Request.Context(), all) + if err != nil { + response.BadRequest(c, err) + return + } + slog.Info("cluster: replication repair triggered by peer", "primary", host, "node", h.LocalID) + if h.Audit != nil { + _ = h.Audit.Log(c.Request.Context(), "cluster-peer", "cluster.repair-replication", + host, gin.H{"target": "local", "primary": host, "via": "agent"}, h.NodeID) + } + response.Accepted(c, gin.H{"dispatched": true, "primary": host}) +} + +// runLocalRepair startet den Resync auf DIESEM Node. Verweigert auf dem +// Primary (kein Subscriber). Gibt den ermittelten Primary-Host zurück. +func (h *ClusterHandler) runLocalRepair(_ context.Context, all []models.HANode) (string, error) { + local := findNode(all, h.LocalID) + primary := findByPGRole(all, "primary") + + if local != nil && local.PGRole == "primary" { + return "", errors.New("dieser Node ist der PostgreSQL-Primary — Resync läuft nur auf einem Standby/Subscriber") + } + if primary == nil { + return "", errors.New("kein PostgreSQL-Primary im Cluster gefunden — Resync-Quelle unbekannt") + } + if primary.ID == h.LocalID { + return "", errors.New("der lokale Node ist als Primary markiert — Resync nicht möglich") + } + + host := pickPrimaryHost(primary) + if host == "" { + return "", errors.New("Primary hat keine erreichbare IP/FQDN in ha_nodes") + } + if !validRepairHost.MatchString(host) { + return "", fmt.Errorf("ungültige Primary-Adresse: %q", host) + } + if st := repairUnitState(); st == "activating" || st == "active" { + return "", errors.New("Resync läuft bereits") + } + + script := fmt.Sprintf(`#!/bin/bash +set -uo pipefail +echo "[repair] resync der Logical-Replication-Subscription von Primary %[1]s" +/usr/bin/edgeguard-ctl cluster-setup-standby %[1]s +rc=$? +if [ "$rc" -ne 0 ]; then + echo "[repair] cluster-setup-standby fehlgeschlagen (rc=$rc)" + exit "$rc" +fi +echo "[repair] abgeschlossen — config_hash wird beim nächsten Cluster-Status neu berechnet" +rm -f %[2]s +`, host, repairScriptPath) + + if err := os.WriteFile(repairScriptPath, []byte(script), 0o755); err != nil { + return "", fmt.Errorf("write repair script: %w", err) + } + _ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", repairUnitName).Run() + cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run", + "--unit="+repairUnitName, + "--description=EdgeGuard replication repair", + "--collect", + "bash", repairScriptPath) + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("systemd-run failed: %w", err) + } + slog.Info("cluster: replication repair dispatched (local)", "primary", host, "node", h.LocalID) + return host, nil +} + +// repairStatusResponse spiegelt den Zustand der transienten Repair-Unit. +type repairStatusResponse struct { + Phase string `json:"phase"` // idle | running | success | failed + State string `json:"state"` + Result string `json:"result"` + ExitCode int `json:"exit_code"` + StartedAt string `json:"started_at,omitempty"` + FinishedAt string `json:"finished_at,omitempty"` + Log []string `json:"log"` +} + +// RepairReplicationStatus liest den Job-Zustand. Auf dem Primary wird der +// Status vom Standby-Peer geholt (dort läuft der Job); sonst lokal. +func (h *ClusterHandler) RepairReplicationStatus(c *gin.Context) { + if h.Store != nil { + if all, err := h.Store.List(c.Request.Context()); err == nil { + local := findNode(all, h.LocalID) + standby := findByPGRole(all, "standby") + if local != nil && local.PGRole == "primary" && h.Aggregator != nil && standby != nil { + results := h.Aggregator.FanOut(c.Request.Context(), + []models.HANode{*standby}, repairAgentPath+"/status", h.LocalID) + if len(results) == 1 && results[0].OK && len(results[0].Data) > 0 { + c.Data(200, "application/json", wrapEnvelope(results[0].Data)) + return + } + // Peer nicht erreichbar → idle zurückgeben statt Fehler, + // damit das UI-Polling nicht hart abbricht. + response.OK(c, repairStatusResponse{Phase: "idle", Log: []string{}}) + return + } + } + } + response.OK(c, localRepairStatus()) +} + +// AgentRepairReplicationStatus liefert den lokalen Job-Zustand an den +// abfragenden Primary. +func (h *ClusterHandler) AgentRepairReplicationStatus(c *gin.Context) { + response.OK(c, localRepairStatus()) +} + +// wrapEnvelope verpackt eine bereits entpackte data-Payload wieder in die +// Standard-Envelope, damit das UI (isEnvelope) sie konsistent liest. +func wrapEnvelope(data []byte) []byte { + out := []byte(`{"data":`) + out = append(out, data...) + out = append(out, []byte(`,"error":null,"message":"ok"}`)...) + return out +} + +// localRepairStatus liest den Zustand der lokalen Repair-Unit aus systemd +// (analog UpgradeStatus). Quelle der Wahrheit für Job-Ende ist die Unit. +func localRepairStatus() repairStatusResponse { + out := repairStatusResponse{Phase: "idle", Log: []string{}} + + if data, err := exec.Command("systemctl", "show", repairUnitName, + "--no-page", + "-p", "ActiveState", + "-p", "Result", + "-p", "ExecMainStatus", + "-p", "ExecMainStartTimestamp", + "-p", "ExecMainExitTimestamp", + ).CombinedOutput(); err == nil { + for _, line := range strings.Split(string(data), "\n") { + kv := strings.SplitN(strings.TrimSpace(line), "=", 2) + if len(kv) != 2 { + continue + } + switch kv[0] { + case "ActiveState": + out.State = kv[1] + case "Result": + out.Result = kv[1] + case "ExecMainStatus": + out.ExitCode, _ = strconv.Atoi(kv[1]) + case "ExecMainStartTimestamp": + if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", kv[1]); err == nil { + out.StartedAt = t.UTC().Format(time.RFC3339) + } + case "ExecMainExitTimestamp": + if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", kv[1]); err == nil { + out.FinishedAt = t.UTC().Format(time.RFC3339) + } + } + } + } + + switch out.State { + case "activating", "active", "deactivating": + out.Phase = "running" + case "failed": + out.Phase = "failed" + case "inactive": + if out.Result == "success" && out.ExitCode == 0 && out.FinishedAt != "" { + out.Phase = "success" + } else if out.Result != "" && out.Result != "success" { + out.Phase = "failed" + } + } + + if data, err := exec.Command("journalctl", + "-u", repairUnitName, + "--no-pager", "-n", "100", "-o", "cat", + ).CombinedOutput(); err == nil { + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + if !(len(lines) == 1 && (lines[0] == "" || strings.HasPrefix(lines[0], "-- No entries"))) { + out.Log = lines + } + } + return out +} + +// findNode / findByPGRole: kleine Helfer über die ha_nodes-Liste. +func findNode(nodes []models.HANode, id string) *models.HANode { + for i := range nodes { + if nodes[i].ID == id { + return &nodes[i] + } + } + return nil +} + +func findByPGRole(nodes []models.HANode, role string) *models.HANode { + for i := range nodes { + if nodes[i].PGRole == role { + return &nodes[i] + } + } + return nil +} + +// pickPrimaryHost wählt die beste erreichbare Adresse des Primary: +// Mgmt-IP → Internal-IP → Public-IP → FQDN. Strippt eine etwaige +// CIDR-Maske (inet-Spalten können "10.0.0.5/32" liefern). +func pickPrimaryHost(n *models.HANode) string { + for _, cand := range []*string{n.MgmtIP, n.InternalIP, n.PublicIP} { + if cand != nil { + if h := strings.TrimSpace(strings.SplitN(*cand, "/", 2)[0]); h != "" { + return h + } + } + } + return strings.TrimSpace(n.FQDN) +} + +// repairUnitState gibt den ActiveState der Repair-Unit zurück ("" wenn +// unbekannt). Für den Doppelstart-Schutz. +func repairUnitState() string { + out, err := exec.Command("systemctl", "show", repairUnitName, "--no-page", "-p", "ActiveState").CombinedOutput() + if err != nil { + return "" + } + for _, line := range strings.Split(string(out), "\n") { + if kv := strings.SplitN(strings.TrimSpace(line), "=", 2); len(kv) == 2 && kv[0] == "ActiveState" { + return kv[1] + } + } + return "" +} diff --git a/management-ui/src/components/ErrorBoundary.tsx b/management-ui/src/components/ErrorBoundary.tsx index 07952b2..d3fe178 100644 --- a/management-ui/src/components/ErrorBoundary.tsx +++ b/management-ui/src/components/ErrorBoundary.tsx @@ -1,35 +1,65 @@ import { Component, type ErrorInfo, type ReactNode } from 'react' -// Top-level ErrorBoundary. Catches throws aus dem React-Tree (inkl. -// Lazy-Chunk-Loadfehler, die auf flakigem Mobilfunk häufig sind) und -// rendert eine sichtbare Fehlerseite statt #root leer zu lassen. -// Ohne diese Boundary endet jeder Render-Throw als „blank page". -// -// Wir loggen den Fehler in die Browser-Console (für Remote-Debug via -// Safari-Inspector/Chrome-Remote) und zeigen dem Operator die -// Fehlermeldung wörtlich — kein Translation-Layer, weil i18n selbst -// schon kaputt sein kann. +import { isStaleChunkError, reloadForStaleChunkOnce } from '../lib/staleChunkReload' -interface State { error: Error | null } +// Top-level ErrorBoundary. Catches throws aus dem React-Tree (inkl. +// Lazy-Chunk-Loadfehler nach einem Deploy) und rendert eine sichtbare +// Fehlerseite statt #root leer zu lassen. Ohne diese Boundary endet +// jeder Render-Throw als „blank page". +// +// Stale-Chunk-Fehler (alter Tab referenziert nicht mehr existierende +// gehashte Chunks nach einem Deploy) werden automatisch per einmaligem +// Reload behoben — der Operator sieht dann nur kurz „Aktualisiere…". +// Erst wenn auch der Reload nicht hilft (giveUp) zeigen wir die manuelle +// Fehlerkarte. Andere Fehler werden wörtlich angezeigt — kein +// Translation-Layer, weil i18n selbst kaputt sein kann. + +interface State { error: Error | null; giveUp: boolean } export default class ErrorBoundary extends Component<{ children: ReactNode }, State> { - state: State = { error: null } + state: State = { error: null, giveUp: false } - static getDerivedStateFromError(error: Error): State { + static getDerivedStateFromError(error: Error): Partial { return { error } } componentDidCatch(error: Error, info: ErrorInfo) { // eslint-disable-next-line no-console console.error('[ErrorBoundary]', error, info.componentStack) + // Stale-Chunk → einmalig neu laden. Schlägt der Loop-Schutz an + // (Reload half nicht), auf die manuelle Karte zurückfallen. + if (isStaleChunkError(error) && !reloadForStaleChunkOnce()) { + this.setState({ giveUp: true }) + } } - reset = () => { this.setState({ error: null }) } + reset = () => { this.setState({ error: null, giveUp: false }) } render() { const err = this.state.error if (!err) return this.props.children - const isChunkErr = /Loading chunk|Failed to fetch dynamically imported module|Importing a module script failed/i.test(err.message) + const isChunkErr = isStaleChunkError(err) + + // Auto-Reload läuft (Chunk-Fehler, Loop-Schutz noch nicht erreicht): + // neutralen Lade-Hinweis zeigen statt der Fehlerkarte. + if (isChunkErr && !this.state.giveUp) { + return ( +
+ Aktualisiere EdgeGuard… +
+ ) + } + return (
{isChunkErr - ? 'Ein Teil der App konnte nicht aus dem Netz geladen werden. Das passiert häufig bei wechselndem Mobilfunk-Empfang. Versuche es mit einem Reload.' + ? 'Ein Teil der App konnte nicht geladen werden — auch ein automatischer Reload hat nicht geholfen. Bitte lade die Seite manuell neu (ggf. mit Strg+F5), oder prüfe die Verbindung zum Server.' : 'Beim Initialisieren der Oberfläche ist ein Fehler aufgetreten.'}
 {
+    e.preventDefault()
+    reloadForStaleChunkOnce()
+  })
+}
diff --git a/management-ui/src/main.tsx b/management-ui/src/main.tsx
index d9d5835..77e57ec 100644
--- a/management-ui/src/main.tsx
+++ b/management-ui/src/main.tsx
@@ -1,12 +1,17 @@
 import { StrictMode } from 'react'
 import { createRoot } from 'react-dom/client'
 import { ensureStorageSchema } from './lib/storageSchema'
+import { installStaleChunkReload } from './lib/staleChunkReload'
 
 // Vor allen anderen Imports die Storage prüfen — i18n und auth-store
 // lesen beim Modul-Init aus Storage, also muss der Cleanup davor
 // passieren wenn die Schema-Version nicht stimmt.
 ensureStorageSchema()
 
+// Stale-Chunk-Reload global registrieren: fängt Lazy-Import-Fehler nach
+// einem Deploy ab und lädt einmalig neu, statt eine Fehlerseite zu zeigen.
+installStaleChunkReload()
+
 import './styles/enterprise.css'
 import './i18n'
 import App from './App.tsx'
diff --git a/management-ui/src/pages/Cluster/index.tsx b/management-ui/src/pages/Cluster/index.tsx
index 8b9d46a..a703917 100644
--- a/management-ui/src/pages/Cluster/index.tsx
+++ b/management-ui/src/pages/Cluster/index.tsx
@@ -40,6 +40,16 @@ interface ClusterStatus {
   updated_at: string
 }
 
+interface RepairStatus {
+  phase: 'idle' | 'running' | 'success' | 'failed'
+  state: string
+  result: string
+  exit_code: number
+  started_at?: string
+  finished_at?: string
+  log: string[]
+}
+
 interface NodeResources {
   load_avg_1: number
   load_avg_5: number
@@ -330,8 +340,67 @@ export default function ClusterPage() {
     onError: (e: Error) => void message.error(e.message),
   })
 
+  // ── Replication-Repair ("Resync erzwingen") ──────────────────
+  const [repairing, setRepairing] = useState(false)
+
+  const repairStatusQuery = useQuery({
+    queryKey: ['cluster', 'repair-status'],
+    queryFn: async () => {
+      const r = await apiClient.get('/cluster/repair-replication/status')
+      return isEnvelope(r.data) ? (r.data.data as RepairStatus) : null
+    },
+    enabled: repairing,
+    refetchInterval: 3_000,
+    retry: 1,
+  })
+
+  useEffect(() => {
+    if (!repairing) return
+    const st = repairStatusQuery.data
+    // Job meldet Fehler → abbrechen mit letzter Log-Zeile.
+    if (st?.phase === 'failed') {
+      setRepairing(false)
+      const tail = st.log?.slice(-1)[0] ?? ''
+      void message.error(t('cluster.repair.failed') + (tail ? ': ' + tail : ''))
+      return
+    }
+    // Erfolg = Job meldet success ODER der Drift ist verschwunden. Letzteres
+    // ist das verlässliche Signal, da die transiente systemd-Unit (--collect)
+    // nach Erfolg verschwindet und "success" so verpasst werden kann.
+    if (st?.phase === 'success' || data?.drift_found === false) {
+      setRepairing(false)
+      void message.success(t('cluster.repair.ok'))
+      void qc.invalidateQueries({ queryKey: ['cluster'] })
+      return
+    }
+    // Cluster-Status frisch halten, damit drift_found zeitnah umspringt.
+    void qc.invalidateQueries({ queryKey: ['cluster', 'status'] })
+  }, [repairing, repairStatusQuery.data, data?.drift_found, qc, t])
+
+  const repairReplication = useMutation({
+    mutationFn: async () => {
+      const r = await apiClient.post('/cluster/repair-replication')
+      return r.data
+    },
+    onSuccess: () => {
+      setRepairing(true)
+      void message.info(t('cluster.repair.started'))
+      void repairStatusQuery.refetch()
+    },
+    onError: (e: Error) => void message.error(t('cluster.repair.failed') + ': ' + e.message),
+  })
+
   const primaryFqdn = data?.local_node?.fqdn ?? window.location.hostname
 
+  // Repair-Button: sichtbar bei Drift, für Admins, wenn ein Resync-Ziel
+  // existiert — auf dem Standby (lokal) oder auf dem Primary (delegiert
+  // an den Standby-Peer).
+  const localRole = data?.local_node?.pg_role
+  const canRepair = !isViewer
+    && !!data?.drift_found
+    && (localRole === 'standby'
+      || (localRole === 'primary' && (data?.peers?.some(p => p.pg_role === 'standby') ?? false)))
+
   const peerColumns: ColumnsType = [
     {
       title: t('cluster.col.node'), key: 'node',
@@ -468,7 +537,35 @@ export default function ClusterPage() {
           banner
           className="mb-16"
           message={t('cluster.driftBanner')}
-          description={t('cluster.driftBannerDesc')}
+          description={
+            <>
+              {t('cluster.driftBannerDesc')}
+              {data.local_node?.pg_role === 'primary'
+                && !(data.peers?.some(p => p.pg_role === 'standby'))
+                && {t('cluster.repair.noStandbyHint')}}
+            
+          }
+          action={
+            canRepair ? (
+               repairReplication.mutate()}
+              >
+                
+              
+            ) : undefined
+          }
         />
       )}
 
diff --git a/packaging/debian/edgeguard-api/DEBIAN/postinst b/packaging/debian/edgeguard-api/DEBIAN/postinst
index 89320be..aea4913 100755
--- a/packaging/debian/edgeguard-api/DEBIAN/postinst
+++ b/packaging/debian/edgeguard-api/DEBIAN/postinst
@@ -146,6 +146,11 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/systemd-run --unit=edgeguard-upgrade.ser
 # unter /var/lib/edgeguard/restore.sh, Unit-Form ist fix.
 edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reset-failed edgeguard-restore.service
 edgeguard ALL=(root) NOPASSWD: /usr/bin/systemd-run --unit=edgeguard-restore.service --description=EdgeGuard self-restore --collect bash /var/lib/edgeguard/restore.sh
+# Replication-Repair: Resync der Logical-Replication-Subscription vom
+# Primary (Config-Drift-Banner → "Resync erzwingen"). Skript landet immer
+# unter /var/lib/edgeguard/repair-replication.sh, Unit-Form ist fix.
+edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reset-failed edgeguard-repair-replication.service
+edgeguard ALL=(root) NOPASSWD: /usr/bin/systemd-run --unit=edgeguard-repair-replication.service --description=EdgeGuard replication repair --collect bash /var/lib/edgeguard/repair-replication.sh
 # Keepalived reload: VIP-Settings-Änderung triggert keepalived-Reload.
 edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload-or-restart keepalived.service
 edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload-or-restart keepalived.service