From ee3b05edd08b326b39514393215e34100226f051 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 14 Nov 2026 19:09:21 +0100 Subject: [PATCH] controller (unreleased): only certificates of the household's current domain are reported (review) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 3 +++ controller/internal/report/builder.go | 23 ++++++++++++++++--- .../internal/report/web_certs_report_test.go | 19 +++++++++++---- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79493002..6f22866f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ in the test; the key, the account key and the e-mail must not appear in what is reported), `TestWebCertsForReport_PathFieldAndBrokenStore`; 8 mutations seen red (one stayed green at first: the test had no certificate in the folder — a real one and a decoy in the wrong folder were added). **Not proven on a box.** +- **After review (2026-11-14): only certificates of the household's CURRENT domain are reported** (`web_certs`; the + name, `*.`, or a name under it). Traefik's store keeps a certificate for a name the box no longer serves; it + can never renew, and reporting it would warn the operator about a name nobody uses. 2 mutations seen red. - **HSTS is one year (R-1050, felhom.eu `09` §3 decision 249).** `infra.HSTSMaxAgeSeconds` 86400 → 31536000; still no `includeSubDomains`, no `preload` (the test asserts both). The ruling's condition — a month with no https trouble since 2026-10-15 — was searched on 2026-11-14: the open and closed registers (ASCII fragments, with a diff --git a/controller/internal/report/builder.go b/controller/internal/report/builder.go index 7617af47..a690efb9 100644 --- a/controller/internal/report/builder.go +++ b/controller/internal/report/builder.go @@ -7,6 +7,7 @@ import ( "log" "os" "path/filepath" + "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" @@ -202,7 +203,7 @@ func BuildReport( r.Offsite = backupMgr.OffboxReportStatus() } - r.WebCerts = webCertsForReport(cfg.Paths.StacksDir, logger) // R-1379: the web certificate's end date + r.WebCerts = webCertsForReport(cfg.Paths.StacksDir, cfg.Customer.Domain, 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", @@ -346,7 +347,12 @@ func rootStorageEntry(sysInfo system.SystemInfo) StorageReport { // 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 { +// +// Only certificates for the household's CURRENT domain are reported (the name itself, `*.`, or a name under +// it). Traefik's store keeps a certificate for a name the box no longer serves — after a domain change, say — and +// that one can no longer renew: reporting it would warn the operator about a name nobody uses (review, 2026-11-14). +// With no domain configured nothing can be told apart, so everything is reported. +func webCertsForReport(stacksDir, domain string, logger *log.Logger) []infra.WebCert { if stacksDir == "" { return nil } @@ -357,5 +363,16 @@ func webCertsForReport(stacksDir string, logger *log.Logger) []infra.WebCert { } return nil } - return certs + domain = strings.ToLower(strings.TrimSpace(domain)) + if domain == "" { + return certs + } + var mine []infra.WebCert + for _, c := range certs { + name := strings.ToLower(c.Domain) + if name == domain || name == "*."+domain || strings.HasSuffix(name, "."+domain) { + mine = append(mine, c) + } + } + return mine } diff --git a/controller/internal/report/web_certs_report_test.go b/controller/internal/report/web_certs_report_test.go index f4ca0eea..83c1af36 100644 --- a/controller/internal/report/web_certs_report_test.go +++ b/controller/internal/report/web_certs_report_test.go @@ -25,7 +25,7 @@ import ( // 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 { + if got := webCertsForReport(stacks, "", nil); got != nil { t.Fatalf("no store: %+v", got) } if err := os.MkdirAll(filepath.Join(stacks, "traefik"), 0o755); err != nil { @@ -33,11 +33,11 @@ func TestWebCertsForReport_PathFieldAndBrokenStore(t *testing.T) { } store := filepath.Join(stacks, "traefik", "acme.json") _ = os.WriteFile(store, []byte("{broken"), 0o600) - if got := webCertsForReport(stacks, nil); got != nil { + 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)} + 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) @@ -45,7 +45,7 @@ func TestWebCertsForReport_PathFieldAndBrokenStore(t *testing.T) { // 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) + 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 /traefik/acme.json", got) } @@ -53,6 +53,17 @@ func TestWebCertsForReport_PathFieldAndBrokenStore(t *testing.T) { 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) } + // only the household's CURRENT domain: a certificate the store still holds for a name the box no longer serves + // (it can never renew) is not reported — the operator would be warned about a name nobody uses + if got := webCertsForReport(stacks, "Example.hu", nil); len(got) != 1 || got[0].Domain != "*.example.hu" { + t.Fatalf("the household's own domain: %+v", got) + } + if got := webCertsForReport(stacks, "other.hu", nil); len(got) != 0 { + t.Fatalf("a certificate for ANOTHER domain was reported: %+v", got) + } + if got := webCertsForReport(stacks, "ample.hu", nil); len(got) != 0 { + t.Fatalf("a domain that only ENDS the same way matched: %+v", got) + } // 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 {