diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index ef3e427..e6366e1 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -3,12 +3,15 @@ package handlers import ( "bytes" "context" + "crypto/x509" + "encoding/pem" "errors" "fmt" "log" "net" "net/http" "net/url" + "os" "strconv" "strings" "sync" @@ -67,6 +70,10 @@ type Server struct { healthRegistry *health.Registry logBuf *logbuf.Buffer expectedHosts []string + ownCACache struct { + once sync.Once + cert *x509.Certificate + } } // RequestSnapshot represents an immutable snapshot of an HTTP request. @@ -109,10 +116,14 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red health.RegisterSpeakerInfoReachable(s.healthRegistry, ds) health.RegisterSourcesXMLDiff(s.healthRegistry, ds) health.RegisterSpeakerMargeURLCheck(s.healthRegistry, ds, s.ExpectedHosts) - health.RegisterCertChainCheck(s.healthRegistry, func() string { - _, httpsURL := s.GetSettings() - return httpsURL - }) + health.RegisterCertChainCheck( + s.healthRegistry, + func() string { + _, httpsURL := s.GetSettings() + return httpsURL + }, + s.loadOwnCACert, + ) health.RegisterTestPlaybackCheck(s.healthRegistry, ds, func() string { serverURL, _ := s.GetSettings() return serverURL @@ -163,6 +174,49 @@ func (s *Server) ExpectedHosts() []string { return out } +// 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 +// service's built-in CA (as opposed to a public CA or a foreign +// chain from a reverse proxy). Returns nil when the CA isn't +// configured or fails to parse — the caller falls back to a +// Subject==Issuer heuristic in that case. +// +// The parse is cached in ownCACache so repeated Health polls +// don't re-read the PEM. Restart-based config changes are +// picked up because Server itself is reconstructed. +func (s *Server) loadOwnCACert() *x509.Certificate { + s.ownCACache.once.Do(func() { + if s.sm == nil || s.sm.Crypto == nil { + return + } + + path := s.sm.Crypto.GetCACertPath() + if path == "" { + return + } + + data, err := os.ReadFile(path) + if err != nil { + return + } + + block, _ := pem.Decode(data) + if block == nil { + return + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return + } + + s.ownCACache.cert = cert + }) + + return s.ownCACache.cert +} + // TrustedRealIPMiddleware returns a chi middleware that rewrites // r.RemoteAddr from X-Real-IP / X-Forwarded-For / True-Client-IP, but only // when the immediate TCP peer is in the configured trusted-proxy list. diff --git a/pkg/service/health/checks_cert_chain.go b/pkg/service/health/checks_cert_chain.go index 0727f7b..274a87d 100644 --- a/pkg/service/health/checks_cert_chain.go +++ b/pkg/service/health/checks_cert_chain.go @@ -20,25 +20,31 @@ const CheckIDCertChain = "service_cert_chain" // - validates against system roots → no finding (the // speaker's firmware ships with the major roots, so a public- // CA chain such as Let's Encrypt is usable directly). -// - chain doesn't validate → warning, with a note that -// `install-ca` is the fix when AfterTouch is using its own -// self-signed CA, or "review the proxy / ingress cert" when -// the chain looks foreign. +// - chain doesn't validate but the served leaf was issued by +// our own AfterTouch CA → warning with an `install-ca` +// suggestion (definitive: we checked the signature against +// our CA, not a Subject==Issuer heuristic). +// - chain doesn't validate and the served leaf was issued by +// something else → warning with an `openssl s_client` +// investigation prompt (foreign chain / reverse proxy / +// ingress cert). // - HTTPS URL not configured → skip silently. // -// httpsURLFn is a closure so config changes are picked up at run -// time (today only at restart, but cheap to keep flexible). -func RegisterCertChainCheck(r *Registry, httpsURLFn func() string) { +// caCertFn returns AfterTouch's own CA leaf certificate (nil if +// unavailable). It's called per check run; the handler-side +// implementation caches the parse via sync.Once so we don't +// re-read the PEM on every poll. +func RegisterCertChainCheck(r *Registry, httpsURLFn func() string, caCertFn func() *x509.Certificate) { r.Register(Check{ ID: CheckIDCertChain, Title: "HTTPS endpoint certificate validates", Run: func() []Finding { - return runCertChainCheck(httpsURLFn()) + return runCertChainCheck(httpsURLFn(), caCertFn) }, }) } -func runCertChainCheck(httpsURL string) []Finding { +func runCertChainCheck(httpsURL string, caCertFn func() *x509.Certificate) []Finding { if strings.TrimSpace(httpsURL) == "" { return nil } @@ -108,17 +114,25 @@ func runCertChainCheck(httpsURL string) []Finding { var hints []ManualCommand - if leafLooksSelfSigned(leaf) { + classification := classifyLeaf(leaf, caCertFn) + switch classification { + case leafFromOwnCA: hints = append(hints, ManualCommand{ - Label: "If this is AfterTouch's built-in CA, install it on each speaker:", + Label: "Install AfterTouch's CA on each speaker:", Command: "soundtouch-cli --host= setup install-ca --service-url=" + httpsURL, - Hint: "Requires SSH on the speaker. After install, re-run this check.", + Hint: "The served leaf was issued by AfterTouch's own CA (verified by signature). Requires SSH on the speaker. After install, re-run this check.", }) - } else { + case leafSubjectEqualsIssuer: + hints = append(hints, ManualCommand{ + Label: "If this is a self-signed cert from AfterTouch, install its CA on each speaker:", + Command: "soundtouch-cli --host= setup install-ca --service-url=" + httpsURL, + Hint: "Heuristic match (Subject == Issuer) — AfterTouch's own CA wasn't loadable, so this is a best guess. If wrong, treat the chain as foreign.", + }) + default: hints = append(hints, ManualCommand{ Label: "Investigate the chain manually:", Command: fmt.Sprintf("openssl s_client -connect %s -servername %s -showcerts