14 Commits

Author SHA1 Message Date
noroot
3ca37ee226 chore(release): v1.3.34 stable 2026-09-11 12:21:59 +02:00
noroot
86aee33308 fix(cluster): jeder Node registrierte sich selbst fest als "primary"
Die self-Registrierung in ha_nodes uebergab beim API-Start hart
"primary" — fuer JEDEN Node. Da ha_nodes node-lokal ist (nicht
repliziert), trug sich damit auch ein per Join dazugekommener Standby
bei sich selbst als Primary ein. In der Cluster-Ansicht DIESES Nodes
erschienen beide Knoten als Primary, und eine Korrektur direkt in der
DB hielt nur bis zum naechsten Neustart.

Nicht kosmetisch: keepalived.go nimmt `role` als Fallback, wenn pg_role
nicht 'standby' ist. Ein Standby, der sich selbst "primary" nennt, ist
damit genau der Zustand, der 2026-05 schon einmal einen Split-Brain
ausgeloest hat (beide Knoten Prioritaet 200, hoehere IP gewinnt).
Aktuell deckt pg_role='standby' das ab — aber als alleinige Absicherung
ist das duenn.

Die Rolle wird jetzt aus der Replikations-Topologie abgeleitet, also der
in cluster_repair.go dokumentierten verlaesslichen Quelle: nur der
Primary hat die PUBLICATION, nur der Standby die SUBSCRIPTION (beide
Kataloge darf der edgeguard-DB-User lesen, verifiziert). Das ist
selbstheilend und ueberlebt `edgeguard-ctl promote` korrekt — eine
Ableitung aus setup.json wuerde den Promote dagegen bei jedem Neustart
wieder ueberschreiben. Ohne eingerichtete Replikation entscheidet
IsClusterNode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:21:22 +02:00
noroot
4be9f7f280 chore(release): v1.3.33 stable 2026-09-11 12:15:56 +02:00
noroot
a34457f13f fix(ui): Cluster-Karte zeigte auf dem Standby beide Knoten als DB-Primary
Auf utm-2 trug die Cluster-Karte an BEIDEN Knoten "DB-Primary" — auf
utm-1 dagegen korrekt "primary" und "peer". Es kann aber nur einen
DB-Primary geben, also war mindestens eine der beiden Ansichten falsch.

Ursache: ha_nodes ist NICHT repliziert (node-lokale Tabelle, jeder Knoten
fuehrt seine eigene Sicht), und in `role` traegt sich jeder Knoten selbst
ein. Ein per Join dazugekommener Knoten behaelt dort den Default
"primary" — utm-2 behauptete in seiner eigenen Ansicht also, es sei
Primary. Der Code weiss das an anderer Stelle bereits:
cluster_repair.go dokumentiert "role/pg_role sind je Node lokal und
unzuverlaessig", und keepalived.go gibt pg_role deshalb seit v1.2.46
absoluten Vorrang vor role.

Die Karte zeigt jetzt pg_role statt role. Das folgt der tatsaechlichen
Replikationsrolle und stimmt auf beiden Seiten ueberein (utm-1=primary,
utm-2=standby). 'standalone'/leer heisst "keine Replikation eingerichtet"
und zeigt bewusst gar keine Rolle, statt eine zu erfinden.

Die stale role='primary'-Zeile auf utm-2 wurde separat direkt in der
node-lokalen DB korrigiert. Sie war nicht nur kosmetisch: faellt pg_role
irgendwann aus (leer/'standalone'), greift in keepalived.go der
role-Fallback — und genau diese Konstellation hat 2026-05 schon einmal
einen Split-Brain ausgeloest (beide Knoten Prio 200, hoehere IP gewinnt).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:15:19 +02:00
noroot
1b0320a5da chore(release): v1.3.32 stable 2026-09-11 12:03:03 +02:00
noroot
9137c07c95 fix(tls): Management-Zertifikat des Standby lief ab und erneuerte sich nie
Auf utm-2 war das Zertifikat der Management-UI seit dem 28.08. abgelaufen
(entdeckt am 11.09.) und haette sich aus eigener Kraft nie wieder
erneuert. Zwei Ursachen trafen zusammen:

1) Das FQDN eines per Join dazugekommenen Nodes landet in KEINER
   tls_certs-Zeile — es wird beim Setup einmalig ausgestellt und danach
   von niemandem mehr angefasst. certrenewer arbeitet nur die Tabelle ab
   und sieht es deshalb nie. (utm-1 steht dort drin, weil sein FQDN beim
   Erst-Setup regulaer als Domain angelegt wurde — deshalb fiel es dort
   nicht auf.)
2) Der Scheduler blockt auf einem Nicht-VIP-Master jede ACME-Erneuerung
   (v1.3.20). Fuer geteilte Domains ist das richtig: die zeigen per DNS
   auf die VIP, nur der Master kann die Challenge bestehen. Fuer das
   eigene Management-FQDN stimmt es nicht — utm-2.netcell-it.de zeigt auf
   89.163.205.8, die eigene IP des Nodes, und Port 80 antwortet dort.

Neu: runManagementCertRenew laeuft auf JEDEM Node unabhaengig von der
VIP, aber ausschliesslich fuer das eigene FQDN aus setup.json. Prueft die
Restlaufzeit der Datei unter /etc/edgeguard/tls und erneuert ab 30 Tagen
Rest (gleicher Schwellwert wie bei den Domain-Certs).

Bewusst NICHT ueber tls_certs: das ist eine replizierte Shared-Table, und
cluster-reconcile-replication TRUNCATEt solche Tabellen beim Refresh —
eine lokal auf dem Subscriber eingefuegte Zeile waere beim naechsten
Paket-Upgrade wieder weg. Das Management-Zertifikat ist node-lokale
Infrastruktur und wird wie die cluster-tls-Certs als reine Datei
behandelt. Existiert dagegen bereits eine tls_certs-Zeile fuer das eigene
FQDN (Fall utm-1), bleibt alles beim Alten und certrenewer behaelt die
Zustaendigkeit — sonst haetten zwei Mechanismen dieselbe Datei.

Tests decken die Schwellwert-Entscheidung ab, inklusive des
utm-2-Falls (bereits abgelaufen) und kaputter PEM-Dateien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:02:26 +02:00
noroot
84112d399b chore(release): v1.3.31 stable 2026-09-11 11:52:15 +02:00
noroot
0f2fba4a62 fix(ui): Dashboard riss bei SPA-Navigation die ganze Oberflaeche
Symptom: Klick auf einen Link und zurueck aufs Dashboard →
"EdgeGuard konnte nicht laden / TypeError: Cannot read properties of
undefined (reading 'length')". Nach F5 ging es wieder, bis man erneut
navigierte.

Ursache ist ein Cache-Key-Konflikt. Unter ['haproxy','stats'] lagen zwei
unvereinbare Formate:
  - Dashboard cachte { backends, frontends, error } (es zeigt auch
    Frontends an),
  - Domains, Domains/Detail, Backends, Backends/Detail und RoutingRules
    cachten via listHAProxyStats nur das Backend-ARRAY.

Wer zuletzt lud, bestimmte die Form im Cache. Nach einem Besuch einer
dieser Seiten bekam das Dashboard bei der Rueckkehr das Array serviert,
stats.frontends war undefined und der Throw landete in der
ErrorBoundary. Ein Reload half nur, weil er den Cache leert und das
Dashboard wieder selbst befuellt.

Fix: alle sechs Stellen cachen jetzt die vollstaendige Antwort; die fuenf
Seiten, die nur die Backends brauchen, reduzieren per `select`. Damit
gibt es unter dem Key genau eine Form, egal wer zuerst laedt.

Zusaetzlich im Dashboard defensive Guards (`?? []`) auf data.vips,
stats.frontends und stats.backends. Ein unerwartetes Format darf eine
einzelne Karte kosten, aber nie die komplette Oberflaeche.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 11:51:38 +02:00
noroot
60358a6d47 chore(release): v1.3.30 stable 2026-09-11 11:41:23 +02:00
noroot
808f6fc055 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>
2026-09-11 11:40:44 +02:00
noroot
bb19562bc1 chore(release): v1.3.29 stable 2026-09-11 10:33:34 +02:00
noroot
a31c94f9b8 fix(ha): squid/unbound liefen auf dem Standby nicht — ip_nonlocal_bind
squid und unbound lauschen auf den VLAN-Gateway-VIPs. Bei
ip_nonlocal_bind=0 kann ein Node diese Adressen nur binden, waehrend er
die VIP haelt. Bootet ein Node als Standby, scheitert der Start deshalb
mit "FATAL: Unable to open HTTP Socket" und die Unit bleibt dauerhaft
`failed` — systemd versucht es nicht erneut, keepalived-master.sh startet
sie erst bei VIP-Uebernahme (Kaltstart im Umschaltmoment).

Zwei Probleme daran: der Dauer-`failed`-Zustand ist nicht von einem
echten Ausfall zu unterscheiden, und beim Failover kommen die Dienste
erst nach dem Start hoch statt sofort bereit zu stehen.

Mit nonlocal_bind laufen beide auf beiden Nodes durch. Traffic bekommt
weiterhin nur der Node, der die VIP per ARP haelt — die VIP-Wahl selbst
ist nicht betroffen, keepalived-check.sh prueft ausschliesslich
edgeguard-api, haproxy und den :443-Bind.

Zusaetzlich repariert der postinst gezielt Units, die enabled UND failed
sind (also genau den obigen Fall); laufende oder bewusst deaktivierte
Dienste bleiben unangetastet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 10:32:54 +02:00
noroot
2c72a82a91 chore(release): v1.3.28 stable 2026-09-11 10:24:22 +02:00
noroot
ed419c1f5f fix(ui): Cluster-Karte las sich als Widerspruch zur VIP-Karte
Die Dashboard-Cluster-Karte zeigte ha_nodes.role als nacktes "primary".
Das ist die DB-/Cluster-Rolle (wohin Schreibzugriffe gehen); sie wandert
bewusst NICHT mit der VIP und aendert sich nur durch `edgeguard-ctl
promote`. Direkt daneben steht aber die VIP/VRRP-Karte mit "BACKUP" —
waehrend eines Failovers (z.B. Node-Reboot) sah der Operator also
gleichzeitig "primary" und "BACKUP" und musste raten, was stimmt.

Die Daten waren korrekt, nur das Label mehrdeutig: jetzt "DB-Primary"
statt "primary", plus Tooltip der den Unterschied zur VRRP-Rolle
benennt. Auf der Cluster-Seite bleibt es unveraendert — dort steht die
Spalte direkt neben pg_role, der Kontext erklaert sich dort selbst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 10:23:36 +02:00
21 changed files with 973 additions and 53 deletions

View File

@@ -1 +1 @@
1.3.27 1.3.34

View File

@@ -168,7 +168,8 @@ func main() {
if st != nil && st.Completed { if st != nil && st.Completed {
// Auto-create /etc/edgeguard/node.conf falls fehlt. // Auto-create /etc/edgeguard/node.conf falls fehlt.
_, _ = cluster.EnsureLocalConfig("") _, _ = cluster.EnsureLocalConfig("")
if _, err := cluster.EnsureSelfRegistered(ctx, clusterStore, st.FQDN, "primary", version); err != nil { if _, err := cluster.EnsureSelfRegistered(ctx, clusterStore, st.FQDN,
localClusterRole(ctx, pool, st), version); err != nil {
slog.Warn("self-register in ha_nodes failed", "error", err) slog.Warn("self-register in ha_nodes failed", "error", err)
} }
} }
@@ -303,12 +304,12 @@ func main() {
systemHdl.WithAudit(auditRepo, nodeID) systemHdl.WithAudit(auditRepo, nodeID)
systemHdl.WithDB(pool) systemHdl.WithDB(pool)
systemHdl.WithConfigPreviewers(map[string]func(context.Context) (string, error){ systemHdl.WithConfigPreviewers(map[string]func(context.Context) (string, error){
"haproxy": haproxy.New(pool).RenderToString, "haproxy": haproxy.New(pool).RenderToString,
"nftables": firewallrender.New(pool).RenderToString, "nftables": firewallrender.New(pool).RenderToString,
"squid": squidrender.New(pool).RenderToString, "squid": squidrender.New(pool).RenderToString,
"unbound": unboundrender.New(pool).RenderToString, "unbound": unboundrender.New(pool).RenderToString,
"chrony": chronyrender.New(pool).RenderToString, "chrony": chronyrender.New(pool).RenderToString,
"wireguard": wgrender.New(pool, secretsBox).RenderToString, "wireguard": wgrender.New(pool, secretsBox).RenderToString,
"crowdsec-whitelist": crowdsec.NewWhitelistGenerator(pool).RenderToString, "crowdsec-whitelist": crowdsec.NewWhitelistGenerator(pool).RenderToString,
}) })
setupHdl.WithAudit(auditRepo, nodeID) setupHdl.WithAudit(auditRepo, nodeID)
@@ -927,3 +928,47 @@ func randomEphemeralSecret() []byte {
} }
return b return b
} }
// localClusterRole ermittelt die eigene Cluster-Rolle für die node-lokale
// ha_nodes-Zeile.
//
// Befund 2026-09-11: Hier stand fest "primary" — für JEDEN Node, bei jedem
// API-Start. ha_nodes ist node-lokal (nicht repliziert), also trug sich auch
// ein per Join dazugekommener Standby bei sich selbst als "primary" ein. In
// der Cluster-Ansicht DIESES Nodes erschienen dadurch beide Knoten als
// Primary, und eine Korrektur direkt in der DB hielt nur bis zum nächsten
// Neustart.
//
// Nicht kosmetisch: keepalived.go nutzt `role` als Fallback, wenn pg_role
// nicht 'standby' ist. Ein Standby, der sich selbst "primary" nennt, ist
// damit genau der Zustand, der 2026-05 schon einmal einen Split-Brain
// ausgelöst hat (beide Knoten Priorität 200, höhere IP gewinnt).
//
// Verlässlich ist — wie in cluster_repair.go dokumentiert — die
// Replikations-Topologie selbst: nur der Primary hat die PUBLICATION, nur
// der Standby die SUBSCRIPTION. Beide Kataloge darf der edgeguard-DB-User
// lesen. Das ist zugleich selbstheilend: nach `edgeguard-ctl promote` hat
// der neue Primary die Publication und meldet sich ab dem nächsten Start
// korrekt als "primary" — anders als eine Ableitung aus setup.json, die
// den Promote überschreiben würde.
func localClusterRole(ctx context.Context, pool *pgxpool.Pool, st *setup.State) string {
if pool != nil {
var hasPub, hasSub bool
if err := pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname = 'edgeguard_shared')`,
).Scan(&hasPub); err == nil && hasPub {
return "primary"
}
if err := pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM pg_subscription WHERE subname = 'edgeguard_sub')`,
).Scan(&hasSub); err == nil && hasSub {
return "peer"
}
}
// Keine Replikation eingerichtet: ein per Join dazugekommener Node ist
// trotzdem kein Primary, alles andere (Founder/Single-Node) schon.
if st != nil && st.IsClusterNode {
return "peer"
}
return "primary"
}

View File

@@ -52,8 +52,12 @@ func cmdClusterJoin(args []string) int {
fmt.Printf(" CN: %s\n", commonName) fmt.Printf(" CN: %s\n", commonName)
fmt.Printf(" Files: %s/{ca.crt,peer.crt,peer.key}\n", *clusterTLSDir) fmt.Printf(" Files: %s/{ca.crt,peer.crt,peer.key}\n", *clusterTLSDir)
fmt.Printf("\nNächste Schritte:\n") fmt.Printf("\nNächste Schritte:\n")
fmt.Printf(" 1) sudo systemctl restart edgeguard-api # lädt das neue Cert ins mTLS-Agent-Listener\n") fmt.Printf(" 1) sudo edgeguard-ctl cluster-setup-standby %s\n", primary)
fmt.Printf(" 2) Auf dem Primary in der Cluster-UI prüfen ob der neue Peer in /cluster/nodes auftaucht\n") fmt.Printf(" → richtet die Logical Replication ein. OHNE diesen Schritt ist der\n")
fmt.Printf(" 3) PG-Basebackup + KeyDB-Replica-Setup folgt mit Phase 3.5 (manuell bis dahin)\n") fmt.Printf(" Node zwar im Cluster, bekommt aber KEINE geteilte Config.\n")
fmt.Printf(" 2) sudo systemctl restart edgeguard-api # lädt das neue Cert in den mTLS-Agent-Listener\n")
fmt.Printf(" 3) Auf dem Primary in der Cluster-UI prüfen ob der neue Peer auftaucht\n")
fmt.Printf("\nHinweis: Beim Join über den Setup-Wizard passiert Schritt 1 automatisch;\n")
fmt.Printf("dieser CLI-Pfad ist der manuelle Weg und braucht ihn explizit.\n")
return 0 return 0
} }

View File

@@ -167,8 +167,10 @@ func main() {
st, _ := setupStore.Load() st, _ := setupStore.Load()
var renewer *certrenewer.Service var renewer *certrenewer.Service
var acmeIssuer *acme.Service
if st != nil && st.ACMEEmail != "" { if st != nil && st.ACMEEmail != "" {
issuer := acme.New(st.ACMEEmail) issuer := acme.New(st.ACMEEmail)
acmeIssuer = issuer
renewer = certrenewer.New(tlsRepo, issuer, certDir, 30*24*time.Hour) renewer = certrenewer.New(tlsRepo, issuer, certDir, 30*24*time.Hour)
slog.Info("scheduler: ACME renewer enabled", slog.Info("scheduler: ACME renewer enabled",
"email", st.ACMEEmail, "tick", renewTickInterval, "threshold", "30d") "email", st.ACMEEmail, "tick", renewTickInterval, "threshold", "30d")
@@ -195,6 +197,11 @@ func main() {
if renewer != nil && nodeHoldsVIP(ctx, pool) { if renewer != nil && nodeHoldsVIP(ctx, pool) {
runRenewer(ctx, renewer, alertSvc, alertDedupe) runRenewer(ctx, renewer, alertSvc, alertDedupe)
} }
// Das EIGENE Management-Zertifikat dagegen auf jedem Node — dessen FQDN
// zeigt auf die eigene IP, nicht auf die VIP (siehe mgmtcert.go).
if acmeIssuer != nil {
runManagementCertRenew(ctx, setupStore, tlsRepo, acmeIssuer, alertSvc, alertDedupe)
}
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe) runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
// Lokale Node-ID für Heartbeat. EnsureNodeID liefert dieselbe ID // Lokale Node-ID für Heartbeat. EnsureNodeID liefert dieselbe ID
@@ -268,6 +275,10 @@ func main() {
if renewer != nil && nodeHoldsVIP(ctx, pool) { if renewer != nil && nodeHoldsVIP(ctx, pool) {
runRenewer(ctx, renewer, alertSvc, alertDedupe) runRenewer(ctx, renewer, alertSvc, alertDedupe)
} }
// Eigenes Management-Cert: unabhaengig von der VIP, siehe oben.
if acmeIssuer != nil {
runManagementCertRenew(ctx, setupStore, tlsRepo, acmeIssuer, alertSvc, alertDedupe)
}
runCertExpiryCheck(ctx, tlsRepo, alertSvc, alertDedupe) runCertExpiryCheck(ctx, tlsRepo, alertSvc, alertDedupe)
case <-licTick.C: case <-licTick.C:
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe) runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)

View File

@@ -0,0 +1,180 @@
package main
import (
"context"
"crypto/x509"
"encoding/pem"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/certstore"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
)
// Node-lokale Erneuerung des eigenen Management-Zertifikats.
//
// Befund 2026-09-11: Auf utm-2 war das Zertifikat fuer die Management-UI
// seit zwei Wochen abgelaufen und haette sich nie erneuert. Zwei Gruende
// trafen zusammen:
//
// 1. Das FQDN eines per Join dazugekommenen Nodes landet in KEINER
// tls_certs-Zeile — es wird beim Setup einmalig ausgestellt und danach
// von niemandem mehr angefasst. certrenewer arbeitet ausschliesslich
// die Tabelle ab und sieht es deshalb nie.
// 2. Der Scheduler blockt auf einem Nicht-VIP-Master jede ACME-Erneuerung
// (v1.3.20). Das ist fuer geteilte Domains richtig — die zeigen per DNS
// auf die VIP, nur der Master kann die Challenge bestehen. Fuer das
// eigene Management-FQDN stimmt es NICHT: das zeigt auf die eigene IP
// des Nodes, der die HTTP-01-Challenge also selbst beantworten kann.
//
// Deshalb laeuft diese Pruefung auf JEDEM Node, unabhaengig von der VIP —
// aber ausschliesslich fuer das eigene FQDN aus setup.json.
//
// Bewusst NICHT ueber die tls_certs-Tabelle: die ist eine replizierte
// Shared-Table, und cluster-reconcile-replication TRUNCATEt solche Tabellen
// beim Refresh. Eine lokal auf dem Subscriber eingefuegte Zeile waere beim
// naechsten Paket-Upgrade wieder weg. Das Management-Zertifikat ist
// node-lokale Infrastruktur (wie die cluster-tls-Certs) und wird auch so
// behandelt: reine Datei unter certDir.
//
// Existiert dagegen eine tls_certs-Zeile fuer das eigene FQDN (so ist es
// auf dem Primary, dessen FQDN beim Setup regulaer als Domain angelegt
// wurde), bleibt alles beim Alten — dann macht certrenewer weiter seine
// Arbeit und wir fassen nichts an. Sonst haetten wir zwei Mechanismen auf
// derselben Datei.
// mgmtCertRenewThreshold: ab wann erneuert wird. Gleicher Wert wie der
// certrenewer fuer die Domain-Certs.
const mgmtCertRenewThreshold = 30 * 24 * time.Hour
// runManagementCertRenew prueft das eigene Management-Zertifikat und
// erneuert es bei Bedarf. Best-effort: Fehler werden geloggt/gemeldet,
// der Tick laeuft beim naechsten Zyklus erneut.
func runManagementCertRenew(
ctx context.Context,
setupStore *setup.Store,
tlsRepo *tlscerts.Repo,
issuer interface {
Issue(domain string) (string, string, string, error)
},
a *alerts.Service, d *dedupe,
) {
if setupStore == nil || issuer == nil {
return
}
st, err := setupStore.Load()
if err != nil || st == nil || st.FQDN == "" {
return
}
fqdn := strings.ToLower(strings.TrimSpace(st.FQDN))
// Wird das FQDN bereits als regulaere Domain verwaltet, ist der
// certrenewer zustaendig — nicht zusaetzlich hier anfassen.
if tlsRepo != nil {
if managed, err := mgmtCertIsManaged(ctx, tlsRepo, fqdn); err == nil && managed {
return
}
}
path := filepath.Join(certDir, fqdn+".pem")
remaining, err := certRemainingValidity(path)
switch {
case err != nil:
slog.Info("scheduler: management cert missing/unreadable — issuing",
"fqdn", fqdn, "path", path, "error", err)
case remaining > mgmtCertRenewThreshold:
return // noch lange gueltig
default:
slog.Info("scheduler: management cert expiring — renewing",
"fqdn", fqdn, "remaining", remaining.Round(time.Hour).String())
}
certPEM, chainPEM, keyPEM, err := issuer.Issue(fqdn)
if err != nil {
slog.Error("scheduler: management cert issue failed", "fqdn", fqdn, "error", err)
if a != nil && d != nil && d.shouldFire("cert.mgmt_renew_failed:"+fqdn) {
_, _ = a.Fire(ctx, "cert.mgmt_renew_failed", alerts.SeverityError,
"Management-Zertifikat konnte nicht erneuert werden: "+fqdn,
"Die HTTP-01-Challenge fuer das eigene Management-FQDN ist fehlgeschlagen. "+
"Pruefe, ob "+fqdn+" auf die oeffentliche IP DIESES Nodes zeigt und Port 80 "+
"von aussen erreichbar ist. Fehler: "+err.Error())
}
return
}
if _, err := certstore.WriteCombined(certDir, fqdn, certPEM, chainPEM, keyPEM); err != nil {
slog.Error("scheduler: management cert write failed", "fqdn", fqdn, "error", err)
return
}
if err := reloadHAProxyForMgmtCert(); err != nil {
slog.Warn("scheduler: haproxy reload after management cert renewal failed", "error", err)
}
slog.Info("scheduler: management cert renewed", "fqdn", fqdn)
}
// mgmtCertIsManaged sagt, ob fuer das FQDN bereits eine tls_certs-Zeile
// existiert (dann gehoert es dem certrenewer).
func mgmtCertIsManaged(ctx context.Context, repo *tlscerts.Repo, fqdn string) (bool, error) {
rows, err := repo.List(ctx)
if err != nil {
return false, err
}
for _, r := range rows {
if strings.EqualFold(strings.TrimSpace(r.Domain), fqdn) {
return true, nil
}
}
return false, nil
}
// certRemainingValidity liest die Restlaufzeit des ersten Zertifikats in
// einer kombinierten PEM-Datei. Fehler (Datei fehlt, unlesbar, kein
// Zertifikat drin) bedeuten "muss ausgestellt werden".
func certRemainingValidity(path string) (time.Duration, error) {
raw, err := os.ReadFile(path) //nolint:gosec // fester Pfad aus certDir + eigenem FQDN
if err != nil {
return 0, err
}
rest := raw
for {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
return 0, os.ErrNotExist
}
if block.Type != "CERTIFICATE" {
continue
}
crt, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return 0, err
}
return time.Until(crt.NotAfter), nil
}
}
// reloadHAProxyForMgmtCert: "haproxy.service" ausgeschrieben, weil die
// sudoers-Regel im postinst exakt darauf gepinnt ist — ohne Suffix wuerde
// sudo den Aufruf ablehnen. Gleicher Aufruf wie in certrenewer.
func reloadHAProxyForMgmtCert() error {
//nolint:noctx // System-Reload darf nicht am Tick-Context haengen
out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").CombinedOutput()
if err != nil {
return &exitErr{msg: strings.TrimSpace(string(out)), err: err}
}
return nil
}
type exitErr struct {
msg string
err error
}
func (e *exitErr) Error() string { return e.err.Error() + ": " + e.msg }
func (e *exitErr) Unwrap() error { return e.err }

View File

@@ -0,0 +1,113 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
"time"
)
// certRemainingValidity entscheidet, ob ueberhaupt erneuert wird — ein
// falsches Ergebnis heisst entweder "Zertifikat laeuft unbemerkt ab"
// (genau der Befund auf utm-2) oder "wir erneuern bei jedem Tick".
func writeTestPEM(t *testing.T, dir, name string, notAfter time.Time, withKey bool) string {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: name},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: notAfter,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("cert: %v", err)
}
var buf []byte
// Reihenfolge wie certstore.WriteCombined: erst Cert(-Kette), dann Key.
buf = append(buf, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})...)
if withKey {
kd, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
buf = append(buf, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kd})...)
}
p := filepath.Join(dir, name+".pem")
if err := os.WriteFile(p, buf, 0o600); err != nil {
t.Fatalf("write: %v", err)
}
return p
}
func TestCertRemainingValidity_LongLived(t *testing.T) {
dir := t.TempDir()
p := writeTestPEM(t, dir, "node.example.com", time.Now().Add(60*24*time.Hour), true)
got, err := certRemainingValidity(p)
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
if got <= mgmtCertRenewThreshold {
t.Errorf("60d-Cert muss ueber dem 30d-Schwellwert liegen, got %v", got)
}
}
func TestCertRemainingValidity_ExpiringSoon(t *testing.T) {
dir := t.TempDir()
p := writeTestPEM(t, dir, "node.example.com", time.Now().Add(5*24*time.Hour), true)
got, err := certRemainingValidity(p)
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
if got > mgmtCertRenewThreshold {
t.Errorf("5d-Cert muss unter dem Schwellwert liegen, got %v", got)
}
}
// Der utm-2-Fall: bereits abgelaufen → negative Restlaufzeit, also
// eindeutig unter dem Schwellwert und damit erneuerungspflichtig.
func TestCertRemainingValidity_AlreadyExpired(t *testing.T) {
dir := t.TempDir()
p := writeTestPEM(t, dir, "node.example.com", time.Now().Add(-14*24*time.Hour), true)
got, err := certRemainingValidity(p)
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
if got >= 0 {
t.Errorf("abgelaufenes Cert muss negative Restlaufzeit liefern, got %v", got)
}
if got > mgmtCertRenewThreshold {
t.Errorf("abgelaufenes Cert muss erneuert werden, got %v", got)
}
}
func TestCertRemainingValidity_MissingFile(t *testing.T) {
if _, err := certRemainingValidity(filepath.Join(t.TempDir(), "nope.pem")); err == nil {
t.Error("fehlende Datei muss einen Fehler liefern (→ ausstellen)")
}
}
// Nur-Key-Datei: darf nicht als gueltiges Zertifikat durchgehen, sonst
// wuerde ein kaputter Zustand nie repariert.
func TestCertRemainingValidity_NoCertificateBlock(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "keyonly.pem")
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
kd, _ := x509.MarshalECPrivateKey(key)
if err := os.WriteFile(p, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kd}), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if _, err := certRemainingValidity(p); err == nil {
t.Error("PEM ohne CERTIFICATE-Block muss einen Fehler liefern")
}
}

View File

@@ -339,7 +339,14 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash -s -- \
--token <cluster-join-token> --token <cluster-join-token>
``` ```
`edgeguard-ctl cluster-join` führt aus: TLS-Cert-Pull via mTLS (CSR→issue-cert), Node-Registrierung in `ha_nodes` (`autoRegister`), Setup als **Logical-Replication-Subscriber** (`cluster-setup-standby`: `CREATE SUBSCRIPTION … copy_data=true`, Initialkopie der geteilten Tabellen), Config-Regeneration, Service-Start. _(Kein `pg_basebackup`, kein KeyDB-Setup — beides war nur im ursprünglichen Entwurf.)_ `edgeguard-ctl cluster-join` führt aus: TLS-Cert-Pull via mTLS (CSR→issue-cert) und Node-Registrierung in `ha_nodes` (`autoRegister`)**mehr nicht**. Die Logical Replication ist ein eigener Schritt (`cluster-setup-standby`: `CREATE SUBSCRIPTION … copy_data=true`, Initialkopie der geteilten Tabellen, Master-Key-Sync, Config-Regeneration). _(Kein `pg_basebackup`, kein KeyDB-Setup — beides war nur im ursprünglichen Entwurf.)_
**Join über den Setup-Wizard (empfohlener Weg) macht beides automatisch:**
1. Auf dem Primary erzeugt `POST /cluster/join-tokens` den Token — und stellt dabei vorher via `cluster-init-replication` sicher, dass die Publisher-Seite steht (Replikations-Rolle + Secret, `wal_level=logical`, `pg_hba`, PUBLICATION). Ein frisch installierter Single-Node hat das alles noch nicht; ohne diesen Schritt liefe das spätere `CREATE SUBSCRIPTION` in ein 404. Idempotent; der einmalige PG-Restart (`wal_level` ist ein postmaster-Parameter) passiert bewusst hier, solange noch kein zweiter Node Traffic erwartet.
2. Auf dem neuen Node startet `POST /setup/join-cluster` nach erfolgreichem Join `cluster-setup-standby` detached (via `sudo`, da root nötig). Fortschritt pollbar über `GET /setup/replication-status` (`running`/`done`/`failed`); der Wizard zeigt ihn an und gibt bei Fehlschlag das manuelle Kommando aus.
Der reine CLI-Pfad (`cluster-join`) bleibt der manuelle Weg und erfordert `cluster-setup-standby` weiterhin explizit.
--- ---

View File

@@ -53,6 +53,14 @@ type ClusterHandler struct {
NodeID string 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 { func NewClusterHandler(store *cluster.Store, localID string) *ClusterHandler {
return &ClusterHandler{Store: store, LocalID: localID} 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 // aus /var/lib/edgeguard/pg-replication-secret. Gibt 404 zurück wenn die
// Datei fehlt (cluster-init-replication noch nicht ausgeführt). // Datei fehlt (cluster-init-replication noch nicht ausgeführt).
func (h *ClusterHandler) AgentPGReplicationInfo(c *gin.Context) { func (h *ClusterHandler) AgentPGReplicationInfo(c *gin.Context) {
const secretPath = "/var/lib/edgeguard/pg-replication-secret" pass, err := readFileString(pgReplicationSecretPath)
pass, err := readFileString(secretPath)
if err != nil { if err != nil {
response.NotFound(c, simpleError("pg-replication-secret nicht gefunden — cluster-init-replication auf dem Primary ausführen")) response.NotFound(c, simpleError("pg-replication-secret nicht gefunden — cluster-init-replication auf dem Primary ausführen"))
return return
@@ -518,6 +525,20 @@ func (h *ClusterHandler) GenerateJoinToken(c *gin.Context) {
// Body optional — wenn leer, läuft der Flow ohne Pre-Register. // Body optional — wenn leer, läuft der Flow ohne Pre-Register.
_ = c.ShouldBindJSON(&req) _ = 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() token, exp, err := h.Tokens.Generate()
if err != nil { if err != nil {
response.Internal(c, err) response.Internal(c, err)
@@ -1128,3 +1149,43 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
"client_cn", cn, "remote", c.ClientIP()) "client_cn", cn, "remote", c.ClientIP())
response.OK(c, out) 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
}

View File

@@ -64,6 +64,7 @@ func (h *SetupHandler) Register(rg *gin.RouterGroup) {
g.POST("/complete", h.Complete) g.POST("/complete", h.Complete)
g.POST("/complete-node", h.CompleteAsNode) g.POST("/complete-node", h.CompleteAsNode)
g.POST("/join-cluster", h.JoinCluster) g.POST("/join-cluster", h.JoinCluster)
g.GET("/replication-status", h.ReplicationStatus)
} }
// RegisterAuthed mountet die Endpoints die nach abgeschlossenem Setup // RegisterAuthed mountet die Endpoints die nach abgeschlossenem Setup
@@ -206,6 +207,13 @@ func (h *SetupHandler) JoinCluster(c *gin.Context) {
go h.preRegisterPrimary(body.PrimaryFQDN) go h.preRegisterPrimary(body.PrimaryFQDN)
} }
// Logical Replication automatisch einrichten. Ohne diesen Schritt waere
// der Node zwar im Cluster registriert, wuerde aber keinerlei geteilte
// Config (Domains, Backends, Firewall-Rules, WireGuard, …) bekommen —
// was frueher erst beim Failover auffiel. Laeuft detached, der Wizard
// pollt /setup/replication-status.
h.startReplicationSetup(body.PrimaryFQDN)
response.OK(c, gin.H{ response.OK(c, gin.H{
"completed": st.Completed, "completed": st.Completed,
"is_cluster_node": st.IsClusterNode, "is_cluster_node": st.IsClusterNode,

View File

@@ -0,0 +1,195 @@
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 <primary>`
// 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())
}

View File

@@ -0,0 +1,53 @@
package handlers
import "testing"
// validPrimaryHost bewacht das einzige variable Argument einer sudo-Regel
// (`edgeguard-ctl cluster-setup-standby *`). Der Aufruf laeuft zwar ohne
// Shell, aber die Pruefung soll trotzdem halten was sie verspricht.
func TestValidPrimaryHost(t *testing.T) {
valid := []string{
"utm-1.netcell-it.de",
"primary",
"10.0.5.1",
"89.163.205.6",
"2001:db8::1",
"a-b-c.example.com",
}
for _, h := range valid {
if !validPrimaryHost(h) {
t.Errorf("validPrimaryHost(%q) = false, erwartet true", h)
}
}
invalid := []string{
"",
" ",
"host; rm -rf /",
"host && reboot",
"host|tee",
"host$(id)",
"host`id`",
"--tls-dir=/tmp/evil",
"host with space",
"host\nsecond-line",
"..",
"host..example.com",
"/etc/passwd",
}
for _, h := range invalid {
if validPrimaryHost(h) {
t.Errorf("validPrimaryHost(%q) = true, erwartet false", h)
}
}
}
func TestValidPrimaryHostRejectsOverlongName(t *testing.T) {
long := make([]byte, 254)
for i := range long {
long[i] = 'a'
}
if validPrimaryHost(string(long)) {
t.Error("Hostname > 253 Zeichen muss abgelehnt werden")
}
}

View File

@@ -376,7 +376,13 @@
"joinInsecure": "TLS-Prüfung überspringen (falls der Primary ein self-signed Zertifikat hat)", "joinInsecure": "TLS-Prüfung überspringen (falls der Primary ein self-signed Zertifikat hat)",
"nodeSuccessDesc": "Cluster-Zertifikate wurden geschrieben. Noch ein letzter Schritt:", "nodeSuccessDesc": "Cluster-Zertifikate wurden geschrieben. Noch ein letzter Schritt:",
"nodeRestartTitle": "Neustart erforderlich", "nodeRestartTitle": "Neustart erforderlich",
"nodeRestartDesc": "Führe folgenden Befehl auf diesem Server aus, um die neuen Cluster-Zertifikate zu laden:" "nodeRestartDesc": "Führe folgenden Befehl auf diesem Server aus, um die neuen Cluster-Zertifikate zu laden:",
"replRunningTitle": "Cluster-Replikation wird eingerichtet…",
"replRunningDesc": "Die geteilte Konfiguration (Domains, Backends, Firewall-Regeln, WireGuard, DNS, Zertifikate, Benutzer) wird vom Primary kopiert. Das kann je nach Datenmenge einige Minuten dauern — dieses Fenster offen lassen.",
"replDoneTitle": "Cluster-Replikation aktiv",
"replDoneDesc": "Der Knoten ist Logical-Replication-Subscriber. Änderungen am Primary erscheinen ab jetzt automatisch hier.",
"replFailedTitle": "Cluster-Replikation fehlgeschlagen",
"replFailedDesc": "Der Knoten ist im Cluster registriert, repliziert aber noch keine Konfiguration. Auf diesem Knoten manuell nachholen:"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -416,7 +422,11 @@
"ok": "OK", "ok": "OK",
"degraded": "degraded", "degraded": "degraded",
"split-brain": "split-brain" "split-brain": "split-brain"
} },
"roleDbPrimary": "DB-Primary",
"roleHint": "Datenbank-Rolle aus der Replikation: DB-Primary nimmt Schreibzugriffe entgegen, DB-Standby repliziert von dort. Sie wandert NICHT mit der VIP — ein Knoten kann DB-Primary sein und trotzdem gerade keepalived-BACKUP (siehe VIP/VRRP-Karte). Sie aendert sich nur durch „edgeguard-ctl promote“.",
"roleDbStandby": "DB-Standby",
"roleDbUnknown": "—"
}, },
"routingCard": { "routingCard": {
"title": "Routing", "title": "Routing",

View File

@@ -376,7 +376,13 @@
"joinInsecure": "Skip TLS verification (use if the primary has a self-signed certificate)", "joinInsecure": "Skip TLS verification (use if the primary has a self-signed certificate)",
"nodeSuccessDesc": "Cluster certs have been written. One last step:", "nodeSuccessDesc": "Cluster certs have been written. One last step:",
"nodeRestartTitle": "Restart required", "nodeRestartTitle": "Restart required",
"nodeRestartDesc": "Run the following command on this box to load the new cluster certificates:" "nodeRestartDesc": "Run the following command on this box to load the new cluster certificates:",
"replRunningTitle": "Setting up cluster replication…",
"replRunningDesc": "Shared configuration (domains, backends, firewall rules, WireGuard, DNS, certificates, users) is being copied from the primary. Depending on the amount of data this can take a few minutes — keep this window open.",
"replDoneTitle": "Cluster replication active",
"replDoneDesc": "This node is a logical replication subscriber. Changes on the primary now appear here automatically.",
"replFailedTitle": "Cluster replication failed",
"replFailedDesc": "The node is registered in the cluster but is not replicating configuration yet. Run this manually on this node:"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -416,7 +422,11 @@
"ok": "OK", "ok": "OK",
"degraded": "degraded", "degraded": "degraded",
"split-brain": "split-brain" "split-brain": "split-brain"
} },
"roleDbPrimary": "DB primary",
"roleHint": "Database role from replication: the DB primary accepts writes, the DB standby replicates from it. It does NOT follow the VIP — a node can be DB primary while currently being keepalived BACKUP (see the VIP/VRRP card). It only changes via \"edgeguard-ctl promote\".",
"roleDbStandby": "DB standby",
"roleDbUnknown": "—"
}, },
"routingCard": { "routingCard": {
"title": "Routing", "title": "Routing",

View File

@@ -74,12 +74,28 @@ interface HAProxyStat {
req_tot: number; req_rate: number req_tot: number; req_rate: number
last_change_sec: number; health: string last_change_sec: number; health: string
} }
async function listHAProxyStats(): Promise<HAProxyStat[]> { // Der Cache-Eintrag ['haproxy','stats'] wird mit dem Dashboard geteilt,
// das aus derselben Antwort zusaetzlich `frontends` liest. Deshalb hier
// IMMER die vollstaendige Antwort cachen und erst per `select` auf die
// Backends reduzieren, die diese Seite braucht.
//
// Befund 2026-09-11: Lieferte diese Funktion nur das Backend-Array, hing
// es vom zuletzt besuchten Screen ab, welche Form unter dem Key lag —
// nach einem Wechsel hierher und zurueck riss das Dashboard mit
// "Cannot read properties of undefined (reading 'length')" die ganze
// Oberflaeche in die ErrorBoundary.
interface HAProxyStatsPayload {
backends: HAProxyStat[]
frontends: unknown[]
error?: string
}
async function fetchHAProxyStats(): Promise<HAProxyStatsPayload> {
try { try {
const r = await apiClient.get('/haproxy/stats') const r = await apiClient.get('/haproxy/stats')
if (!isEnvelope(r.data)) return [] if (!isEnvelope(r.data)) return { backends: [], frontends: [] }
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? [] const d = r.data.data as Partial<HAProxyStatsPayload>
} catch { return [] } return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error }
} catch { return { backends: [], frontends: [] } }
} }
function fmtBytes(n: number): string { function fmtBytes(n: number): string {
@@ -112,7 +128,8 @@ export default function BackendDetailPage() {
const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains }) const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains })
const { data: haproxyStats } = useQuery({ const { data: haproxyStats } = useQuery({
queryKey: ['haproxy', 'stats'], queryKey: ['haproxy', 'stats'],
queryFn: listHAProxyStats, queryFn: fetchHAProxyStats,
select: (d: HAProxyStatsPayload) => d.backends,
refetchInterval: 10_000, refetchInterval: 10_000,
}) })
const [form] = Form.useForm<BackendFormValues>() const [form] = Form.useForm<BackendFormValues>()

View File

@@ -118,12 +118,28 @@ function fmtBytes(n: number): string {
if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB' if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB'
return n + ' B' return n + ' B'
} }
async function listHAProxyStats(): Promise<HAProxyStat[]> { // Der Cache-Eintrag ['haproxy','stats'] wird mit dem Dashboard geteilt,
// das aus derselben Antwort zusaetzlich `frontends` liest. Deshalb hier
// IMMER die vollstaendige Antwort cachen und erst per `select` auf die
// Backends reduzieren, die diese Seite braucht.
//
// Befund 2026-09-11: Lieferte diese Funktion nur das Backend-Array, hing
// es vom zuletzt besuchten Screen ab, welche Form unter dem Key lag —
// nach einem Wechsel hierher und zurueck riss das Dashboard mit
// "Cannot read properties of undefined (reading 'length')" die ganze
// Oberflaeche in die ErrorBoundary.
interface HAProxyStatsPayload {
backends: HAProxyStat[]
frontends: unknown[]
error?: string
}
async function fetchHAProxyStats(): Promise<HAProxyStatsPayload> {
try { try {
const r = await apiClient.get('/haproxy/stats') const r = await apiClient.get('/haproxy/stats')
if (!isEnvelope(r.data)) return [] if (!isEnvelope(r.data)) return { backends: [], frontends: [] }
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? [] const d = r.data.data as Partial<HAProxyStatsPayload>
} catch { return [] } return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error }
} catch { return { backends: [], frontends: [] } }
} }
export default function BackendsPage() { export default function BackendsPage() {
@@ -146,7 +162,8 @@ export default function BackendsPage() {
const haproxyService = services?.find(s => s.unit === 'haproxy.service' || s.unit === 'haproxy') const haproxyService = services?.find(s => s.unit === 'haproxy.service' || s.unit === 'haproxy')
const { data: haproxyStats } = useQuery({ const { data: haproxyStats } = useQuery({
queryKey: ['haproxy', 'stats'], queryKey: ['haproxy', 'stats'],
queryFn: listHAProxyStats, queryFn: fetchHAProxyStats,
select: (d: HAProxyStatsPayload) => d.backends,
refetchInterval: 15_000, refetchInterval: 15_000,
}) })

View File

@@ -68,7 +68,7 @@ interface FwRule { id: number; enabled: boolean; action: string }
interface FwNAT { id: number; enabled: boolean; kind: string } interface FwNAT { id: number; enabled: boolean; kind: string }
interface FwZone { id: number; name: string; builtin: boolean } interface FwZone { id: number; name: string; builtin: boolean }
interface TLSCert { id: number; common_name: string; not_after?: string } interface TLSCert { id: number; common_name: string; not_after?: string }
interface ClusterNode { id: string; fqdn: string; role: string } interface ClusterNode { id: string; fqdn: string; role: string; pg_role?: string }
interface WGIface { id: number; name: string; mode: string; active: boolean } interface WGIface { id: number; name: string; mode: string; active: boolean }
interface WGStatusRow { interface WGStatusRow {
interface: string interface: string
@@ -613,11 +613,11 @@ function VIPCard({ data }: { data?: VIPStatus | null }) {
> >
{!data ? ( {!data ? (
<Text type="secondary" style={{ fontSize: 12 }}></Text> <Text type="secondary" style={{ fontSize: 12 }}></Text>
) : data.vips.length === 0 ? ( ) : (data.vips ?? []).length === 0 ? (
<Text type="secondary" style={{ fontSize: 12 }}>{t('dashboard.vipCard.noVips')}</Text> <Text type="secondary" style={{ fontSize: 12 }}>{t('dashboard.vipCard.noVips')}</Text>
) : ( ) : (
<Space direction="vertical" style={{ width: '100%' }} size={0}> <Space direction="vertical" style={{ width: '100%' }} size={0}>
{data.vips.map((v) => ( {(data.vips ?? []).map((v) => (
<div key={v.address} style={{ <div key={v.address} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '5px 0', borderBottom: '1px solid #F1F5F9', padding: '5px 0', borderBottom: '1px solid #F1F5F9',
@@ -644,6 +644,24 @@ function VIPCard({ data }: { data?: VIPStatus | null }) {
// ── Cluster card ────────────────────────────────────────────── // ── Cluster card ──────────────────────────────────────────────
// dbRoleLabel/-Color bilden pg_role ab. 'standalone' bzw. leer heisst:
// keine Replikation eingerichtet — dann gibt es schlicht keine DB-Rolle
// zu zeigen, statt eine zu erfinden.
function dbRoleLabel(pgRole: string | undefined, t: (k: string) => string): string {
switch (pgRole) {
case 'primary': return t('dashboard.clusterCard.roleDbPrimary')
case 'standby': return t('dashboard.clusterCard.roleDbStandby')
default: return t('dashboard.clusterCard.roleDbUnknown')
}
}
function dbRoleColor(pgRole: string | undefined): string {
switch (pgRole) {
case 'primary': return 'green'
case 'standby': return 'blue'
default: return 'default'
}
}
interface ClusterStatusCardProps { interface ClusterStatusCardProps {
nodes: ClusterNode[] nodes: ClusterNode[]
status: { mode: string; health: string; drift_found: boolean } | null status: { mode: string; health: string; drift_found: boolean } | null
@@ -677,7 +695,21 @@ function ClusterStatusCard({ nodes, status }: ClusterStatusCardProps) {
padding: '4px 0', borderBottom: '1px solid #F1F5F9', fontSize: 12, padding: '4px 0', borderBottom: '1px solid #F1F5F9', fontSize: 12,
}}> }}>
<code style={{ color: '#334155' }}>{n.fqdn}</code> <code style={{ color: '#334155' }}>{n.fqdn}</code>
<Tag color={n.role === 'primary' ? 'green' : 'default'} style={{ margin: 0 }}>{n.role}</Tag> {/* Bewusst pg_role, NICHT role: ha_nodes ist node-lokal (nicht
repliziert), und jeder Node traegt sich in `role` selbst ein —
ein per Join dazugekommener Node behaelt dort den Default
"primary" und behauptete deshalb in seiner eigenen Ansicht,
beide Knoten seien DB-Primary. `pg_role` folgt der
tatsaechlichen Replikationsrolle und stimmt auf beiden Seiten
ueberein. Siehe auch cluster_repair.go: role/pg_role sind
node-lokal, verlaesslich ist letztlich die PUBLICATION.
Die DB-Rolle wandert NICHT mit der VIP — sie aendert sich nur
durch `edgeguard-ctl promote`. */}
<Tooltip title={t('dashboard.clusterCard.roleHint')}>
<Tag color={dbRoleColor(n.pg_role)} style={{ margin: 0 }}>
{dbRoleLabel(n.pg_role, t)}
</Tag>
</Tooltip>
</div> </div>
))} ))}
</Space> </Space>
@@ -704,7 +736,7 @@ function HAProxyFullCard({ stats, resolveHAName }: HAProxyFullCardProps) {
className="h-100" className="h-100"
title={<><DatabaseOutlined style={{ color: '#0EA5E9' }} /> {t('dashboard.haproxyCard.title')}</>} title={<><DatabaseOutlined style={{ color: '#0EA5E9' }} /> {t('dashboard.haproxyCard.title')}</>}
extra={ extra={
stats.frontends.length > 0 && ( (stats.frontends ?? []).length > 0 && (
<Space size={8}> <Space size={8}>
<Text type="secondary" style={{ fontSize: 11 }}>{totalSessions} sess</Text> <Text type="secondary" style={{ fontSize: 11 }}>{totalSessions} sess</Text>
{totalReqRate > 0 && <Text type="secondary" style={{ fontSize: 11 }}>{totalReqRate}/s</Text>} {totalReqRate > 0 && <Text type="secondary" style={{ fontSize: 11 }}>{totalReqRate}/s</Text>}
@@ -720,7 +752,7 @@ function HAProxyFullCard({ stats, resolveHAName }: HAProxyFullCardProps) {
)} )}
{/* Listeners */} {/* Listeners */}
{stats.frontends.length > 0 && ( {(stats.frontends ?? []).length > 0 && (
<> <>
<Text type="secondary" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}> <Text type="secondary" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{t('dashboard.haproxyCard.frontends')} {t('dashboard.haproxyCard.frontends')}
@@ -741,7 +773,7 @@ function HAProxyFullCard({ stats, resolveHAName }: HAProxyFullCardProps) {
)} )}
{/* Backends */} {/* Backends */}
{stats.backends.length === 0 && !stats.error ? ( {(stats.backends ?? []).length === 0 && !stats.error ? (
<Text type="secondary" style={{ fontSize: 12 }}>{t('dashboard.haproxyCard.empty')}</Text> <Text type="secondary" style={{ fontSize: 12 }}>{t('dashboard.haproxyCard.empty')}</Text>
) : ( ) : (
<> <>

View File

@@ -101,12 +101,28 @@ async function listCerts(): Promise<TLSCertLite[]> {
if (!isEnvelope(r.data)) return [] if (!isEnvelope(r.data)) return []
return (r.data.data as { tls_certs?: TLSCertLite[] }).tls_certs ?? [] return (r.data.data as { tls_certs?: TLSCertLite[] }).tls_certs ?? []
} }
async function listHAProxyStats(): Promise<HAProxyStat[]> { // Der Cache-Eintrag ['haproxy','stats'] wird mit dem Dashboard geteilt,
// das aus derselben Antwort zusaetzlich `frontends` liest. Deshalb hier
// IMMER die vollstaendige Antwort cachen und erst per `select` auf die
// Backends reduzieren, die diese Seite braucht.
//
// Befund 2026-09-11: Lieferte diese Funktion nur das Backend-Array, hing
// es vom zuletzt besuchten Screen ab, welche Form unter dem Key lag —
// nach einem Wechsel hierher und zurueck riss das Dashboard mit
// "Cannot read properties of undefined (reading 'length')" die ganze
// Oberflaeche in die ErrorBoundary.
interface HAProxyStatsPayload {
backends: HAProxyStat[]
frontends: unknown[]
error?: string
}
async function fetchHAProxyStats(): Promise<HAProxyStatsPayload> {
try { try {
const r = await apiClient.get('/haproxy/stats') const r = await apiClient.get('/haproxy/stats')
if (!isEnvelope(r.data)) return [] if (!isEnvelope(r.data)) return { backends: [], frontends: [] }
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? [] const d = r.data.data as Partial<HAProxyStatsPayload>
} catch { return [] } return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error }
} catch { return { backends: [], frontends: [] } }
} }
export default function DomainDetailPage() { export default function DomainDetailPage() {
@@ -126,7 +142,8 @@ export default function DomainDetailPage() {
const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts }) const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
const { data: haproxyStats } = useQuery({ const { data: haproxyStats } = useQuery({
queryKey: ['haproxy', 'stats'], queryKey: ['haproxy', 'stats'],
queryFn: listHAProxyStats, queryFn: fetchHAProxyStats,
select: (d: HAProxyStatsPayload) => d.backends,
refetchInterval: 15_000, refetchInterval: 15_000,
}) })

View File

@@ -83,12 +83,28 @@ async function listCerts(): Promise<TLSCertLite[]> {
} }
interface HAProxyStat { backend: string; server: string; status: string } interface HAProxyStat { backend: string; server: string; status: string }
async function listHAProxyStats(): Promise<HAProxyStat[]> { // Der Cache-Eintrag ['haproxy','stats'] wird mit dem Dashboard geteilt,
// das aus derselben Antwort zusaetzlich `frontends` liest. Deshalb hier
// IMMER die vollstaendige Antwort cachen und erst per `select` auf die
// Backends reduzieren, die diese Seite braucht.
//
// Befund 2026-09-11: Lieferte diese Funktion nur das Backend-Array, hing
// es vom zuletzt besuchten Screen ab, welche Form unter dem Key lag —
// nach einem Wechsel hierher und zurueck riss das Dashboard mit
// "Cannot read properties of undefined (reading 'length')" die ganze
// Oberflaeche in die ErrorBoundary.
interface HAProxyStatsPayload {
backends: HAProxyStat[]
frontends: unknown[]
error?: string
}
async function fetchHAProxyStats(): Promise<HAProxyStatsPayload> {
try { try {
const r = await apiClient.get('/haproxy/stats') const r = await apiClient.get('/haproxy/stats')
if (!isEnvelope(r.data)) return [] if (!isEnvelope(r.data)) return { backends: [], frontends: [] }
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? [] const d = r.data.data as Partial<HAProxyStatsPayload>
} catch { return [] } return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error }
} catch { return { backends: [], frontends: [] } }
} }
export default function DomainsPage() { export default function DomainsPage() {
@@ -109,7 +125,8 @@ export default function DomainsPage() {
const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts }) const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
const { data: haproxyStats } = useQuery({ const { data: haproxyStats } = useQuery({
queryKey: ['haproxy', 'stats'], queryKey: ['haproxy', 'stats'],
queryFn: listHAProxyStats, queryFn: fetchHAProxyStats,
select: (d: HAProxyStatsPayload) => d.backends,
refetchInterval: 15_000, refetchInterval: 15_000,
}) })
const certByDomain = new Map((certs ?? []).map(c => [c.domain, c])) const certByDomain = new Map((certs ?? []).map(c => [c.domain, c]))

View File

@@ -58,12 +58,28 @@ function fmtBytes(n: number): string {
if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB' if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB'
return n + ' B' return n + ' B'
} }
async function listHAProxyStats(): Promise<HAProxyStat[]> { // Der Cache-Eintrag ['haproxy','stats'] wird mit dem Dashboard geteilt,
// das aus derselben Antwort zusaetzlich `frontends` liest. Deshalb hier
// IMMER die vollstaendige Antwort cachen und erst per `select` auf die
// Backends reduzieren, die diese Seite braucht.
//
// Befund 2026-09-11: Lieferte diese Funktion nur das Backend-Array, hing
// es vom zuletzt besuchten Screen ab, welche Form unter dem Key lag —
// nach einem Wechsel hierher und zurueck riss das Dashboard mit
// "Cannot read properties of undefined (reading 'length')" die ganze
// Oberflaeche in die ErrorBoundary.
interface HAProxyStatsPayload {
backends: HAProxyStat[]
frontends: unknown[]
error?: string
}
async function fetchHAProxyStats(): Promise<HAProxyStatsPayload> {
try { try {
const r = await apiClient.get('/haproxy/stats') const r = await apiClient.get('/haproxy/stats')
if (!isEnvelope(r.data)) return [] if (!isEnvelope(r.data)) return { backends: [], frontends: [] }
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? [] const d = r.data.data as Partial<HAProxyStatsPayload>
} catch { return [] } return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error }
} catch { return { backends: [], frontends: [] } }
} }
export default function RoutingRulesPage() { export default function RoutingRulesPage() {
@@ -76,7 +92,8 @@ export default function RoutingRulesPage() {
const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends }) const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
const { data: haproxyStats } = useQuery({ const { data: haproxyStats } = useQuery({
queryKey: ['haproxy', 'stats'], queryKey: ['haproxy', 'stats'],
queryFn: listHAProxyStats, queryFn: fetchHAProxyStats,
select: (d: HAProxyStatsPayload) => d.backends,
refetchInterval: 15_000, refetchInterval: 15_000,
}) })

View File

@@ -1,10 +1,10 @@
import { useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Alert, Button, Card, Form, Input, Space, Typography, message } from 'antd' import { Alert, Button, Card, Form, Input, Space, Spin, Typography, message } from 'antd'
import { ArrowLeftOutlined, CheckCircleOutlined, ClusterOutlined, DesktopOutlined } from '@ant-design/icons' import { ArrowLeftOutlined, CheckCircleOutlined, ClusterOutlined, DesktopOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import apiClient from '../../api/client' import apiClient, { isEnvelope } from '../../api/client'
import type { SessionUser } from '../../stores/auth' import type { SessionUser } from '../../stores/auth'
interface Props { interface Props {
@@ -26,6 +26,13 @@ interface JoinValues {
token: string token: string
} }
interface ReplState {
phase: 'idle' | 'running' | 'done' | 'failed'
primary?: string
error?: string
log?: string
}
const FQDN_RE = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/ const FQDN_RE = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
type Mode = 'standalone' | 'node' type Mode = 'standalone' | 'node'
@@ -69,6 +76,38 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
} }
} }
// Die Logical-Replication-Einrichtung laeuft server-seitig detached
// weiter, nachdem /setup/join-cluster geantwortet hat (die Initialkopie
// der geteilten Tabellen dauert je nach Datenmenge). Hier nur pollen und
// anzeigen — der Wizard ist an dieser Stelle noch pre-auth.
const [repl, setRepl] = useState<ReplState | null>(null)
const replTimer = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
if (!joinDone) return
let stopped = false
const poll = async () => {
try {
const r = await apiClient.get('/setup/replication-status')
const st = isEnvelope(r.data) ? (r.data.data as ReplState) : null
if (stopped || !st) return
setRepl(st)
if (st.phase === 'done' || st.phase === 'failed') {
if (replTimer.current) { clearInterval(replTimer.current); replTimer.current = null }
}
} catch {
// Waehrend des abschliessenden API-Neustarts ist der Endpoint kurz
// weg — weiterpollen statt einen Fehler anzuzeigen.
}
}
void poll()
replTimer.current = setInterval(poll, 3000)
return () => {
stopped = true
if (replTimer.current) { clearInterval(replTimer.current); replTimer.current = null }
}
}, [joinDone])
const onJoin = async (vals: JoinValues) => { const onJoin = async (vals: JoinValues) => {
setLoading(true) setLoading(true)
try { try {
@@ -299,6 +338,34 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
</div> </div>
</Space> </Space>
{repl && repl.phase !== 'idle' && (
<Alert
type={repl.phase === 'done' ? 'success' : repl.phase === 'failed' ? 'error' : 'info'}
showIcon={repl.phase !== 'running'}
icon={repl.phase === 'running' ? <Spin size="small" /> : undefined}
message={
repl.phase === 'running' ? t('setup.replRunningTitle')
: repl.phase === 'done' ? t('setup.replDoneTitle')
: t('setup.replFailedTitle')
}
description={
<Space direction="vertical" size={6} style={{ width: '100%', marginTop: 4 }}>
<Typography.Text type="secondary">
{repl.phase === 'running' ? t('setup.replRunningDesc')
: repl.phase === 'done' ? t('setup.replDoneDesc')
: t('setup.replFailedDesc')}
</Typography.Text>
{repl.phase === 'failed' && (
<>
{repl.error && <Typography.Text code>{repl.error}</Typography.Text>}
<CopyCode value={`sudo edgeguard-ctl cluster-setup-standby ${repl.primary ?? ''}`} />
</>
)}
</Space>
}
/>
)}
<Alert <Alert
type="warning" type="warning"
showIcon showIcon

View File

@@ -155,6 +155,17 @@ edgeguard ALL=(root) NOPASSWD: /bin/rm -f /etc/apt/apt.conf.d/52edgeguard-auto-u
# Update-Kanal-Switch (Settings → Update-Kanal) schreibt exakt diese # Update-Kanal-Switch (Settings → Update-Kanal) schreibt exakt diese
# sources.list-Zeile. Gleiches Restrict-Pattern wie oben. # sources.list-Zeile. Gleiches Restrict-Pattern wie oben.
edgeguard ALL=(root) NOPASSWD: /usr/bin/tee /etc/apt/sources.list.d/edgeguard.list edgeguard ALL=(root) NOPASSWD: /usr/bin/tee /etc/apt/sources.list.d/edgeguard.list
# Cluster-Replikation wird beim Join automatisch eingerichtet (frueher ein
# manueller Schritt, der leicht vergessen wurde → Node ohne replizierte
# Config). Beide Kommandos brauchen root: psql als postgres-User, pg_hba
# schreiben, ggf. PG-Restart fuer wal_level=logical.
# cluster-init-replication: argumentlos, exakt pinnbar.
# cluster-setup-standby: nimmt den Primary-Host als Argument. Die API
# validiert ihn vorher gegen Hostname/IP-Syntax (validPrimaryHost), und
# der Aufruf laeuft ohne Shell (exec, kein sh -c) — es gibt also keine
# Wortaufspaltung, an der sich ein zweites Kommando anhaengen liesse.
edgeguard ALL=(root) NOPASSWD: /usr/bin/edgeguard-ctl cluster-init-replication
edgeguard ALL=(root) NOPASSWD: /usr/bin/edgeguard-ctl cluster-setup-standby *
# Backup-Pfad: pg_dump als postgres-User. Whitelist exakt mit # Backup-Pfad: pg_dump als postgres-User. Whitelist exakt mit
# --clean --if-exists --no-owner --no-acl + dem festen DB-Namen. # --clean --if-exists --no-owner --no-acl + dem festen DB-Namen.
edgeguard ALL=(postgres) NOPASSWD: /usr/bin/pg_dump --clean --if-exists --no-owner --no-acl edgeguard edgeguard ALL=(postgres) NOPASSWD: /usr/bin/pg_dump --clean --if-exists --no-owner --no-acl edgeguard
@@ -346,6 +357,19 @@ net.ipv6.conf.all.accept_source_route = 0
net.ipv4.conf.all.rp_filter = 2 net.ipv4.conf.all.rp_filter = 2
net.ipv4.conf.default.rp_filter = 2 net.ipv4.conf.default.rp_filter = 2
# ─── Nonlocal-Bind (HA/VIP) ───────────────────────────────────────
# squid und unbound lauschen auf den VLAN-Gateway-VIPs. Ohne diese
# Option kann ein Node sie nur binden, WÄHREND er die VIP haelt — auf
# dem Standby scheitert der Start mit "FATAL: Unable to open HTTP
# Socket" und die Unit steht dauerhaft auf `failed`. Das ist nicht von
# einem echten Ausfall zu unterscheiden und kostet beim Failover
# zusätzlich einen Kaltstart. Mit nonlocal_bind laufen beide Dienste auf
# beiden Nodes durch und sind im Umschaltmoment sofort bereit; Traffic
# bekommt weiterhin nur der Node, der die VIP per ARP wirklich haelt.
# Standard-Pattern für keepalived-Setups.
net.ipv4.ip_nonlocal_bind = 1
net.ipv6.ip_nonlocal_bind = 1
# ─── Conntrack — Edge-Box trackt viele parallele Sessions ───────── # ─── Conntrack — Edge-Box trackt viele parallele Sessions ─────────
net.netfilter.nf_conntrack_max = 524288 net.netfilter.nf_conntrack_max = 524288
net.netfilter.nf_conntrack_tcp_timeout_established = 86400 net.netfilter.nf_conntrack_tcp_timeout_established = 86400
@@ -400,6 +424,21 @@ vm.dirty_background_ratio = 5
SYSCTL SYSCTL
sysctl --system >/dev/null 2>&1 || true sysctl --system >/dev/null 2>&1 || true
# Ein Node, der als Standby gebootet hat, kann squid/unbound vor
# dem nonlocal_bind oben nicht gestartet haben — die Unit steht
# dann auf `failed` und systemd versucht es von sich aus nicht
# erneut. Gezielt nur solche Units anfassen: enabled UND failed.
# Laeuft der Dienst bereits oder ist er bewusst disabled (Forward-
# Proxy/DNS optional), passiert hier nichts.
for svc in squid unbound; do
if systemctl is-enabled --quiet "$svc" 2>/dev/null \
&& systemctl is-failed --quiet "$svc" 2>/dev/null; then
systemctl reset-failed "$svc" 2>/dev/null || true
systemctl start "$svc" 2>/dev/null || \
echo "postinst: $svc start after nonlocal_bind failed" >&2
fi
done
# ── Firewall-Logging via ulogd2 (NFLOG group 0) ────────────── # ── Firewall-Logging via ulogd2 (NFLOG group 0) ──────────────
# nft-Renderer emittiert `log prefix "edgeguard:<rule-id>" group 0` # nft-Renderer emittiert `log prefix "edgeguard:<rule-id>" group 0`
# für jede Rule mit log=true. ulogd2 subscribed auf netlink-group # für jede Rule mit log=true. ulogd2 subscribed auf netlink-group