diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index 3f7d3b4..9d29ae7 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -109,6 +109,10 @@ 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 + }) return s } diff --git a/pkg/service/health/checks_cert_chain.go b/pkg/service/health/checks_cert_chain.go new file mode 100644 index 0000000..0727f7b --- /dev/null +++ b/pkg/service/health/checks_cert_chain.go @@ -0,0 +1,159 @@ +package health + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net" + "net/url" + "strings" + "time" +) + +// CheckIDCertChain is the registry id of the cert-chain probe. +const CheckIDCertChain = "service_cert_chain" + +// RegisterCertChainCheck registers a check that dials the +// configured HTTPS endpoint and reports whether its certificate +// chain validates against the system trust store. Three outcomes: +// +// - 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. +// - 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) { + r.Register(Check{ + ID: CheckIDCertChain, + Title: "HTTPS endpoint certificate validates", + Run: func() []Finding { + return runCertChainCheck(httpsURLFn()) + }, + }) +} + +func runCertChainCheck(httpsURL string) []Finding { + if strings.TrimSpace(httpsURL) == "" { + return nil + } + + host, port := splitHTTPSHostPort(httpsURL) + if host == "" { + return []Finding{{ + Severity: SeverityWarning, + Message: fmt.Sprintf("Configured HTTPS URL %q is not parseable.", httpsURL), + }} + } + + addr := net.JoinHostPort(host, port) + + dialer := &net.Dialer{Timeout: 2 * time.Second} + + // Phase 1: try with the system trust store. ServerName is set + // from the URL so the verifier checks SAN coverage too. + conn, err := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{ + ServerName: host, + MinVersion: tls.VersionTLS12, + }) + if err == nil { + _ = conn.Close() + return nil // validates against system roots + } + + // Phase 2: re-dial with InsecureSkipVerify so we can read the + // chain and report what was actually served. + insecureConn, insecureErr := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{ + ServerName: host, + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS12, + }) + if insecureErr != nil { + return []Finding{{ + Severity: SeverityError, + Message: fmt.Sprintf("Could not connect to %s: %v", addr, insecureErr), + Details: "AfterTouch's HTTPS endpoint isn't reachable from inside the service. Check that the listener is bound and the URL host:port resolves correctly.", + }} + } + defer func() { _ = insecureConn.Close() }() + + peers := insecureConn.ConnectionState().PeerCertificates + if len(peers) == 0 { + return []Finding{{ + Severity: SeverityWarning, + Message: "HTTPS endpoint connected but presented no certificates.", + }} + } + + leaf := peers[0] + + subject := leaf.Subject.String() + issuer := leaf.Issuer.String() + notAfter := leaf.NotAfter.Format("2006-01-02") + + dnsNames := strings.Join(leaf.DNSNames, ", ") + if dnsNames == "" { + dnsNames = "(none)" + } + + details := fmt.Sprintf( + "Verification error: %v. Leaf subject: %s. Issuer: %s. SANs: %s. Expires: %s.", + err, subject, issuer, dnsNames, notAfter, + ) + + var hints []ManualCommand + + if leafLooksSelfSigned(leaf) { + hints = append(hints, ManualCommand{ + Label: "If this is AfterTouch's built-in CA, install it 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.", + }) + } else { + hints = append(hints, ManualCommand{ + Label: "Investigate the chain manually:", + Command: fmt.Sprintf("openssl s_client -connect %s -servername %s -showcerts