fix(tls): Management-Zertifikat des Standby lief ab und erneuerte sich nie

Auf utm-2 war das Zertifikat der Management-UI seit dem 28.08. abgelaufen
(entdeckt am 11.09.) und haette sich aus eigener Kraft nie wieder
erneuert. Zwei Ursachen trafen zusammen:

1) Das FQDN eines per Join dazugekommenen Nodes landet in KEINER
   tls_certs-Zeile — es wird beim Setup einmalig ausgestellt und danach
   von niemandem mehr angefasst. certrenewer arbeitet nur die Tabelle ab
   und sieht es deshalb nie. (utm-1 steht dort drin, weil sein FQDN beim
   Erst-Setup regulaer als Domain angelegt wurde — deshalb fiel es dort
   nicht auf.)
2) Der Scheduler blockt auf einem Nicht-VIP-Master jede ACME-Erneuerung
   (v1.3.20). Fuer geteilte Domains ist das richtig: die zeigen per DNS
   auf die VIP, nur der Master kann die Challenge bestehen. Fuer das
   eigene Management-FQDN stimmt es nicht — utm-2.netcell-it.de zeigt auf
   89.163.205.8, die eigene IP des Nodes, und Port 80 antwortet dort.

Neu: runManagementCertRenew laeuft auf JEDEM Node unabhaengig von der
VIP, aber ausschliesslich fuer das eigene FQDN aus setup.json. Prueft die
Restlaufzeit der Datei unter /etc/edgeguard/tls und erneuert ab 30 Tagen
Rest (gleicher Schwellwert wie bei den Domain-Certs).

Bewusst NICHT ueber tls_certs: das ist eine replizierte Shared-Table, und
cluster-reconcile-replication TRUNCATEt solche Tabellen beim Refresh —
eine lokal auf dem Subscriber eingefuegte Zeile waere beim naechsten
Paket-Upgrade wieder weg. Das Management-Zertifikat ist node-lokale
Infrastruktur und wird wie die cluster-tls-Certs als reine Datei
behandelt. Existiert dagegen bereits eine tls_certs-Zeile fuer das eigene
FQDN (Fall utm-1), bleibt alles beim Alten und certrenewer behaelt die
Zustaendigkeit — sonst haetten zwei Mechanismen dieselbe Datei.

Tests decken die Schwellwert-Entscheidung ab, inklusive des
utm-2-Falls (bereits abgelaufen) und kaputter PEM-Dateien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-09-11 12:02:26 +02:00
parent 84112d399b
commit 9137c07c95
3 changed files with 304 additions and 0 deletions

View File

@@ -167,8 +167,10 @@ func main() {
st, _ := setupStore.Load()
var renewer *certrenewer.Service
var acmeIssuer *acme.Service
if st != nil && st.ACMEEmail != "" {
issuer := acme.New(st.ACMEEmail)
acmeIssuer = issuer
renewer = certrenewer.New(tlsRepo, issuer, certDir, 30*24*time.Hour)
slog.Info("scheduler: ACME renewer enabled",
"email", st.ACMEEmail, "tick", renewTickInterval, "threshold", "30d")
@@ -195,6 +197,11 @@ func main() {
if renewer != nil && nodeHoldsVIP(ctx, pool) {
runRenewer(ctx, renewer, alertSvc, alertDedupe)
}
// Das EIGENE Management-Zertifikat dagegen auf jedem Node — dessen FQDN
// zeigt auf die eigene IP, nicht auf die VIP (siehe mgmtcert.go).
if acmeIssuer != nil {
runManagementCertRenew(ctx, setupStore, tlsRepo, acmeIssuer, alertSvc, alertDedupe)
}
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
// Lokale Node-ID für Heartbeat. EnsureNodeID liefert dieselbe ID
@@ -268,6 +275,10 @@ func main() {
if renewer != nil && nodeHoldsVIP(ctx, pool) {
runRenewer(ctx, renewer, alertSvc, alertDedupe)
}
// Eigenes Management-Cert: unabhaengig von der VIP, siehe oben.
if acmeIssuer != nil {
runManagementCertRenew(ctx, setupStore, tlsRepo, acmeIssuer, alertSvc, alertDedupe)
}
runCertExpiryCheck(ctx, tlsRepo, alertSvc, alertDedupe)
case <-licTick.C:
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)

View File

@@ -0,0 +1,180 @@
package main
import (
"context"
"crypto/x509"
"encoding/pem"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/certstore"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
)
// Node-lokale Erneuerung des eigenen Management-Zertifikats.
//
// Befund 2026-09-11: Auf utm-2 war das Zertifikat fuer die Management-UI
// seit zwei Wochen abgelaufen und haette sich nie erneuert. Zwei Gruende
// trafen zusammen:
//
// 1. Das FQDN eines per Join dazugekommenen Nodes landet in KEINER
// tls_certs-Zeile — es wird beim Setup einmalig ausgestellt und danach
// von niemandem mehr angefasst. certrenewer arbeitet ausschliesslich
// die Tabelle ab und sieht es deshalb nie.
// 2. Der Scheduler blockt auf einem Nicht-VIP-Master jede ACME-Erneuerung
// (v1.3.20). Das ist fuer geteilte Domains richtig — die zeigen per DNS
// auf die VIP, nur der Master kann die Challenge bestehen. Fuer das
// eigene Management-FQDN stimmt es NICHT: das zeigt auf die eigene IP
// des Nodes, der die HTTP-01-Challenge also selbst beantworten kann.
//
// Deshalb laeuft diese Pruefung auf JEDEM Node, unabhaengig von der VIP —
// aber ausschliesslich fuer das eigene FQDN aus setup.json.
//
// Bewusst NICHT ueber die tls_certs-Tabelle: die ist eine replizierte
// Shared-Table, und cluster-reconcile-replication TRUNCATEt solche Tabellen
// beim Refresh. Eine lokal auf dem Subscriber eingefuegte Zeile waere beim
// naechsten Paket-Upgrade wieder weg. Das Management-Zertifikat ist
// node-lokale Infrastruktur (wie die cluster-tls-Certs) und wird auch so
// behandelt: reine Datei unter certDir.
//
// Existiert dagegen eine tls_certs-Zeile fuer das eigene FQDN (so ist es
// auf dem Primary, dessen FQDN beim Setup regulaer als Domain angelegt
// wurde), bleibt alles beim Alten — dann macht certrenewer weiter seine
// Arbeit und wir fassen nichts an. Sonst haetten wir zwei Mechanismen auf
// derselben Datei.
// mgmtCertRenewThreshold: ab wann erneuert wird. Gleicher Wert wie der
// certrenewer fuer die Domain-Certs.
const mgmtCertRenewThreshold = 30 * 24 * time.Hour
// runManagementCertRenew prueft das eigene Management-Zertifikat und
// erneuert es bei Bedarf. Best-effort: Fehler werden geloggt/gemeldet,
// der Tick laeuft beim naechsten Zyklus erneut.
func runManagementCertRenew(
ctx context.Context,
setupStore *setup.Store,
tlsRepo *tlscerts.Repo,
issuer interface {
Issue(domain string) (string, string, string, error)
},
a *alerts.Service, d *dedupe,
) {
if setupStore == nil || issuer == nil {
return
}
st, err := setupStore.Load()
if err != nil || st == nil || st.FQDN == "" {
return
}
fqdn := strings.ToLower(strings.TrimSpace(st.FQDN))
// Wird das FQDN bereits als regulaere Domain verwaltet, ist der
// certrenewer zustaendig — nicht zusaetzlich hier anfassen.
if tlsRepo != nil {
if managed, err := mgmtCertIsManaged(ctx, tlsRepo, fqdn); err == nil && managed {
return
}
}
path := filepath.Join(certDir, fqdn+".pem")
remaining, err := certRemainingValidity(path)
switch {
case err != nil:
slog.Info("scheduler: management cert missing/unreadable — issuing",
"fqdn", fqdn, "path", path, "error", err)
case remaining > mgmtCertRenewThreshold:
return // noch lange gueltig
default:
slog.Info("scheduler: management cert expiring — renewing",
"fqdn", fqdn, "remaining", remaining.Round(time.Hour).String())
}
certPEM, chainPEM, keyPEM, err := issuer.Issue(fqdn)
if err != nil {
slog.Error("scheduler: management cert issue failed", "fqdn", fqdn, "error", err)
if a != nil && d != nil && d.shouldFire("cert.mgmt_renew_failed:"+fqdn) {
_, _ = a.Fire(ctx, "cert.mgmt_renew_failed", alerts.SeverityError,
"Management-Zertifikat konnte nicht erneuert werden: "+fqdn,
"Die HTTP-01-Challenge fuer das eigene Management-FQDN ist fehlgeschlagen. "+
"Pruefe, ob "+fqdn+" auf die oeffentliche IP DIESES Nodes zeigt und Port 80 "+
"von aussen erreichbar ist. Fehler: "+err.Error())
}
return
}
if _, err := certstore.WriteCombined(certDir, fqdn, certPEM, chainPEM, keyPEM); err != nil {
slog.Error("scheduler: management cert write failed", "fqdn", fqdn, "error", err)
return
}
if err := reloadHAProxyForMgmtCert(); err != nil {
slog.Warn("scheduler: haproxy reload after management cert renewal failed", "error", err)
}
slog.Info("scheduler: management cert renewed", "fqdn", fqdn)
}
// mgmtCertIsManaged sagt, ob fuer das FQDN bereits eine tls_certs-Zeile
// existiert (dann gehoert es dem certrenewer).
func mgmtCertIsManaged(ctx context.Context, repo *tlscerts.Repo, fqdn string) (bool, error) {
rows, err := repo.List(ctx)
if err != nil {
return false, err
}
for _, r := range rows {
if strings.EqualFold(strings.TrimSpace(r.Domain), fqdn) {
return true, nil
}
}
return false, nil
}
// certRemainingValidity liest die Restlaufzeit des ersten Zertifikats in
// einer kombinierten PEM-Datei. Fehler (Datei fehlt, unlesbar, kein
// Zertifikat drin) bedeuten "muss ausgestellt werden".
func certRemainingValidity(path string) (time.Duration, error) {
raw, err := os.ReadFile(path) //nolint:gosec // fester Pfad aus certDir + eigenem FQDN
if err != nil {
return 0, err
}
rest := raw
for {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
return 0, os.ErrNotExist
}
if block.Type != "CERTIFICATE" {
continue
}
crt, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return 0, err
}
return time.Until(crt.NotAfter), nil
}
}
// reloadHAProxyForMgmtCert: "haproxy.service" ausgeschrieben, weil die
// sudoers-Regel im postinst exakt darauf gepinnt ist — ohne Suffix wuerde
// sudo den Aufruf ablehnen. Gleicher Aufruf wie in certrenewer.
func reloadHAProxyForMgmtCert() error {
//nolint:noctx // System-Reload darf nicht am Tick-Context haengen
out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").CombinedOutput()
if err != nil {
return &exitErr{msg: strings.TrimSpace(string(out)), err: err}
}
return nil
}
type exitErr struct {
msg string
err error
}
func (e *exitErr) Error() string { return e.err.Error() + ": " + e.msg }
func (e *exitErr) Unwrap() error { return e.err }

View File

@@ -0,0 +1,113 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
"time"
)
// certRemainingValidity entscheidet, ob ueberhaupt erneuert wird — ein
// falsches Ergebnis heisst entweder "Zertifikat laeuft unbemerkt ab"
// (genau der Befund auf utm-2) oder "wir erneuern bei jedem Tick".
func writeTestPEM(t *testing.T, dir, name string, notAfter time.Time, withKey bool) string {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: name},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: notAfter,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("cert: %v", err)
}
var buf []byte
// Reihenfolge wie certstore.WriteCombined: erst Cert(-Kette), dann Key.
buf = append(buf, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})...)
if withKey {
kd, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
buf = append(buf, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kd})...)
}
p := filepath.Join(dir, name+".pem")
if err := os.WriteFile(p, buf, 0o600); err != nil {
t.Fatalf("write: %v", err)
}
return p
}
func TestCertRemainingValidity_LongLived(t *testing.T) {
dir := t.TempDir()
p := writeTestPEM(t, dir, "node.example.com", time.Now().Add(60*24*time.Hour), true)
got, err := certRemainingValidity(p)
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
if got <= mgmtCertRenewThreshold {
t.Errorf("60d-Cert muss ueber dem 30d-Schwellwert liegen, got %v", got)
}
}
func TestCertRemainingValidity_ExpiringSoon(t *testing.T) {
dir := t.TempDir()
p := writeTestPEM(t, dir, "node.example.com", time.Now().Add(5*24*time.Hour), true)
got, err := certRemainingValidity(p)
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
if got > mgmtCertRenewThreshold {
t.Errorf("5d-Cert muss unter dem Schwellwert liegen, got %v", got)
}
}
// Der utm-2-Fall: bereits abgelaufen → negative Restlaufzeit, also
// eindeutig unter dem Schwellwert und damit erneuerungspflichtig.
func TestCertRemainingValidity_AlreadyExpired(t *testing.T) {
dir := t.TempDir()
p := writeTestPEM(t, dir, "node.example.com", time.Now().Add(-14*24*time.Hour), true)
got, err := certRemainingValidity(p)
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
if got >= 0 {
t.Errorf("abgelaufenes Cert muss negative Restlaufzeit liefern, got %v", got)
}
if got > mgmtCertRenewThreshold {
t.Errorf("abgelaufenes Cert muss erneuert werden, got %v", got)
}
}
func TestCertRemainingValidity_MissingFile(t *testing.T) {
if _, err := certRemainingValidity(filepath.Join(t.TempDir(), "nope.pem")); err == nil {
t.Error("fehlende Datei muss einen Fehler liefern (→ ausstellen)")
}
}
// Nur-Key-Datei: darf nicht als gueltiges Zertifikat durchgehen, sonst
// wuerde ein kaputter Zustand nie repariert.
func TestCertRemainingValidity_NoCertificateBlock(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "keyonly.pem")
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
kd, _ := x509.MarshalECPrivateKey(key)
if err := os.WriteFile(p, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kd}), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if _, err := certRemainingValidity(p); err == nil {
t.Error("PEM ohne CERTIFICATE-Block muss einen Fehler liefern")
}
}