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>
114 lines
3.6 KiB
Go
114 lines
3.6 KiB
Go
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")
|
|
}
|
|
}
|