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>
181 lines
6.3 KiB
Go
181 lines
6.3 KiB
Go
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 }
|