feat(health): add CA cert expiry check

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) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-19 23:20:40 +02:00
co-authored by Claude Opus 4.7
parent 29d611f9c1
commit 8571595aef
3 changed files with 328 additions and 0 deletions
+13
View File
@@ -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
+152
View File
@@ -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)
}
+163
View File
@@ -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)
}
}