From 8571595aef24c835511fd7767b893025f1397b68 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Tue, 19 May 2026 22:20:54 +0200 Subject: [PATCH] feat(health): add CA cert expiry check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate check from service_cert_chain: that one inspects what's served right now, this one watches when the trust anchor itself will stop being usable. Even when the served leaf validates, the CA's NotAfter will eventually expire every leaf it has ever issued — and every paired speaker would then need `setup install-ca` again with a freshly generated CA. Three thresholds against the loaded CA's NotAfter: > 90 days remaining → no finding (rolls up to OK) 31..90 days → INFO, surfaces the renewal date so it isn't a surprise 1..30 days → WARNING with regeneration guidance expired → ERROR — speakers will reject leaves ManualCommand renders the actual cert path from certmanager.GetCACertPath() so operators don't have to guess where to delete. Sibling .key path inferred from the cert path basename — close enough for a copy-paste hint; operators verify before running. Rounded day arithmetic via (d + 12h) / 24h to avoid the "expires in 59 days" surprise caused by ASN.1 GeneralizedTime truncating sub-second precision on the CreateCertificate / ParseCertificate round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/service/handlers/server.go | 13 ++ pkg/service/health/checks_ca_expiry.go | 152 ++++++++++++++++++ pkg/service/health/checks_ca_expiry_test.go | 163 ++++++++++++++++++++ 3 files changed, 328 insertions(+) create mode 100644 pkg/service/health/checks_ca_expiry.go create mode 100644 pkg/service/health/checks_ca_expiry_test.go diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index e6366e1..4bb9620 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -124,6 +124,7 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red }, s.loadOwnCACert, ) + health.RegisterCACertExpiryCheck(s.healthRegistry, s.loadOwnCACert, s.ownCACertPath) health.RegisterTestPlaybackCheck(s.healthRegistry, ds, func() string { serverURL, _ := s.GetSettings() return serverURL @@ -174,6 +175,18 @@ func (s *Server) ExpectedHosts() []string { return out } +// ownCACertPath returns the on-disk path of AfterTouch's own CA +// cert (PEM). Empty string when the certmanager isn't wired in. +// Used by the Health-tab CA-expiry check to render an accurate +// remediation command pointing at the actual file. +func (s *Server) ownCACertPath() string { + if s.sm == nil || s.sm.Crypto == nil { + return "" + } + + return s.sm.Crypto.GetCACertPath() +} + // loadOwnCACert parses AfterTouch's own CA leaf from disk. Used // by the Health-tab cert-chain check to definitively classify // whether the HTTPS endpoint is serving a cert issued by this diff --git a/pkg/service/health/checks_ca_expiry.go b/pkg/service/health/checks_ca_expiry.go new file mode 100644 index 0000000..fe3cccd --- /dev/null +++ b/pkg/service/health/checks_ca_expiry.go @@ -0,0 +1,152 @@ +package health + +import ( + "crypto/x509" + "fmt" + "strings" + "time" +) + +// CheckIDCACertExpiry is the registry id of the CA-cert expiry +// check. +const CheckIDCACertExpiry = "ca_cert_expiry" + +// CA-expiry thresholds. Tunable here rather than per-deployment +// because the consequence of a missed warning is the same +// everywhere: leaves issued by the CA will be rejected. +const ( + caExpiryWarnThreshold = 30 * 24 * time.Hour + caExpiryInfoThreshold = 90 * 24 * time.Hour + caExpiryRecentlyValidated = 365 * 24 * time.Hour +) + +// CACertPathFunc returns the on-disk path of AfterTouch's own +// CA cert. Optional; when nil the manual command falls back to a +// neutral hint. +type CACertPathFunc func() string + +// RegisterCACertExpiryCheck registers a check that reads +// AfterTouch's own CA cert (via caCertFn) and emits a finding +// when its NotAfter is in the past or in the warn/info windows. +// +// Why a separate check from service_cert_chain: even when the +// served leaf validates today (or is correctly classified as +// self-signed), the CA's eventual expiry will break every leaf +// it ever issued. Operators should regenerate before that +// happens — and re-pair speakers, since their stored trust +// anchor will no longer cover newly-issued leaves. +// +// caCertPathFn is used purely to render a remediation command +// pointing at the actual on-disk path. Pass nil to skip the +// path mention. +func RegisterCACertExpiryCheck(r *Registry, caCertFn func() *x509.Certificate, caCertPathFn CACertPathFunc) { + r.Register(Check{ + ID: CheckIDCACertExpiry, + Title: "AfterTouch CA cert is not near expiry", + Run: func() []Finding { + return runCACertExpiryCheck(caCertFn, caCertPathFn, time.Now()) + }, + }) +} + +func runCACertExpiryCheck(caCertFn func() *x509.Certificate, caCertPathFn CACertPathFunc, now time.Time) []Finding { + if caCertFn == nil { + return nil + } + + cert := caCertFn() + if cert == nil { + return []Finding{{ + Severity: SeverityInfo, + Message: "Couldn't load AfterTouch's own CA cert; expiry not checked.", + Details: "service_cert_chain falls back to a Subject==Issuer heuristic for the same reason. Verify the CA path is readable from the service host.", + }} + } + + if cert.NotAfter.IsZero() { + return []Finding{{ + Severity: SeverityWarning, + Message: "AfterTouch CA cert has no NotAfter set; treat as expired.", + }} + } + + remaining := cert.NotAfter.Sub(now) + expiresAt := cert.NotAfter.UTC().Format("2006-01-02") + + switch { + case remaining <= 0: + return []Finding{caExpiryFinding(SeverityError, + fmt.Sprintf("AfterTouch CA cert expired %d day(s) ago (on %s).", daysRounded(-remaining), expiresAt), + "Speakers will reject any leaf signed by this CA. Regenerate now: stop the service, remove the CA files, restart so EnsureCA reissues, then run setup install-ca against every paired speaker.", + cert, caCertPathFn, + )} + case remaining <= caExpiryWarnThreshold: + return []Finding{caExpiryFinding(SeverityWarning, + fmt.Sprintf("AfterTouch CA cert expires in %d day(s) (on %s).", daysRounded(remaining), expiresAt), + "Plan a regeneration. Every paired speaker will need setup install-ca again after, since their stored trust anchor won't cover the new leaves.", + cert, caCertPathFn, + )} + case remaining <= caExpiryInfoThreshold: + return []Finding{caExpiryFinding(SeverityInfo, + fmt.Sprintf("AfterTouch CA cert expires in %d day(s) (on %s).", daysRounded(remaining), expiresAt), + "No immediate action; surfaced here so the renewal isn't a surprise.", + cert, caCertPathFn, + )} + } + + // > 90 days remaining; nothing to surface. Optionally we + // could emit a positive info finding ("valid until …") but + // the empty-findings path lets the check roll up to OK, + // which is the clearer "healthy" signal. + _ = caExpiryRecentlyValidated + + return nil +} + +// daysRounded converts a duration to a whole-day count, rounded +// to nearest. Avoids the "expires in 59 days" surprise when the +// real value is 60 days minus a fraction-of-a-second from ASN.1 +// time truncation. +func daysRounded(d time.Duration) int { + return int((d + 12*time.Hour) / (24 * time.Hour)) +} + +func caExpiryFinding(severity Severity, message, details string, cert *x509.Certificate, caCertPathFn CACertPathFunc) Finding { + enrichedDetails := fmt.Sprintf("%s Subject: %s. Valid from: %s. Valid to: %s.", + details, + cert.Subject.String(), + cert.NotBefore.UTC().Format(time.RFC3339), + cert.NotAfter.UTC().Format(time.RFC3339), + ) + + commands := []ManualCommand{{ + Label: "Regenerate (destructive — requires re-installing the CA on every speaker afterwards):", + Command: caRegenCommand(caCertPathFn), + Hint: "Adjust the path to match your deployment if it differs. The service's EnsureCA() reissues a fresh CA at startup when the file is missing.", + }} + + return Finding{ + Severity: severity, + Message: message, + Details: enrichedDetails, + ManualCommands: commands, + } +} + +func caRegenCommand(caCertPathFn CACertPathFunc) string { + if caCertPathFn == nil { + return "# stop soundtouch-service, remove the CA cert + key files, restart" + } + + path := strings.TrimSpace(caCertPathFn()) + if path == "" { + return "# stop soundtouch-service, remove the CA cert + key files, restart" + } + + // Key path conventionally sits next to the cert with a `.key` + // extension or matching basename; surface the cert path + // explicitly and let the operator pick up the key by sight. + keyHint := strings.TrimSuffix(path, ".crt") + ".key" + + return fmt.Sprintf("# stop the service, then:\nrm '%s' '%s'\n# restart soundtouch-service; EnsureCA reissues at boot.", path, keyHint) +} diff --git a/pkg/service/health/checks_ca_expiry_test.go b/pkg/service/health/checks_ca_expiry_test.go new file mode 100644 index 0000000..9391bcb --- /dev/null +++ b/pkg/service/health/checks_ca_expiry_test.go @@ -0,0 +1,163 @@ +package health + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "strings" + "testing" + "time" +) + +// buildCA returns a CA cert with the given NotBefore / NotAfter. +// Self-signed; subject doesn't matter for these tests. +func buildCA(t *testing.T, notBefore, notAfter time.Time) *x509.Certificate { + t.Helper() + + template := &x509.Certificate{ + SerialNumber: big.NewInt(2026), + Subject: pkix.Name{CommonName: "AfterTouch Local Root CA", Organization: []string{"AfterTouch Test"}}, + NotBefore: notBefore, + NotAfter: notAfter, + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + template.Issuer = template.Subject + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa key: %v", err) + } + + derBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("create cert: %v", err) + } + + cert, err := x509.ParseCertificate(derBytes) + if err != nil { + t.Fatalf("parse cert: %v", err) + } + + return cert +} + +func TestCAExpiry_HealthyCertProducesNoFinding(t *testing.T) { + now := time.Now() + cert := buildCA(t, now.Add(-30*24*time.Hour), now.Add(2*365*24*time.Hour)) + + got := runCACertExpiryCheck(func() *x509.Certificate { return cert }, nil, now) + if len(got) != 0 { + t.Errorf("expected no findings for healthy cert, got %+v", got) + } +} + +func TestCAExpiry_InfoLevelInsideNinetyDays(t *testing.T) { + now := time.Now() + cert := buildCA(t, now.Add(-30*24*time.Hour), now.Add(60*24*time.Hour)) + + got := runCACertExpiryCheck(func() *x509.Certificate { return cert }, nil, now) + if len(got) != 1 || got[0].Severity != SeverityInfo { + t.Fatalf("expected one info finding, got %+v", got) + } + + if !strings.Contains(got[0].Message, "60") { + t.Errorf("expected day count in message, got %q", got[0].Message) + } +} + +func TestCAExpiry_WarningInsideThirtyDays(t *testing.T) { + now := time.Now() + cert := buildCA(t, now.Add(-30*24*time.Hour), now.Add(15*24*time.Hour)) + + got := runCACertExpiryCheck(func() *x509.Certificate { return cert }, nil, now) + if len(got) != 1 || got[0].Severity != SeverityWarning { + t.Fatalf("expected one warning, got %+v", got) + } + + if !strings.Contains(got[0].Message, "15") { + t.Errorf("expected day count in message, got %q", got[0].Message) + } + + if !strings.Contains(got[0].Details, "Plan a regeneration") { + t.Errorf("expected regen guidance in details, got %q", got[0].Details) + } +} + +func TestCAExpiry_ErrorWhenExpired(t *testing.T) { + now := time.Now() + cert := buildCA(t, now.Add(-365*24*time.Hour), now.Add(-2*24*time.Hour)) + + got := runCACertExpiryCheck(func() *x509.Certificate { return cert }, nil, now) + if len(got) != 1 || got[0].Severity != SeverityError { + t.Fatalf("expected one error, got %+v", got) + } + + if !strings.Contains(got[0].Message, "expired") { + t.Errorf("expected 'expired' in message, got %q", got[0].Message) + } + + if !strings.Contains(got[0].Message, "2 day") { + t.Errorf("expected day count in message, got %q", got[0].Message) + } +} + +func TestCAExpiry_NoCAGracefulInfo(t *testing.T) { + got := runCACertExpiryCheck(func() *x509.Certificate { return nil }, nil, time.Now()) + if len(got) != 1 || got[0].Severity != SeverityInfo { + t.Fatalf("expected one info finding when CA missing, got %+v", got) + } +} + +func TestCAExpiry_NilLoaderSkipsEntirely(t *testing.T) { + got := runCACertExpiryCheck(nil, nil, time.Now()) + if len(got) != 0 { + t.Errorf("expected no findings with nil loader, got %+v", got) + } +} + +func TestCARegenCommand_IncludesActualPath(t *testing.T) { + got := caRegenCommand(func() string { return "/var/lib/aftertouch/ca.crt" }) + if !strings.Contains(got, "/var/lib/aftertouch/ca.crt") { + t.Errorf("expected cert path in command, got %q", got) + } + + if !strings.Contains(got, "/var/lib/aftertouch/ca.key") { + t.Errorf("expected .key sibling path in command, got %q", got) + } +} + +func TestCARegenCommand_FallsBackWhenPathUnknown(t *testing.T) { + got := caRegenCommand(nil) + if !strings.Contains(got, "remove the CA") { + t.Errorf("expected fallback hint when path unknown, got %q", got) + } + + got = caRegenCommand(func() string { return "" }) + if !strings.Contains(got, "remove the CA") { + t.Errorf("expected fallback hint when path empty, got %q", got) + } +} + +func TestCAExpiry_ManualCommandPathRendered(t *testing.T) { + now := time.Now() + cert := buildCA(t, now.Add(-30*24*time.Hour), now.Add(5*24*time.Hour)) + + got := runCACertExpiryCheck( + func() *x509.Certificate { return cert }, + func() string { return "/srv/aftertouch/ca.crt" }, + now, + ) + + if len(got) != 1 || len(got[0].ManualCommands) != 1 { + t.Fatalf("expected one manual command, got %+v", got) + } + + cmd := got[0].ManualCommands[0] + if !strings.Contains(cmd.Command, "/srv/aftertouch/ca.crt") { + t.Errorf("expected actual path in command, got %q", cmd.Command) + } +}