Files
Bose-SoundTouch/pkg/service/handlers/server.go
T
Tobias GesellchenandClaude Sonnet 4.6 3d5f8717d0 sec5a: sanitize log-injection in pkg/service/handlers
Fixes CodeQL go/log-injection alerts in the handlers package.

Adds pkg/service/handlers/logutil.go with a package-private
sanitizeLog helper that strips \n and \r from strings before they
reach log call sites. Values from speakers, HTTP requests, and
external APIs (device IDs, account IDs, IP addresses, speaker names,
OAuth user IDs/emails, station IDs, URL paths, user-agent strings)
may contain attacker-controlled newlines.

Wraps all external-data string arguments across 12 files:
handlers_account_mgmt.go, handlers_alexa.go, handlers_bmx_orion.go,
handlers_bmx_siriusxm.go, handlers_bmx_tunein.go, handlers_catchall.go,
handlers_export.go, handlers_marge.go, handlers_mgmt.go,
handlers_oauth.go, origin_middleware.go, server.go.

No behaviour change — purely a logging concern. make check passes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:20:25 +02:00

1292 lines
38 KiB
Go

package handlers
import (
"bytes"
"context"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"log"
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/amazon"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/health"
"github.com/gesellix/bose-soundtouch/pkg/service/logbuf"
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
"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/miekg/dns"
)
// Server handles HTTP requests for the SoundTouch service.
type Server struct {
ds *datastore.DataStore
sm *setup.Manager
mu sync.RWMutex
serverURL string
httpsServerURL string
discovering bool
redactLogs bool
logBodies bool
recordEnabled bool
discoveryInterval time.Duration
discoveryEnabled bool
dnsEnabled bool
dnsUpstream []string
dnsBindAddr string
internalPaths []string
shortcuts map[string]int
recorder *proxy.Recorder
dnsDiscovery *discovery.DNSDiscovery
Version string
Commit string
Date string
RepoURL string
mgmtUsername string
mgmtPassword string
spotifyClientID string
spotifyClientSecret string
spotifyRedirectURI string
spotifyService *spotify.Service
amazonClientID string
amazonClientSecret string
amazonRedirectURI string
amazonService *amazon.Service
peerObserver *peerObserver
healthRegistry *health.Registry
logBuf *logbuf.Buffer
expectedHosts []string
ownCACache struct {
once sync.Once
cert *x509.Certificate
}
}
// RequestSnapshot represents an immutable snapshot of an HTTP request.
type RequestSnapshot struct {
Method string
URL *url.URL
Headers http.Header
Body []byte
Host string
Timestamp time.Time
}
type ctxKey struct{ name string }
// SnapshotKey is the context key for the RequestSnapshot.
var SnapshotKey = &ctxKey{"request_snapshot"}
var bufferPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
// NewServer creates a new SoundTouch service server.
func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, redactLogs, logBodies, recordEnabled bool) *Server {
s := &Server{
ds: ds,
sm: sm,
serverURL: serverURL,
redactLogs: redactLogs,
logBodies: logBodies,
recordEnabled: recordEnabled,
discoveryInterval: 5 * time.Minute,
discoveryEnabled: true,
peerObserver: newPeerObserver(),
healthRegistry: health.NewRegistry(),
}
health.RegisterSourcesXMLPresent(s.healthRegistry, ds)
health.RegisterSpeakerInfoReachable(s.healthRegistry, ds)
health.RegisterSourcesXMLDiff(s.healthRegistry, ds)
health.RegisterSpeakerMargeURLCheck(s.healthRegistry, ds, s.ExpectedHosts)
health.RegisterCertChainCheck(
s.healthRegistry,
func() string {
_, httpsURL := s.GetSettings()
return httpsURL
},
s.loadOwnCACert,
)
health.RegisterCACertExpiryCheck(s.healthRegistry, s.loadOwnCACert, s.ownCACertPath)
health.RegisterTestPlaybackCheck(s.healthRegistry, ds, func() string {
serverURL, _ := s.GetSettings()
return serverURL
})
health.RegisterOrionPathsCheck(s.healthRegistry, ds)
health.RegisterPresetsCountCheck(s.healthRegistry, ds)
health.RegisterPresetsConsistencyCheck(s.healthRegistry, ds)
health.RegisterRefreshSourcesCheck(s.healthRegistry, ds)
health.RegisterStaleInternetRadioCheck(s.healthRegistry, ds)
health.RegisterDefaultAccountNonBoseDevicesCheck(s.healthRegistry, ds)
health.RegisterOAuthTargetReachableCheck(
s.healthRegistry,
func() string {
serverURL, _ := s.GetSettings()
return serverURL
},
s.GetDNSRunning,
)
// Health QuickFix executor for the empty-margeAccountUUID
// finding from RegisterSpeakerInfoReachable. Lives here (not in
// the health package) because the executor needs setup.Manager
// to drive PairAccount — and the health package deliberately
// avoids importing setup to keep its transitive dep surface
// small (see the boundary comment near speakerInfoXML).
s.healthRegistry.RegisterFix(
health.CheckIDSpeakerInfoReachable,
health.FixIDCompleteSpeakerPairing,
s.completeSpeakerPairingFix,
)
// QuickFix executor for the speaker_marge_url mismatch finding.
// Adds the speaker's actual margeURL host to settings.TLSExtraHosts
// so a subsequent restart picks it up via applyPersistedSettings.
s.healthRegistry.RegisterFix(
health.CheckIDSpeakerMargeURL,
health.FixIDAddMargeHostToTLS,
s.addMargeHostToTLSFix,
)
health.RegisterDNSSanityCheck(
s.healthRegistry,
s.GetDNSRunning,
func() string {
serverURL, _ := s.GetSettings()
ip, err := s.ResolveServerURLIPForPreflight(serverURL)
if err != nil {
return ""
}
return ip
},
)
return s
}
// SetExpectedHosts records the hostnames the service considers its
// own (serverURL host + httpsServerURL host + --tls-extra-host
// values). The Health tab's Marge-URL check reads this list at
// run time to decide whether a speaker's <margeURL> points at us.
func (s *Server) SetExpectedHosts(hosts []string) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]string, len(hosts))
copy(out, hosts)
s.expectedHosts = out
}
// ExpectedHosts returns a copy of the recorded expected-hosts list.
func (s *Server) ExpectedHosts() []string {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]string, len(s.expectedHosts))
copy(out, s.expectedHosts)
return out
}
// persistedTLSExtraHosts returns the slice of TLS extra hosts that
// live in settings.json. Used by HandleGetSettings to render the
// "edit list" UI separately from the full effective SAN list
// (ExpectedHosts also contains serverURL host, httpsServerURL host,
// hostname, and CLI/env-pinned extras). Returns an empty slice if
// the settings file is missing or unreadable — the caller should
// treat that the same as "operator hasn't added anything yet".
func (s *Server) persistedTLSExtraHosts() []string {
persisted, err := s.ds.GetSettings()
if err != nil {
return []string{}
}
out := make([]string, len(persisted.TLSExtraHosts))
copy(out, persisted.TLSExtraHosts)
return out
}
// ownCACertPath returns the on-disk path of AfterTouch's own CA
// cert (PEM). Empty string when the certmanager isn't wired in.
// Used by the Health-tab CA-expiry check to render an accurate
// remediation command pointing at the actual file.
func (s *Server) ownCACertPath() string {
if s.sm == nil || s.sm.Crypto == nil {
return ""
}
return s.sm.Crypto.GetCACertPath()
}
// loadOwnCACert parses AfterTouch's own CA leaf from disk. Used
// by the Health-tab cert-chain check to definitively classify
// whether the HTTPS endpoint is serving a cert issued by this
// service's built-in CA (as opposed to a public CA or a foreign
// chain from a reverse proxy). Returns nil when the CA isn't
// configured or fails to parse — the caller falls back to a
// Subject==Issuer heuristic in that case.
//
// The parse is cached in ownCACache so repeated Health polls
// don't re-read the PEM. Restart-based config changes are
// picked up because Server itself is reconstructed.
func (s *Server) loadOwnCACert() *x509.Certificate {
s.ownCACache.once.Do(func() {
if s.sm == nil || s.sm.Crypto == nil {
return
}
path := s.sm.Crypto.GetCACertPath()
if path == "" {
return
}
data, err := os.ReadFile(path)
if err != nil {
return
}
block, _ := pem.Decode(data)
if block == nil {
return
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return
}
s.ownCACache.cert = cert
})
return s.ownCACache.cert
}
// TrustedRealIPMiddleware returns a chi middleware that rewrites
// r.RemoteAddr from X-Real-IP / X-Forwarded-For / True-Client-IP, but only
// when the immediate TCP peer is in the configured trusted-proxy list.
// Returns nil when Settings.TrustForwardedHeaders is false (the safe
// default), so the caller can skip wiring the middleware entirely.
//
// The trusted-peer gate prevents the typical X-Forwarded-* spoofing surface:
// on a flat LAN where a malicious speaker could send the headers itself, we
// won't honour them; behind a documented reverse proxy on loopback we will.
func (s *Server) TrustedRealIPMiddleware() func(http.Handler) http.Handler {
settings, err := s.ds.GetSettings()
if err != nil {
log.Printf("[RealIP] failed to load settings: %v — skipping forwarded-header trust", err)
return nil
}
if !settings.TrustForwardedHeaders {
return nil
}
cidrs, err := ParseTrustedProxyCIDRs(settings.TrustedProxyCIDRs)
if err != nil {
log.Printf("[RealIP] invalid trusted_proxy_cidrs: %v — skipping forwarded-header trust", err)
return nil
}
return TrustedRealIP(cidrs)
}
// SetVersionInfo sets the version information for the server.
func (s *Server) SetVersionInfo(version, commit, date, repoURL string) {
s.mu.Lock()
defer s.mu.Unlock()
s.Version = version
s.Commit = commit
s.Date = date
s.RepoURL = repoURL
}
// SetLogBuffer attaches a logbuf.Buffer to the server. When set,
// HandleGetLogs returns its contents; when nil, the endpoint
// reports an empty snapshot. Optional so that tests and
// alternative composers (the standalone web binary, etc.) don't
// have to construct a buffer they don't need.
func (s *Server) SetLogBuffer(buf *logbuf.Buffer) {
s.mu.Lock()
defer s.mu.Unlock()
s.logBuf = buf
}
// LogBuffer returns the attached log buffer, or nil if none.
func (s *Server) LogBuffer() *logbuf.Buffer {
s.mu.RLock()
defer s.mu.RUnlock()
return s.logBuf
}
// SetDiscoverySettings sets the discovery settings for the server.
func (s *Server) SetDiscoverySettings(interval time.Duration, enabled bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.discoveryInterval = interval
s.discoveryEnabled = enabled
}
// parseUpstreamDNS splits a comma-separated string of DNS servers.
func parseUpstreamDNS(upstream string) []string {
var upstreamList []string
if upstream != "" {
for _, u := range strings.Split(upstream, ",") {
u = strings.TrimSpace(u)
if u != "" {
upstreamList = append(upstreamList, u)
}
}
}
return upstreamList
}
// getSystemDNS returns the DNS servers from /etc/resolv.conf.
func getSystemDNS() []string {
config, _ := dns.ClientConfigFromFile("/etc/resolv.conf")
if config != nil && len(config.Servers) > 0 {
return config.Servers
}
return nil
}
// areUpstreamsEqual compares two slices of DNS server addresses.
func areUpstreamsEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// SetDNSSettings sets the DNS discovery settings for the server.
func (s *Server) SetDNSSettings(enabled bool, upstream, bind string) {
s.mu.Lock()
defer s.mu.Unlock()
oldBind := s.dnsBindAddr
oldUpstream := s.dnsUpstream
s.dnsEnabled = enabled
s.dnsBindAddr = bind
upstreamList := parseUpstreamDNS(upstream)
// Try to get system DNS if none provided
if enabled && len(upstreamList) == 0 {
upstreamList = getSystemDNS()
if len(upstreamList) > 0 {
log.Printf("[DNS] Using system DNS servers from /etc/resolv.conf: %v", upstreamList)
}
}
s.dnsUpstream = upstreamList
upstreamChanged := !areUpstreamsEqual(upstreamList, oldUpstream)
if s.dnsDiscovery != nil {
if !enabled || bind != oldBind || upstreamChanged {
log.Printf("[DNS] Settings changed, stopping DNS discovery server")
_ = s.dnsDiscovery.Shutdown()
s.dnsDiscovery = nil
}
}
if enabled && len(upstreamList) == 0 {
log.Printf("[DNS] Cannot start DNS discovery server: upstream DNS is empty and no system DNS found")
s.dnsEnabled = false
return
}
if enabled && s.dnsDiscovery == nil {
s.startDNSDiscovery(bind, upstreamList)
}
}
// ResolveServerURLIPForPreflight is an exported wrapper around resolveServerURLIP
// so callers outside the package (e.g. the service startup pre-flight) can
// reuse the same resolution path the DNS server uses.
func (s *Server) ResolveServerURLIPForPreflight(serverURL string) (string, error) {
return s.resolveServerURLIP(serverURL)
}
// resolveServerURLIP returns the IP that the DNS server would hand out as the
// intercept answer for the given server URL. An empty URL, empty hostname, or a
// hostname that cannot be resolved to an IP is reported as an error so callers
// can refuse to start (or reject user input) instead of silently degrading.
// "localhost" is treated as 127.0.0.1.
func (s *Server) resolveServerURLIP(serverURL string) (string, error) {
if strings.TrimSpace(serverURL) == "" {
return "", fmt.Errorf("server URL is empty")
}
u, err := url.Parse(serverURL)
if err != nil {
return "", fmt.Errorf("invalid server URL %q: %w", serverURL, err)
}
hostname := u.Hostname()
if hostname == "" {
return "", fmt.Errorf("server URL %q has no hostname", serverURL)
}
if hostname == "localhost" {
return "127.0.0.1", nil
}
if ip := net.ParseIP(hostname); ip != nil {
return ip.String(), nil
}
// Prefer the setup manager's resolver (it cascades through device SSH ping
// then system DNS). Fall back to plain system DNS when no manager is wired,
// so this works in tests and lightweight server constructions.
if s.sm != nil {
if resolved := s.sm.GetResolvedIP(hostname); net.ParseIP(resolved) != nil {
return resolved, nil
}
} else if ips, lookupErr := net.LookupIP(hostname); lookupErr == nil {
for _, ip := range ips {
if v4 := ip.To4(); v4 != nil {
return v4.String(), nil
}
}
if len(ips) > 0 {
return ips[0].String(), nil
}
}
return "", fmt.Errorf("hostname %q did not resolve to an IP — "+
"set the server URL to an IP, or to a hostname this host can resolve",
hostname)
}
func (s *Server) startDNSDiscovery(bind string, upstreamList []string) {
log.Printf("[DNS] Starting DNS discovery server on %s", bind)
serviceIP, err := s.resolveServerURLIP(s.serverURL)
if err != nil {
log.Printf("[DNS] Cannot start DNS discovery server: %v", err)
s.dnsEnabled = false
return
}
s.dnsDiscovery = discovery.NewDNSDiscovery(upstreamList, serviceIP, s.serverURL)
go func(d *discovery.DNSDiscovery, addr string) {
if err := d.Start(addr); err != nil {
log.Printf("Warning: DNS discovery server error: %v", err)
}
}(s.dnsDiscovery, bind)
}
// GetDNSRunning returns whether DNS discovery is active and its bind address.
func (s *Server) GetDNSRunning() (bool, string) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.dnsDiscovery == nil {
return false, ""
}
return s.dnsDiscovery.IsRunning(s.dnsBindAddr), s.dnsBindAddr
}
// SetDNSDiscoveries sets the initial DNS discoveries for the server.
func (s *Server) SetDNSDiscoveries(discoveries map[string]*discovery.DiscoveredHost) {
s.mu.Lock()
defer s.mu.Unlock()
if s.dnsDiscovery != nil {
s.dnsDiscovery.SetDiscovered(discoveries)
}
}
// GetDNSDiscovery returns the current DNS discoveries.
func (s *Server) GetDNSDiscovery() map[string]*discovery.DiscoveredHost {
s.mu.RLock()
defer s.mu.RUnlock()
if s.dnsDiscovery == nil {
return nil
}
return s.dnsDiscovery.GetDiscovered()
}
// SetShortcuts sets the request shortcuts for the server.
func (s *Server) SetShortcuts(shortcuts map[string]int) {
s.mu.Lock()
defer s.mu.Unlock()
s.shortcuts = shortcuts
}
// GetShortcuts returns the current request shortcuts.
func (s *Server) GetShortcuts() map[string]int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.shortcuts
}
// GetDiscoverySettings returns the current discovery settings.
func (s *Server) GetDiscoverySettings() (time.Duration, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.discoveryInterval, s.discoveryEnabled
}
// SetHTTPServerURL sets the external HTTPS URL of the service.
func (s *Server) SetHTTPServerURL(url string) {
s.mu.Lock()
defer s.mu.Unlock()
s.httpsServerURL = url
}
// SetRecorder sets the recorder for the server.
func (s *Server) SetRecorder(r *proxy.Recorder) {
s.mu.Lock()
defer s.mu.Unlock()
s.recorder = r
if r != nil {
r.Redact = s.redactLogs
}
}
// SetSpotifyConfig sets the Spotify OAuth configuration.
func (s *Server) SetSpotifyConfig(clientID, clientSecret, redirectURI string) {
s.mu.Lock()
defer s.mu.Unlock()
s.spotifyClientID = clientID
s.spotifyClientSecret = clientSecret
s.spotifyRedirectURI = redirectURI
}
// SetAmazonConfig sets the Amazon LWA OAuth configuration.
func (s *Server) SetAmazonConfig(clientID, clientSecret, redirectURI string) {
s.mu.Lock()
defer s.mu.Unlock()
s.amazonClientID = clientID
s.amazonClientSecret = clientSecret
s.amazonRedirectURI = redirectURI
}
// GetSpotifyConfig returns the current Spotify OAuth configuration.
func (s *Server) GetSpotifyConfig() (clientID, clientSecret, redirectURI string) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.spotifyClientID, s.spotifyClientSecret, s.spotifyRedirectURI
}
// GetAmazonConfig returns the current Amazon LWA OAuth configuration.
func (s *Server) GetAmazonConfig() (clientID, clientSecret, redirectURI string) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.amazonClientID, s.amazonClientSecret, s.amazonRedirectURI
}
// applyMusicServiceCredentials updates music service credential fields on the server.
// Must be called with s.mu held. Empty string or "***" (the masked GET value) means "unchanged".
func (s *Server) applyMusicServiceCredentials(spotifyID, spotifySecret, spotifyURI, amazonID, amazonSecret, amazonURI string) {
if spotifyID != "" {
s.spotifyClientID = spotifyID
}
if spotifySecret != "" && spotifySecret != "***" {
s.spotifyClientSecret = spotifySecret
}
if spotifyURI != "" {
s.spotifyRedirectURI = spotifyURI
}
if amazonID != "" {
s.amazonClientID = amazonID
}
if amazonSecret != "" && amazonSecret != "***" {
s.amazonClientSecret = amazonSecret
}
if amazonURI != "" {
s.amazonRedirectURI = amazonURI
}
}
// ReinitSpotifyService creates a new Spotify service from current config and replaces the running one.
func (s *Server) ReinitSpotifyService() {
clientID, clientSecret, redirectURI := s.GetSpotifyConfig()
if clientID == "" {
return
}
if redirectURI == "" {
redirectURI = s.serverURL + "/mgmt/spotify/callback"
}
svc := spotify.NewSpotifyService(clientID, clientSecret, redirectURI, s.ds.DataDir)
if err := svc.Load(); err != nil {
log.Printf("[Spotify] Failed to load accounts during reinit: %v", err)
}
s.SetSpotifyService(svc)
log.Printf("[Spotify] Service reinitialized")
}
// ReinitAmazonService creates a new Amazon service from current config and replaces the running one.
func (s *Server) ReinitAmazonService() {
clientID, clientSecret, redirectURI := s.GetAmazonConfig()
if clientID == "" {
return
}
if redirectURI == "" {
redirectURI = s.serverURL + "/mgmt/amazon/callback"
}
svc := amazon.NewAmazonService(clientID, clientSecret, redirectURI, s.ds.DataDir)
if err := svc.Load(); err != nil {
log.Printf("[Amazon] Failed to load accounts during reinit: %v", err)
}
s.SetAmazonService(svc)
log.Printf("[Amazon] Service reinitialized")
}
// SetMgmtConfig sets the management API authentication credentials.
func (s *Server) SetMgmtConfig(username, password string) {
s.mu.Lock()
defer s.mu.Unlock()
s.mgmtUsername = username
s.mgmtPassword = password
}
// SetInternalPaths sets the internal paths for the server.
func (s *Server) SetInternalPaths(paths []string) {
s.mu.Lock()
defer s.mu.Unlock()
s.internalPaths = paths
}
// SetAmazonService sets the Amazon OAuth service.
func (s *Server) SetAmazonService(as *amazon.Service) {
s.mu.Lock()
defer s.mu.Unlock()
s.amazonService = as
}
// IsAmazonConfigured returns whether Amazon Music integration is configured.
func (s *Server) IsAmazonConfigured() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.amazonService != nil
}
// SetSpotifyService sets the Spotify OAuth service.
func (s *Server) SetSpotifyService(ss *spotify.Service) {
s.mu.Lock()
defer s.mu.Unlock()
s.spotifyService = ss
}
// GetRecordEnabled returns whether recording is enabled.
func (s *Server) GetRecordEnabled() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.recordEnabled
}
// GetSettings returns the current server settings.
func (s *Server) GetSettings() (string, string) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.serverURL, s.httpsServerURL
}
// IsSpotifyConfigured returns whether Spotify integration is configured.
func (s *Server) IsSpotifyConfigured() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.spotifyService != nil
}
// GetLoggingSettings returns the current logging settings (redact / log-body / record).
func (s *Server) GetLoggingSettings() (bool, bool, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.redactLogs, s.logBodies, s.recordEnabled
}
// DiscoverDevices starts a background device discovery process.
//
//nolint:contextcheck
func (s *Server) DiscoverDevices(ctx context.Context) {
s.discovering = true
defer func() { s.discovering = false }()
log.Println("Scanning for Bose devices...")
// Use background context if none provided or if it's likely a request context
if ctx == nil {
ctx = context.Background()
}
// Always wrap in a timeout to prevent hanging forever
discoveryCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
svc := discovery.NewService(10 * time.Second)
devices, err := svc.DiscoverDevices(discoveryCtx)
if err != nil {
log.Printf("Discovery error: %v", err)
return
}
for _, d := range devices {
s.handleDiscoveredDevice(*d)
}
// Post-discovery cleanup: merge overlapping IP/Serial entries
s.mergeOverlappingDevices()
}
// findExistingDeviceInfoByDeviceID looks for existing device info by deviceID
func (s *Server) findExistingDeviceInfoByDeviceID(deviceID string) *models.ServiceDeviceInfo {
allDevices, err := s.ds.ListAllDevices()
if err != nil {
return nil
}
for i := range allDevices {
device := &allDevices[i]
if device.DeviceID == deviceID {
return device
}
}
return nil
}
// PrimeDeviceWithSpotify triggers a Spotify priming of the speaker if a Spotify account is linked.
func (s *Server) PrimeDeviceWithSpotify(deviceIP string) {
s.mu.RLock()
svc := s.spotifyService
s.mu.RUnlock()
if svc == nil {
return
}
accounts := svc.GetAccounts()
if len(accounts) == 0 {
return
}
// We'll use the first linked account. In the future, we might want to let the user
// pick or map accounts to speakers, but for now, we follow the "One linked account" model.
accessToken, username, err := svc.GetFreshToken()
if err != nil {
log.Printf("[Spotify Watchdog] Failed to get fresh token for %s: %v", sanitizeLog(deviceIP), err)
return
}
log.Printf("[Spotify Watchdog] Proactively priming %s with Spotify user %s", sanitizeLog(deviceIP), sanitizeLog(username))
// Register the SPOTIFY source in our marge datastore before pushing credentials.
// Without this, storePreset later fails with "AddPreset - failed due to invalid SourceID"
// because marge.UpdatePreset can't match SourceID="SPOTIFY" against any ConfiguredSource.
s.registerSpotifySourceForDevice(deviceIP, accounts)
if err := s.pushSpotifyTokenToDevice(deviceIP, username, accessToken); err != nil {
// addUser may return a benign 404+empty-body no-op when the speaker
// already has the activeUser set. The zeroconf-level log already
// recorded the specifics; here we just upgrade the watchdog's view to
// "primed" since marge holds the authoritative SPOTIFY source.
if errors.Is(err, spotify.ErrAddUserNoOp) {
log.Printf("[Spotify Watchdog] Successfully primed %s (ZeroConf addUser was an expected no-op)", sanitizeLog(deviceIP))
} else {
log.Printf("[Spotify Watchdog] Failed to prime %s: %v", sanitizeLog(deviceIP), err)
}
} else {
log.Printf("[Spotify Watchdog] Successfully primed %s", sanitizeLog(deviceIP))
}
}
// registerSpotifySourceForDevice writes a SPOTIFY ConfiguredSource into the marge
// datastore under the device's currently-paired account. No-op (with a log
// message) if the device can't be resolved to an account — falling back to
// "default" here would risk polluting an unrelated account's source list, and
// any storePreset the device sends will be under its real paired account anyway.
func (s *Server) registerSpotifySourceForDevice(deviceIP string, accounts []spotify.Account) {
host := deviceIP
if h, _, err := net.SplitHostPort(deviceIP); err == nil {
host = h
}
accountID, deviceID := s.resolvePairedAccount(deviceIP, host)
if accountID == "" {
log.Printf("[Spotify Watchdog] No paired account for %s yet — skipping marge source registration", sanitizeLog(deviceIP))
return
}
registered := false
for _, acc := range accounts {
credential := acc.BoseSecret
if credential == "" {
credential = acc.AccessToken
}
if _, err := marge.AddSource(s.ds, accountID, acc.UserID, strconv.Itoa(constants.SpotifyProviderID), credential, "token_version_3", acc.DisplayName); err != nil {
log.Printf("[Spotify Watchdog] Failed to register Spotify source for account %s: %v", sanitizeLog(accountID), err)
continue
}
log.Printf("[Spotify Watchdog] Registered Spotify source %s for account %s (device %s)", sanitizeLog(acc.UserID), sanitizeLog(accountID), sanitizeLog(deviceID))
registered = true
}
// Tell the speaker its sources list changed so it re-fetches from marge.
// Without this its on-device Sources.xml stays stale until something else
// triggers a sync — which leaves storePreset failing with
// "AddPreset - failed due to invalid SourceID" even though our marge
// datastore already has the SPOTIFY entry.
if registered && deviceID != "" {
c := client.NewClientFromHost(deviceIP)
if err := c.NotifySourcesUpdated(deviceID); err != nil {
log.Printf("[Spotify Watchdog] sourcesUpdated notification for %s failed: %v", sanitizeLog(deviceIP), err)
} else {
log.Printf("[Spotify Watchdog] Notified %s to re-sync sources (deviceID=%s)", sanitizeLog(deviceIP), sanitizeLog(deviceID))
}
}
}
// resolvePairedAccount returns the device's currently-paired account ID and its
// canonical deviceID. It prefers the live :8090/info margeAccountUUID (matches
// what the device will actually send on storePreset) and falls back to the
// datastore record. Mirrors setup.populateDeviceInfo's resolution order so
// priming and migration agree on which account a device belongs to.
//
// deviceIP is the original input (may carry a :port for tests); host is the
// bare host for datastore IPAddress matching.
func (s *Server) resolvePairedAccount(deviceIP, host string) (accountID, deviceID string) {
if devInfo := s.findExistingDeviceInfoByIP(host); devInfo != nil {
accountID = devInfo.AccountID
deviceID = devInfo.DeviceID
}
if s.sm != nil {
if info, err := s.sm.GetLiveDeviceInfo(deviceIP); err == nil {
if info.MargeAccountUUID != "" {
accountID = info.MargeAccountUUID
}
if info.DeviceID != "" {
deviceID = info.DeviceID
}
} else {
log.Printf("[Spotify Watchdog] live /info lookup for %s failed: %v (falling back to datastore account=%q)", sanitizeLog(deviceIP), err, sanitizeLog(accountID))
}
}
return accountID, deviceID
}
// findExistingDeviceInfoByIP looks up a device record by IP address across all accounts.
func (s *Server) findExistingDeviceInfoByIP(ip string) *models.ServiceDeviceInfo {
allDevices, err := s.ds.ListAllDevices()
if err != nil {
return nil
}
for i := range allDevices {
if allDevices[i].IPAddress == ip {
return &allDevices[i]
}
}
return nil
}
func (s *Server) pushSpotifyTokenToDevice(deviceIP, username, accessToken string) error {
var zcURL string
if _, _, err := net.SplitHostPort(deviceIP); err == nil {
zcURL = fmt.Sprintf("http://%s/zc", deviceIP)
} else {
zcURL = fmt.Sprintf("http://%s:8200/zc", deviceIP)
}
return spotify.PushSpotifyCredentials(zcURL, username, accessToken)
}
// PrimeDeviceWithAmazon triggers an Amazon Music priming of the speaker if an Amazon account is linked.
func (s *Server) PrimeDeviceWithAmazon(deviceIP string) {
s.mu.RLock()
svc := s.amazonService
s.mu.RUnlock()
if svc == nil {
return
}
accounts := svc.GetAccounts()
if len(accounts) == 0 {
return
}
accessToken, username, err := svc.GetFreshToken()
if err != nil {
log.Printf("[Amazon Watchdog] Failed to get fresh token for %s: %v", sanitizeLog(deviceIP), err)
return
}
log.Printf("[Amazon Watchdog] Proactively priming %s with Amazon user %s", sanitizeLog(deviceIP), sanitizeLog(username))
if err := s.pushAmazonTokenToDevice(deviceIP, username, accessToken); err != nil {
if errors.Is(err, amazon.ErrAddUserNoOp) {
log.Printf("[Amazon Watchdog] Successfully primed %s (ZeroConf addUser was an expected no-op)", sanitizeLog(deviceIP))
} else {
log.Printf("[Amazon Watchdog] Failed to prime %s: %v", sanitizeLog(deviceIP), err)
}
} else {
log.Printf("[Amazon Watchdog] Successfully primed %s", sanitizeLog(deviceIP))
}
}
func (s *Server) pushAmazonTokenToDevice(deviceIP, username, accessToken string) error {
var zcURL string
if _, _, err := net.SplitHostPort(deviceIP); err == nil {
zcURL = fmt.Sprintf("http://%s/zc", deviceIP)
} else {
zcURL = fmt.Sprintf("http://%s:8200/zc", deviceIP)
}
return amazon.PushAmazonCredentials(zcURL, username, accessToken)
}
func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) {
log.Printf("Discovered Bose device: %s at %s (Serial: %s)", sanitizeLog(d.Name), sanitizeLog(d.Host), sanitizeLog(d.SerialNo))
// 1. Always fetch live device info from /info endpoint as the authoritative source
liveInfo, err := s.sm.GetLiveDeviceInfo(d.Host)
if err != nil {
log.Printf("Failed to fetch live device info for %s at %s: %v", sanitizeLog(d.Name), sanitizeLog(d.Host), err)
// Fallback to discovery info if /info is not available
s.handleDiscoveredDeviceFallback(d)
return
}
// 2. Use deviceID from /info as the canonical device identifier
deviceID := liveInfo.DeviceID
if deviceID == "" {
log.Printf("No deviceID found in /info response for %s at %s, using fallback", sanitizeLog(d.Name), sanitizeLog(d.Host))
s.handleDiscoveredDeviceFallback(d)
return
}
log.Printf("Using deviceID '%s' from /info for device %s at %s", sanitizeLog(deviceID), sanitizeLog(d.Name), sanitizeLog(d.Host))
// 3. Get account ID from live info or fallback to existing/default
storedAccount := ""
if existing := s.findExistingDeviceInfoByDeviceID(deviceID); existing != nil {
storedAccount = existing.AccountID
}
accountID := liveInfo.MargeAccountUUID
if accountID == "" {
accountID = storedAccount
}
if accountID == "" {
accountID = "default"
}
// If the speaker reports a paired account that differs from the stored
// location, migrate the device directory so ListAllDevices doesn't return duplicates.
if liveInfo.MargeAccountUUID != "" && storedAccount != "" && liveInfo.MargeAccountUUID != storedAccount {
if err := s.ds.MoveDevice(storedAccount, accountID, deviceID); err != nil {
log.Printf("Failed to migrate device %s from %s to %s: %v",
sanitizeLog(deviceID), sanitizeLog(storedAccount), sanitizeLog(accountID), err)
}
}
// 4. Get primary MAC address from networkInfo
macAddress := liveInfo.GetPrimaryMacAddress()
// 5. Build complete device info from live data
info := &models.ServiceDeviceInfo{
DeviceID: deviceID, // Use deviceID from /info (MAC address)
AccountID: accountID,
Name: liveInfo.Name, // Use name from /info
IPAddress: d.Host, // IP from discovery
MacAddress: macAddress, // MAC from /info networkInfo
DeviceSerialNumber: liveInfo.SerialNumber, // Serial from components
ProductCode: liveInfo.Type + " " + liveInfo.ModuleType, // Type + ModuleType
FirmwareVersion: liveInfo.SoftwareVer,
ProductSerialNumber: "", // Will be populated from components if available
DiscoveryMethod: d.DiscoveryMethod,
}
// 6. Extract product serial number from PackagedProduct component
for _, comp := range liveInfo.Components {
if comp.Category == "PackagedProduct" && comp.SerialNumber != "" {
info.ProductSerialNumber = comp.SerialNumber
break
}
}
// 7. Save the updated device info
if err := s.ds.SaveDeviceInfo(accountID, deviceID, info); err != nil {
log.Printf("Failed to save device info for %s: %v", sanitizeLog(deviceID), err)
return
}
// If the device was (or needed to be) relocated to a different account, ensure the
// stale source entry is gone. MoveDevice's rename is a no-op if the target already
// existed (e.g. partial duplicate state), leaving the source dir behind; removing it
// here is safe because SaveDeviceInfo above has already written fresh data to
// accountID. RemoveDevice returns nil when the path does not exist, so this is also
// a harmless no-op when MoveDevice already renamed the directory successfully.
if storedAccount != "" && storedAccount != accountID {
if err := s.ds.RemoveDevice(storedAccount, deviceID); err != nil {
log.Printf("Failed to remove stale device entry for %s in %s: %v",
sanitizeLog(deviceID), sanitizeLog(storedAccount), err)
}
}
// 8. Create default Sources.xml only when no sources file exists yet
if !s.ds.HasConfiguredSources(accountID, deviceID) {
if sources, err := s.ds.GetConfiguredSources(accountID, deviceID); err == nil {
log.Printf("Creating default Sources.xml for device %s", sanitizeLog(deviceID))
if err := s.ds.SaveConfiguredSources(accountID, deviceID, sources); err != nil {
log.Printf("Failed to save default sources for %s: %v", sanitizeLog(deviceID), err)
}
}
}
log.Printf("Successfully saved device %s (%s) with MAC-based deviceID: %s", sanitizeLog(info.Name), sanitizeLog(d.Host), sanitizeLog(deviceID))
}
// handleDiscoveredDeviceFallback handles device discovery when /info endpoint is not available
func (s *Server) handleDiscoveredDeviceFallback(d models.DiscoveredDevice) {
log.Printf("Using fallback discovery method for device: %s at %s", sanitizeLog(d.Name), sanitizeLog(d.Host))
// Use discovery data as-is with the old logic
existingID := s.findExistingDeviceID(d)
deviceID := d.SerialNo
if deviceID == "" {
deviceID = d.Host
}
accountID := "default"
if existing := s.findExistingDeviceInfo(d); existing != nil {
accountID = existing.AccountID
}
info := &models.ServiceDeviceInfo{
DeviceID: deviceID,
AccountID: accountID,
Name: d.Name,
IPAddress: d.Host,
DeviceSerialNumber: d.SerialNo,
ProductCode: d.ModelID,
FirmwareVersion: "0.0.0", // Unknown from discovery
DiscoveryMethod: d.DiscoveryMethod,
}
// If we had an IP-based entry and now have a Serial, clean up the IP-based entry
if d.SerialNo != "" && existingID != "" && existingID != d.SerialNo {
log.Printf("Device %s previously known as %s, migrating to serial-based ID %s", sanitizeLog(d.Name), sanitizeLog(existingID), sanitizeLog(d.SerialNo))
_ = s.ds.RemoveDevice(accountID, existingID)
}
if err := s.ds.SaveDeviceInfo(accountID, deviceID, info); err != nil {
log.Printf("Failed to save device info for %s: %v", sanitizeLog(deviceID), err)
return
}
// Create default Sources.xml only when no sources file exists yet
if !s.ds.HasConfiguredSources(accountID, deviceID) {
if sources, err := s.ds.GetConfiguredSources(accountID, deviceID); err == nil {
log.Printf("Creating default Sources.xml for device %s (fallback)", sanitizeLog(deviceID))
if err := s.ds.SaveConfiguredSources(accountID, deviceID, sources); err != nil {
log.Printf("Failed to save default sources for %s: %v", sanitizeLog(deviceID), err)
}
}
}
log.Printf("Successfully saved device %s (%s) with fallback deviceID: %s", sanitizeLog(info.Name), sanitizeLog(d.Host), sanitizeLog(deviceID))
}
func (s *Server) mergeOverlappingDevices() {
allDevices, err := s.ds.ListAllDevices()
if err != nil {
return
}
// Group devices by IP
byIP := make(map[string][]models.ServiceDeviceInfo)
for i := range allDevices {
dev := allDevices[i]
if dev.IPAddress != "" {
byIP[dev.IPAddress] = append(byIP[dev.IPAddress], dev)
}
}
for ip, devices := range byIP {
if len(devices) <= 1 {
continue
}
// We have multiple entries for the same IP.
// Try to find one with a Serial Number to be the master.
var master *models.ServiceDeviceInfo
for i := range devices {
if devices[i].DeviceSerialNumber != "" {
master = &devices[i]
break
}
}
if master == nil {
// Fallback: look for one with DeviceID that isn't the IP
for i := range devices {
if devices[i].DeviceID != "" && devices[i].DeviceID != devices[i].IPAddress {
master = &devices[i]
break
}
}
}
if master == nil {
// None have serials, just keep the first one
continue
}
masterID := master.DeviceID
if masterID == "" {
masterID = master.DeviceSerialNumber
}
for i := range devices {
dev := devices[i]
devID := dev.DeviceID
if devID == "" {
devID = dev.IPAddress
}
if devID != masterID && dev.IPAddress == ip {
log.Printf("Merging overlapping device entry %s into %s (IP: %s)", sanitizeLog(devID), sanitizeLog(masterID), sanitizeLog(ip))
_ = s.ds.RemoveDevice(dev.AccountID, devID)
}
}
}
}
func (s *Server) findExistingDeviceID(d models.DiscoveredDevice) string {
info := s.findExistingDeviceInfo(d)
if info != nil {
return info.DeviceID
}
return ""
}
func (s *Server) findExistingDeviceInfo(d models.DiscoveredDevice) *models.ServiceDeviceInfo {
allDevices, _ := s.ds.ListAllDevices()
for i := range allDevices {
known := allDevices[i]
// Match by Serial
if d.SerialNo != "" && (known.DeviceID == d.SerialNo || known.DeviceSerialNumber == d.SerialNo) {
return &known
}
// Match by IP
if d.Host != "" && known.IPAddress == d.Host {
return &known
}
}
return nil
}
func (s *Server) resolveDeviceIDToIP(deviceID string) (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
// 1. Try to find in Datastore
devices, err := s.ds.ListAllDevices()
if err == nil {
for i := range devices {
if devices[i].DeviceID == deviceID {
return devices[i].IPAddress, nil
}
}
}
return "", fmt.Errorf("device not found: %s", deviceID)
}