From 9137c07c952f0666456b0a7f1a5994168eefefe8 Mon Sep 17 00:00:00 2001 From: noroot Date: Fri, 11 Sep 2026 12:02:26 +0200 Subject: [PATCH] fix(tls): Management-Zertifikat des Standby lief ab und erneuerte sich nie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auf utm-2 war das Zertifikat der Management-UI seit dem 28.08. abgelaufen (entdeckt am 11.09.) und haette sich aus eigener Kraft nie wieder erneuert. Zwei Ursachen trafen zusammen: 1) Das FQDN eines per Join dazugekommenen Nodes landet in KEINER tls_certs-Zeile — es wird beim Setup einmalig ausgestellt und danach von niemandem mehr angefasst. certrenewer arbeitet nur die Tabelle ab und sieht es deshalb nie. (utm-1 steht dort drin, weil sein FQDN beim Erst-Setup regulaer als Domain angelegt wurde — deshalb fiel es dort nicht auf.) 2) Der Scheduler blockt auf einem Nicht-VIP-Master jede ACME-Erneuerung (v1.3.20). Fuer geteilte Domains ist das richtig: die zeigen per DNS auf die VIP, nur der Master kann die Challenge bestehen. Fuer das eigene Management-FQDN stimmt es nicht — utm-2.netcell-it.de zeigt auf 89.163.205.8, die eigene IP des Nodes, und Port 80 antwortet dort. Neu: runManagementCertRenew laeuft auf JEDEM Node unabhaengig von der VIP, aber ausschliesslich fuer das eigene FQDN aus setup.json. Prueft die Restlaufzeit der Datei unter /etc/edgeguard/tls und erneuert ab 30 Tagen Rest (gleicher Schwellwert wie bei den Domain-Certs). Bewusst NICHT ueber tls_certs: das ist eine replizierte Shared-Table, und cluster-reconcile-replication TRUNCATEt solche Tabellen beim Refresh — eine lokal auf dem Subscriber eingefuegte Zeile waere beim naechsten Paket-Upgrade wieder weg. Das Management-Zertifikat ist node-lokale Infrastruktur und wird wie die cluster-tls-Certs als reine Datei behandelt. Existiert dagegen bereits eine tls_certs-Zeile fuer das eigene FQDN (Fall utm-1), bleibt alles beim Alten und certrenewer behaelt die Zustaendigkeit — sonst haetten zwei Mechanismen dieselbe Datei. Tests decken die Schwellwert-Entscheidung ab, inklusive des utm-2-Falls (bereits abgelaufen) und kaputter PEM-Dateien. Co-Authored-By: Claude Opus 5 --- cmd/edgeguard-scheduler/main.go | 11 ++ cmd/edgeguard-scheduler/mgmtcert.go | 180 +++++++++++++++++++++++ cmd/edgeguard-scheduler/mgmtcert_test.go | 113 ++++++++++++++ 3 files changed, 304 insertions(+) create mode 100644 cmd/edgeguard-scheduler/mgmtcert.go create mode 100644 cmd/edgeguard-scheduler/mgmtcert_test.go diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index a552533..4825d8b 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -167,8 +167,10 @@ func main() { st, _ := setupStore.Load() var renewer *certrenewer.Service + var acmeIssuer *acme.Service if st != nil && st.ACMEEmail != "" { issuer := acme.New(st.ACMEEmail) + acmeIssuer = issuer renewer = certrenewer.New(tlsRepo, issuer, certDir, 30*24*time.Hour) slog.Info("scheduler: ACME renewer enabled", "email", st.ACMEEmail, "tick", renewTickInterval, "threshold", "30d") @@ -195,6 +197,11 @@ func main() { if renewer != nil && nodeHoldsVIP(ctx, pool) { runRenewer(ctx, renewer, alertSvc, alertDedupe) } + // Das EIGENE Management-Zertifikat dagegen auf jedem Node — dessen FQDN + // zeigt auf die eigene IP, nicht auf die VIP (siehe mgmtcert.go). + if acmeIssuer != nil { + runManagementCertRenew(ctx, setupStore, tlsRepo, acmeIssuer, alertSvc, alertDedupe) + } runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe) // Lokale Node-ID für Heartbeat. EnsureNodeID liefert dieselbe ID @@ -268,6 +275,10 @@ func main() { if renewer != nil && nodeHoldsVIP(ctx, pool) { runRenewer(ctx, renewer, alertSvc, alertDedupe) } + // Eigenes Management-Cert: unabhaengig von der VIP, siehe oben. + if acmeIssuer != nil { + runManagementCertRenew(ctx, setupStore, tlsRepo, acmeIssuer, alertSvc, alertDedupe) + } runCertExpiryCheck(ctx, tlsRepo, alertSvc, alertDedupe) case <-licTick.C: runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe) diff --git a/cmd/edgeguard-scheduler/mgmtcert.go b/cmd/edgeguard-scheduler/mgmtcert.go new file mode 100644 index 0000000..b5d70b7 --- /dev/null +++ b/cmd/edgeguard-scheduler/mgmtcert.go @@ -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 } diff --git a/cmd/edgeguard-scheduler/mgmtcert_test.go b/cmd/edgeguard-scheduler/mgmtcert_test.go new file mode 100644 index 0000000..8917a4e --- /dev/null +++ b/cmd/edgeguard-scheduler/mgmtcert_test.go @@ -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") + } +}