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) + } +}