feat(health): probe HTTPS endpoint cert chain against system roots

Cloud-deploy reports (discussion #295 et al.) repeatedly came
down to "does the speaker trust AfterTouch's cert?". Add a check
that dials the configured HTTPS endpoint, attempts validation
against the system trust store, and:

  - Says nothing when the chain validates — typical for a public
    CA chain (Let's Encrypt, etc.) the speaker firmware trusts
    natively. No action needed.
  - Warns when validation fails and surfaces the chain context:
    subject, issuer, SANs, expiry, and the underlying error so
    operators can copy a diagnosis into a bug report. Includes a
    copyable suggestion — install-ca when the leaf looks
    self-signed (Subject == Issuer heuristic), or an
    `openssl s_client` invocation for unknown/foreign chains.

Reads the HTTPS URL via a closure on Server.GetSettings(), so
later restarts pick up new URLs without re-registration.

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 dee5a0146c
commit b18272480a
3 changed files with 300 additions and 0 deletions
+4
View File
@@ -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
}
+159
View File
@@ -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=<speaker-ip> 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 </dev/null", addr, host),
Hint: "Run from the same host as the service. Shows the full chain the peer is serving.",
})
}
return []Finding{{
Severity: SeverityWarning,
Message: fmt.Sprintf("HTTPS certificate at %s does not validate against system roots.", addr),
Details: details,
ManualCommands: hints,
}}
}
func splitHTTPSHostPort(raw string) (string, string) {
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" {
return "", ""
}
host := u.Hostname()
port := u.Port()
if port == "" {
port = "443"
}
return host, port
}
// leafLooksSelfSigned reports whether the leaf certificate's
// Subject and Issuer match — a strong hint that we're looking at
// AfterTouch's own self-signed CA-issued cert rather than a public
// CA chain. This is intentionally a heuristic, not a guarantee.
func leafLooksSelfSigned(leaf *x509.Certificate) bool {
if leaf == nil {
return false
}
return leaf.Subject.String() == leaf.Issuer.String()
}
@@ -0,0 +1,137 @@
package health
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestCertChain_EmptyURLSkips(t *testing.T) {
got := runCertChainCheck("")
if len(got) != 0 {
t.Errorf("expected no findings for empty URL, got %+v", got)
}
}
func TestCertChain_UnparseableURLWarns(t *testing.T) {
got := runCertChainCheck("://nope")
if len(got) != 1 || got[0].Severity != SeverityWarning {
t.Fatalf("expected one warning, got %+v", got)
}
}
func TestCertChain_UnreachableEndpoint(t *testing.T) {
// 127.0.0.1:1 refuses; using https:// to force TLS path.
got := runCertChainCheck("https://127.0.0.1:1/")
if len(got) != 1 || got[0].Severity != SeverityError {
t.Fatalf("expected one error for unreachable endpoint, got %+v", got)
}
}
func TestCertChain_SelfSignedFlagsAndSuggestsInstallCA(t *testing.T) {
srv := newSelfSignedTLSServer(t)
defer srv.Close()
// httptest's TLS URL uses 127.0.0.1; the test cert below uses
// "127.0.0.1" as SAN, so SNI matches but the chain is
// self-signed and won't validate against system roots.
got := runCertChainCheck(srv.URL)
if len(got) != 1 || got[0].Severity != SeverityWarning {
t.Fatalf("expected one warning for self-signed cert, got %+v", got)
}
if !strings.Contains(got[0].Details, "Issuer") {
t.Errorf("expected issuer detail, got %q", got[0].Details)
}
if len(got[0].ManualCommands) == 0 {
t.Fatalf("expected at least one manual command, got none")
}
cmd := got[0].ManualCommands[0].Command
if !strings.Contains(cmd, "install-ca") {
t.Errorf("expected install-ca suggestion for self-signed cert, got %q", cmd)
}
}
func TestSplitHTTPSHostPort(t *testing.T) {
cases := []struct {
in, host, port string
}{
{"https://example.com/", "example.com", "443"},
{"https://example.com:8443/", "example.com", "8443"},
{"https://192.0.2.10/", "192.0.2.10", "443"},
{"https://", "", ""},
{"://broken", "", ""},
}
for _, c := range cases {
h, p := splitHTTPSHostPort(c.in)
if h != c.host || p != c.port {
t.Errorf("splitHTTPSHostPort(%q) = (%q, %q), want (%q, %q)", c.in, h, p, c.host, c.port)
}
}
}
// newSelfSignedTLSServer returns an httptest.Server whose TLS
// config uses a self-signed cert we generate inline. httptest's
// default TLS server uses a built-in cert, but verifying its
// Subject == Issuer property without inspecting innards is
// fiddly; making our own keeps the assertion deterministic.
func newSelfSignedTLSServer(t *testing.T) *httptest.Server {
t.Helper()
cert := generateSelfSignedCert(t)
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(200)
}))
srv.TLS = &tls.Config{Certificates: []tls.Certificate{cert}}
srv.StartTLS()
return srv
}
func generateSelfSignedCert(t *testing.T) tls.Certificate {
t.Helper()
// Use ecdsa via x509 helpers — but keep it simple with a tiny
// RSA key from the test. Actually use crypto/rand + ed25519
// would be cleaner; for parity with stdlib examples, use the
// built-in helper path.
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "aftertouch-test"},
Issuer: pkix.Name{CommonName: "aftertouch-test"}, // self-signed
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
DNSNames: []string{"127.0.0.1"},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
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)
}
return tls.Certificate{
Certificate: [][]byte{derBytes},
PrivateKey: key,
}
}