Files
felhom-controller/controller/internal/infra/webcert.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

108 lines
3.7 KiB
Go

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
}