Files
felhom-controller/controller/internal/infra/webcert_test.go
T
admin 7daf3d24a7
gates / gates (push) Successful in 34s
controller (unreleased): the report carries the web certificate's end date (felhom.eu R-1379, D77)
Names and end dates only, read from Traefik's store; no key material leaves the box.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-11-14 18:14:24 +01:00

120 lines
5.4 KiB
Go

package infra
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// acmeEntry makes one store entry the way Traefik writes it: the certificate (leaf + a second block, as a chain) and
// its PRIVATE KEY, both base64 of PEM.
func acmeEntry(t *testing.T, main string, notAfter time.Time) map[string]any {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: main},
Issuer: pkix.Name{CommonName: "Test CA"}, DNSNames: []string{main}, NotBefore: notAfter.Add(-90 * 24 * time.Hour), NotAfter: notAfter}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
leaf := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
chain := append(append([]byte{}, leaf...), leaf...) // a second block stands in for the issuer's certificate
kb, _ := x509.MarshalECPrivateKey(key)
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb})
return map[string]any{"domain": map[string]any{"main": main, "sans": []string{main}},
"certificate": base64.StdEncoding.EncodeToString(chain), "key": base64.StdEncoding.EncodeToString(keyPEM), "Store": "default"}
}
func writeStore(t *testing.T, entries ...map[string]any) string {
t.Helper()
p := filepath.Join(t.TempDir(), ACMEStoreFile)
b, _ := json.Marshal(map[string]any{"letsencrypt": map[string]any{
"Account": map[string]any{"Email": "household@example.hu", "PrivateKey": "QUNDT1VOVC1LRVktTVVTVC1OT1QtTEVBSw=="},
"Certificates": entries}})
if err := os.WriteFile(p, b, 0o600); err != nil {
t.Fatal(err)
}
return p
}
// felhom.eu R-1379 (D77): the box reads its web certificate's END DATE from Traefik's store so the hub can warn 14
// days before — and reads NOTHING else from a file that also holds the private keys. The consequence asserted: the
// right date per name, soonest first; and no byte of a key, the account key or the e-mail in what comes back.
// COMPANION RED-PROOFS (observed, felhom.eu audits/night-build-2026-11-14/B/redproof-controller-webcert.txt).
func TestReadWebCerts_EndDatesAndNoKeyMaterial(t *testing.T) {
soon := time.Date(2026, 11, 20, 1, 46, 0, 0, time.UTC)
later := time.Date(2027, 1, 19, 1, 46, 0, 0, time.UTC)
e1, e2 := acmeEntry(t, "*.example.hu", later), acmeEntry(t, "old.example.hu", soon)
got, err := ReadWebCerts(writeStore(t, e1, e2))
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].Domain != "old.example.hu" || got[0].NotAfter != "2026-11-20T01:46:00Z" ||
got[1].Domain != "*.example.hu" || got[1].NotAfter != "2027-01-19T01:46:00Z" {
t.Fatalf("certificates = %+v, want old.example.hu 2026-11-20 first, then *.example.hu 2027-01-19", got)
}
wire, _ := json.Marshal(got)
for _, secret := range []string{e1["key"].(string), e2["key"].(string), "QUNDT1VOVC1LRVktTVVTVC1OT1QtTEVBSw==", "household@example.hu", "PRIVATE KEY"} {
if strings.Contains(string(wire), secret) || strings.Contains(string(wire), secret[:min(20, len(secret))]) {
t.Fatalf("what the box reports carries key material or the e-mail: %s", wire)
}
}
for _, c := range got {
if len(c.Domain)+len(c.NotAfter)+len(c.Issuer) > 200 {
t.Errorf("a reported certificate is %d bytes — more than a name, a date and an issuer: %+v", len(c.Domain)+len(c.NotAfter)+len(c.Issuer), c)
}
}
}
// No store, an empty store (the file the controller creates before Traefik's first run), and a store with no
// certificate yet: nothing to report and no error. A store that is not JSON: an error that does not quote the file.
func TestReadWebCerts_EmptyMissingAndBroken(t *testing.T) {
dir := t.TempDir()
if got, err := ReadWebCerts(filepath.Join(dir, "none.json")); got != nil || err != nil {
t.Errorf("a missing store: %v, %v", got, err)
}
empty := filepath.Join(dir, "empty.json")
_ = os.WriteFile(empty, nil, 0o600)
if got, err := ReadWebCerts(empty); got != nil || err != nil {
t.Errorf("an empty store: %v, %v", got, err)
}
if got, err := ReadWebCerts(writeStore(t)); len(got) != 0 || err != nil {
t.Errorf("a store with no certificate: %v, %v", got, err)
}
broken := filepath.Join(dir, "broken.json")
_ = os.WriteFile(broken, []byte(`{"letsencrypt": SECRET-LOOKING-BYTES`), 0o600)
_, err := ReadWebCerts(broken)
if err == nil || strings.Contains(err.Error(), "SECRET") {
t.Errorf("a broken store: err = %v, want an error that does not quote the file", err)
}
// an entry whose certificate is not a certificate is skipped, the good one stays
bad := map[string]any{"domain": map[string]any{"main": "bad.example.hu"}, "certificate": "bm90IGEgY2VydA=="}
got, err := ReadWebCerts(writeStore(t, bad, acmeEntry(t, "ok.example.hu", time.Date(2027, 2, 1, 0, 0, 0, 0, time.UTC))))
if err != nil || len(got) != 1 || got[0].Domain != "ok.example.hu" {
t.Errorf("a store with one unreadable entry: %+v, %v", got, err)
}
// more than the cap: the soonest ends stay
var many []map[string]any
for i := 0; i < 12; i++ {
many = append(many, acmeEntry(t, "n"+string(rune('a'+i))+".example.hu", time.Date(2027, 1, 1+i, 0, 0, 0, 0, time.UTC)))
}
got, _ = ReadWebCerts(writeStore(t, many...))
if len(got) != webCertMax || got[0].NotAfter != "2027-01-01T00:00:00Z" {
t.Errorf("cap: %d entries, first %+v", len(got), got[0])
}
}