Files
Tobias GesellchenandClaude Sonnet 5 81b915bfca fix(on-device): stop leaking the speaker's own hostname into BMX/TLS URLs
On an on-device install, soundtouch-service defaulted its server URL to
os.Hostname() when --server-url wasn't set. Since the service runs on the
speaker's own Linux, that returns the speaker's internal variant codename
(e.g. "spotty", "mojo") -- never resolvable, not even by the speaker itself
-- breaking TuneIn/BMX playback with CURL ErrorCode 6 (issue #546).

Add a --deployment-mode/DEPLOYMENT_MODE flag (on-device, private-network,
public-network) so the fallback is chosen deliberately instead of guessed:
on-device defaults to localhost, public-network refuses to start rather
than guess a public address, and the previous hostname-guessing behavior
is kept for private-network/unset installs, now with a startup warning.

The on-device init script sets DEPLOYMENT_MODE=on-device automatically and
now auto-exports aftertouch.conf into the daemon's environment generally,
which also unblocks discussion #610 (setting MGMT_USERNAME/MGMT_PASSWORD
on-device) without any further code change.

Verified end-to-end on real ST20 hardware: service now resolves
http://localhost:8000, a re-migrate updates the speaker's own runtime
config to match, and TuneIn playback works again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 20:12:44 +02:00

2102 lines
77 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"
"math/rand"
"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/bmx"
"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/soundtouchweb"
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
"github.com/gesellix/bose-soundtouch/pkg/service/stockholm"
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
"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
}
// Only fall back to build info when the version was not injected via
// -ldflags (i.e. still the "dev" default, e.g. `go install …@vX.Y.Z`).
// This keeps an explicitly stamped release version from being clobbered
// by a VCS pseudo-version (e.g. v0.0.0-… from a shallow checkout).
if version == "dev" && 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.GetInitialSources()
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))
}
}
// initTTSService loads the text-to-speech configuration onto the server and
// builds the running service. The provider construction and (re)build logic
// lives on the server so the settings UI can re-apply changes at runtime; see
// handlers.Server.ReinitTTSService.
func initTTSService(config serviceConfig, server *handlers.Server) {
server.SetTTSConfig(
config.ttsProvider,
config.ttsGoogleAPIKey,
config.ttsGoogleEndpoint,
config.ttsAppKey,
config.ttsLanguage,
config.ttsVoice,
config.ttsVolume,
)
server.ReinitTTSService()
}
// 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
}
// serviceFlags is the full flag/env-var surface for soundtouch-service.
// Extracted to a package-level var (rather than inlined in main()'s
// cli.App literal) so tests can build a real *cli.Context against the
// exact same flags loadConfig reads, instead of hand-duplicating them.
var serviceFlags = []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: "deployment-mode",
Usage: "Where this service runs: on-device, private-network, or public-network " +
"- informs the server-url fallback when --server-url isn't set",
EnvVars: []string{"DEPLOYMENT_MODE"},
},
&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: "update-check-enabled",
Usage: "Periodically check GitHub for a newer release (opt-in; the only network call this makes beyond speaker/provider traffic)",
Value: false,
EnvVars: []string{"UPDATE_CHECK_ENABLED"},
},
&cli.StringFlag{
Name: "update-check-interval",
Usage: "Update check interval",
Value: "24h",
EnvVars: []string{"UPDATE_CHECK_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: "tunein-opml-url",
Usage: "TuneIn OPML base URL, covering Tune.ashx/describe.ashx/navigate (for testing / local mock; defaults to opml.radiotime.com)",
EnvVars: []string{"TUNEIN_OPML_URL"},
},
&cli.StringFlag{
Name: "tunein-api-url",
Usage: "TuneIn API base URL, covering search and profile contents (for testing / local mock; defaults to api.radiotime.com)",
EnvVars: []string{"TUNEIN_API_URL"},
},
&cli.StringFlag{
Name: "tts-provider",
Usage: "Text-to-speech provider: 'translate' (Google Translate, no credentials, default) or 'google-cloud' (Google Cloud TTS, needs an API key). Empty falls back to translate; leave unset to let a value saved in the settings UI take effect",
EnvVars: []string{"TTS_PROVIDER"},
},
&cli.StringFlag{
Name: "tts-google-api-key",
Usage: "Google Cloud Text-to-Speech API key (required when --tts-provider=google-cloud)",
EnvVars: []string{"TTS_GOOGLE_API_KEY"},
},
&cli.StringFlag{
Name: "tts-google-endpoint",
Usage: "Google Cloud TTS synthesize endpoint override (for testing)",
EnvVars: []string{"TTS_GOOGLE_ENDPOINT"},
},
&cli.StringFlag{
Name: "tts-language",
Usage: "Default TTS language code. Provider-specific: 'EN'/'DE' for translate, BCP-47 like 'en-US' for google-cloud",
EnvVars: []string{"TTS_LANGUAGE"},
},
&cli.StringFlag{
Name: "tts-voice",
Usage: "Default Google Cloud TTS voice name (e.g. en-US-Neural2-C); ignored by the translate provider",
EnvVars: []string{"TTS_VOICE"},
},
&cli.StringFlag{
Name: "tts-app-key",
Usage: "Bose /speaker app_key used to play TTS notifications on speakers",
EnvVars: []string{"TTS_APP_KEY"},
},
&cli.IntFlag{
Name: "tts-volume",
Usage: "Default TTS playback volume (0-100, 0 = keep current volume)",
Value: 0,
EnvVars: []string{"TTS_VOLUME"},
},
&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"},
},
}
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: serviceFlags,
Action: func(c *cli.Context) error {
config, err := loadConfig(c)
if err != nil {
return err
}
ds := initDataStore(config.dataDir)
// Detect a genuinely fresh data dir by the ABSENCE of settings.json,
// not by an empty server_url. A hand-authored settings.json (e.g. one
// that only sets trust_forwarded_headers and leaves server_url to the
// --server-url flag) exists but has no server_url; keying the "first
// run" default-write off server_url would treat it as fresh and
// clobber the operator's file, dropping fields createDefaultSettings
// doesn't know about.
settingsExisted := settingsFileExists(config.dataDir)
persisted := applyPersistedSettings(ds, &config)
if !settingsExisted {
log.Printf("Creating default settings.json in %s", sanitizeLog(config.dataDir))
log.Printf("Data directory %s looks empty (first run). If you did NOT expect this "+
"(e.g. after recreating a Docker container), your previous settings, datastore and "+
"CA were not persisted; mount a persistent volume at the data dir (Docker: "+
"-v <volume>:/app/data) so device state and the CA survive restarts. A lost CA "+
"forces re-migrating speakers and re-trusting the new CA.",
sanitizeLog(config.dataDir))
persisted = createDefaultSettings(ds, config)
}
// Recalculate domains if settings changed. Reuses the same mode-aware
// fallback host loadConfig already resolved, rather than a raw
// os.Hostname() call, so an on-device install doesn't leak its
// unresolvable variant codename back in here (see issue #546).
config.domains = getDomains(config.serverURL, config.httpsServerURL, config.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.SetHTTPSListenAddr(config.httpsAddr)
server.SetHTTPSSettings(config.httpsOverride, config.httpsPort, config.httpsDefaultURL)
server.SetExpectedHosts(config.domains)
server.SetVersionInfo(version, commit, date, repoURL)
server.SetDiscoverySettings(config.discoveryInterval, config.discoveryEnabled)
server.SetUpdateCheckSettings(config.updateCheckInterval, config.updateCheckEnabled)
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)
// Invalid values (e.g. a hand-edited settings.json) fall back to the
// unset default rather than failing startup.
adminAreaAuth, _ := handlers.NormalizeAdminAreaAuth(persisted.AdminAreaAuth)
server.SetAdminAreaAuth(adminAreaAuth)
initMusicServices(config, server)
initTTSService(config, server)
// Redirect TuneIn upstream calls when overridden (e.g. to a local
// mock in integration tests); empty values keep the real hosts.
if config.tuneInOpmlURL != "" || config.tuneInAPIURL != "" {
bmx.SetTuneInEndpoints(config.tuneInOpmlURL, config.tuneInAPIURL)
}
// 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)
updateChecker := updatecheck.NewChecker(ds, "gesellix/Bose-SoundTouch", version)
server.SetUpdateChecker(updateChecker)
startUpdateCheck(server, updateChecker)
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))
}
}
// Embedded web UI (soundtouch-player): LAN control UI under /app, control
// API under /api/control. Same LAN-trust tier as /setup, no auth.
// Server-side self-calls (TTS proxy) use the service's own loopback
// HTTP listener so they never depend on TLS / the service CA.
loopbackHost := config.bindAddr
if loopbackHost == "" {
loopbackHost = "127.0.0.1"
}
internalURL := "http://" + net.JoinHostPort(loopbackHost, config.port)
webApp := newEmbeddedWebApp(server, config.serverURL, internalURL, ds)
r := setupRouter(server, stockholmHandler, webApp)
// Bind the listener before logging so we print the true
// effective port (handles :0 and catches "address already
// in use" before the TLS goroutine launches).
ln, err := net.Listen("tcp", config.addr)
if err != nil {
return fmt.Errorf("failed to listen on %s: %w", config.addr, err)
}
log.Printf("Go service listening on %s (configured: %s, server URL: %s)",
ln.Addr().String(), sanitizeLog(config.addr), 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.Serve(ln, 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 // effective (derived or overridden)
httpsOverride string // explicit override; "" = derive from serverURL
httpsPort string
httpsDefaultURL string // hostname-based fallback
httpsAddr string
redact bool
logBody bool
record bool
dnsEnabled bool
dnsUpstream string
dnsBind string
internalPaths []string
tlsExtraHosts []string
discoveryEnabled bool
discoveryInterval time.Duration
updateCheckEnabled bool
updateCheckInterval time.Duration
domains []string
spotifyClientID string
spotifyClientSecret string
spotifyRedirectURI string
spotifyTokenURL string
spotifyAPIBase string
amazonClientID string
amazonClientSecret string
amazonRedirectURI string
amazonTokenURL string
amazonProfileURL string
tuneInOpmlURL string
tuneInAPIURL string
mgmtUsername string
mgmtPassword string
ttsProvider string
ttsGoogleAPIKey string
ttsGoogleEndpoint string
ttsLanguage string
ttsVoice string
ttsAppKey string
ttsVolume int
migrationEnabled bool
migrationDryRun bool
stockholmDir string
stockholmBasePath string
}
// resolveFallbackHost picks the host used to guess a server URL when
// --server-url/SERVER_URL isn't set, based on where this service runs.
// On-device (running on the speaker's own Linux) is the one case where
// os.Hostname() is guaranteed useless: it returns the speaker's internal
// variant codename (e.g. "spotty", "mojo"), which nothing can resolve, not
// even the speaker itself (see issue #546). warnOnUse reports whether
// falling back to the returned host is risky enough to warrant a startup
// warning.
func resolveFallbackHost(deploymentMode string) (host string, warnOnUse bool) {
switch deploymentMode {
case "on-device":
return "localhost", false
case "public-network":
// Caller must refuse to guess a publicly reachable address.
return "", false
default: // "private-network", "", or any unrecognized value: today's behavior.
h, _ := os.Hostname()
if h == "" {
h = "localhost"
}
return strings.ToLower(h), true
}
}
func loadConfig(c *cli.Context) (serviceConfig, error) {
port := c.String("port")
bindAddr := c.String("bind")
addr := bindAddr + ":" + port
if bindAddr == "" {
addr = ":" + port
}
dataDir := c.String("data-dir")
deploymentMode := c.String("deployment-mode")
fallbackHost, warnOnFallback := resolveFallbackHost(deploymentMode)
serverURL := c.String("server-url")
if serverURL == "" {
if deploymentMode == "public-network" {
return serviceConfig{}, fmt.Errorf(
"--server-url (or SERVER_URL) is required when --deployment-mode=public-network; refusing to guess a public address")
}
serverURL = "http://" + fallbackHost + ":" + port
if warnOnFallback {
log.Printf("Warning: --server-url not set; defaulting to %s using this host's own hostname. "+
"If your SoundTouch speakers can't reach this address, set --server-url/SERVER_URL explicitly, "+
"or pass --deployment-mode=on-device if this runs on the speaker itself.", sanitizeLog(serverURL))
}
}
// Strip a trailing slash so it cannot leak into the BMX registry base or the
// margeServerUrl/bmxRegistryUrl pushed to speakers during migration.
serverURL = handlers.NormalizeServerURL(serverURL)
httpsPort := c.String("https-port")
httpsAddr := bindAddr + ":" + httpsPort
if bindAddr == "" {
httpsAddr = ":" + httpsPort
}
// The HTTPS URL is an override (from the flag/env); when empty it is
// derived from serverURL + https port so one setting (Target Domain)
// drives both. httpsDefaultURL is the same mode-aware fallback used
// before a Target Domain is configured.
httpsOverride := c.String("https-server-url")
httpsDefaultURL := "https://" + fallbackHost + ":" + httpsPort
httpsServerURL := handlers.DeriveHTTPSURL(serverURL, httpsOverride, httpsPort, httpsDefaultURL)
tlsExtraHosts := c.StringSlice("tls-extra-host")
domains := getDomains(serverURL, httpsServerURL, fallbackHost, 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
}
updateCheckEnabled := c.Bool("update-check-enabled")
updateCheckIntervalStr := c.String("update-check-interval")
updateCheckInterval, err := time.ParseDuration(updateCheckIntervalStr)
if err != nil {
log.Printf("Warning: Failed to parse update check interval %s, using default 24h: %v", sanitizeLog(updateCheckIntervalStr), err)
updateCheckInterval = 24 * time.Hour
}
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")
tuneInOpmlURL := c.String("tunein-opml-url")
tuneInAPIURL := c.String("tunein-api-url")
mgmtUsername := c.String("mgmt-username")
mgmtPassword := c.String("mgmt-password")
ttsProvider := c.String("tts-provider")
ttsGoogleAPIKey := c.String("tts-google-api-key")
ttsGoogleEndpoint := c.String("tts-google-endpoint")
ttsLanguage := c.String("tts-language")
ttsVoice := c.String("tts-voice")
ttsAppKey := c.String("tts-app-key")
ttsVolume := c.Int("tts-volume")
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: fallbackHost,
serverURL: serverURL,
httpsServerURL: httpsServerURL,
httpsOverride: httpsOverride,
httpsPort: httpsPort,
httpsDefaultURL: httpsDefaultURL,
httpsAddr: httpsAddr,
redact: redact,
logBody: logBody,
record: record,
dnsEnabled: dnsEnabled,
dnsUpstream: dnsUpstream,
dnsBind: dnsBind,
internalPaths: internalPaths,
tlsExtraHosts: tlsExtraHosts,
discoveryEnabled: discoveryEnabled,
discoveryInterval: discoveryInterval,
updateCheckEnabled: updateCheckEnabled,
updateCheckInterval: updateCheckInterval,
domains: domains,
spotifyClientID: spotifyClientID,
spotifyClientSecret: spotifyClientSecret,
spotifyRedirectURI: spotifyRedirectURI,
spotifyTokenURL: spotifyTokenURL,
spotifyAPIBase: spotifyAPIBase,
amazonClientID: amazonClientID,
amazonClientSecret: amazonClientSecret,
amazonRedirectURI: amazonRedirectURI,
amazonTokenURL: amazonTokenURL,
amazonProfileURL: amazonProfileURL,
tuneInOpmlURL: tuneInOpmlURL,
tuneInAPIURL: tuneInAPIURL,
mgmtUsername: mgmtUsername,
mgmtPassword: mgmtPassword,
ttsProvider: ttsProvider,
ttsGoogleAPIKey: ttsGoogleAPIKey,
ttsGoogleEndpoint: ttsGoogleEndpoint,
ttsLanguage: ttsLanguage,
ttsVoice: ttsVoice,
ttsAppKey: ttsAppKey,
ttsVolume: ttsVolume,
migrationEnabled: migrationEnabled,
migrationDryRun: migrationDryRun,
stockholmDir: stockholmDir,
stockholmBasePath: stockholmBasePath,
}, nil
}
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
}
// settingsFileExists reports whether a settings.json is already present in the
// data dir. It's the first-run discriminator: an existing file (even an
// incomplete, hand-authored one) must never be overwritten by the default
// seed, while a truly empty data dir gets defaults plus the lost-volume notice.
func settingsFileExists(dataDir string) bool {
if dataDir == "" {
return false
}
_, err := os.Stat(filepath.Join(dataDir, "settings.json"))
return err == nil
}
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 = handlers.NormalizeServerURL(persisted.ServerURL)
}
// persisted.HTTPServerURL is the HTTPS override (empty = derive).
// Existing installs carry their old effective value here; if it is
// exactly what we would derive anyway, treat it as "derive" so those
// installs don't show a spurious override in the UI. A genuinely custom
// value is kept as an override. Recompute either way, since serverURL
// may have come from the persisted settings above.
config.httpsOverride = persisted.HTTPServerURL
if config.httpsOverride != "" &&
config.httpsOverride == handlers.DeriveHTTPSURL(config.serverURL, "", config.httpsPort, config.httpsDefaultURL) {
config.httpsOverride = ""
}
config.httpsServerURL = handlers.DeriveHTTPSURL(config.serverURL, config.httpsOverride, config.httpsPort, config.httpsDefaultURL)
config.discoveryEnabled = persisted.DiscoveryEnabled
if persisted.DiscoveryInterval != "" {
if d, durErr := time.ParseDuration(persisted.DiscoveryInterval); durErr == nil {
config.discoveryInterval = d
}
}
// Installs upgraded from a build that predates the Settings-page toggle
// have no update_check_* keys at all, and an absent JSON bool decodes as
// false — taking it at face value would silently switch the check off for
// everyone who had opted in via UPDATE_CHECK_ENABLED. The interval is
// always written together with the flag (createDefaultSettings and
// HandleUpdateSettings both set both, and a time.Duration never
// stringifies to ""), so a non-empty interval is the marker for
// "settings.json genuinely carries an update-check preference".
if persisted.UpdateCheckInterval != "" {
config.updateCheckEnabled = persisted.UpdateCheckEnabled
if d, durErr := time.ParseDuration(persisted.UpdateCheckInterval); durErr == nil {
config.updateCheckInterval = 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
}
if config.ttsProvider == "" {
config.ttsProvider = persisted.TTSProvider
}
if config.ttsGoogleAPIKey == "" {
config.ttsGoogleAPIKey = persisted.TTSGoogleAPIKey
}
if config.ttsAppKey == "" {
config.ttsAppKey = persisted.TTSAppKey
}
if config.ttsLanguage == "" {
config.ttsLanguage = persisted.TTSLanguage
}
if config.ttsVoice == "" {
config.ttsVoice = persisted.TTSVoice
}
if config.ttsVolume == 0 {
config.ttsVolume = persisted.TTSVolume
}
}
func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings {
settings := datastore.Settings{
ServerURL: config.serverURL,
HTTPServerURL: config.httpsOverride,
RedactLogs: config.redact,
LogBodies: config.logBody,
RecordInteractions: config.record,
DiscoveryEnabled: config.discoveryEnabled,
DiscoveryInterval: config.discoveryInterval.String(),
// Seed the update-check preference from the CLI/env flags so a fresh
// install's settings.json matches what the operator asked for (and so
// the Settings page shows it) instead of silently reverting to off.
UpdateCheckEnabled: config.updateCheckEnabled,
UpdateCheckInterval: config.updateCheckInterval.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 {
warnIfDataDirNotWritable(dataDir)
ds := datastore.NewDataStore(dataDir)
if err := ds.Initialize(); err != nil {
log.Printf("Warning: Failed to initialize datastore: %v", err)
}
return ds
}
// warnIfDataDirNotWritable probes the data dir and logs an actionable message
// when the process can't write to it. The common cause is running the
// container as non-root (uid 65532) while a bind-mounted host directory is
// owned by someone else; without this the failure would surface later as a
// cryptic permission error deep in a save. It only warns: the datastore's own
// resilience handles the degraded state.
func warnIfDataDirNotWritable(dataDir string) {
if dataDir == "" {
return
}
if err := os.MkdirAll(dataDir, 0o755); err != nil {
log.Printf("WARNING: data dir %s cannot be created: %v", sanitizeLog(dataDir), err)
logDataDirChownHint(dataDir)
return
}
probe := filepath.Join(dataDir, ".write-probe")
if err := os.WriteFile(probe, []byte("ok"), 0o600); err != nil {
log.Printf("WARNING: data dir %s is not writable: %v", sanitizeLog(dataDir), err)
logDataDirChownHint(dataDir)
return
}
_ = os.Remove(probe)
}
// logDataDirChownHint prints the one-time fix for a non-writable bind-mounted
// data dir, using the process's own uid. Skipped where uid is unavailable
// (e.g. Windows), where the hint wouldn't apply.
func logDataDirChownHint(dataDir string) {
uid := os.Getuid()
if uid < 0 {
return
}
log.Printf(" The service runs as uid %d. If you bind-mounted a host directory as the data dir, "+
"make it writable once: chown -R %d:%d %s", uid, uid, uid, sanitizeLog(dataDir))
}
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)
}
}()
}
// updateCheckPollTick is how often the background update-check goroutine
// re-reads the live settings. It is deliberately much shorter than the
// check interval itself: sleeping a full (possibly 24h) interval between
// reads would make flipping the Settings-page toggle on appear to do
// nothing for up to a day.
const updateCheckPollTick = time.Minute
// shouldRunUpdateCheckNow reports whether the background goroutine should
// perform a real GitHub request on this poll tick. Pure/testable — no
// sleeping, no I/O.
//
// A zero interval is treated as "don't check": with interval 0 every tick
// would look due (shouldCheckImmediately), so an enabled check would hit
// GitHub once a minute forever. HandleUpdateSettings also refuses to keep
// the check enabled with a zero interval; this is the same guard for values
// that arrive via the CLI flag or a hand-edited settings.json.
func shouldRunUpdateCheckNow(
enabled bool,
lastCheckedAt time.Time,
interval time.Duration,
lastErrorAt, now time.Time,
) bool {
return enabled &&
interval > 0 &&
shouldCheckImmediately(lastCheckedAt, interval, now) &&
!shouldSkipDueToBackoff(lastErrorAt, now)
}
// startUpdateCheck runs the opt-in periodic check against GitHub Releases in
// the background (#591, _/i591/design-update-check.md). Unlike the original
// v1 design, enabled/interval are now live-reloadable from Settings (see
// handlers.Server.SetUpdateCheckSettings) — so this goroutine always runs,
// mirroring startDeviceDiscovery's live-settings pattern, and re-reads the
// current settings every updateCheckPollTick. It only performs the actual
// GitHub request when the check is enabled and the configured interval has
// elapsed since the last check, so "always running" does not mean "always
// talking to GitHub": with the check disabled it does nothing but wake up
// once a minute and go back to sleep.
func startUpdateCheck(server *handlers.Server, checker *updatecheck.Checker) {
go func() {
time.Sleep(randomJitter(5 * time.Minute))
lastResult := checker.LastResult()
lastLoggedVersion := lastResult.LatestVersion
var lastErrorAt time.Time
for {
interval, enabled := server.GetUpdateCheckSettings()
if shouldRunUpdateCheckNow(enabled, lastResult.CheckedAt, interval, lastErrorAt, time.Now()) {
lastErrorAt, lastLoggedVersion = runUpdateCheckTick(checker, lastLoggedVersion)
lastResult = checker.LastResult()
}
time.Sleep(updateCheckPollTick)
}
}()
}
// randomJitter returns a random duration in [0, upperBound) — the startup
// delay so many installs restarting together (e.g. after a Docker image
// bump) don't all hit GitHub at once. Not a security-sensitive use of
// randomness.
func randomJitter(upperBound time.Duration) time.Duration {
if upperBound <= 0 {
return 0
}
// nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used
return time.Duration(rand.Int63n(int64(upperBound))) //nolint:gosec
}
// shouldCheckImmediately reports whether a check should run right at
// startup (after jitter) rather than waiting a full interval: true when
// there's no persisted last-check time, or it's stale (older than one
// interval). Pure/testable — no sleeping.
func shouldCheckImmediately(lastCheckedAt time.Time, interval time.Duration, now time.Time) bool {
return lastCheckedAt.IsZero() || now.Sub(lastCheckedAt) >= interval
}
// shouldSkipDueToBackoff reports whether a tick should be skipped because
// the last attempt failed less than an hour ago — so a short
// UPDATE_CHECK_INTERVAL doesn't hammer GitHub while it's erroring. A zero
// lastErrorAt means "no recent failure", never skip. Pure/testable.
func shouldSkipDueToBackoff(lastErrorAt, now time.Time) bool {
return !lastErrorAt.IsZero() && now.Sub(lastErrorAt) < time.Hour
}
// logUpdateIfNewlyAvailable logs once when result reports a version newer
// than lastLoggedVersion, and returns the version to remember as "already
// logged" — unchanged when there's nothing new, so a persistently-available
// update doesn't spam the log every tick. Pure/testable.
func logUpdateIfNewlyAvailable(result updatecheck.Result, lastLoggedVersion string) string {
if result.Available && result.LatestVersion != "" && result.LatestVersion != lastLoggedVersion {
log.Printf("[UpdateCheck] Update available: %s (current %s) — %s",
result.LatestVersion, result.CurrentVersion, result.ReleaseURL)
return result.LatestVersion
}
return lastLoggedVersion
}
// runUpdateCheckTick performs one check, logs on failure, and returns the
// updated (lastErrorAt, lastLoggedVersion) pair for the caller to carry
// into the next iteration.
func runUpdateCheckTick(checker *updatecheck.Checker, lastLoggedVersion string) (time.Time, string) {
result, err := checker.CheckNow(context.Background())
if err != nil {
log.Printf("[UpdateCheck] check failed: %v", err)
return time.Now(), lastLoggedVersion
}
return time.Time{}, logUpdateIfNewlyAvailable(result, lastLoggedVersion)
}
// newEmbeddedWebApp builds the soundtouch-player application for embedding in the
// service router: release metadata from the build vars, the service's public
// ServiceURL (used by Play URL for speaker-fetched stream URLs and shown in the
// UI), a loopback InternalServiceURL for the player's own server-side self-calls
// (the TTS proxy) so they never depend on TLS or the service CA, and device
// state sourced entirely from the service.
//
// The web UI shares the service's discovery rather than running its own (the
// datastore is the single source of truth): ExtraDeviceHosts reads it,
// TriggerDiscovery runs the service sweep on a UI-initiated "discover", and the
// devices-changed hook re-syncs the UI registry whenever the service's
// discovery or a manual add changes the set.
func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, ds *datastore.DataStore) *soundtouchweb.WebApp {
webApp := soundtouchweb.NewWebApp()
webApp.Version = version
webApp.Commit = commit
webApp.Date = date
webApp.RepoURL = repoURL
webApp.ServiceURL = strings.TrimRight(serverURL, "/")
// The player's own server-side calls (the TTS proxy hits
// /api/setup/tts/speak) go to the service's loopback HTTP listener, not the
// public ServiceURL. That avoids the "service doesn't trust its own CA"
// x509 failure entirely: loopback is plain HTTP, so it needs no CA and
// works on HTTP and HTTPS deployments alike — and before the CA is even
// generated. ServiceURL stays the public URL because Play URL bakes it into
// stream URLs the speaker fetches and the UI displays it.
webApp.InternalServiceURL = internalURL
webApp.ExtraDeviceHosts = func() []string {
devices, listErr := ds.ListAllDevices()
if listErr != nil {
log.Printf("web UI: failed to list devices from datastore: %v", listErr)
return nil
}
hosts := make([]string, 0, len(devices))
for i := range devices {
if devices[i].IPAddress != "" {
hosts = append(hosts, devices[i].IPAddress)
}
}
return hosts
}
// UI "discover" runs the service's sweep, not a second mDNS stack.
webApp.TriggerDiscovery = server.DiscoverDevices
// A removal from the player UI cascades to the datastore (the single
// source of truth), so the device does not reappear on the next re-sync.
webApp.RemoveDeviceHook = func(deviceID string) error {
_, err := server.RemoveDeviceByID(deviceID)
return err
}
// Keep the UI registry live as the service discovers or devices are added.
server.SetDevicesChangedHook(func() {
webApp.SeedExtraDevices()
webApp.BroadcastDeviceList()
})
go func() {
// Project the current device set into the UI; the devices-changed hook
// and the service's periodic discovery keep it current from here on.
webApp.SeedExtraDevices()
webApp.BroadcastDeviceList()
}()
return webApp
}
func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, webApp *soundtouchweb.WebApp) *chi.Mux {
r := chi.NewRouter()
// CleanPath collapses duplicate slashes ("//bmx/..." -> "/bmx/...") and
// resolves . / .. before routing. Defensive net for the double-slash
// playback bug: even if a misconfigured base URL hands a speaker a "//bmx"
// path, it still reaches the right handler instead of 404ing. Runs first so
// every downstream middleware and the recorder see the cleaned path.
r.Use(middleware.CleanPath)
// ClientIPMiddleware must run before any handler that reads the client IP —
// SnapshotMiddleware captures the request, and several handlers
// (HandleMargePowerOn, etc.) inspect the source IP via middleware.GetClientIP.
// Always wired: at minimum the socket peer is recorded; when
// TrustForwardedHeaders is on and the peer is trusted, XFF is resolved.
r.Use(server.ClientIPMiddleware())
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.With(server.BasicAuthAdmin()).Get("/admin", server.HandleAdmin)
r.Get("/health", server.HandleHealth)
// Deliberately not behind BasicAuthAdmin — see HandleListAnnouncements'
// doc comment. #419.
r.Get("/api/announcements", server.HandleListAnnouncements)
r.Post("/api/announcements/{id}/dismiss", server.HandleDismissAnnouncement)
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)
// Synthesized TTS clips (Google Cloud provider). Served before the
// /media/* wildcard so the {id} param route takes precedence.
r.Get("/media/tts/{id}", server.HandleTTSMedia)
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) {
// Bare service descriptor (the registry's `self` link for TuneIn).
r.Get("/", server.HandleTuneInService)
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.Get("/core02/svc-bmx-adapter-orion/prod/orion", server.HandleOrionService)
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.Delete("/account/{account}/source/{sourceID}", server.HandleMargeDeleteSource)
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.
// 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)
// Speakers send DELETE /group/ (no group ID, trailing slash) during
// stereo-pair teardown; master and slave use their own account IDs
// so each deletes its own copy.
r.Delete("/group", server.HandleMargeDeleteAccountGroups)
r.Delete("/group/", server.HandleMargeDeleteAccountGroups)
})
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)
})
// The /accounts/* group mirrored /streaming/account/* for compatibility, but
// no speaker or app was ever observed using this prefix in the recording
// corpus (the integration tests that exercised it were migrated onto the
// /streaming equivalents). The whole mirror is therefore treated as unused
// and stubbed (HandleUnsupported): it logs + 501s so any real-world use
// surfaces instead of being silently dropped, leaving the prefix a clean
// removal candidate for the #451 refactor.
r.Route("/accounts", func(r chi.Router) {
r.Route("/{account}", func(r chi.Router) {
r.Get("/full", server.HandleUnsupported)
r.Get("/sources", server.HandleUnsupported)
r.Get("/devices", server.HandleUnsupported)
r.Post("/devices", server.HandleUnsupported)
r.Delete("/devices/{device}", server.HandleUnsupported)
r.Get("/devices/{device}/group", server.HandleUnsupported)
r.Get("/devices/{device}/group/", server.HandleUnsupported)
r.Get("/devices/{device}/group/server", server.HandleUnsupported)
r.Get("/devices/{device}/group/member", server.HandleUnsupported)
r.Post("/group", server.HandleUnsupported)
r.Post("/group/", server.HandleUnsupported)
r.Post("/group/{groupId}", server.HandleUnsupported)
r.Delete("/group/{groupId}", server.HandleUnsupported)
r.Delete("/group", server.HandleUnsupported)
r.Delete("/group/", server.HandleUnsupported)
r.Get("/devices/{device}/presets", server.HandleUnsupported)
r.Get("/devices/{device}/recents", server.HandleUnsupported)
r.Post("/devices/{device}/presets/{presetNumber}", server.HandleUnsupported)
r.Post("/devices/{device}/recents", server.HandleUnsupported)
})
})
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)
})
// app_key validation for the /speaker notification endpoint. Real Bose
// validated the app_key against its cloud; as the cloud replacement we
// accept it (200). A 404 here makes the speaker report "invalid app key"
// (HandleInvalidAppKeyCb) and refuse TTS/URL notifications.
// When an active DNS-path probe is running (POST /setup/health/dns-path-probe),
// a matching probe nonce returns 403 instead so no audio plays.
r.Get("/auth", server.HandleSpeakerAuth)
})
// Management API (admin tier). Registered under both /mgmt (legacy) and
// /api/mgmt (new canonical — issue #451 route-transition step 1) from one
// shared registration so the two paths stay byte-identical; both carry the
// same Basic Auth. The browser OAuth callbacks are externally-pinned
// (provider redirect URIs) and therefore stay at /mgmt only, not aliased.
mountMgmtAuthed := func(r chi.Router) {
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("/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. Not aliased under
// /api/mgmt (externally-pinned redirect URIs).
r.Get("/spotify/callback", server.HandleMgmtSpotifyCallback)
r.Get("/amazon/callback", server.HandleMgmtAmazonCallback)
// All other management endpoints require Basic Auth. On the legacy mount
// they also carry the deprecation signal (counts + one-time warning); the
// callbacks above are excluded (externally-pinned, not deprecated).
r.Group(func(r chi.Router) {
r.Use(server.BasicAuthMgmt())
r.Use(server.DeprecatedRouteMiddleware)
mountMgmtAuthed(r)
})
})
r.Route("/api/mgmt", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(server.BasicAuthMgmt())
mountMgmtAuthed(r)
})
})
// Setup / admin API (admin tier). Registered under both /setup (legacy) and
// /api/setup (new canonical) from one shared registration. The Stockholm
// setup-wizard static catch-all is a frontend concern and stays under /setup
// only — /api/setup serves data only.
//
// Split in two: mountSetupAPIShared is reachable regardless of
// AdminAreaAuth — soundtouch-cli and the embedded player call these
// directly without Management API credentials (ca.crt for `setup
// install-ca`, tts/speak+tts/config for the Play URL / TTS integration
// surface). mountSetupAPIAdmin is everything else — genuinely admin-UI-only,
// gated by BasicAuthAdmin() once #419's admin-area toggle is enabled.
mountSetupAPIShared := func(r chi.Router) {
r.Get("/ca.crt", server.HandleGetCACert)
// TTS lives under /setup (LAN-trust, like the rest of the integration
// surface and Play URL), not /mgmt: the API key is already configured
// via /setup/settings, and -web/CLI reach this without mgmt credentials.
r.Post("/tts/speak", server.HandleTTSSpeak)
r.Get("/tts/config", server.HandleTTSConfig)
}
mountSetupAPIAdmin := 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)
// 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("/peer-probe/{deviceId}", server.HandlePeerProbe)
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("/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.Post("/health/dns-path-probe", server.HandleDNSPathProbe)
r.Get("/export/diagnostic", server.HandleExportDiagnostic)
r.Get("/logs", server.HandleGetLogs)
}
r.Route("/setup", func(r chi.Router) {
// Legacy admin API: same handlers as /api/setup, plus the deprecation
// signal (counts + one-time warning). Scoped to the API routes only — the
// Stockholm wizard catch-all below is frontend, not a deprecated API path.
r.Group(func(r chi.Router) {
r.Use(server.DeprecatedRouteMiddleware)
mountSetupAPIShared(r)
})
r.Group(func(r chi.Router) {
r.Use(server.DeprecatedRouteMiddleware)
r.Use(server.BasicAuthAdmin())
mountSetupAPIAdmin(r)
})
// 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/*. Frontend-only — not mirrored under /api/setup.
// Not gated by AdminAreaAuth: Stockholm is a separate, off-by-default
// (--stockholm-dir) legacy wizard, out of scope for #419.
if stockholmHandler != nil {
r.Get("/*", stockholmHandler.HandleStatic)
r.Get("/", stockholmHandler.HandleStatic)
}
})
r.Route("/api/setup", func(r chi.Router) {
mountSetupAPIShared(r)
r.Group(func(r chi.Router) {
r.Use(server.BasicAuthAdmin())
mountSetupAPIAdmin(r)
})
})
// Embedded web UI: control API under /api/control and the SPA under /app
// (LAN-trust, like /setup). Additive — nothing here collides with the
// service's own /, /health, or /static. The web app shares the service's
// discovery (nil discovery service here), so it runs no mDNS of its own.
// Skipped when nil, e.g. unit tests that only exercise the service surface.
if webApp != nil {
webApp.MountWeb(r, nil)
}
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
}
go func() {
listener, err := net.Listen("tcp", httpsAddr)
if err != nil {
log.Printf("[TLS] Failed to create listener: %v", err)
return
}
log.Printf("Go service listening HTTPS on %s (server URL: %s)",
listener.Addr().String(), sanitizeLog(httpsServerURL))
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
}