8 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
13 changed files with 509 additions and 52 deletions

View File

@@ -1 +1 @@
1.3.30 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

@@ -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

@@ -424,8 +424,9 @@
"split-brain": "split-brain" "split-brain": "split-brain"
}, },
"roleDbPrimary": "DB-Primary", "roleDbPrimary": "DB-Primary",
"rolePeer": "Peer", "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“.",
"roleHint": "DB-/Cluster-Rolle: bestimmt, wohin Schreibzugriffe gehen. Sie wandert NICHT mit der VIP — ein Node 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

@@ -424,8 +424,9 @@
"split-brain": "split-brain" "split-brain": "split-brain"
}, },
"roleDbPrimary": "DB primary", "roleDbPrimary": "DB primary",
"rolePeer": "Peer", "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\".",
"roleHint": "Database/cluster role: decides where writes go. 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,16 +695,19 @@ 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>
{/* ha_nodes.role ist die DB-/Cluster-Rolle (wohin Schreibzugriffe {/* Bewusst pg_role, NICHT role: ha_nodes ist node-lokal (nicht
gehen) und wandert bewusst NICHT mit der VIP — sie aendert repliziert), und jeder Node traegt sich in `role` selbst ein —
sich nur durch `edgeguard-ctl promote`. Ein nacktes "primary" ein per Join dazugekommener Node behaelt dort den Default
hier las sich neben der VIP-Karte ("BACKUP") wie ein "primary" und behauptete deshalb in seiner eigenen Ansicht,
Widerspruch, deshalb explizit als DB-Rolle beschriftet. */} 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')}> <Tooltip title={t('dashboard.clusterCard.roleHint')}>
<Tag color={n.role === 'primary' ? 'green' : 'default'} style={{ margin: 0 }}> <Tag color={dbRoleColor(n.pg_role)} style={{ margin: 0 }}>
{n.role === 'primary' {dbRoleLabel(n.pg_role, t)}
? t('dashboard.clusterCard.roleDbPrimary')
: t('dashboard.clusterCard.rolePeer')}
</Tag> </Tag>
</Tooltip> </Tooltip>
</div> </div>
@@ -715,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>}
@@ -731,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')}
@@ -752,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,
}) })