6 Commits

Author SHA1 Message Date
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
20 changed files with 849 additions and 42 deletions

View File

@@ -1 +1 @@
1.3.29
1.3.32

View File

@@ -52,8 +52,12 @@ func cmdClusterJoin(args []string) int {
fmt.Printf(" CN: %s\n", commonName)
fmt.Printf(" Files: %s/{ca.crt,peer.crt,peer.key}\n", *clusterTLSDir)
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(" 2) Auf dem Primary in der Cluster-UI prüfen ob der neue Peer in /cluster/nodes auftaucht\n")
fmt.Printf(" 3) PG-Basebackup + KeyDB-Replica-Setup folgt mit Phase 3.5 (manuell bis dahin)\n")
fmt.Printf(" 1) sudo edgeguard-ctl cluster-setup-standby %s\n", primary)
fmt.Printf(" → richtet die Logical Replication ein. OHNE diesen Schritt ist der\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
}

View File

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

View File

@@ -64,6 +64,7 @@ func (h *SetupHandler) Register(rg *gin.RouterGroup) {
g.POST("/complete", h.Complete)
g.POST("/complete-node", h.CompleteAsNode)
g.POST("/join-cluster", h.JoinCluster)
g.GET("/replication-status", h.ReplicationStatus)
}
// RegisterAuthed mountet die Endpoints die nach abgeschlossenem Setup
@@ -206,6 +207,13 @@ func (h *SetupHandler) JoinCluster(c *gin.Context) {
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{
"completed": st.Completed,
"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)",
"nodeSuccessDesc": "Cluster-Zertifikate wurden geschrieben. Noch ein letzter Schritt:",
"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": {
"title": "Dashboard",

View File

@@ -376,7 +376,13 @@
"joinInsecure": "Skip TLS verification (use if the primary has a self-signed certificate)",
"nodeSuccessDesc": "Cluster certs have been written. One last step:",
"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": {
"title": "Dashboard",

View File

@@ -74,12 +74,28 @@ interface HAProxyStat {
req_tot: number; req_rate: number
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 {
const r = await apiClient.get('/haproxy/stats')
if (!isEnvelope(r.data)) return []
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
} catch { return [] }
if (!isEnvelope(r.data)) return { backends: [], frontends: [] }
const d = r.data.data as Partial<HAProxyStatsPayload>
return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error }
} catch { return { backends: [], frontends: [] } }
}
function fmtBytes(n: number): string {
@@ -112,7 +128,8 @@ export default function BackendDetailPage() {
const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains })
const { data: haproxyStats } = useQuery({
queryKey: ['haproxy', 'stats'],
queryFn: listHAProxyStats,
queryFn: fetchHAProxyStats,
select: (d: HAProxyStatsPayload) => d.backends,
refetchInterval: 10_000,
})
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'
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 {
const r = await apiClient.get('/haproxy/stats')
if (!isEnvelope(r.data)) return []
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
} catch { return [] }
if (!isEnvelope(r.data)) return { backends: [], frontends: [] }
const d = r.data.data as Partial<HAProxyStatsPayload>
return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error }
} catch { return { backends: [], frontends: [] } }
}
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 { data: haproxyStats } = useQuery({
queryKey: ['haproxy', 'stats'],
queryFn: listHAProxyStats,
queryFn: fetchHAProxyStats,
select: (d: HAProxyStatsPayload) => d.backends,
refetchInterval: 15_000,
})

View File

@@ -613,11 +613,11 @@ function VIPCard({ data }: { data?: VIPStatus | null }) {
>
{!data ? (
<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>
) : (
<Space direction="vertical" style={{ width: '100%' }} size={0}>
{data.vips.map((v) => (
{(data.vips ?? []).map((v) => (
<div key={v.address} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '5px 0', borderBottom: '1px solid #F1F5F9',
@@ -715,7 +715,7 @@ function HAProxyFullCard({ stats, resolveHAName }: HAProxyFullCardProps) {
className="h-100"
title={<><DatabaseOutlined style={{ color: '#0EA5E9' }} /> {t('dashboard.haproxyCard.title')}</>}
extra={
stats.frontends.length > 0 && (
(stats.frontends ?? []).length > 0 && (
<Space size={8}>
<Text type="secondary" style={{ fontSize: 11 }}>{totalSessions} sess</Text>
{totalReqRate > 0 && <Text type="secondary" style={{ fontSize: 11 }}>{totalReqRate}/s</Text>}
@@ -731,7 +731,7 @@ function HAProxyFullCard({ stats, resolveHAName }: HAProxyFullCardProps) {
)}
{/* Listeners */}
{stats.frontends.length > 0 && (
{(stats.frontends ?? []).length > 0 && (
<>
<Text type="secondary" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{t('dashboard.haproxyCard.frontends')}
@@ -752,7 +752,7 @@ function HAProxyFullCard({ stats, resolveHAName }: HAProxyFullCardProps) {
)}
{/* Backends */}
{stats.backends.length === 0 && !stats.error ? (
{(stats.backends ?? []).length === 0 && !stats.error ? (
<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 []
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 {
const r = await apiClient.get('/haproxy/stats')
if (!isEnvelope(r.data)) return []
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
} catch { return [] }
if (!isEnvelope(r.data)) return { backends: [], frontends: [] }
const d = r.data.data as Partial<HAProxyStatsPayload>
return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error }
} catch { return { backends: [], frontends: [] } }
}
export default function DomainDetailPage() {
@@ -126,7 +142,8 @@ export default function DomainDetailPage() {
const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
const { data: haproxyStats } = useQuery({
queryKey: ['haproxy', 'stats'],
queryFn: listHAProxyStats,
queryFn: fetchHAProxyStats,
select: (d: HAProxyStatsPayload) => d.backends,
refetchInterval: 15_000,
})

View File

@@ -83,12 +83,28 @@ async function listCerts(): Promise<TLSCertLite[]> {
}
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 {
const r = await apiClient.get('/haproxy/stats')
if (!isEnvelope(r.data)) return []
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
} catch { return [] }
if (!isEnvelope(r.data)) return { backends: [], frontends: [] }
const d = r.data.data as Partial<HAProxyStatsPayload>
return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error }
} catch { return { backends: [], frontends: [] } }
}
export default function DomainsPage() {
@@ -109,7 +125,8 @@ export default function DomainsPage() {
const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
const { data: haproxyStats } = useQuery({
queryKey: ['haproxy', 'stats'],
queryFn: listHAProxyStats,
queryFn: fetchHAProxyStats,
select: (d: HAProxyStatsPayload) => d.backends,
refetchInterval: 15_000,
})
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'
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 {
const r = await apiClient.get('/haproxy/stats')
if (!isEnvelope(r.data)) return []
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
} catch { return [] }
if (!isEnvelope(r.data)) return { backends: [], frontends: [] }
const d = r.data.data as Partial<HAProxyStatsPayload>
return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error }
} catch { return { backends: [], frontends: [] } }
}
export default function RoutingRulesPage() {
@@ -76,7 +92,8 @@ export default function RoutingRulesPage() {
const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
const { data: haproxyStats } = useQuery({
queryKey: ['haproxy', 'stats'],
queryFn: listHAProxyStats,
queryFn: fetchHAProxyStats,
select: (d: HAProxyStatsPayload) => d.backends,
refetchInterval: 15_000,
})

View File

@@ -1,10 +1,10 @@
import { useState } from 'react'
import { Alert, Button, Card, Form, Input, Space, Typography, message } from 'antd'
import { useEffect, useRef, useState } from 'react'
import { Alert, Button, Card, Form, Input, Space, Spin, Typography, message } from 'antd'
import { ArrowLeftOutlined, CheckCircleOutlined, ClusterOutlined, DesktopOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import apiClient from '../../api/client'
import apiClient, { isEnvelope } from '../../api/client'
import type { SessionUser } from '../../stores/auth'
interface Props {
@@ -26,6 +26,13 @@ interface JoinValues {
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,}$/
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) => {
setLoading(true)
try {
@@ -299,6 +338,34 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
</div>
</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
type="warning"
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
# sources.list-Zeile. Gleiches Restrict-Pattern wie oben.
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
# --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