diff --git a/CHANGELOG.md b/CHANGELOG.md index 31faea56..79493002 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## Unreleased (2026-11-14 night) — built and NOT released: a clock step is not a new boot (felhom.eu R-1395); HSTS one year (R-1050) +## Unreleased (2026-11-14 night) — built and NOT released: a clock step is not a new boot (felhom.eu R-1395); HSTS one year (R-1050); the web certificate's end date in the report (R-1379) **MinAgent: 0.158.1** (unchanged — the new field is optional; without it the controller behaves as before). @@ -14,6 +14,16 @@ byte. Tests `TestGuestBootVerdict_AClockStepIsNotANewBoot` (14 cases), `TestGuestBootVerdict_IsWhatTheBootPassReads`; 8 mutations seen red (felhom.eu `documentation/audits/night-build-2026-11-14/C/redproof-controller-boot-key.txt`). **Not proven on a box.** With an older agent a clock step still recreates the apps once. +- **The report carries the web certificate's end date (felhom.eu R-1379, D77, `09` §3 decision 298; `08` §12).** Only + the box knows it: the certificate lives in Traefik's store in the guest (`/traefik/acme.json`), and nothing + read it — a renewal that failed was silent until the browser warning on the end date. `infra.ReadWebCerts` decodes + the store into a shape with NO key field, parses the certificate only, and returns names, end dates and the issuer's + name (at most 8, soonest first); the report's `web_certs` carries them. A missing or empty store is no field; a + broken store is a WARN line that does not quote the file, and the report still goes. The hub's warning (14 days + before) is built in its own repository, not released. Tests `TestReadWebCerts_*` (real certificates and keys made + 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.** - **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/infra/webcert.go b/controller/internal/infra/webcert.go new file mode 100644 index 00000000..7b5935ca --- /dev/null +++ b/controller/internal/infra/webcert.go @@ -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): `{"":{"Certificates": +// [{"domain":{"main":…},"certificate":""}]}}`. +// +// 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 +} diff --git a/controller/internal/infra/webcert_test.go b/controller/internal/infra/webcert_test.go new file mode 100644 index 00000000..6e8d36df --- /dev/null +++ b/controller/internal/infra/webcert_test.go @@ -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]) + } +} diff --git a/controller/internal/report/builder.go b/controller/internal/report/builder.go index cfc0756d..7617af47 100644 --- a/controller/internal/report/builder.go +++ b/controller/internal/report/builder.go @@ -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 +} diff --git a/controller/internal/report/types.go b/controller/internal/report/types.go index 5ad53917..410bbdc5 100644 --- a/controller/internal/report/types.go +++ b/controller/internal/report/types.go @@ -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. diff --git a/controller/internal/report/web_certs_report_test.go b/controller/internal/report/web_certs_report_test.go new file mode 100644 index 00000000..f4ca0eea --- /dev/null +++ b/controller/internal/report/web_certs_report_test.go @@ -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 (`/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 /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 +}