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
108 lines
3.7 KiB
Go
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
|
|
}
|