Compare commits
6 Commits
bb19562bc1
...
v1.3.32
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b0320a5da | ||
|
|
9137c07c95 | ||
|
|
84112d399b | ||
|
|
0f2fba4a62 | ||
|
|
60358a6d47 | ||
|
|
808f6fc055 |
@@ -52,8 +52,12 @@ func cmdClusterJoin(args []string) int {
|
|||||||
fmt.Printf(" CN: %s\n", commonName)
|
fmt.Printf(" CN: %s\n", commonName)
|
||||||
fmt.Printf(" Files: %s/{ca.crt,peer.crt,peer.key}\n", *clusterTLSDir)
|
fmt.Printf(" Files: %s/{ca.crt,peer.crt,peer.key}\n", *clusterTLSDir)
|
||||||
fmt.Printf("\nNächste Schritte:\n")
|
fmt.Printf("\nNächste Schritte:\n")
|
||||||
fmt.Printf(" 1) sudo systemctl restart edgeguard-api # lädt das neue Cert ins mTLS-Agent-Listener\n")
|
fmt.Printf(" 1) sudo edgeguard-ctl cluster-setup-standby %s\n", primary)
|
||||||
fmt.Printf(" 2) Auf dem Primary in der Cluster-UI prüfen ob der neue Peer in /cluster/nodes auftaucht\n")
|
fmt.Printf(" → richtet die Logical Replication ein. OHNE diesen Schritt ist der\n")
|
||||||
fmt.Printf(" 3) PG-Basebackup + KeyDB-Replica-Setup folgt mit Phase 3.5 (manuell bis dahin)\n")
|
fmt.Printf(" Node zwar im Cluster, bekommt aber KEINE geteilte Config.\n")
|
||||||
|
fmt.Printf(" 2) sudo systemctl restart edgeguard-api # lädt das neue Cert in den mTLS-Agent-Listener\n")
|
||||||
|
fmt.Printf(" 3) Auf dem Primary in der Cluster-UI prüfen ob der neue Peer auftaucht\n")
|
||||||
|
fmt.Printf("\nHinweis: Beim Join über den Setup-Wizard passiert Schritt 1 automatisch;\n")
|
||||||
|
fmt.Printf("dieser CLI-Pfad ist der manuelle Weg und braucht ihn explizit.\n")
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
180
cmd/edgeguard-scheduler/mgmtcert.go
Normal file
180
cmd/edgeguard-scheduler/mgmtcert.go
Normal 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 }
|
||||||
113
cmd/edgeguard-scheduler/mgmtcert_test.go
Normal file
113
cmd/edgeguard-scheduler/mgmtcert_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -339,7 +339,14 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash -s -- \
|
|||||||
--token <cluster-join-token>
|
--token <cluster-join-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
`edgeguard-ctl cluster-join` führt aus: TLS-Cert-Pull via mTLS (CSR→issue-cert), Node-Registrierung in `ha_nodes` (`autoRegister`), Setup als **Logical-Replication-Subscriber** (`cluster-setup-standby`: `CREATE SUBSCRIPTION … copy_data=true`, Initialkopie der geteilten Tabellen), Config-Regeneration, Service-Start. _(Kein `pg_basebackup`, kein KeyDB-Setup — beides war nur im ursprünglichen Entwurf.)_
|
`edgeguard-ctl cluster-join` führt aus: TLS-Cert-Pull via mTLS (CSR→issue-cert) und Node-Registrierung in `ha_nodes` (`autoRegister`) — **mehr nicht**. Die Logical Replication ist ein eigener Schritt (`cluster-setup-standby`: `CREATE SUBSCRIPTION … copy_data=true`, Initialkopie der geteilten Tabellen, Master-Key-Sync, Config-Regeneration). _(Kein `pg_basebackup`, kein KeyDB-Setup — beides war nur im ursprünglichen Entwurf.)_
|
||||||
|
|
||||||
|
**Join über den Setup-Wizard (empfohlener Weg) macht beides automatisch:**
|
||||||
|
|
||||||
|
1. Auf dem Primary erzeugt `POST /cluster/join-tokens` den Token — und stellt dabei vorher via `cluster-init-replication` sicher, dass die Publisher-Seite steht (Replikations-Rolle + Secret, `wal_level=logical`, `pg_hba`, PUBLICATION). Ein frisch installierter Single-Node hat das alles noch nicht; ohne diesen Schritt liefe das spätere `CREATE SUBSCRIPTION` in ein 404. Idempotent; der einmalige PG-Restart (`wal_level` ist ein postmaster-Parameter) passiert bewusst hier, solange noch kein zweiter Node Traffic erwartet.
|
||||||
|
2. Auf dem neuen Node startet `POST /setup/join-cluster` nach erfolgreichem Join `cluster-setup-standby` detached (via `sudo`, da root nötig). Fortschritt pollbar über `GET /setup/replication-status` (`running`/`done`/`failed`); der Wizard zeigt ihn an und gibt bei Fehlschlag das manuelle Kommando aus.
|
||||||
|
|
||||||
|
Der reine CLI-Pfad (`cluster-join`) bleibt der manuelle Weg und erfordert `cluster-setup-standby` weiterhin explizit.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ type ClusterHandler struct {
|
|||||||
NodeID string
|
NodeID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// pgPublicationName + pgReplicationSecretPath spiegeln die Werte aus
|
||||||
|
// cmd/edgeguard-ctl (egPubName / egReplSecret) — beide Seiten muessen
|
||||||
|
// dasselbe meinen.
|
||||||
|
pgPublicationName = "edgeguard_shared"
|
||||||
|
pgReplicationSecretPath = "/var/lib/edgeguard/pg-replication-secret"
|
||||||
|
)
|
||||||
|
|
||||||
func NewClusterHandler(store *cluster.Store, localID string) *ClusterHandler {
|
func NewClusterHandler(store *cluster.Store, localID string) *ClusterHandler {
|
||||||
return &ClusterHandler{Store: store, LocalID: localID}
|
return &ClusterHandler{Store: store, LocalID: localID}
|
||||||
}
|
}
|
||||||
@@ -284,8 +292,7 @@ func (h *ClusterHandler) AgentIdentity(c *gin.Context) {
|
|||||||
// aus /var/lib/edgeguard/pg-replication-secret. Gibt 404 zurück wenn die
|
// aus /var/lib/edgeguard/pg-replication-secret. Gibt 404 zurück wenn die
|
||||||
// Datei fehlt (cluster-init-replication noch nicht ausgeführt).
|
// Datei fehlt (cluster-init-replication noch nicht ausgeführt).
|
||||||
func (h *ClusterHandler) AgentPGReplicationInfo(c *gin.Context) {
|
func (h *ClusterHandler) AgentPGReplicationInfo(c *gin.Context) {
|
||||||
const secretPath = "/var/lib/edgeguard/pg-replication-secret"
|
pass, err := readFileString(pgReplicationSecretPath)
|
||||||
pass, err := readFileString(secretPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.NotFound(c, simpleError("pg-replication-secret nicht gefunden — cluster-init-replication auf dem Primary ausführen"))
|
response.NotFound(c, simpleError("pg-replication-secret nicht gefunden — cluster-init-replication auf dem Primary ausführen"))
|
||||||
return
|
return
|
||||||
@@ -518,6 +525,20 @@ func (h *ClusterHandler) GenerateJoinToken(c *gin.Context) {
|
|||||||
// Body optional — wenn leer, läuft der Flow ohne Pre-Register.
|
// Body optional — wenn leer, läuft der Flow ohne Pre-Register.
|
||||||
_ = c.ShouldBindJSON(&req)
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
|
||||||
|
// Publisher-Seite sicherstellen, BEVOR ein Token rausgeht. Ein frisch
|
||||||
|
// installierter Single-Node hat weder Replikations-Rolle noch
|
||||||
|
// PUBLICATION noch wal_level=logical — der beitretende Node bekaeme
|
||||||
|
// beim CREATE SUBSCRIPTION nur ein 404 ("pg-replication-secret nicht
|
||||||
|
// gefunden") und stuende ohne replizierte Config da. Idempotent; der
|
||||||
|
// PG-Restart (nur beim allerersten Mal noetig, wal_level ist ein
|
||||||
|
// postmaster-Parameter) passiert hier bewusst, solange der Admin
|
||||||
|
// danebensteht und noch kein zweiter Node Traffic erwartet.
|
||||||
|
if err := h.ensureReplicationPublisher(c.Request.Context()); err != nil {
|
||||||
|
slog.Error("cluster: publisher setup before join-token failed", "error", err)
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
token, exp, err := h.Tokens.Generate()
|
token, exp, err := h.Tokens.Generate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Internal(c, err)
|
response.Internal(c, err)
|
||||||
@@ -1128,3 +1149,43 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
|
|||||||
"client_cn", cn, "remote", c.ClientIP())
|
"client_cn", cn, "remote", c.ClientIP())
|
||||||
response.OK(c, out)
|
response.OK(c, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureReplicationPublisher richtet die lokale PG-Instanz als Logical-
|
||||||
|
// Replication-Publisher ein (Rolle + Secret, wal_level=logical, pg_hba,
|
||||||
|
// Grants, PUBLICATION). Idempotent — auf einem bereits eingerichteten
|
||||||
|
// Primary ist es ein No-Op.
|
||||||
|
//
|
||||||
|
// Braucht root (psql als postgres, pg_hba schreiben, ggf. PG-Restart), die
|
||||||
|
// API laeuft als unprivilegierter `edgeguard` → Aufruf via sudo mit
|
||||||
|
// gepinnter Regel, wie bei den uebrigen privilegierten Operationen.
|
||||||
|
func (h *ClusterHandler) ensureReplicationPublisher(ctx context.Context) error {
|
||||||
|
// WICHTIG: nur ausfuehren wenn die Publisher-Seite noch NICHT steht.
|
||||||
|
// setupReplicationPrimary generiert bei JEDEM Lauf ein neues
|
||||||
|
// Replikations-Passwort (ALTER ROLE … PASSWORD). Auf einem Cluster mit
|
||||||
|
// bereits angebundenem Subscriber wuerde dessen gespeicherter
|
||||||
|
// Connection-String damit ungueltig und die Replikation bliebe still
|
||||||
|
// stehen — ein zweiter Token-Klick duerfte das niemals ausloesen.
|
||||||
|
// Das Passwort laesst sich nicht wiederverwenden (in PG nur gehasht),
|
||||||
|
// deshalb ist "schon eingerichtet" hier ein hartes Abbruchkriterium.
|
||||||
|
if h.Store != nil {
|
||||||
|
var hasPub bool
|
||||||
|
if err := h.Store.Pool.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname = $1)`,
|
||||||
|
pgPublicationName).Scan(&hasPub); err == nil && hasPub {
|
||||||
|
if _, err := os.Stat(pgReplicationSecretPath); err == nil {
|
||||||
|
slog.Info("cluster: replication publisher already set up — skipping init")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("sudo", "-n", "/usr/bin/edgeguard-ctl", //nolint:noctx // System-Setup, darf nicht am Request-Context haengen
|
||||||
|
"cluster-init-replication")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cluster-init-replication: %w: %s",
|
||||||
|
err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
slog.Info("cluster: replication publisher ensured")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ func (h *SetupHandler) Register(rg *gin.RouterGroup) {
|
|||||||
g.POST("/complete", h.Complete)
|
g.POST("/complete", h.Complete)
|
||||||
g.POST("/complete-node", h.CompleteAsNode)
|
g.POST("/complete-node", h.CompleteAsNode)
|
||||||
g.POST("/join-cluster", h.JoinCluster)
|
g.POST("/join-cluster", h.JoinCluster)
|
||||||
|
g.GET("/replication-status", h.ReplicationStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterAuthed mountet die Endpoints die nach abgeschlossenem Setup
|
// RegisterAuthed mountet die Endpoints die nach abgeschlossenem Setup
|
||||||
@@ -206,6 +207,13 @@ func (h *SetupHandler) JoinCluster(c *gin.Context) {
|
|||||||
go h.preRegisterPrimary(body.PrimaryFQDN)
|
go h.preRegisterPrimary(body.PrimaryFQDN)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Logical Replication automatisch einrichten. Ohne diesen Schritt waere
|
||||||
|
// der Node zwar im Cluster registriert, wuerde aber keinerlei geteilte
|
||||||
|
// Config (Domains, Backends, Firewall-Rules, WireGuard, …) bekommen —
|
||||||
|
// was frueher erst beim Failover auffiel. Laeuft detached, der Wizard
|
||||||
|
// pollt /setup/replication-status.
|
||||||
|
h.startReplicationSetup(body.PrimaryFQDN)
|
||||||
|
|
||||||
response.OK(c, gin.H{
|
response.OK(c, gin.H{
|
||||||
"completed": st.Completed,
|
"completed": st.Completed,
|
||||||
"is_cluster_node": st.IsClusterNode,
|
"is_cluster_node": st.IsClusterNode,
|
||||||
|
|||||||
195
internal/handlers/setup_replication.go
Normal file
195
internal/handlers/setup_replication.go
Normal 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())
|
||||||
|
}
|
||||||
53
internal/handlers/setup_replication_test.go
Normal file
53
internal/handlers/setup_replication_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -376,7 +376,13 @@
|
|||||||
"joinInsecure": "TLS-Prüfung überspringen (falls der Primary ein self-signed Zertifikat hat)",
|
"joinInsecure": "TLS-Prüfung überspringen (falls der Primary ein self-signed Zertifikat hat)",
|
||||||
"nodeSuccessDesc": "Cluster-Zertifikate wurden geschrieben. Noch ein letzter Schritt:",
|
"nodeSuccessDesc": "Cluster-Zertifikate wurden geschrieben. Noch ein letzter Schritt:",
|
||||||
"nodeRestartTitle": "Neustart erforderlich",
|
"nodeRestartTitle": "Neustart erforderlich",
|
||||||
"nodeRestartDesc": "Führe folgenden Befehl auf diesem Server aus, um die neuen Cluster-Zertifikate zu laden:"
|
"nodeRestartDesc": "Führe folgenden Befehl auf diesem Server aus, um die neuen Cluster-Zertifikate zu laden:",
|
||||||
|
"replRunningTitle": "Cluster-Replikation wird eingerichtet…",
|
||||||
|
"replRunningDesc": "Die geteilte Konfiguration (Domains, Backends, Firewall-Regeln, WireGuard, DNS, Zertifikate, Benutzer) wird vom Primary kopiert. Das kann je nach Datenmenge einige Minuten dauern — dieses Fenster offen lassen.",
|
||||||
|
"replDoneTitle": "Cluster-Replikation aktiv",
|
||||||
|
"replDoneDesc": "Der Knoten ist Logical-Replication-Subscriber. Änderungen am Primary erscheinen ab jetzt automatisch hier.",
|
||||||
|
"replFailedTitle": "Cluster-Replikation fehlgeschlagen",
|
||||||
|
"replFailedDesc": "Der Knoten ist im Cluster registriert, repliziert aber noch keine Konfiguration. Auf diesem Knoten manuell nachholen:"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
|
|||||||
@@ -376,7 +376,13 @@
|
|||||||
"joinInsecure": "Skip TLS verification (use if the primary has a self-signed certificate)",
|
"joinInsecure": "Skip TLS verification (use if the primary has a self-signed certificate)",
|
||||||
"nodeSuccessDesc": "Cluster certs have been written. One last step:",
|
"nodeSuccessDesc": "Cluster certs have been written. One last step:",
|
||||||
"nodeRestartTitle": "Restart required",
|
"nodeRestartTitle": "Restart required",
|
||||||
"nodeRestartDesc": "Run the following command on this box to load the new cluster certificates:"
|
"nodeRestartDesc": "Run the following command on this box to load the new cluster certificates:",
|
||||||
|
"replRunningTitle": "Setting up cluster replication…",
|
||||||
|
"replRunningDesc": "Shared configuration (domains, backends, firewall rules, WireGuard, DNS, certificates, users) is being copied from the primary. Depending on the amount of data this can take a few minutes — keep this window open.",
|
||||||
|
"replDoneTitle": "Cluster replication active",
|
||||||
|
"replDoneDesc": "This node is a logical replication subscriber. Changes on the primary now appear here automatically.",
|
||||||
|
"replFailedTitle": "Cluster replication failed",
|
||||||
|
"replFailedDesc": "The node is registered in the cluster but is not replicating configuration yet. Run this manually on this node:"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
|
|||||||
@@ -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>()
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -715,7 +715,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 +731,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 +752,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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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]))
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Alert, Button, Card, Form, Input, Space, Typography, message } from 'antd'
|
import { Alert, Button, Card, Form, Input, Space, Spin, Typography, message } from 'antd'
|
||||||
import { ArrowLeftOutlined, CheckCircleOutlined, ClusterOutlined, DesktopOutlined } from '@ant-design/icons'
|
import { ArrowLeftOutlined, CheckCircleOutlined, ClusterOutlined, DesktopOutlined } from '@ant-design/icons'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import apiClient from '../../api/client'
|
import apiClient, { isEnvelope } from '../../api/client'
|
||||||
import type { SessionUser } from '../../stores/auth'
|
import type { SessionUser } from '../../stores/auth'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -26,6 +26,13 @@ interface JoinValues {
|
|||||||
token: string
|
token: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ReplState {
|
||||||
|
phase: 'idle' | 'running' | 'done' | 'failed'
|
||||||
|
primary?: string
|
||||||
|
error?: string
|
||||||
|
log?: string
|
||||||
|
}
|
||||||
|
|
||||||
const FQDN_RE = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
|
const FQDN_RE = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
|
||||||
|
|
||||||
type Mode = 'standalone' | 'node'
|
type Mode = 'standalone' | 'node'
|
||||||
@@ -69,6 +76,38 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Die Logical-Replication-Einrichtung laeuft server-seitig detached
|
||||||
|
// weiter, nachdem /setup/join-cluster geantwortet hat (die Initialkopie
|
||||||
|
// der geteilten Tabellen dauert je nach Datenmenge). Hier nur pollen und
|
||||||
|
// anzeigen — der Wizard ist an dieser Stelle noch pre-auth.
|
||||||
|
const [repl, setRepl] = useState<ReplState | null>(null)
|
||||||
|
const replTimer = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!joinDone) return
|
||||||
|
let stopped = false
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const r = await apiClient.get('/setup/replication-status')
|
||||||
|
const st = isEnvelope(r.data) ? (r.data.data as ReplState) : null
|
||||||
|
if (stopped || !st) return
|
||||||
|
setRepl(st)
|
||||||
|
if (st.phase === 'done' || st.phase === 'failed') {
|
||||||
|
if (replTimer.current) { clearInterval(replTimer.current); replTimer.current = null }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Waehrend des abschliessenden API-Neustarts ist der Endpoint kurz
|
||||||
|
// weg — weiterpollen statt einen Fehler anzuzeigen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void poll()
|
||||||
|
replTimer.current = setInterval(poll, 3000)
|
||||||
|
return () => {
|
||||||
|
stopped = true
|
||||||
|
if (replTimer.current) { clearInterval(replTimer.current); replTimer.current = null }
|
||||||
|
}
|
||||||
|
}, [joinDone])
|
||||||
|
|
||||||
const onJoin = async (vals: JoinValues) => {
|
const onJoin = async (vals: JoinValues) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -299,6 +338,34 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
|
{repl && repl.phase !== 'idle' && (
|
||||||
|
<Alert
|
||||||
|
type={repl.phase === 'done' ? 'success' : repl.phase === 'failed' ? 'error' : 'info'}
|
||||||
|
showIcon={repl.phase !== 'running'}
|
||||||
|
icon={repl.phase === 'running' ? <Spin size="small" /> : undefined}
|
||||||
|
message={
|
||||||
|
repl.phase === 'running' ? t('setup.replRunningTitle')
|
||||||
|
: repl.phase === 'done' ? t('setup.replDoneTitle')
|
||||||
|
: t('setup.replFailedTitle')
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<Space direction="vertical" size={6} style={{ width: '100%', marginTop: 4 }}>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{repl.phase === 'running' ? t('setup.replRunningDesc')
|
||||||
|
: repl.phase === 'done' ? t('setup.replDoneDesc')
|
||||||
|
: t('setup.replFailedDesc')}
|
||||||
|
</Typography.Text>
|
||||||
|
{repl.phase === 'failed' && (
|
||||||
|
<>
|
||||||
|
{repl.error && <Typography.Text code>{repl.error}</Typography.Text>}
|
||||||
|
<CopyCode value={`sudo edgeguard-ctl cluster-setup-standby ${repl.primary ?? ''}`} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
type="warning"
|
type="warning"
|
||||||
showIcon
|
showIcon
|
||||||
|
|||||||
@@ -155,6 +155,17 @@ edgeguard ALL=(root) NOPASSWD: /bin/rm -f /etc/apt/apt.conf.d/52edgeguard-auto-u
|
|||||||
# Update-Kanal-Switch (Settings → Update-Kanal) schreibt exakt diese
|
# Update-Kanal-Switch (Settings → Update-Kanal) schreibt exakt diese
|
||||||
# sources.list-Zeile. Gleiches Restrict-Pattern wie oben.
|
# sources.list-Zeile. Gleiches Restrict-Pattern wie oben.
|
||||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/tee /etc/apt/sources.list.d/edgeguard.list
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/tee /etc/apt/sources.list.d/edgeguard.list
|
||||||
|
# Cluster-Replikation wird beim Join automatisch eingerichtet (frueher ein
|
||||||
|
# manueller Schritt, der leicht vergessen wurde → Node ohne replizierte
|
||||||
|
# Config). Beide Kommandos brauchen root: psql als postgres-User, pg_hba
|
||||||
|
# schreiben, ggf. PG-Restart fuer wal_level=logical.
|
||||||
|
# cluster-init-replication: argumentlos, exakt pinnbar.
|
||||||
|
# cluster-setup-standby: nimmt den Primary-Host als Argument. Die API
|
||||||
|
# validiert ihn vorher gegen Hostname/IP-Syntax (validPrimaryHost), und
|
||||||
|
# der Aufruf laeuft ohne Shell (exec, kein sh -c) — es gibt also keine
|
||||||
|
# Wortaufspaltung, an der sich ein zweites Kommando anhaengen liesse.
|
||||||
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/edgeguard-ctl cluster-init-replication
|
||||||
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/edgeguard-ctl cluster-setup-standby *
|
||||||
# Backup-Pfad: pg_dump als postgres-User. Whitelist exakt mit
|
# Backup-Pfad: pg_dump als postgres-User. Whitelist exakt mit
|
||||||
# --clean --if-exists --no-owner --no-acl + dem festen DB-Namen.
|
# --clean --if-exists --no-owner --no-acl + dem festen DB-Namen.
|
||||||
edgeguard ALL=(postgres) NOPASSWD: /usr/bin/pg_dump --clean --if-exists --no-owner --no-acl edgeguard
|
edgeguard ALL=(postgres) NOPASSWD: /usr/bin/pg_dump --clean --if-exists --no-owner --no-acl edgeguard
|
||||||
|
|||||||
Reference in New Issue
Block a user