controller (unreleased): the report carries the web certificate's end date (felhom.eu R-1379, D77)
gates / gates (push) Successful in 34s
gates / gates (push) Successful in 34s
Names and end dates only, read from Traefik's store; no key material leaves the box. Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
1 parent
fdc0e29400
commit
7daf3d24a7
6 files changed
+362
-1
No files matched your search
@@ -0,0 +1,107 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ACMEStoreFile is Traefik's certificate store inside its stack folder (traefik.yml.tmpl: `storage:
|
||||
// /etc/traefik/acme.json`, mounted from the stack folder).
|
||||
const ACMEStoreFile = "acme.json"
|
||||
|
||||
// WebCert is one certificate of Traefik's ACME store, as the hub needs it to warn before the end date
|
||||
// (felhom.eu R-1379, D77, `09` §3 decision 298). NO key material: only the certificate's own public fields.
|
||||
type WebCert struct {
|
||||
Domain string `json:"domain"` // the store's main name, e.g. "*.example.hu"
|
||||
NotAfter string `json:"not_after"` // RFC3339 UTC — the end date
|
||||
Issuer string `json:"issuer,omitempty"` // the issuer's common name
|
||||
}
|
||||
|
||||
const (
|
||||
webCertMaxStoreBytes = 4 << 20
|
||||
webCertMax = 8
|
||||
)
|
||||
|
||||
// ReadWebCerts reads the END DATES out of Traefik's ACME store. Who knows a box's web certificate date: only the
|
||||
// box — the certificate is issued to Traefik in the guest and lives in this file; the household's public names end
|
||||
// at Cloudflare, so nobody outside can read it. Until this nobody read it at all: when a renewal failed (Traefik
|
||||
// renews by itself, 30 days before the end), the first sign was a browser warning on the end date.
|
||||
//
|
||||
// The store also holds the PRIVATE keys. This function decodes into a shape that has no key field, parses the
|
||||
// certificate only, and returns nothing but names and dates; it never logs. Shape measured on three boxes on
|
||||
// 2026-11-11 (felhom.eu audits/end-dates-list-2026-11-11/tools/guest-dates.sh): `{"<resolver>":{"Certificates":
|
||||
// [{"domain":{"main":…},"certificate":"<base64 PEM>"}]}}`.
|
||||
//
|
||||
// A missing or EMPTY store is (nil, nil): no certificate yet (a box with no ACME e-mail serves Traefik's own
|
||||
// certificate, which is not in the store). At most webCertMax entries, soonest end first. Pinned by TestReadWebCerts_*.
|
||||
func ReadWebCerts(path string) ([]WebCert, error) {
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("web certificate store: %w", err)
|
||||
}
|
||||
if st.Size() == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if st.Size() > webCertMaxStoreBytes {
|
||||
return nil, fmt.Errorf("web certificate store is %d bytes — not read", st.Size())
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("web certificate store: %w", err)
|
||||
}
|
||||
var store map[string]struct {
|
||||
Certificates []struct {
|
||||
Domain struct {
|
||||
Main string `json:"main"`
|
||||
} `json:"domain"`
|
||||
Certificate string `json:"certificate"`
|
||||
} `json:"Certificates"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &store); err != nil {
|
||||
return nil, fmt.Errorf("web certificate store is not readable JSON") // never the content
|
||||
}
|
||||
var out []WebCert
|
||||
for _, res := range store {
|
||||
for _, c := range res.Certificates {
|
||||
pemBytes, err := base64.StdEncoding.DecodeString(c.Certificate)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
block, _ := pem.Decode(pemBytes) // the first block is the leaf; the rest is its chain
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
name := c.Domain.Main
|
||||
if name == "" {
|
||||
name = cert.Subject.CommonName
|
||||
}
|
||||
if name == "" && len(cert.DNSNames) > 0 {
|
||||
name = cert.DNSNames[0]
|
||||
}
|
||||
out = append(out, WebCert{Domain: name, NotAfter: cert.NotAfter.UTC().Format(time.RFC3339), Issuer: cert.Issuer.CommonName})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].NotAfter != out[j].NotAfter {
|
||||
return out[i].NotAfter < out[j].NotAfter
|
||||
}
|
||||
return out[i].Domain < out[j].Domain
|
||||
})
|
||||
if len(out) > webCertMax {
|
||||
out = out[:webCertMax]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||||
@@ -200,6 +202,8 @@ func BuildReport(
|
||||
r.Offsite = backupMgr.OffboxReportStatus()
|
||||
}
|
||||
|
||||
r.WebCerts = webCertsForReport(cfg.Paths.StacksDir, logger) // R-1379: the web certificate's end date
|
||||
|
||||
if debug && logger != nil {
|
||||
logger.Printf("[DEBUG] [report] BuildReport: complete — containers=%d, health=%s, deployed=%d, available=%d, app_telemetry=%d",
|
||||
r.Containers.Total, r.Health.Status, len(r.Stacks.Deployed), len(r.Stacks.Available), len(r.AppTelemetry))
|
||||
@@ -339,3 +343,19 @@ func rootStorageEntry(sysInfo system.SystemInfo) StorageReport {
|
||||
ReadFailed: !sysInfo.DiskKnown,
|
||||
}
|
||||
}
|
||||
|
||||
// webCertsForReport reads the end dates of the box's web certificates for the report (felhom.eu R-1379). A store that
|
||||
// cannot be read is a WARN line and an absent field — never a failed report, and never the store's content in a log.
|
||||
func webCertsForReport(stacksDir string, logger *log.Logger) []infra.WebCert {
|
||||
if stacksDir == "" {
|
||||
return nil
|
||||
}
|
||||
certs, err := infra.ReadWebCerts(filepath.Join(stacksDir, "traefik", infra.ACMEStoreFile))
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.Printf("[WARN] [report] web certificate end date not read: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return certs
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
@@ -64,6 +65,12 @@ type Report struct {
|
||||
// leg on this box: steps done / undone / held / failed / skipped, each skip with its reason. Absent
|
||||
// until a leg has run since the controller started. Additive: the hub stores the raw report.
|
||||
UpdateLeg *stacks.UpdateLegSummary `json:"update_leg,omitempty"`
|
||||
|
||||
// WebCerts (felhom.eu R-1379, D77) — the end dates of the box's web certificates, read from Traefik's store
|
||||
// (infra.ReadWebCerts: names and dates only, no key material). The hub warns the operator 14 days before an end
|
||||
// date: only the box can know it, and a renewal that fails is silent until the day. Absent when the box has no
|
||||
// certificate in the store. Additive: an older hub stores the raw report and ignores it.
|
||||
WebCerts []infra.WebCert `json:"web_certs,omitempty"`
|
||||
}
|
||||
|
||||
// SystemReport holds host-level system info.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// felhom.eu R-1379 (D77): the report carries the web certificate's end date from the SAME folder Traefik's stack
|
||||
// lives in (`<stacks>/traefik/acme.json`), the field is `web_certs` on the wire, and a store that is broken costs the
|
||||
// field, not the report. (The reader itself, and that it returns no key material: infra TestReadWebCerts_*.)
|
||||
func TestWebCertsForReport_PathFieldAndBrokenStore(t *testing.T) {
|
||||
stacks := t.TempDir()
|
||||
if got := webCertsForReport(stacks, nil); got != nil {
|
||||
t.Fatalf("no store: %+v", got)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(stacks, "traefik"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := filepath.Join(stacks, "traefik", "acme.json")
|
||||
_ = os.WriteFile(store, []byte("{broken"), 0o600)
|
||||
if got := webCertsForReport(stacks, nil); got != nil {
|
||||
t.Fatalf("a broken store: %+v, want nil (and no panic with a nil logger)", got)
|
||||
}
|
||||
_ = os.WriteFile(store, []byte(`{"letsencrypt":{"Certificates":[]}}`), 0o600)
|
||||
r := &Report{WebCerts: webCertsForReport(stacks, nil)}
|
||||
wire, _ := json.Marshal(r)
|
||||
if strings.Contains(string(wire), "web_certs") {
|
||||
t.Fatalf("a box with no certificate must omit the field: %s", wire)
|
||||
}
|
||||
// a real certificate in Traefik's folder is read; one in ANOTHER folder (the stacks folder itself) is not
|
||||
_ = os.WriteFile(store, testACMEStore(t, "*.example.hu", time.Date(2027, 1, 19, 1, 46, 0, 0, time.UTC)), 0o600)
|
||||
_ = os.WriteFile(filepath.Join(stacks, "acme.json"), testACMEStore(t, "decoy.example.hu", time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC)), 0o600)
|
||||
got := webCertsForReport(stacks, nil)
|
||||
if len(got) != 1 || got[0].Domain != "*.example.hu" || got[0].NotAfter != "2027-01-19T01:46:00Z" {
|
||||
t.Fatalf("certificates read for the report = %+v, want the one in <stacks>/traefik/acme.json", got)
|
||||
}
|
||||
wire, _ = json.Marshal(&Report{WebCerts: got})
|
||||
if !strings.Contains(string(wire), `"web_certs":[{"domain":"*.example.hu","not_after":"2027-01-19T01:46:00Z"`) {
|
||||
t.Fatalf("the field on the wire: %s", wire)
|
||||
}
|
||||
// BuildReport is what fills it (a helper nobody calls is the inert-seam defect)
|
||||
f, err := parser.ParseFile(token.NewFileSet(), "builder.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wired := 0
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
as, ok := n.(*ast.AssignStmt)
|
||||
if !ok || len(as.Lhs) != 1 || len(as.Rhs) != 1 {
|
||||
return true
|
||||
}
|
||||
sel, ok := as.Lhs[0].(*ast.SelectorExpr)
|
||||
call, ok2 := as.Rhs[0].(*ast.CallExpr)
|
||||
if ok && ok2 && sel.Sel.Name == "WebCerts" {
|
||||
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "webCertsForReport" {
|
||||
wired++
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if wired != 1 {
|
||||
t.Fatalf("BuildReport assigns r.WebCerts from webCertsForReport %d time(s), want 1", wired)
|
||||
}
|
||||
}
|
||||
|
||||
// testACMEStore is a Traefik store with one certificate (and its key, as the real file has).
|
||||
func testACMEStore(t *testing.T, main string, notAfter time.Time) []byte {
|
||||
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: main}, 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})
|
||||
b, _ := json.Marshal(map[string]any{"letsencrypt": map[string]any{"Certificates": []map[string]any{{
|
||||
"domain": map[string]any{"main": main}, "certificate": base64.StdEncoding.EncodeToString(leaf), "key": "S0VZ"}}}})
|
||||
return b
|
||||
}
|
||||
Reference in new issue
Block a user