Files
Bose-SoundTouch/cmd/soundtouch-service/main.go
T
Tobias GesellchenandClaude Sonnet 4.6 dc8ec69c61 sec5e: sanitize log-injection in client, discovery, testutils, cmd
Fixes CodeQL go/log-injection alerts in the final batch of packages.

New logutil.go helpers: pkg/client, pkg/testutils/amazon,
pkg/testutils/spotify, cmd/soundtouch-service, cmd/soundtouch-web,
cmd/dummy-speaker, cmd/mdns-scanner.

pkg/discovery/logger.go: added sanitizeLog and a nil-safe
remoteAddrString helper to the existing file (alongside logVerbose).

Call sites wrapped across 11 files — device IDs, source types,
hostnames, IPs, interface names, URLs, service names, HTTP method/form
values, WebSocket URLs and payloads, TLS SNI names, remote addresses.

No behaviour change. golangci-lint and make check pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:29:39 +02:00

1448 lines
49 KiB
Go

// Package main provides the SoundTouch service daemon that acts as a proxy and management
// interface for Bose SoundTouch devices, providing Marge service emulation and device discovery.
package main
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"runtime/debug"
"strconv"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/service/amazon"
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
"github.com/gesellix/bose-soundtouch/pkg/service/logbuf"
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
"github.com/gesellix/bose-soundtouch/pkg/service/stockholm"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/urfave/cli/v2"
)
var (
version = "dev"
commit = "unknown"
date = "unknown"
repoURL = "https://github.com/gesellix/bose-soundtouch"
)
func updateBuildInfo() {
if info, ok := debug.ReadBuildInfo(); ok {
if info.Main.Path != "" {
repoURL = "https://" + info.Main.Path
}
if info.Main.Version != "" && info.Main.Version != "(devel)" {
version = info.Main.Version
}
for _, setting := range info.Settings {
switch setting.Key {
case "vcs.revision":
commit = setting.Value
case "vcs.time":
if t, err := time.Parse(time.RFC3339, setting.Value); err == nil {
date = t.Format("2006-01-02 15:04:05")
}
}
}
}
}
func initializeDefaultSources(ds *datastore.DataStore) {
// Ensure default sources exist for all known devices on startup
allDevices, _ := ds.ListAllDevices()
for i := range allDevices {
dev := &allDevices[i]
if sources, errGet := ds.GetConfiguredSources(dev.AccountID, dev.DeviceID); errGet == nil {
log.Printf("Initializing default Sources.xml for existing device %s", sanitizeLog(dev.DeviceID))
// Find default sources and merge them if missing or outdated tokens.
// claimed tracks which stored sources have already been matched by a default,
// so two defaults with the same SourceKeyType but different SourceProviderIDs
// (e.g. INTERNET_RADIO/2 and INTERNET_RADIO/39) are treated as distinct entries.
defaults := ds.GetDefaultSources()
modified := false
claimed := make(map[int]bool)
for i := range defaults {
def := defaults[i]
foundIdx := -1
for j := range sources {
if claimed[j] || sources[j].SourceKeyType != def.SourceKeyType {
continue
}
// When both sides have a providerID, require it to match.
if def.SourceProviderID != "" && sources[j].SourceProviderID != "" && sources[j].SourceProviderID != def.SourceProviderID {
continue
}
foundIdx = j
break
}
if foundIdx >= 0 {
claimed[foundIdx] = true
if sources[foundIdx].Secret == "" && def.Secret != "" {
log.Printf("Initializing missing token for source %s on device %s", sanitizeLog(def.SourceKeyType), sanitizeLog(dev.DeviceID))
sources[foundIdx].Secret = def.Secret
sources[foundIdx].SecretType = def.SecretType
modified = true
}
} else {
log.Printf("Adding missing default source %s (providerID=%s) to device %s", sanitizeLog(def.SourceKeyType), sanitizeLog(def.SourceProviderID), sanitizeLog(dev.DeviceID))
sources = append(sources, def)
modified = true
}
}
if modified {
if errSave := ds.SaveConfiguredSources(dev.AccountID, dev.DeviceID, sources); errSave != nil {
log.Printf("Failed to save updated sources for %s: %v", sanitizeLog(dev.DeviceID), errSave)
}
}
}
}
}
func initMusicServices(config serviceConfig, server *handlers.Server) {
if config.spotifyClientID != "" {
spotifyService := spotify.NewSpotifyService(
config.spotifyClientID,
config.spotifyClientSecret,
config.spotifyRedirectURI,
config.dataDir,
)
if config.spotifyTokenURL != "" || config.spotifyAPIBase != "" {
spotifyService.SetEndpoints(config.spotifyTokenURL, config.spotifyAPIBase)
}
if err := spotifyService.Load(); err != nil {
log.Printf("[Spotify] Failed to load accounts: %v", err)
}
server.SetSpotifyService(spotifyService)
clientIDPrefix := config.spotifyClientID
if len(clientIDPrefix) > 8 {
clientIDPrefix = clientIDPrefix[:8]
}
log.Printf("Spotify service initialized (client ID: %s...)", sanitizeLog(clientIDPrefix))
}
if config.amazonClientID != "" {
amazonService := amazon.NewAmazonService(
config.amazonClientID,
config.amazonClientSecret,
config.amazonRedirectURI,
config.dataDir,
)
if config.amazonTokenURL != "" || config.amazonProfileURL != "" {
amazonService.SetEndpoints(config.amazonTokenURL, config.amazonProfileURL)
}
if err := amazonService.Load(); err != nil {
log.Printf("[Amazon] Failed to load accounts: %v", err)
}
server.SetAmazonService(amazonService)
clientIDPrefix := config.amazonClientID
if len(clientIDPrefix) > 8 {
clientIDPrefix = clientIDPrefix[:8]
}
log.Printf("Amazon Music service initialized (client ID: %s...)", sanitizeLog(clientIDPrefix))
}
}
// logBufferCapacityFromEnv reads SOUNDTOUCH_LOG_BUFFER_LINES and
// returns a positive capacity. Invalid or unset values fall back
// to the default; a value of 0 or negative is treated as "disable"
// and returns 0 so the caller can skip wiring the buffer.
func logBufferCapacityFromEnv(defaultCap int) int {
raw := os.Getenv("SOUNDTOUCH_LOG_BUFFER_LINES")
if raw == "" {
return defaultCap
}
v, err := strconv.Atoi(raw)
if err != nil {
log.Printf("[Logs] Invalid SOUNDTOUCH_LOG_BUFFER_LINES=%q, using default %d", sanitizeLog(raw), defaultCap)
return defaultCap
}
if v < 0 {
return 0
}
return v
}
func main() {
updateBuildInfo()
// Mirror log output to an in-memory ring buffer so the admin
// UI can show a live trace. Stderr keeps receiving every line
// verbatim — the buffer is a second sink, not a replacement.
// Installing this before the cli.Action runs means every
// log.Printf from initialisation onwards is captured.
var logBuf *logbuf.Buffer
if bufCap := logBufferCapacityFromEnv(2000); bufCap > 0 {
logBuf = logbuf.New(bufCap)
log.SetOutput(io.MultiWriter(os.Stderr, logBuf))
}
app := &cli.App{
Name: "soundtouch-service",
Usage: "Local service for Bose SoundTouch cloud emulation and management",
Description: `⠎⠕⠥⠝⠙⠤⠞⠕⠥⠉⠓ A local server that emulates Bose cloud services (BMX, Marge).
It enables offline operation, device migration, and HTTP interaction recording.`,
Version: version,
Authors: []*cli.Author{
{
Name: "Tobias Gesellchen, and the Bose-SoundTouch Contributors",
},
},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "port",
Aliases: []string{"p"},
Usage: "HTTP port to bind the service to",
Value: "8000",
EnvVars: []string{"PORT"},
},
&cli.StringFlag{
Name: "bind",
Usage: "Network interface to bind to",
EnvVars: []string{"BIND_ADDR"},
},
&cli.StringFlag{
Name: "data-dir",
Usage: "Directory for persistent data",
Value: "data",
EnvVars: []string{"DATA_DIR"},
},
&cli.StringFlag{
Name: "server-url",
Aliases: []string{"s"},
Usage: "External URL of this service",
EnvVars: []string{"SERVER_URL"},
},
&cli.StringFlag{
Name: "https-port",
Usage: "HTTPS port to bind the service to",
Value: "8443",
EnvVars: []string{"HTTPS_PORT"},
},
&cli.StringFlag{
Name: "https-server-url",
Aliases: []string{"S"},
Usage: "External HTTPS URL",
EnvVars: []string{"HTTPS_SERVER_URL"},
},
&cli.BoolFlag{
Name: "redact-logs",
Usage: "Redact sensitive data in proxy logs",
Value: true,
EnvVars: []string{"REDACT_PROXY_LOGS"},
},
&cli.BoolFlag{
Name: "log-bodies",
Usage: "Log full request/response bodies",
EnvVars: []string{"LOG_PROXY_BODY"},
},
&cli.BoolFlag{
Name: "record-interactions",
Usage: "Record HTTP interactions to disk",
Value: true,
EnvVars: []string{"RECORD_INTERACTIONS"},
},
&cli.BoolFlag{
Name: "discovery-enabled",
Usage: "Enable periodic device discovery",
Value: true,
EnvVars: []string{"DISCOVERY_ENABLED"},
},
&cli.StringFlag{
Name: "discovery-interval",
Usage: "Device discovery interval",
Value: "5m",
EnvVars: []string{"DISCOVERY_INTERVAL"},
},
&cli.BoolFlag{
Name: "dns-discovery",
Usage: "Enable DNS discovery server",
EnvVars: []string{"ENABLE_DNS_DISCOVERY"},
},
&cli.StringFlag{
Name: "dns-upstream",
Usage: "Upstream DNS server(s) for non-Bose queries (comma-separated). If empty, /etc/resolv.conf is used.",
Value: "",
EnvVars: []string{"DNS_UPSTREAM"},
},
&cli.StringFlag{
Name: "dns-bind",
Usage: "Bind address for the DNS discovery server",
Value: ":53",
EnvVars: []string{"DNS_BIND_ADDR"},
},
&cli.StringFlag{
Name: "spotify-client-id",
Usage: "Spotify OAuth client ID",
EnvVars: []string{"SPOTIFY_CLIENT_ID"},
},
&cli.StringFlag{
Name: "spotify-client-secret",
Usage: "Spotify OAuth client secret",
EnvVars: []string{"SPOTIFY_CLIENT_SECRET"},
},
&cli.StringFlag{
Name: "spotify-redirect-uri",
Usage: "Spotify OAuth redirect URI (defaults to <server-url>/mgmt/spotify/callback)",
EnvVars: []string{"SPOTIFY_REDIRECT_URI"},
},
&cli.StringFlag{
Name: "spotify-token-url",
Usage: "Spotify OAuth token URL (for testing)",
EnvVars: []string{"SPOTIFY_TOKEN_URL"},
},
&cli.StringFlag{
Name: "spotify-api-base",
Usage: "Spotify API base URL (for testing)",
EnvVars: []string{"SPOTIFY_API_BASE"},
},
&cli.StringFlag{
Name: "amazon-client-id",
Usage: "Amazon LWA OAuth client ID",
EnvVars: []string{"AMAZON_CLIENT_ID"},
},
&cli.StringFlag{
Name: "amazon-client-secret",
Usage: "Amazon LWA OAuth client secret",
EnvVars: []string{"AMAZON_CLIENT_SECRET"},
},
&cli.StringFlag{
Name: "amazon-redirect-uri",
Usage: "Amazon LWA OAuth redirect URI (defaults to <server-url>/mgmt/amazon/callback)",
EnvVars: []string{"AMAZON_REDIRECT_URI"},
},
&cli.StringFlag{
Name: "amazon-token-url",
Usage: "Amazon LWA token URL (for testing)",
EnvVars: []string{"AMAZON_TOKEN_URL"},
},
&cli.StringFlag{
Name: "amazon-profile-url",
Usage: "Amazon LWA profile URL (for testing)",
EnvVars: []string{"AMAZON_PROFILE_URL"},
},
&cli.StringFlag{
Name: "mgmt-username",
Usage: "Management API username for HTTP Basic Auth",
Value: "admin",
EnvVars: []string{"MGMT_USERNAME"},
},
&cli.StringFlag{
Name: "mgmt-password",
Usage: "Management API password for HTTP Basic Auth",
Value: "change_me!",
EnvVars: []string{"MGMT_PASSWORD"},
},
&cli.StringFlag{
Name: "base-url",
Usage: "External base URL for OAuth callbacks behind reverse proxy",
EnvVars: []string{"BASE_URL"},
},
&cli.StringSliceFlag{
Name: "internal-paths",
Usage: "Paths for internal requests (comma-separated or multiple flags)",
EnvVars: []string{"INTERNAL_PATHS"},
},
&cli.StringSliceFlag{
Name: "tls-extra-host",
Usage: "Additional DNS name or IP to include in the server TLS certificate SAN list (repeatable)",
EnvVars: []string{"TLS_EXTRA_HOST"},
},
&cli.BoolFlag{
Name: "migration-enabled",
Usage: "Enable device directory migration from serial to MAC-based structure",
Value: true,
EnvVars: []string{"MIGRATION_ENABLED"},
},
&cli.BoolFlag{
Name: "migration-dry-run",
Usage: "Log what would be migrated without actually doing it",
EnvVars: []string{"MIGRATION_DRY_RUN"},
},
&cli.StringFlag{
Name: "stockholm-dir",
Usage: "Path to the extracted Stockholm frontend directory (enables Stockholm UI when set)",
EnvVars: []string{"STOCKHOLM_DIR"},
},
&cli.StringFlag{
Name: "stockholm-base-path",
Usage: "URL prefix under which the Stockholm UI is served (e.g. /stockholm). Empty serves at root.",
Value: "/stockholm",
EnvVars: []string{"STOCKHOLM_BASE_PATH"},
},
},
Action: func(c *cli.Context) error {
config := loadConfig(c)
ds := initDataStore(config.dataDir)
persisted := applyPersistedSettings(ds, &config)
if persisted.ServerURL == "" {
log.Printf("Creating default settings.json in %s", sanitizeLog(config.dataDir))
persisted = createDefaultSettings(ds, config)
}
// Recalculate domains if settings changed
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "localhost"
}
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname, config.tlsExtraHosts)
cm := initCertificateManager(config.dataDir, config.hostname)
sm := setup.NewManager(config.serverURL, ds, cm)
sm.MgmtUsername = config.mgmtUsername
sm.MgmtPassword = config.mgmtPassword
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record)
sm.GetDNSRunning = server.GetDNSRunning
server.SetLogBuffer(logBuf)
server.SetHTTPServerURL(config.httpsServerURL)
server.SetExpectedHosts(config.domains)
server.SetVersionInfo(version, commit, date, repoURL)
server.SetDiscoverySettings(config.discoveryInterval, config.discoveryEnabled)
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
server.SetInternalPaths(persisted.InternalPaths)
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
server.SetAmazonConfig(config.amazonClientID, config.amazonClientSecret, config.amazonRedirectURI)
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
initMusicServices(config, server)
// Load and set initial DNS discoveries
dnsDiscoveries, err := ds.LoadDNSDiscoveries()
if err == nil && len(dnsDiscoveries) > 0 {
initial := make(map[string]*discovery.DiscoveredHost)
for _, entry := range dnsDiscoveries {
initial[entry.Hostname] = &discovery.DiscoveredHost{
Hostname: entry.Hostname,
FirstSeen: entry.FirstSeen,
LastSeen: entry.LastSeen,
QueryCount: entry.QueryCount,
IsBoseService: entry.IsBoseService,
IsIntercepted: entry.IsIntercepted,
RemoteAddr: entry.RemoteAddr,
}
}
server.SetDNSDiscoveries(initial)
}
server.SetShortcuts(persisted.Shortcuts)
for path, status := range persisted.Shortcuts {
log.Printf("Warning: configured shortcut: %s -> %d", sanitizeLog(path), status)
}
recorder := proxy.NewRecorder(config.dataDir)
recorder.Redact = config.redact
patternsPath := filepath.Join(config.dataDir, "patterns.json")
patterns, err := proxy.LoadPatterns(patternsPath)
if err != nil {
log.Printf("Warning: Failed to load patterns from %s: %v", sanitizeLog(patternsPath), err)
}
if len(patterns) == 0 {
log.Printf("Creating default patterns at %s", sanitizeLog(patternsPath))
patterns = proxy.DefaultPatterns()
patternsData, jsonErr := json.MarshalIndent(patterns, "", " ")
if jsonErr != nil {
log.Printf("Warning: Failed to marshal default patterns: %v", jsonErr)
} else {
_ = os.WriteFile(patternsPath, patternsData, 0644)
}
}
if len(patterns) > 0 {
recorder.Patterns = patterns
}
server.SetRecorder(recorder)
initializeDefaultSources(ds)
startDeviceDiscovery(server)
var stockholmHandler *stockholm.Handler
if config.stockholmDir != "" {
sh, shErr := stockholm.New(config.stockholmDir, config.dataDir, config.serverURL, config.stockholmBasePath)
if shErr != nil {
log.Printf("Warning: Failed to initialise Stockholm handler: %v", shErr)
} else {
stockholmHandler = sh
log.Printf("Stockholm frontend enabled from %s", sanitizeLog(config.stockholmDir))
}
}
r := setupRouter(server, stockholmHandler)
log.Printf("Go service starting on %s", sanitizeLog(config.serverURL))
// TLS cert generation can be slow on constrained hardware; run it in the
// background so the HTTP server is available immediately.
log.Printf("HTTPS setup running in background; %s will be available shortly", sanitizeLog(config.httpsServerURL))
go func() {
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
if err != nil {
log.Printf("Warning: Failed to setup TLS: %v", err)
return
}
startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL)
runHTTPSPreflight(config.httpsServerURL, config.serverURL, config.dnsEnabled, server.ResolveServerURLIPForPreflight)
}()
return http.ListenAndServe(config.addr, r)
},
Commands: []*cli.Command{
{
Name: "version",
Aliases: []string{"v"},
Usage: "Show detailed version information",
Action: showVersionInfo,
},
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}
func showVersionInfo(_ *cli.Context) error {
fmt.Printf("%s version %s\n", os.Args[0], version)
fmt.Printf("Build commit: %s\n", commit)
fmt.Printf("Build date: %s\n", date)
fmt.Printf("Go version: %s\n", runtime.Version())
fmt.Printf("Platform: %s/%s\n", runtime.GOOS, runtime.GOARCH)
return nil
}
type serviceConfig struct {
port string
bindAddr string
addr string
dataDir string
hostname string
serverURL string
httpsServerURL string
httpsAddr string
redact bool
logBody bool
record bool
dnsEnabled bool
dnsUpstream string
dnsBind string
internalPaths []string
tlsExtraHosts []string
discoveryEnabled bool
discoveryInterval time.Duration
domains []string
spotifyClientID string
spotifyClientSecret string
spotifyRedirectURI string
spotifyTokenURL string
spotifyAPIBase string
amazonClientID string
amazonClientSecret string
amazonRedirectURI string
amazonTokenURL string
amazonProfileURL string
mgmtUsername string
mgmtPassword string
migrationEnabled bool
migrationDryRun bool
stockholmDir string
stockholmBasePath string
}
func loadConfig(c *cli.Context) serviceConfig {
port := c.String("port")
bindAddr := c.String("bind")
addr := bindAddr + ":" + port
if bindAddr == "" {
addr = ":" + port
}
dataDir := c.String("data-dir")
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "localhost"
}
hostname = strings.ToLower(hostname)
serverURL := c.String("server-url")
if serverURL == "" {
serverURL = "http://" + hostname + ":" + port
}
httpsPort := c.String("https-port")
httpsAddr := bindAddr + ":" + httpsPort
if bindAddr == "" {
httpsAddr = ":" + httpsPort
}
httpsServerURL := c.String("https-server-url")
if httpsServerURL == "" {
httpsServerURL = "https://" + hostname + ":" + httpsPort
}
tlsExtraHosts := c.StringSlice("tls-extra-host")
domains := getDomains(serverURL, httpsServerURL, hostname, tlsExtraHosts)
redact := c.Bool("redact-logs")
logBody := c.Bool("log-bodies")
record := c.Bool("record-interactions")
dnsEnabled := c.Bool("dns-discovery")
dnsUpstream := c.String("dns-upstream")
dnsBind := c.String("dns-bind")
discoveryEnabled := c.Bool("discovery-enabled")
discoveryIntervalStr := c.String("discovery-interval")
discoveryInterval, err := time.ParseDuration(discoveryIntervalStr)
if err != nil {
log.Printf("Warning: Failed to parse discovery interval %s, using default 5m: %v", sanitizeLog(discoveryIntervalStr), err)
discoveryInterval = 5 * time.Minute
}
spotifyClientID := c.String("spotify-client-id")
spotifyClientSecret := c.String("spotify-client-secret")
spotifyRedirectURI := c.String("spotify-redirect-uri")
spotifyTokenURL := c.String("spotify-token-url")
spotifyAPIBase := c.String("spotify-api-base")
amazonClientID := c.String("amazon-client-id")
amazonClientSecret := c.String("amazon-client-secret")
amazonRedirectURI := c.String("amazon-redirect-uri")
amazonTokenURL := c.String("amazon-token-url")
amazonProfileURL := c.String("amazon-profile-url")
mgmtUsername := c.String("mgmt-username")
mgmtPassword := c.String("mgmt-password")
internalPaths := c.StringSlice("internal-paths")
migrationEnabled := c.Bool("migration-enabled")
migrationDryRun := c.Bool("migration-dry-run")
stockholmDir := c.String("stockholm-dir")
stockholmBasePath := c.String("stockholm-base-path")
return serviceConfig{
port: port,
bindAddr: bindAddr,
addr: addr,
dataDir: dataDir,
hostname: hostname,
serverURL: serverURL,
httpsServerURL: httpsServerURL,
httpsAddr: httpsAddr,
redact: redact,
logBody: logBody,
record: record,
dnsEnabled: dnsEnabled,
dnsUpstream: dnsUpstream,
dnsBind: dnsBind,
internalPaths: internalPaths,
tlsExtraHosts: tlsExtraHosts,
discoveryEnabled: discoveryEnabled,
discoveryInterval: discoveryInterval,
domains: domains,
spotifyClientID: spotifyClientID,
spotifyClientSecret: spotifyClientSecret,
spotifyRedirectURI: spotifyRedirectURI,
spotifyTokenURL: spotifyTokenURL,
spotifyAPIBase: spotifyAPIBase,
amazonClientID: amazonClientID,
amazonClientSecret: amazonClientSecret,
amazonRedirectURI: amazonRedirectURI,
amazonTokenURL: amazonTokenURL,
amazonProfileURL: amazonProfileURL,
mgmtUsername: mgmtUsername,
mgmtPassword: mgmtPassword,
migrationEnabled: migrationEnabled,
migrationDryRun: migrationDryRun,
stockholmDir: stockholmDir,
stockholmBasePath: stockholmBasePath,
}
}
func getDomains(serverURL, httpsServerURL, hostname string, extraHosts []string) []string {
domainsMap := map[string]bool{
// 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,
"streamingoauth.bose.com": true,
"bosecm.com": true,
"bose.io": true,
"bose-prod.apigee.net": true,
"bose-test.apigee.net": true,
"downloads.bose.com": 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() != "" {
domainsMap[strings.ToLower(u.Hostname())] = true
}
if u, err := url.Parse(httpsServerURL); err == nil && u.Hostname() != "" {
domainsMap[strings.ToLower(u.Hostname())] = true
}
// The speaker firmware constructs the OAuth host by appending `oauth`
// to the first label of the streaming hostname (see issue #337 and
// pkg/discovery/dns.go DeriveOAuthHostnames). The DNS hijack catches
// it; the TLS cert must also cover it, otherwise the speaker rejects
// the handshake and Spotify / Amazon Music OAuth dies before reaching
// AfterTouch. Derive once from each of serverURL and httpsServerURL —
// they typically share a hostname but a multi-homed deployment may
// differ.
for _, h := range discovery.DeriveOAuthHostnames(serverURL) {
domainsMap[h] = true
}
for _, h := range discovery.DeriveOAuthHostnames(httpsServerURL) {
domainsMap[h] = true
}
// Explicit overrides / additions for multi-homed hosts, reverse proxies,
// or browsing the admin UI via a LAN IP that isn't part of serverURL.
for _, h := range extraHosts {
h = strings.ToLower(strings.TrimSpace(h))
if h != "" {
domainsMap[h] = true
}
}
domains := make([]string, 0, len(domainsMap))
for d := range domainsMap {
domains = append(domains, d)
}
return domains
}
func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) datastore.Settings {
persisted, err := ds.GetSettings()
if err != nil {
return datastore.Settings{}
}
// Only override CLI values if settings file exists
// If no settings file exists, GetSettings returns empty Settings{} and we should preserve CLI values
settingsPath := filepath.Join(ds.DataDir, "settings.json")
if _, err := os.Stat(settingsPath); os.IsNotExist(err) {
return datastore.Settings{}
}
if persisted.ServerURL != "" {
config.serverURL = persisted.ServerURL
}
if persisted.HTTPServerURL != "" {
config.httpsServerURL = persisted.HTTPServerURL
}
config.discoveryEnabled = persisted.DiscoveryEnabled
if persisted.DiscoveryInterval != "" {
if d, durErr := time.ParseDuration(persisted.DiscoveryInterval); durErr == nil {
config.discoveryInterval = d
}
}
config.redact = persisted.RedactLogs
config.logBody = persisted.LogBodies
config.record = persisted.RecordInteractions
config.dnsEnabled = persisted.DNSEnabled
if len(persisted.DNSUpstream) > 0 {
config.dnsUpstream = strings.Join(persisted.DNSUpstream, ",")
}
if persisted.DNSBindAddr != "" {
config.dnsBind = persisted.DNSBindAddr
}
config.internalPaths = persisted.InternalPaths
// CLI/env args take precedence; only apply persisted credentials when not set via CLI.
applyPersistedMusicServiceCredentials(config, persisted)
config.tlsExtraHosts = mergeTLSExtraHosts(config.tlsExtraHosts, persisted.TLSExtraHosts)
return persisted
}
// mergeTLSExtraHosts merges the CLI/env-supplied hosts with the persisted
// list. CLI/env wins (so an operator who pinned a host via systemd unit
// always sees it applied); persisted values are additive. Returns a
// deduplicated, order-preserving slice with CLI/env entries first.
func mergeTLSExtraHosts(cli, persisted []string) []string {
seen := make(map[string]bool, len(cli)+len(persisted))
out := make([]string, 0, len(cli)+len(persisted))
for _, h := range cli {
h = strings.TrimSpace(h)
if h == "" || seen[h] {
continue
}
seen[h] = true
out = append(out, h)
}
for _, h := range persisted {
h = strings.TrimSpace(h)
if h == "" || seen[h] {
continue
}
seen[h] = true
out = append(out, h)
}
return out
}
// applyPersistedMusicServiceCredentials fills in music service credentials from persisted
// settings when they have not been supplied via CLI flags or environment variables.
func applyPersistedMusicServiceCredentials(config *serviceConfig, persisted datastore.Settings) {
if config.spotifyClientID == "" {
config.spotifyClientID = persisted.SpotifyClientID
}
if config.spotifyClientSecret == "" {
config.spotifyClientSecret = persisted.SpotifyClientSecret
}
if config.spotifyRedirectURI == "" {
config.spotifyRedirectURI = persisted.SpotifyRedirectURI
}
if config.amazonClientID == "" {
config.amazonClientID = persisted.AmazonClientID
}
if config.amazonClientSecret == "" {
config.amazonClientSecret = persisted.AmazonClientSecret
}
if config.amazonRedirectURI == "" {
config.amazonRedirectURI = persisted.AmazonRedirectURI
}
}
func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings {
settings := datastore.Settings{
ServerURL: config.serverURL,
HTTPServerURL: config.httpsServerURL,
RedactLogs: config.redact,
LogBodies: config.logBody,
RecordInteractions: config.record,
DiscoveryEnabled: config.discoveryEnabled,
DiscoveryInterval: config.discoveryInterval.String(),
DNSEnabled: config.dnsEnabled,
DNSUpstream: strings.Split(config.dnsUpstream, ","),
DNSBindAddr: config.dnsBind,
InternalPaths: config.internalPaths,
Shortcuts: map[string]int{
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
"/sw.js": http.StatusNotFound,
},
}
_ = ds.SaveSettings(settings)
return settings
}
func initDataStore(dataDir string) *datastore.DataStore {
ds := datastore.NewDataStore(dataDir)
if err := ds.Initialize(); err != nil {
log.Printf("Warning: Failed to initialize datastore: %v", err)
}
return ds
}
func initCertificateManager(dataDir, hostname string) *certmanager.CertificateManager {
cm := certmanager.NewCertificateManager(filepath.Join(dataDir, "certs"))
cm.CommonName = hostname
if err := cm.EnsureCA(); err != nil {
log.Printf("Warning: Failed to ensure CA: %v", err)
}
return cm
}
func startDeviceDiscovery(server *handlers.Server) {
go func() {
for {
currentInterval, enabled := server.GetDiscoverySettings()
if enabled {
server.DiscoverDevices(context.Background())
}
time.Sleep(currentInterval)
}
}()
}
func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *chi.Mux {
r := chi.NewRouter()
// TrustedRealIP must run before any handler that reads r.RemoteAddr —
// SnapshotMiddleware captures the request, and several handlers
// (HandleMargePowerOn, etc.) inspect the source IP. The middleware is
// gated on Settings.TrustForwardedHeaders; when off (the safe default),
// it returns nil and we skip Use'ing it entirely.
if mw := server.TrustedRealIPMiddleware(); mw != nil {
r.Use(mw)
}
r.Use(server.SnapshotMiddleware)
r.Use(server.OriginMiddleware)
r.Use(middleware.Recoverer)
r.Use(server.PeerObserverMiddleware)
r.Use(server.ShortcutMiddleware)
r.Use(server.RecordMiddleware)
r.Get("/", server.HandleRoot)
r.Get("/health", server.HandleHealth)
// Passive peer-reachability probe. Registers a device IP with the
// in-process observer, nudges :8090/swUpdateCheck, and waits for
// any inbound from that IP. Used post-migration where the daemon
// caches its swUpdateUrl at boot and the active round-trip can't
// reach it without a reboot.
r.Post("/setup/peer-probe/{deviceId}", server.HandlePeerProbe)
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
// The favicon lives in the embedded web/img bundle, not under
// static/media — HandleMedia would 404. HandleWeb serves from
// webFS at its native path.
r.URL.Path = "/web/img/favicon-braille.svg"
server.HandleWeb()(w, r)
})
r.Get("/media/aftertouch-ding.wav", server.HandleDing)
r.Get("/media/*", server.HandleMedia())
r.Get("/bmx-icons/*", server.HandleBmxIcons())
r.Get("/ced/*", server.HandleCedStatic())
r.Get("/web/*", server.HandleWeb())
r.Post("/alexa/certificate", server.HandleAlexaCertificate)
r.Get("/docs/*", server.HandleDocs)
r.Route("/bmx", func(r chi.Router) {
r.Get("/registry/v1/services", server.HandleBMXRegistry)
r.Get("/registry/v1/servicesAvailability", server.HandleBMXServicesAvailability)
r.Route("/tunein", func(r chi.Router) {
r.Get("/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
r.Get("/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
r.Get("/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
r.Post("/v1/token", server.HandleTuneInToken)
r.Post("/v1/report", server.HandleTuneInReport)
r.Get("/v1/navigate", server.HandleTuneInNavigate)
r.Get("/v1/navigate/*", server.HandleTuneInNavigate)
r.Get("/v1/search", server.HandleTuneInSearch)
r.Get("/v1/search/next", server.HandleTuneInSearchNext)
r.Post("/v1/favorite/{stationID}", server.HandleTuneInFavorite)
r.Delete("/v1/favorite/{stationID}", server.HandleTuneInDeleteFavorite)
})
})
// Orion (LOCAL_INTERNET_RADIO) lives at the top level — the BMX registry
// advertises baseUrl `{BMX_SERVER}/core02/svc-bmx-adapter-orion/prod/orion`
// (no `/bmx/` prefix; verified against the upstream capture in
// pkg/service/handlers/static/bmx_services_ustream.json), so speakers
// reach the token + station endpoints at exactly these paths under
// either DNS-interception or URL-flip migration.
r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken)
r.Get("/core02/svc-bmx-adapter-orion/prod/orion/station", server.HandleOrionPlayback)
// SiriusXM lives at the top level by the same convention. bmx_services.json
// advertises baseUrl `{BMX_SERVER}/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter`
// (no /bmx/ prefix), so speakers reach this exact path under either
// migration mode. The bare path returns the service descriptor (matches
// soundcork main.py:805); sub-paths advertised by the descriptor's _links
// (/availability, /navigate, /token, /logout) currently log + 404 so
// future implementation work has visibility into real speaker calls.
r.HandleFunc("/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter", server.HandleSiriusXMLiveAdapter)
r.HandleFunc("/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/*", server.HandleSiriusXMLiveAdapterSubpath)
r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback)
r.Route("/streaming", func(r chi.Router) {
r.Get("/sourceproviders", server.HandleMargeSourceProviders)
r.Post("/account", server.HandleMargeCreateAccount)
r.Post("/account/login", server.HandleMargeLogin)
r.Post("/account/{account}/source", server.HandleMargeAddSource)
r.Route("/account/{account}", func(r chi.Router) {
r.Get("/emailaddress", server.HandleMargeGetEmailAddress)
r.Get("/full", server.HandleMargeAccountFull)
r.Get("/sources", server.HandleMargeAccountSources)
r.Get("/devices", server.HandleMargeAccountDevices)
r.Get("/presets", server.HandleMargeAccountPresets)
r.Get("/presets/all", server.HandleMargeAccountPresets)
r.Get("/provider_settings", server.HandleMargeProviderSettings)
// All `/device` routes share one chi subrouter. Two
// overlapping subrouters (`/device` + `/device/{device}`)
// caused chi's radix-tree resolver to bind a runtime
// request to the more-specific prefix even when only the
// less-specific subrouter had a matching method handler,
// producing the [UNHANDLED] → upstream-proxy fall-through
// behind issue #285's first-attempted fix. One subrouter
// keeps every device-scoped path resolvable; see
// TestPUTRenameRoutesToLocalHandler for the regression
// against the production router.
r.Route("/device", func(r chi.Router) {
r.Post("/", server.HandleMargeAddDevice)
r.Post("/{device}", server.HandleMargeAddDevice)
// PUT is the rename / update path — speakers fire
// this against PUT /streaming/account/{a}/device/{d}
// when the user renames via Bose App or
// `soundtouch-cli name set`. Issue #285.
r.Put("/{device}", server.HandleMargeUpdateDevice)
r.Delete("/{device}", server.HandleMargeRemoveDevice)
r.Get("/{device}/presets", server.HandleMargePresets)
r.Post("/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
r.Put("/{device}/preset/{presetNumber}", server.HandleMargeUpdatePreset)
r.Delete("/{device}/preset/{presetNumber}", server.HandleMargeRemovePreset)
r.Get("/{device}/recent", server.HandleMargeRecents)
r.Get("/{device}/recents", server.HandleMargeRecents)
r.Post("/{device}/recent", server.HandleMargeAddRecent)
r.Get("/{device}/group", server.HandleMargeDeviceGroup)
r.Get("/{device}/group/", server.HandleMargeDeviceGroup)
r.Get("/{device}/group/server", server.HandleMargeDeviceGroupServer)
r.Get("/{device}/group/member", server.HandleMargeDeviceGroupMember)
})
// Speakers POST to /group/ (with trailing slash) when forwarding
// the addGroup payload to Marge during stereo-pair formation --
// see issue #252. Register both forms so chi accepts either.
r.Post("/group", server.HandleMargeAddGroup)
r.Post("/group/", server.HandleMargeAddGroup)
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
})
r.Get("/device/{device}/streaming_token", server.HandleMargeStreamingToken)
r.Get("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
r.Post("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
r.Get("/software/update/account/{account}", server.HandleMargeSoftwareUpdate)
r.Route("/support", func(r chi.Router) {
r.Post("/power_on", server.HandleMargePowerOn)
r.Post("/customersupport", server.HandleMargeCustomerSupport)
})
r.Route("/stats", func(r chi.Router) {
r.Post("/usage", server.HandleUsageStats)
r.Post("/error", server.HandleErrorStats)
})
r.Route("/music", func(r chi.Router) {
r.Route("/musicprovider/{providerID}", func(r chi.Router) {
r.Post("/is_eligible", server.HandleMusicProviderIsEligible)
r.Post("/trial/is_eligible", server.HandleMusicProviderIsEligible)
})
})
r.Get("/resources/api_versions.xml", server.HandleMargeAPIVersions)
})
r.Route("/accounts", func(r chi.Router) {
r.Route("/{account}", func(r chi.Router) {
r.Get("/full", server.HandleMargeAccountFull)
r.Get("/sources", server.HandleMargeAccountSources)
r.Get("/devices", server.HandleMargeAccountDevices)
r.Post("/devices", server.HandleMargeAddDevice)
r.Delete("/devices/{device}", server.HandleMargeRemoveDevice)
r.Get("/devices/{device}/group", server.HandleMargeDeviceGroup)
r.Get("/devices/{device}/group/", server.HandleMargeDeviceGroup)
r.Get("/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
r.Get("/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
r.Post("/group", server.HandleMargeAddGroup)
r.Post("/group/", server.HandleMargeAddGroup)
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
r.Get("/devices/{device}/presets", server.HandleMargePresets)
r.Get("/devices/{device}/recents", server.HandleMargeRecents)
r.Post("/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
r.Post("/devices/{device}/recents", server.HandleMargeAddRecent)
})
})
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
r.Route("/customer", func(r chi.Router) {
r.Get("/account/{account}", server.HandleMargeAccountProfile)
r.Post("/account/{account}", server.HandleMargeUpdateAccountProfile)
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
})
r.Route("/oauth", func(r chi.Router) {
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token", server.HandleBoseLegacyToken)
r.Post("/account/{account}/music/musicprovider/{sourceID}/token/cs", server.HandleBoseAccountToken)
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token/cs1", server.HandleBoseToken)
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3", server.HandleBoseToken)
})
r.Route("/v1", func(r chi.Router) {
r.Post("/stapp/{deviceId}", server.HandleAppEvents)
r.Post("/scmudc/{deviceId}", server.HandleAppEvents)
// Return 405 Method Not Allowed as the upstream behavior also returns 405
r.Get("/blacklist/{deviceId}", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed)
})
})
r.Route("/mgmt", func(r chi.Router) {
// Browser OAuth callbacks — no auth required (provider redirects the
// user's browser here directly). The authorization code is single-use,
// short-lived, and useless without the client_secret.
r.Get("/spotify/callback", server.HandleMgmtSpotifyCallback)
r.Get("/amazon/callback", server.HandleMgmtAmazonCallback)
// All other management endpoints require Basic Auth.
r.Group(func(r chi.Router) {
r.Use(server.BasicAuthMgmt())
r.Route("/accounts", func(r chi.Router) {
r.Get("/", server.HandleMgmtListAccounts)
r.Get("/{accountId}", server.HandleMgmtAccountDetails)
r.Post("/{accountId}/language", server.HandleMgmtUpdateAccountLanguage)
r.Post("/{accountId}/provider-settings", server.HandleMgmtUpdateAccountProviderSetting)
r.Get("/{accountId}/speakers", server.HandleMgmtListSpeakers)
})
r.Route("/spotify", func(r chi.Router) {
r.Post("/init", server.HandleMgmtSpotifyInit)
r.Post("/confirm", server.HandleMgmtSpotifyConfirm)
r.Get("/accounts", server.HandleMgmtSpotifyAccounts)
r.Get("/token", server.HandleMgmtSpotifyToken)
r.Post("/entity", server.HandleMgmtSpotifyEntity)
r.Post("/prime", server.HandleMgmtPrimeDevice)
})
r.Route("/amazon", func(r chi.Router) {
r.Post("/init", server.HandleMgmtAmazonInit)
r.Post("/confirm", server.HandleMgmtAmazonConfirm)
r.Get("/accounts", server.HandleMgmtAmazonAccounts)
r.Get("/token", server.HandleMgmtAmazonToken)
r.Post("/prime", server.HandleMgmtPrimeDeviceAmazon)
})
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
})
})
r.Route("/setup", func(r chi.Router) {
r.Get("/devices", server.HandleListDiscoveredDevices)
r.Post("/devices", server.HandleAddManualDevice)
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
r.Post("/discover", server.HandleTriggerDiscovery)
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
r.Get("/settings", server.HandleGetSettings)
r.Post("/settings", server.HandleUpdateSettings)
r.Get("/info/{deviceId}", server.HandleGetDeviceInfo)
r.Get("/summary/{deviceId}", server.HandleGetMigrationSummary)
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
r.Post("/revert/{deviceId}", server.HandleRevertMigration)
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
r.Get("/account-id-suggestions/{deviceId}", server.HandleAccountIDSuggestions)
r.Post("/pair-account/{deviceId}", server.HandlePairAccount)
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services/{deviceId}", server.HandleRemoveRemoteServices)
r.Post("/backup/{deviceId}", server.HandleBackupConfig)
r.Post("/sync/{deviceId}", server.HandleInitialSync)
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
r.Post("/test-dns/{deviceId}", server.HandleTestDNSRedirection)
r.Get("/ca.crt", server.HandleGetCACert)
r.Get("/logging-settings", server.HandleGetLoggingSettings)
r.Post("/logging-settings", server.HandleUpdateLoggingSettings)
r.Get("/version", server.HandleGetVersionInfo)
r.Get("/interaction-stats", server.HandleGetInteractionStats)
r.Get("/interactions", server.HandleListInteractions)
r.Get("/interaction-content", server.HandleGetInteractionContent)
r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
r.Get("/dns-discoveries/download", server.HandleDownloadDNSDiscoveries)
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
r.Delete("/sources/{account}/{device}/{sourceID}", server.HandleDeleteSource)
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
r.Get("/device-summary/{deviceId}", server.HandleDeviceSummary)
r.Get("/health", server.HandleHealthChecks)
r.Post("/health/fix", server.HandleHealthFix)
r.Get("/export/diagnostic", server.HandleExportDiagnostic)
r.Get("/logs", server.HandleGetLogs)
// Serve Stockholm setup wizard pages for paths not matched by the management API.
// The Stockholm frontend has a setup/ directory that must be accessible at /setup/*.
if stockholmHandler != nil {
r.Get("/*", stockholmHandler.HandleStatic)
r.Get("/", stockholmHandler.HandleStatic)
}
})
if stockholmHandler != nil {
stockholmHandler.Mount(r)
}
r.NotFound(server.HandleNotFound)
return r
}
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", sanitizeLog(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", sanitizeLog(httpsServerURL))
go func() {
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)
}
}()
}
// runHTTPSPreflight checks whether speakers' implicit :443 target reaches
// AfterTouch. Runs after the HTTPS listener has had a moment to come up; if
// the listener is already on :443 the check is skipped. Emits a single WARN
// log line with actionable guidance when either probe fails.
//
// Only runs when dnsEnabled is true: the :443 reachability only matters when
// speakers are reaching AfterTouch via intercepted Bose hostnames (i.e. the
// DNS migration method). For direct SDK-override migration the speaker
// connects to the configured https-port directly, so :443 is irrelevant.
// Users with external DNS interception (Pi-hole, router rules) can still see
// the live result on /setup/settings even when this startup warn is silent.
func runHTTPSPreflight(httpsServerURL, serverURL string, dnsEnabled bool, resolver func(string) (string, error)) {
if !dnsEnabled {
return
}
port := handlers.PortFromHTTPSServerURL(httpsServerURL)
if port == 0 {
// Can't determine the listener port — be silent rather than misleading.
return
}
// Give the listener a head start so a successful bind beats the probe.
time.Sleep(2 * time.Second)
res := handlers.Check443Reachability(port, serverURL, resolver, handlers.ProbeDialTimeoutStartup)
guidance := handlers.FormatPreflightGuidance(port, res)
if guidance == "" {
switch {
case res.Skipped:
// Listener already on :443 — nothing to say.
case res.NotApplicable:
log.Printf("HTTPS pre-flight: :443 check skipped — %s", sanitizeLog(res.Reason))
default:
log.Printf("HTTPS pre-flight: :443 reachable at localhost and %s ✓", sanitizeLog(res.LANHost))
}
return
}
log.Print(guidance)
}
// 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", sanitizeLog(c.addr.String()), err)
}
}
}
return n, err
}