8 Commits

Author SHA1 Message Date
noroot
6149670375 chore(release): v1.3.35 stable 2026-09-11 12:26:20 +02:00
noroot
1b13df4032 fix(cluster): pg_role fehlte im SELECT — Feld kam nie beim Client an
baseSelect in cluster/store.go selektierte pg_role nicht. HANode.PGRole
war dadurch ueberall ein leerer String, wo Store.List/Get benutzt wird:
/cluster/nodes, /cluster/status und damit auch die pg_role-Spalte der
Cluster-Seite. Aufgefallen ist es erst, als die Dashboard-Cluster-Karte
in v1.3.33 auf pg_role umgestellt wurde und daraufhin bei beiden Knoten
"—" zeigte — der Fehler lag aber schon vorher im Store.

pg_role ist jetzt in baseSelect, im RETURNING von UpsertSelf und in
scanNode ergaenzt; die drei muessen spaltenweise zueinander passen.
UpsertSelf SCHREIBT pg_role weiterhin bewusst nicht (ON CONFLICT laesst
die Spalte unangetastet, damit eine Self-Registrierung den per promote
gesetzten Wert nicht ueberbuegelt) — liest sie aber im RETURNING mit,
sonst stimmt die Scan-Reihenfolge nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:25:40 +02:00
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
9 changed files with 404 additions and 25 deletions

View File

@@ -1 +1 @@
1.3.31
1.3.35

View File

@@ -168,7 +168,8 @@ func main() {
if st != nil && st.Completed {
// Auto-create /etc/edgeguard/node.conf falls fehlt.
_, _ = 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)
}
}
@@ -303,12 +304,12 @@ func main() {
systemHdl.WithAudit(auditRepo, nodeID)
systemHdl.WithDB(pool)
systemHdl.WithConfigPreviewers(map[string]func(context.Context) (string, error){
"haproxy": haproxy.New(pool).RenderToString,
"nftables": firewallrender.New(pool).RenderToString,
"squid": squidrender.New(pool).RenderToString,
"unbound": unboundrender.New(pool).RenderToString,
"chrony": chronyrender.New(pool).RenderToString,
"wireguard": wgrender.New(pool, secretsBox).RenderToString,
"haproxy": haproxy.New(pool).RenderToString,
"nftables": firewallrender.New(pool).RenderToString,
"squid": squidrender.New(pool).RenderToString,
"unbound": unboundrender.New(pool).RenderToString,
"chrony": chronyrender.New(pool).RenderToString,
"wireguard": wgrender.New(pool, secretsBox).RenderToString,
"crowdsec-whitelist": crowdsec.NewWhitelistGenerator(pool).RenderToString,
})
setupHdl.WithAudit(auditRepo, nodeID)
@@ -927,3 +928,47 @@ func randomEphemeralSecret() []byte {
}
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()
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

@@ -25,8 +25,15 @@ type Store struct {
func NewStore(pool *pgxpool.Pool) *Store { return &Store{Pool: pool} }
// baseSelect MUSS spaltenweise zu scanNode passen. pg_role fehlte hier
// urspruenglich (Befund 2026-09-11): HANode.PGRole kam dadurch ueberall als
// leerer String an, wo Store.List/Get benutzt wird — /cluster/nodes,
// /cluster/status und damit auch die pg_role-Spalte der Cluster-Seite.
// UpsertSelf schreibt pg_role bewusst NICHT (ON CONFLICT laesst die Spalte
// unangetastet), liest sie aber im RETURNING mit — sonst passt die
// Scan-Reihenfolge nicht.
const baseSelect = `
SELECT id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip, role,
SELECT id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip, role, pg_role,
version, config_hash, status,
last_seen, joined_at, created_at, updated_at
FROM ha_nodes
@@ -90,7 +97,7 @@ ON CONFLICT (id) DO UPDATE SET
last_seen = EXCLUDED.last_seen,
updated_at = NOW()
RETURNING id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip,
role, version, config_hash, status,
role, pg_role, version, config_hash, status,
last_seen, joined_at, created_at, updated_at`,
n.ID, n.Name, n.FQDN, n.APIURL,
n.PublicIP, n.InternalIP, n.MgmtIP,
@@ -181,7 +188,7 @@ func scanNode(row interface{ Scan(...any) error }) (*models.HANode, error) {
if err := row.Scan(
&n.ID, &n.Name, &n.FQDN, &n.APIURL,
&n.PublicIP, &n.InternalIP, &n.MgmtIP,
&n.Role, &n.Version, &n.ConfigHash, &n.Status,
&n.Role, &n.PGRole, &n.Version, &n.ConfigHash, &n.Status,
&n.LastSeen, &n.JoinedAt,
&n.CreatedAt, &n.UpdatedAt,
); err != nil {

View File

@@ -424,8 +424,9 @@
"split-brain": "split-brain"
},
"roleDbPrimary": "DB-Primary",
"rolePeer": "Peer",
"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“."
"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": {
"title": "Routing",

View File

@@ -424,8 +424,9 @@
"split-brain": "split-brain"
},
"roleDbPrimary": "DB primary",
"rolePeer": "Peer",
"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\"."
"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": {
"title": "Routing",

View File

@@ -68,7 +68,7 @@ interface FwRule { id: number; enabled: boolean; action: string }
interface FwNAT { id: number; enabled: boolean; kind: string }
interface FwZone { id: number; name: string; builtin: boolean }
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 WGStatusRow {
interface: string
@@ -644,6 +644,24 @@ function VIPCard({ data }: { data?: VIPStatus | null }) {
// ── 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 {
nodes: ClusterNode[]
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,
}}>
<code style={{ color: '#334155' }}>{n.fqdn}</code>
{/* ha_nodes.role ist die DB-/Cluster-Rolle (wohin Schreibzugriffe
gehen) und wandert bewusst NICHT mit der VIP — sie aendert
sich nur durch `edgeguard-ctl promote`. Ein nacktes "primary"
hier las sich neben der VIP-Karte ("BACKUP") wie ein
Widerspruch, deshalb explizit als DB-Rolle beschriftet. */}
{/* 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={n.role === 'primary' ? 'green' : 'default'} style={{ margin: 0 }}>
{n.role === 'primary'
? t('dashboard.clusterCard.roleDbPrimary')
: t('dashboard.clusterCard.rolePeer')}
<Tag color={dbRoleColor(n.pg_role)} style={{ margin: 0 }}>
{dbRoleLabel(n.pg_role, t)}
</Tag>
</Tooltip>
</div>