From 5edab77209782200e86378a0af66376a21591895 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Tue, 24 Feb 2026 21:32:48 +0100 Subject: [PATCH] feat: add comprehensive TLS certificate SAN support with wildcard domains - Add RFC-compliant wildcard certificates (*.api.bose.io, *.api.bosecm.com) for automatic API coverage - Include additional Bose production domains (worldwide.bose.com, music.api.bose.com, bose-prod.apigee.net) - Implement TLS certificate request logging and wildcard domain matching logic - Add detailed TLS handshake debugging with connection state tracking - Wrap TLS listener with logging to capture certificate selection and handshake failures - Update documentation with wildcard certificate coverage and debugging features - Normalize test data to use consistent local IP addresses This enables automatic coverage of all current and future Bose API subdomains while providing comprehensive TLS debugging for DNS redirection troubleshooting. --- cmd/soundtouch-service/main.go | 146 ++++++++++++++++++-- docs/guides/HTTPS-SETUP.md | 11 +- pkg/service/certmanager/certmanager_test.go | 4 +- pkg/service/proxy/recorder_test.go | 10 +- 4 files changed, 152 insertions(+), 19 deletions(-) diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 7d68041..f931fa1 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "log" + "net" "net/http" "net/url" "os" @@ -493,15 +494,23 @@ func loadConfig(c *cli.Context) serviceConfig { func getDomains(serverURL, httpsServerURL, hostname string) []string { domainsMap := map[string]bool{ - "streaming.bose.com": true, - "updates.bose.com": true, - "stats.bose.com": true, - "bmx.bose.com": true, - "content.api.bose.io": true, - setup.TestDomain: true, - hostname: true, - "localhost": true, - "127.0.0.1": true, + // RFC-compliant wildcards for API patterns + "*.api.bose.io": true, + "*.api.bosecm.com": true, + // Core Bose domains (keep specific ones for clarity) + "streaming.bose.com": true, + "updates.bose.com": true, + "stats.bose.com": true, + "bmx.bose.com": true, + "worldwide.bose.com": true, + "music.api.bose.com": true, + "bose-prod.apigee.net": true, + "bose-test.apigee.net": true, + // Local service domains + setup.TestDomain: true, + hostname: true, + "localhost": true, + "127.0.0.1": true, } if u, err := url.Parse(serverURL); err == nil && u.Hostname() != "" { @@ -789,17 +798,134 @@ func setupRouter(server *handlers.Server) *chi.Mux { } func startHTTPSServer(httpsAddr string, r http.Handler, tlsConfig *tls.Config, httpsServerURL string) { + // Add custom error logging and connection state tracking + tlsConfig.GetCertificate = func(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) { + log.Printf("[TLS] Certificate request for ServerName: %s", clientHello.ServerName) + + // Use the default certificate selection logic + for _, cert := range tlsConfig.Certificates { + if cert.Leaf != nil { + for _, name := range cert.Leaf.DNSNames { + if matchesDomain(name, clientHello.ServerName) { + log.Printf("[TLS] ✅ Serving certificate for %s (matched %s)", clientHello.ServerName, name) + return &cert, nil + } + } + } + } + + // If no specific match, return the first certificate and log it + if len(tlsConfig.Certificates) > 0 { + log.Printf("[TLS] ⚠️ No exact match for %s, using default certificate", clientHello.ServerName) + return &tlsConfig.Certificates[0], nil + } + + log.Printf("[TLS] ❌ No certificate available for %s", clientHello.ServerName) + + return nil, fmt.Errorf("no certificate available for %s", clientHello.ServerName) + } + httpsServer := &http.Server{ Addr: httpsAddr, Handler: r, TLSConfig: tlsConfig, + ErrorLog: log.Default(), // Ensure error logging is enabled } log.Printf("Go service starting HTTPS on %s", httpsServerURL) go func() { - if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { + listener, err := net.Listen("tcp", httpsAddr) + if err != nil { + log.Printf("[TLS] Failed to create listener: %v", err) + return + } + + tlsListener := tls.NewListener(listener, tlsConfig) + + // Wrap listener to log connection attempts + wrappedListener := &loggingTLSListener{ + Listener: tlsListener, + } + + if err := httpsServer.Serve(wrappedListener); err != nil && err != http.ErrServerClosed { log.Printf("HTTPS server error: %v", err) } }() } + +// matchesDomain checks if a certificate domain (which may be a wildcard) matches a server name +func matchesDomain(certDomain, serverName string) bool { + if certDomain == serverName { + return true + } + + // Handle wildcard certificates (only at the beginning of a label) + if strings.HasPrefix(certDomain, "*.") { + certBase := certDomain[2:] // Remove "*." + + // For *.api.bose.io to match events.api.bose.io but not test.content.api.bose.io + // We need to ensure only one label is replaced by the wildcard + if strings.HasSuffix(serverName, "."+certBase) { + // Count dots to ensure we're not matching too many levels + serverPrefix := strings.TrimSuffix(serverName, "."+certBase) + if !strings.Contains(serverPrefix, ".") { + return true + } + } + + // Also match the base domain (e.g., api.bose.io matches *.api.bose.io) + if serverName == certBase { + return true + } + } + + return false +} + +// loggingTLSListener wraps a TLS listener to log connection attempts and handshake failures +type loggingTLSListener struct { + net.Listener +} + +func (l *loggingTLSListener) Accept() (net.Conn, error) { + conn, err := l.Listener.Accept() + if err != nil { + return nil, err + } + + // Wrap the connection to log TLS handshake results + return &loggingTLSConn{ + Conn: conn, + addr: conn.RemoteAddr(), + }, nil +} + +// loggingTLSConn wraps a TLS connection to log handshake failures +type loggingTLSConn struct { + net.Conn + addr net.Addr + handshakeLogged bool +} + +func (c *loggingTLSConn) Read(b []byte) (n int, err error) { + n, err = c.Conn.Read(b) + + // Log TLS handshake failures on first read attempt + if !c.handshakeLogged { + c.handshakeLogged = true + + if err != nil { + // Check if this looks like a TLS handshake failure + if strings.Contains(err.Error(), "tls:") || + strings.Contains(err.Error(), "handshake") || + strings.Contains(err.Error(), "certificate") { + log.Printf("[TLS] ❌ Handshake failed from %s: %v", c.addr, err) + } + } else if n > 0 { + log.Printf("[TLS] ✅ Successful connection from %s", c.addr) + } + } + + return n, err +} diff --git a/docs/guides/HTTPS-SETUP.md b/docs/guides/HTTPS-SETUP.md index ae7bc2c..9fc04d8 100644 --- a/docs/guides/HTTPS-SETUP.md +++ b/docs/guides/HTTPS-SETUP.md @@ -33,16 +33,23 @@ The `soundtouch-service` now includes a built-in HTTPS listener. This simplifies - **HTTPS Port**: Configurable via `HTTPS_PORT` environment variable (defaults to `8443`). - **HTTPS Server URL**: Configurable via `HTTPS_SERVER_URL` (e.g., `https://mysoundtouch.local:8443`). If not set, the service attempts to guess it using the system hostname. -- **Domain Coverage**: Automatically presents a certificate for `streaming.bose.com`, `updates.bose.com`, `stats.bose.com`, `bmx.bose.com`, and `content.api.bose.io`. +- **Domain Coverage**: Automatically presents a certificate with comprehensive coverage using wildcard certificates (`*.api.bose.io`, `*.api.bosecm.com`) plus specific domains (`streaming.bose.com`, `updates.bose.com`, `stats.bose.com`, `bmx.bose.com`, `worldwide.bose.com`, `bose-prod.apigee.net`, etc.). +- **Wildcard Support**: Uses RFC-compliant wildcard certificates for automatic coverage of all API subdomains, including event analytics endpoints like `events.api.bosecm.com`, `eventsdev.api.bosecm.com`, and future API services. +- **TLS Error Logging**: Comprehensive logging of TLS handshake attempts, certificate matching, and connection failures for debugging DNS redirection issues. - **Automatic Setup**: On first start, it generates a server certificate signed by your AfterTouch local Root CA. -#### TLS Security +#### TLS Security & Debugging The built-in HTTPS listener is configured to use modern and secure TLS settings while maintaining compatibility with SoundTouch devices (which support up to TLS 1.2 with OpenSSL 1.0.2). - **Minimum TLS Version**: TLS 1.2 - **Preferred Cipher Suites**: - `ECDHE-RSA-AES128-GCM-SHA256` +- **TLS Debugging**: Detailed logging of: + - Certificate requests by domain (`[TLS] Certificate request for ServerName: events.api.bosecm.com`) + - Wildcard certificate matching (`[TLS] ✅ Serving certificate for events.api.bosecm.com (matched *.api.bosecm.com)`) + - Handshake failures (`[TLS] ❌ Handshake failed from 192.168.1.50: tls: certificate not found`) + - Successful connections (`[TLS] ✅ Successful connection from 192.168.1.50`) - `ECDHE-RSA-AES256-GCM-SHA384` - `ECDHE-RSA-CHACHA20-POLY1305` - `RSA-AES128-GCM-SHA256` (Legacy support) diff --git a/pkg/service/certmanager/certmanager_test.go b/pkg/service/certmanager/certmanager_test.go index 5eaa167..616f2ce 100644 --- a/pkg/service/certmanager/certmanager_test.go +++ b/pkg/service/certmanager/certmanager_test.go @@ -103,7 +103,7 @@ func TestCertificateManager(t *testing.T) { } // Test certificate regeneration if domains change - newDomains := append(domains, "mac.fritz.box") + newDomains := append(domains, "foo.local") tlsConfig2, err := cm.GetServerTLSConfig(newDomains) if err != nil { t.Fatalf("Failed to get updated TLS config: %v", err) @@ -116,7 +116,7 @@ func TestCertificateManager(t *testing.T) { cert, _ := x509.ParseCertificate(block.Bytes) found := false for _, d := range cert.DNSNames { - if d == "mac.fritz.box" { + if d == "foo.local" { found = true break } diff --git a/pkg/service/proxy/recorder_test.go b/pkg/service/proxy/recorder_test.go index f945345..2ea3368 100644 --- a/pkg/service/proxy/recorder_test.go +++ b/pkg/service/proxy/recorder_test.go @@ -52,7 +52,7 @@ func TestRecorder_Record_Structure(t *testing.T) { { name: "path_with_ip", category: "self", - path: "/setup/info/192.168.178.35", + path: "/setup/info/192.168.1.100", expected: "setup/info/{ip}", }, { @@ -131,7 +131,7 @@ func TestRecorder_Record_Sanitization(t *testing.T) { req := &http.Request{ Method: "GET", URL: &url.URL{ - Path: "/info/192.168.178.35/A81B6A536A98", + Path: "/info/192.168.1.100/A81B6A536A98", }, Header: make(http.Header), } @@ -331,7 +331,7 @@ func TestRecorder_EnvFile(t *testing.T) { req := &http.Request{ Method: "GET", URL: &url.URL{ - Path: "/info/192.168.178.35", + Path: "/info/192.168.1.100", }, Header: make(http.Header), } @@ -352,8 +352,8 @@ func TestRecorder_EnvFile(t *testing.T) { t.Fatalf("Failed to unmarshal env file: %v", err) } - if content["session"]["ip"] != "192.168.178.35" { - t.Errorf("Expected ip to be 192.168.178.35, got %s", content["session"]["ip"]) + if content["session"]["ip"] != "192.168.1.100" { + t.Errorf("Expected ip to be 192.168.1.100, got %s", content["session"]["ip"]) } }