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 }