Files
diskless-workstation/serving/fleet-manager/certreload_test.go
T
Audiobuzz 1db3977238 fleet-manager: auto-renew the agent-plane server cert (step-ca sidecar + hot-reload)
The agent-plane server cert was issued by hand (server-pki.sh / docker exec
step ca certificate), which needs the step CLI on the host, re-specifies SANs
each time, and silently expires at the CA's 90-day cap — the exact failure that
just broke agent TLS (cert minted with a stale SAN, missing manager.fleet.lab).

Automate it:
- agent-cert-renewer sidecar (smallstep step-cli image, compose profile
  cert-auto): issues the cert once from the co-located fleet step-ca, then
  'step ca renew --daemon' rotates it before the cap. Renewal preserves the
  subject + SANs, so SAN drift cannot recur. Chowns to the manager's nonroot
  uid so the distroless manager reads the key. Opt-in + only valid where the CA
  is co-located; server-pki.sh stays as the manual/remote-CA path.
- manager hot-reloads via GetCertificate (certreload.go): a rotated cert on
  disk is picked up on the next handshake with no restart and no dropped agent
  streams; a half-written pair keeps the last good cert serving. Unit-tested.
- defaults.env documents the cert-auto knobs; DEPLOY.md §2a recommends the
  renewer and flags the mandatory manager.fleet.lab SAN.
2026-07-10 07:42:18 +00:00

110 lines
2.9 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"
)
// writePair mints a throwaway self-signed leaf with the given CN and writes it as
// a cert/key PEM pair, so the reloader has a real, loadable x509 key pair.
func writePair(t *testing.T, certPath, keyPath, cn string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
kder, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kder}), 0o600); err != nil {
t.Fatal(err)
}
}
func leafCN(t *testing.T, r *certReloader) string {
t.Helper()
c, err := r.GetCertificate(nil)
if err != nil {
t.Fatal(err)
}
leaf, err := x509.ParseCertificate(c.Certificate[0])
if err != nil {
t.Fatal(err)
}
return leaf.Subject.CommonName
}
func TestCertReloaderHotSwap(t *testing.T) {
dir := t.TempDir()
crt := filepath.Join(dir, "server.crt")
key := filepath.Join(dir, "server.key")
writePair(t, crt, key, "old")
r, err := newCertReloader(crt, key)
if err != nil {
t.Fatalf("newCertReloader: %v", err)
}
if cn := leafCN(t, r); cn != "old" {
t.Fatalf("initial CN = %q, want old", cn)
}
// Rotate the pair on disk with a strictly-later modtime — GetCertificate must
// pick it up without any explicit reload call (the renewer-rotates-in-place case).
writePair(t, crt, key, "new")
future := time.Now().Add(2 * time.Second)
if err := os.Chtimes(crt, future, future); err != nil {
t.Fatal(err)
}
if cn := leafCN(t, r); cn != "new" {
t.Fatalf("post-rotation CN = %q, want new", cn)
}
}
func TestCertReloaderKeepsPreviousOnBadReload(t *testing.T) {
dir := t.TempDir()
crt := filepath.Join(dir, "server.crt")
key := filepath.Join(dir, "server.key")
writePair(t, crt, key, "good")
r, err := newCertReloader(crt, key)
if err != nil {
t.Fatal(err)
}
// Simulate a half-written rotation: cert file replaced with garbage + a newer
// modtime. Reload fails; the last good cert must keep serving.
if err := os.WriteFile(crt, []byte("not a pem"), 0o644); err != nil {
t.Fatal(err)
}
future := time.Now().Add(2 * time.Second)
if err := os.Chtimes(crt, future, future); err != nil {
t.Fatal(err)
}
if cn := leafCN(t, r); cn != "good" {
t.Fatalf("after bad reload CN = %q, want good (kept previous)", cn)
}
}