mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
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:
co-authored by
Claude Opus 4.7
parent
dee5a0146c
commit
b18272480a
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user