8 Commits

Author SHA1 Message Date
noroot
84112d399b chore(release): v1.3.31 stable 2026-09-11 11:52:15 +02:00
noroot
0f2fba4a62 fix(ui): Dashboard riss bei SPA-Navigation die ganze Oberflaeche
Symptom: Klick auf einen Link und zurueck aufs Dashboard →
"EdgeGuard konnte nicht laden / TypeError: Cannot read properties of
undefined (reading 'length')". Nach F5 ging es wieder, bis man erneut
navigierte.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 11:51:38 +02:00
noroot
60358a6d47 chore(release): v1.3.30 stable 2026-09-11 11:41:23 +02:00
noroot
808f6fc055 feat(cluster): Logical Replication wird beim Join automatisch eingerichtet
Bisher war der zweite Node nach dem Join zwar im Cluster registriert und
in der UI sichtbar, replizierte aber keine einzige geteilte Tabelle —
dafuer musste jemand manuell `edgeguard-ctl cluster-setup-standby`
ausfuehren. Wer das uebersah, merkte es erst beim Failover: der neue
Primary stand ohne Domains, Backends, Firewall-Regeln und WireGuard-Keys
da. Das ist jetzt Teil des Join-Vorgangs.

Beide Seiten muessen dafuer vorbereitet sein:

1) Primary, beim Erzeugen des Join-Tokens: ein frisch installierter
   Single-Node hat weder Replikations-Rolle noch PUBLICATION noch
   wal_level=logical. Ohne das liefe das spaetere CREATE SUBSCRIPTION in
   ein 404. Der Token wird deshalb erst ausgegeben, nachdem die
   Publisher-Seite steht — inklusive des einmaligen PG-Restarts
   (wal_level ist ein postmaster-Parameter), der bewusst hier passiert,
   solange der Admin danebensteht und noch kein Peer Traffic erwartet.

   WICHTIG dabei: setupReplicationPrimary rotiert bei jedem Lauf das
   Replikations-Passwort (ALTER ROLE … PASSWORD). Auf einem Cluster mit
   bereits angebundenem Subscriber wuerde ein zweiter Token-Klick dessen
   Connection-String ungueltig machen und die Replikation still
   anhalten. Deshalb laeuft die Initialisierung nur, wenn PUBLICATION
   und Secret nicht bereits existieren.

2) Neuer Node, nach erfolgreichem Join: cluster-setup-standby laeuft
   detached (die Initialkopie dauert je nach Datenmenge Minuten), der
   Wizard pollt GET /setup/replication-status und zeigt running/done/
   failed an. Schlaegt es fehl, steht das manuelle Kommando inkl.
   Primary-Host direkt daneben statt nur einer Fehlermeldung.

Beides braucht root (psql als postgres, pg_hba, PG-Restart), die API
laeuft als unprivilegierter edgeguard → Aufruf via sudo mit gepinnten
Regeln. Das einzige variable Argument (Primary-Host) wird vorher gegen
Hostname/IP-Syntax geprueft; der Aufruf laeuft ohne Shell. Test dafuer
liegt bei.

Ausserdem zwei Doku-Korrekturen: architecture.md behauptete,
cluster-join richte die Replikation gleich mit ein (tut es nicht,
clusterjoin.Join macht nur Cert + Registrierung), und der Hinweistext
von cluster-join verwies noch auf "PG-Basebackup + KeyDB, Phase 3.5" —
beides laut Doku laengst verworfen.

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

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

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

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

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

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

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

View File

@@ -1 +1 @@
1.3.27
1.3.31

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

@@ -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",
@@ -416,7 +422,10 @@
"ok": "OK",
"degraded": "degraded",
"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“."
},
"routingCard": {
"title": "Routing",

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",
@@ -416,7 +422,10 @@
"ok": "OK",
"degraded": "degraded",
"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\"."
},
"routingCard": {
"title": "Routing",

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',
@@ -677,7 +677,18 @@ function ClusterStatusCard({ nodes, status }: ClusterStatusCardProps) {
padding: '4px 0', borderBottom: '1px solid #F1F5F9', fontSize: 12,
}}>
<code style={{ color: '#334155' }}>{n.fqdn}</code>
<Tag color={n.role === 'primary' ? 'green' : 'default'} style={{ margin: 0 }}>{n.role}</Tag>
{/* 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. */}
<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>
</Tooltip>
</div>
))}
</Space>
@@ -704,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>}
@@ -720,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')}
@@ -741,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
@@ -346,6 +357,19 @@ net.ipv6.conf.all.accept_source_route = 0
net.ipv4.conf.all.rp_filter = 2
net.ipv4.conf.default.rp_filter = 2
# ─── Nonlocal-Bind (HA/VIP) ───────────────────────────────────────
# squid und unbound lauschen auf den VLAN-Gateway-VIPs. Ohne diese
# Option kann ein Node sie nur binden, WÄHREND er die VIP haelt — auf
# dem Standby scheitert der Start mit "FATAL: Unable to open HTTP
# Socket" und die Unit steht dauerhaft auf `failed`. Das ist nicht von
# einem echten Ausfall zu unterscheiden und kostet beim Failover
# zusätzlich einen Kaltstart. Mit nonlocal_bind laufen beide Dienste auf
# beiden Nodes durch und sind im Umschaltmoment sofort bereit; Traffic
# bekommt weiterhin nur der Node, der die VIP per ARP wirklich haelt.
# Standard-Pattern für keepalived-Setups.
net.ipv4.ip_nonlocal_bind = 1
net.ipv6.ip_nonlocal_bind = 1
# ─── Conntrack — Edge-Box trackt viele parallele Sessions ─────────
net.netfilter.nf_conntrack_max = 524288
net.netfilter.nf_conntrack_tcp_timeout_established = 86400
@@ -400,6 +424,21 @@ vm.dirty_background_ratio = 5
SYSCTL
sysctl --system >/dev/null 2>&1 || true
# Ein Node, der als Standby gebootet hat, kann squid/unbound vor
# dem nonlocal_bind oben nicht gestartet haben — die Unit steht
# dann auf `failed` und systemd versucht es von sich aus nicht
# erneut. Gezielt nur solche Units anfassen: enabled UND failed.
# Laeuft der Dienst bereits oder ist er bewusst disabled (Forward-
# Proxy/DNS optional), passiert hier nichts.
for svc in squid unbound; do
if systemctl is-enabled --quiet "$svc" 2>/dev/null \
&& systemctl is-failed --quiet "$svc" 2>/dev/null; then
systemctl reset-failed "$svc" 2>/dev/null || true
systemctl start "$svc" 2>/dev/null || \
echo "postinst: $svc start after nonlocal_bind failed" >&2
fi
done
# ── Firewall-Logging via ulogd2 (NFLOG group 0) ──────────────
# nft-Renderer emittiert `log prefix "edgeguard:<rule-id>" group 0`
# für jede Rule mit log=true. ulogd2 subscribed auf netlink-group