Files
Bose-SoundTouch/pkg/service/handlers/server.go
T
Tobias Gesellchen 9f61d00b2d feat(admin): live Settings-page toggle for the opt-in update check
Follow-up to #591: UpdateCheckEnabled/UpdateCheckInterval are now
persisted, live-reloaded Settings fields (mirroring the discovery
enabled/interval pattern), editable from the admin Settings page
without a restart. The env var/CLI flag remains the seed value for a
fresh install with no settings.json yet.

The background goroutine now always runs and polls the live settings
every minute (updateCheckPollTick), instead of being started only if
enabled at process launch, so flipping the toggle takes effect within
a minute rather than requiring a restart.
2026-08-10 23:17:36 +02:00

1893 lines
58 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/gesellix/bose-soundtouch/pkg/service/tts"
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
"github.com/gesellix/bose-soundtouch/pkg/ssh"
"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 // effective (derived or overridden) HTTPS URL
httpsOverride string // explicit HTTPS URL override; "" means derive from serverURL
httpsPort string // configured HTTPS port, used when deriving
httpsDefaultURL string // startup hostname-based fallback when serverURL has no host
httpsListenAddr string
discovering bool
redactLogs bool
logBodies bool
recordEnabled bool
discoveryInterval time.Duration
discoveryEnabled bool
updateCheckInterval time.Duration // live update-check interval; see SetUpdateCheckSettings
updateCheckEnabled bool // live update-check opt-in; defaults off (#591)
dnsEnabled bool
dnsUpstream []string
dnsBindAddr string
internalPaths []string
shortcuts map[string]int
recorder *proxy.Recorder
dnsDiscovery *discovery.DNSDiscovery
authProbes *authProbeRegistry
authProbeTimeoutOverride time.Duration // zero means use defaultAuthProbeTimeout; injectable for tests
deprecatedRoutes *deprecatedRouteTracker
devicesChangedHook func()
Version string
Commit string
Date string
RepoURL string
mgmtUsername string
mgmtPassword string
adminAreaAuth string // "" (unset) / "enabled" / "disabled" — see datastore.Settings.AdminAreaAuth
dismissedAnnouncements map[string]time.Time // announcement id -> most recent dismissal; see RecordDismissal
updateChecker *updatecheck.Checker // the HTTP-checking object; nil unless SetUpdateChecker was called
spotifyClientID string
spotifyClientSecret string
spotifyRedirectURI string
spotifyService *spotify.Service
amazonClientID string
amazonClientSecret string
amazonRedirectURI string
amazonService *amazon.Service
ttsService *tts.Service
ttsProvider string
ttsGoogleAPIKey string
ttsGoogleEndpoint string // test-only override; not exposed in the UI
ttsAppKey string
ttsLanguage string
ttsVoice string
ttsVolume int
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)
},
}
// NormalizeServerURL trims surrounding whitespace and any trailing slashes from
// a configured server URL. A trailing slash poisons every URL built by string
// concatenation from it, most visibly the BMX registry base ("{BMX_SERVER}/bmx/
// tunein" in bmx_services.json): it would otherwise hand a speaker
// "http://host:8000//bmx/tunein" and make it request "//bmx/tunein/...", a path
// the chi router does not match, so playback 404s. It also keeps {MEDIA_SERVER}
// and the OAuth redirect URIs free of a stray double slash.
func NormalizeServerURL(serverURL string) string {
return strings.TrimRight(strings.TrimSpace(serverURL), "/")
}
// 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: NormalizeServerURL(serverURL),
redactLogs: redactLogs,
logBodies: logBodies,
recordEnabled: recordEnabled,
discoveryInterval: 5 * time.Minute,
discoveryEnabled: true,
// The update check is opt-in (#591): only the interval gets a default,
// updateCheckEnabled stays false so no install starts making outbound
// GitHub calls without an explicit yes.
updateCheckInterval: 24 * time.Hour,
peerObserver: newPeerObserver(),
healthRegistry: health.NewRegistry(),
authProbes: newAuthProbeRegistry(defaultAuthProbeTTL),
deprecatedRoutes: newDeprecatedRouteTracker(),
}
health.RegisterSourcesXMLPresent(s.healthRegistry, ds)
health.RegisterSpeakerInfoReachable(s.healthRegistry, ds)
health.RegisterSourcesXMLDiff(s.healthRegistry, ds)
health.RegisterSpeakerMargeURLCheck(s.healthRegistry, ds, s.ExpectedHosts)
health.RegisterRuntimeBmxURLStaleCheck(
s.healthRegistry,
ds,
s.readSpeakerBmxRegistryURL,
func() bool {
running, _ := s.GetDNSRunning()
return running
},
)
health.RegisterCertChainCheck(
s.healthRegistry,
func() string {
_, httpsURL := s.GetSettings()
return httpsURL
},
s.actualHTTPSPort,
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.RegisterSpeakerCABundleCheck(s.healthRegistry, ds, func(deviceIP string) (string, string, bool) {
return s.sm.ProbeCABundles(deviceIP)
})
health.RegisterSpeakerClockCheck(s.healthRegistry, ds, func(ip string) (int64, int64, bool) {
cfg := client.DefaultConfig()
cfg.Host = ip
cfg.Timeout = 5 * time.Second
c := client.NewClient(cfg)
ct, err := c.GetClockTime()
if err != nil || ct == nil || ct.GetUTC() == 0 {
return 0, 0, false
}
return ct.GetUTC(), ct.GetUTCSyncTime(), true
}, s.setSpeakerClock)
health.RegisterServerURLReachableCheck(s.healthRegistry, func() string {
serverURL, _ := s.GetSettings()
return serverURL
})
health.RegisterOAuthTargetReachableCheck(
s.healthRegistry,
func() string {
serverURL, _ := s.GetSettings()
return serverURL
},
s.GetDNSRunning,
)
health.RegisterSpotifyAccountLinkedCheck(
s.healthRegistry,
func() bool { return s.spotifyService != nil },
func() int {
if s.spotifyService == nil {
return 0
}
return len(s.spotifyService.GetAccounts())
},
)
health.RegisterMgmtDefaultCredentialsCheck(
s.healthRegistry,
func() (string, string) { return s.mgmtUsername, s.mgmtPassword },
)
health.RegisterAdminAreaAuthCheck(s.healthRegistry, s.AdminAreaAuthMode)
// 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,
)
// QuickFix executors for the speaker_ca_bundle integrity check.
// Fix executors live here (not in the health package) because they
// need setup.Manager — the same boundary as completeSpeakerPairingFix.
s.healthRegistry.RegisterFix(
health.CheckIDSpeakerCABundle,
health.FixIDRestoreAndInjectCA,
s.restoreAndInjectCAFix,
)
s.healthRegistry.RegisterFix(
health.CheckIDSpeakerCABundle,
health.FixIDInjectCACert,
s.injectCACertFix,
)
health.RegisterDNSSanityCheck(
s.healthRegistry,
s.GetDNSRunning,
func() string {
serverURL, _ := s.GetSettings()
ip, err := s.ResolveServerURLIPForPreflight(serverURL)
if err != nil {
return ""
}
return ip
},
)
health.RegisterDNSSpeakerUsageCheck(
s.healthRegistry,
s.ds,
s.GetDNSRunning,
func() map[string]time.Time {
if s.dnsDiscovery == nil {
return map[string]time.Time{}
}
return s.dnsDiscovery.InterceptClientIPs()
},
)
// QuickFix executor for the dns_speaker_usage per-device info findings.
// Lives here (not in the health package) because it needs runDNSPathProbe,
// which is part of the handlers layer. The health package deliberately
// avoids importing handlers to keep its transitive dep surface small.
//
// Registered without refresh: this probe is a diagnostic whose value is the
// result message ("DNS path OK" / "no callback ..."). A refresh would re-fetch
// the whole health list and wipe that message from the UI before the operator
// can read it. The operator can refresh manually to see a now-confirmed
// speaker drop its finding.
s.healthRegistry.RegisterFixNoRefresh(
health.CheckIDDNSSpeakerUsage,
"probe_dns_path",
func(target health.Target) (string, error) {
res, err := s.runDNSPathProbe(target.Device, "")
if err != nil {
return "", err
}
if res.Success {
return fmt.Sprintf(
"Speaker resolved a Bose hostname through AfterTouch in %.0fms. DNS path OK.",
res.LatencyMs,
), nil
}
msg := "No /v1/auth callback within the timeout; this speaker likely resolves Bose hostnames via a different DNS resolver."
if res.Remediation != "" {
msg += " " + res.Remediation
}
return msg, nil
},
)
s.dismissedAnnouncements = loadDismissedAnnouncements(ds)
return s
}
// clockSetTolerance is how close the speaker's clock must be to the target
// after a set for the set to count as successful.
const clockSetTolerance = 2 * time.Minute
// setSpeakerClock is the set_clock QuickFix executor. It sets the speaker's
// clock to the service's current time and verifies the clock actually moved
// before reporting success.
//
// Two transports are tried because firmware varies: some builds honour
// POST /clockTime, but others dispatch that POST to their read handler
// (HandleClockGetTime) and silently ignore it, so the HTTP path is a no-op
// there. SSH `date` reliably sets the system clock on an SSH-reachable
// speaker (root, empty password — the usual unlocked state). We verify by
// re-reading /clockTime regardless of which path "succeeded", so we never
// report success when the clock didn't change.
func (s *Server) setSpeakerClock(ip string) error {
now := time.Now()
// 1) HTTP /clockTime: harmless, and works on firmware that honours it.
httpErr := s.setSpeakerClockHTTP(ip, now)
if speakerClockWithin(ip, now, clockSetTolerance) {
return nil
}
// 2) SSH `date`: the reliable path on firmware that ignores the HTTP POST.
sshErr := setSpeakerClockSSH(ip, now)
if speakerClockWithin(ip, now, clockSetTolerance) {
return nil
}
return fmt.Errorf(
"speaker clock unchanged after both transports (http: %v; ssh: %v). "+
"This firmware ignores POST /clockTime (it handles the POST as a read), and SSH was not usable. "+
"The durable fix is time sync: the speaker likely cannot resolve/reach an NTP server, "+
"so restore DNS/NTP reachability (a wrong clock breaks HTTPS/TLS)",
httpErr, sshErr,
)
}
// setSpeakerClockHTTP pushes the time via POST /clockTime. Returns the
// request error (a 200 here does not guarantee the clock changed; the caller
// verifies separately).
func (s *Server) setSpeakerClockHTTP(ip string, t time.Time) error {
cfg := client.DefaultConfig()
cfg.Host = ip
cfg.Timeout = 5 * time.Second
return client.NewClient(cfg).SetClockTime(models.NewClockTimeRequest(t))
}
// setSpeakerClockSSH sets the speaker's system clock over SSH. The command is
// built only from the service's own timestamp (no user input). It tries the
// coreutils `-s` form first and falls back to the BusyBox positional form
// (MMDDhhmmCCYY.ss), covering both firmware flavours.
func setSpeakerClockSSH(ip string, t time.Time) error {
utc := t.UTC()
cmd := fmt.Sprintf(
"date -u -s '%s' || date -u %s",
utc.Format("2006-01-02 15:04:05"),
utc.Format("010215042006.05"),
)
out, err := ssh.NewClient(ip).Run(cmd)
if err != nil {
return fmt.Errorf("ssh date: %w (%s)", err, strings.TrimSpace(out))
}
return nil
}
// speakerClockWithin reports whether the speaker's current clock is within
// tol of target. Used to verify a set actually took effect.
func speakerClockWithin(ip string, target time.Time, tol time.Duration) bool {
cfg := client.DefaultConfig()
cfg.Host = ip
cfg.Timeout = 5 * time.Second
ct, err := client.NewClient(cfg).GetClockTime()
if err != nil || ct == nil {
return false
}
utc := ct.GetUTC()
if utc == 0 {
return false
}
skew := target.Unix() - utc
if skew < 0 {
skew = -skew
}
return skew <= int64(tol.Seconds())
}
// 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
}
// ClientIPMiddleware returns a chi middleware that resolves the client IP into
// the request context (read via middleware.GetClientIP). Always returns a
// non-nil middleware: at minimum, the socket peer is recorded.
//
// When Settings.TrustForwardedHeaders is true and the immediate TCP peer is in
// the configured trusted-proxy list, the X-Forwarded-For header is also
// consulted: chi walks the chain right-to-left, skipping entries that fall
// within the trusted CIDRs, and stores the first untrusted entry as the client.
//
// 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) ClientIPMiddleware() func(http.Handler) http.Handler {
settings, err := s.ds.GetSettings()
if err != nil {
log.Printf("[ClientIP] failed to load settings: %v - falling back to peer-only", err)
return clientIPMiddleware(false, nil, nil)
}
return buildClientIPMiddleware(settings.TrustForwardedHeaders, settings.TrustedProxyCIDRs)
}
// 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
}
// SetUpdateCheckSettings sets the live update-check settings for the server.
//
// Kept adjacent to its getter (rather than next to GetDiscoverySettings
// further down) so the pair reads as one unit; the background goroutine in
// soundtouch-service re-reads them on every poll, which is what makes the
// Settings-page toggle take effect without a restart.
func (s *Server) SetUpdateCheckSettings(interval time.Duration, enabled bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.updateCheckInterval = interval
s.updateCheckEnabled = enabled
}
// GetUpdateCheckSettings returns the current update-check interval and enabled state.
func (s *Server) GetUpdateCheckSettings() (time.Duration, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.updateCheckInterval, s.updateCheckEnabled
}
// SetDevicesChangedHook registers a callback fired after the known device set
// changes (a discovery sweep or a manual add). The embedded web UI uses it to
// re-sync its registry from the shared datastore — the single source of truth —
// so it never runs its own discovery. Nil-safe.
func (s *Server) SetDevicesChangedHook(hook func()) {
s.mu.Lock()
defer s.mu.Unlock()
s.devicesChangedHook = hook
}
// notifyDevicesChanged fires the devices-changed hook, if one is registered.
func (s *Server) notifyDevicesChanged() {
s.mu.RLock()
hook := s.devicesChangedHook
s.mu.RUnlock()
if hook != nil {
hook()
}
}
// 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", sanitizeLog(bind))
serviceIP, err := s.resolveServerURLIP(s.serverURL)
if err != nil {
log.Printf("[DNS] Cannot start DNS discovery server: %s", sanitizeErr(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.
//
// Deprecated: prefer SetHTTPSSettings, which tracks the override vs the
// derived value so the effective URL follows the Target Domain. Kept for
// callers that set the effective URL directly.
func (s *Server) SetHTTPServerURL(url string) {
s.mu.Lock()
defer s.mu.Unlock()
s.httpsServerURL = url
}
// SetHTTPSSettings records the HTTPS URL override (empty = derive from
// the Target Domain), the configured HTTPS port, and the startup
// hostname-based fallback, then recomputes the effective HTTPS URL.
func (s *Server) SetHTTPSSettings(override, httpsPort, defaultURL string) {
s.mu.Lock()
defer s.mu.Unlock()
s.httpsOverride = strings.TrimSpace(override)
s.httpsPort = httpsPort
s.httpsDefaultURL = defaultURL
s.recomputeHTTPSURLLocked()
}
// recomputeHTTPSURLLocked refreshes the effective HTTPS URL from the
// current serverURL + override + port. Callers must hold s.mu.
func (s *Server) recomputeHTTPSURLLocked() {
s.httpsServerURL = DeriveHTTPSURL(s.serverURL, s.httpsOverride, s.httpsPort, s.httpsDefaultURL)
}
// applyHTTPSOverrideLocked sets the HTTPS override from an optional
// request value (nil = preserve the current one, non-nil replaces it,
// empty re-enables deriving) and recomputes the effective URL. Callers
// must hold s.mu.
func (s *Server) applyHTTPSOverrideLocked(override *string) {
if override != nil {
s.httpsOverride = strings.TrimSpace(*override)
}
s.recomputeHTTPSURLLocked()
}
// HTTPSOverride returns the explicit HTTPS URL override, or "" when the
// effective URL is derived from the Target Domain.
func (s *Server) HTTPSOverride() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.httpsOverride
}
// SetHTTPSListenAddr records the address the HTTPS listener is bound
// to (e.g. ":8443"). The cert-chain health check uses its port to
// detect an advertised-URL/listener port mismatch (issue #355).
func (s *Server) SetHTTPSListenAddr(addr string) {
s.mu.Lock()
defer s.mu.Unlock()
s.httpsListenAddr = addr
}
// actualHTTPSPort returns the port the HTTPS listener is bound to, or
// "" if unknown/unparseable.
func (s *Server) actualHTTPSPort() string {
s.mu.RLock()
addr := s.httpsListenAddr
s.mu.RUnlock()
if addr == "" {
return ""
}
_, port, err := net.SplitHostPort(addr)
if err != nil {
return ""
}
return port
}
// 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
}
// SetAdminAreaAuth sets the live admin-area auth mode ("" / "enabled" /
// "disabled", see datastore.Settings.AdminAreaAuth). Does not validate —
// callers (HandleUpdateSettings, startup settings application) are
// responsible for only passing already-validated values.
func (s *Server) SetAdminAreaAuth(mode string) {
s.mu.Lock()
defer s.mu.Unlock()
s.adminAreaAuth = mode
}
// AdminAreaAuthMode returns the live admin-area auth mode. Exported so
// packages that can't import handlers directly (e.g. health checks, which
// take it via a callback to avoid a circular import) can read it.
func (s *Server) AdminAreaAuthMode() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.adminAreaAuth
}
// activityKindNotificationDismissed is the datastore.RecordActivity "kind"
// used for announcement-banner dismissals (see #419 design,
// _/i419/design-admin-area-auth-gate.md).
const activityKindNotificationDismissed = "notification_dismissed"
// loadDismissedAnnouncements scans the local activity log once at startup
// and folds it into an id -> most-recent-dismissal-timestamp map. Called
// from NewServer so the read path (IsAnnouncementDismissed) never touches
// disk — only this one, scoped, boot-time scan does, regardless of how
// large the log grows over time. Errors are logged, not fatal: a missing or
// unreadable activity log means "nothing dismissed yet", not a startup failure.
func loadDismissedAnnouncements(ds *datastore.DataStore) map[string]time.Time {
dismissed := make(map[string]time.Time)
if ds == nil {
return dismissed
}
records, err := ds.GetActivityRecords(activityKindNotificationDismissed)
if err != nil {
log.Printf("[Announcements] Failed to load dismissal history, treating as none: %v", err)
return dismissed
}
for _, record := range records {
ts, parseErr := time.Parse(time.RFC3339Nano, record.Timestamp)
if parseErr != nil {
continue
}
if existing, ok := dismissed[record.ID]; !ok || ts.After(existing) {
dismissed[record.ID] = ts
}
}
return dismissed
}
// RecordDismissal marks an announcement as dismissed: appends to the local
// activity log (write-through) and updates the in-memory cache immediately,
// so IsAnnouncementDismissed reflects it without re-reading disk. The same
// id can be dismissed again later (e.g. if re-shown) — each call is a new
// log entry, not an overwrite.
func (s *Server) RecordDismissal(id string) error {
if s.ds != nil {
if err := s.ds.RecordActivity(activityKindNotificationDismissed, id, nil); err != nil {
return err
}
}
s.mu.Lock()
defer s.mu.Unlock()
if s.dismissedAnnouncements == nil {
s.dismissedAnnouncements = make(map[string]time.Time)
}
s.dismissedAnnouncements[id] = time.Now()
return nil
}
// IsAnnouncementDismissed reports whether the given announcement id has
// been dismissed, from the in-memory cache only — never touches disk.
func (s *Server) IsAnnouncementDismissed(id string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
_, ok := s.dismissedAnnouncements[id]
return ok
}
// SetUpdateChecker registers the update checker (#591). The checker itself is
// always constructed and registered, regardless of whether the periodic check
// is enabled, so /api/setup/version and the Announcements banner can read
// LastResult() (e.g. a result persisted by an earlier run) even before the
// periodic check has ever run. Only the periodic background check is gated by
// the live enabled setting — see SetUpdateCheckSettings. Callers that leave
// this nil are still safe: UpdateCheckResult returns the zero value.
func (s *Server) SetUpdateChecker(c *updatecheck.Checker) {
s.mu.Lock()
defer s.mu.Unlock()
s.updateChecker = c
}
// UpdateCheckResult returns the last known update-check result, or the
// zero value (Available: false) if the check was never enabled.
func (s *Server) UpdateCheckResult() updatecheck.Result {
s.mu.RLock()
checker := s.updateChecker
s.mu.RUnlock()
if checker == nil {
return updatecheck.Result{}
}
return checker.LastResult()
}
// 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
}
// SetTTSService sets the text-to-speech service.
func (s *Server) SetTTSService(t *tts.Service) {
s.mu.Lock()
defer s.mu.Unlock()
s.ttsService = t
}
// ttsSvc returns the configured TTS service, or nil if none is set.
func (s *Server) ttsSvc() *tts.Service {
s.mu.RLock()
defer s.mu.RUnlock()
return s.ttsService
}
// SetTTSConfig sets the text-to-speech configuration. An empty endpoint keeps
// the production default. Call ReinitTTSService afterwards to (re)build the
// running service from this config.
func (s *Server) SetTTSConfig(provider, googleAPIKey, googleEndpoint, appKey, language, voice string, volume int) {
s.mu.Lock()
defer s.mu.Unlock()
s.ttsProvider = provider
s.ttsGoogleAPIKey = googleAPIKey
s.ttsGoogleEndpoint = googleEndpoint
s.ttsAppKey = appKey
s.ttsLanguage = language
s.ttsVoice = voice
s.ttsVolume = volume
}
// GetTTSConfig returns the current text-to-speech configuration.
func (s *Server) GetTTSConfig() (provider, googleAPIKey, appKey, language, voice string, volume int) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.ttsProvider, s.ttsGoogleAPIKey, s.ttsAppKey, s.ttsLanguage, s.ttsVoice, s.ttsVolume
}
// applyTTSConfig updates TTS config from a settings save. Empty provider keeps
// the existing one; empty or "***" secrets (API key, app key) keep the existing
// value so the UI never has to round-trip them. Caller must hold s.mu.
func (s *Server) applyTTSConfig(provider, googleAPIKey, appKey, language, voice string, volume int) {
if provider != "" {
s.ttsProvider = provider
}
if googleAPIKey != "" && googleAPIKey != "***" {
s.ttsGoogleAPIKey = googleAPIKey
}
if appKey != "" && appKey != "***" {
s.ttsAppKey = appKey
}
s.ttsLanguage = language
s.ttsVoice = voice
s.ttsVolume = volume
}
// ttsConfigured reports whether TTS is usefully configured: an app_key is
// required to play on the speaker, and the google-cloud provider additionally
// needs an API key. Caller must hold at least s.mu.RLock.
func (s *Server) ttsConfigured() bool {
if s.ttsAppKey == "" {
return false
}
if s.ttsProvider == tts.ProviderGoogleCloud {
return s.ttsGoogleAPIKey != ""
}
return true
}
// ReinitTTSService builds the TTS service from the current config and replaces
// the running one. Unlike the OAuth services it always installs a service (the
// translate provider needs no credentials); the provider is chosen by
// s.ttsProvider, defaulting to translate.
func (s *Server) ReinitTTSService() {
s.mu.RLock()
provider := s.ttsProvider
apiKey := s.ttsGoogleAPIKey
endpoint := s.ttsGoogleEndpoint
cfg := tts.Config{
BaseURL: s.serverURL,
AppKey: s.ttsAppKey,
DefaultLanguage: s.ttsLanguage,
DefaultVoice: s.ttsVoice,
DefaultVolume: s.ttsVolume,
}
s.mu.RUnlock()
var p tts.Provider
switch provider {
case tts.ProviderGoogleCloud:
if apiKey == "" {
log.Printf("[TTS] Provider 'google-cloud' selected but no API key set; synthesis will fail until one is provided")
}
cloud := tts.NewCloudProvider(apiKey)
if endpoint != "" {
cloud.SetEndpoint(endpoint)
}
p = cloud
case tts.ProviderTranslate, "":
p = tts.NewTranslateProvider()
default:
log.Printf("[TTS] Unknown provider %q; falling back to 'translate'", provider)
p = tts.NewTranslateProvider()
}
s.SetTTSService(tts.NewService(p, cfg))
log.Printf("[TTS] Service reinitialized (provider: %s)", p.Name())
}
// 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()
// Let any observer (e.g. the embedded web UI) re-sync from the datastore.
s.notifyDevicesChanged()
}
// 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: %s", sanitizeLog(deviceIP), sanitizeErr(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: %s (falling back to datastore account=%q)", sanitizeLog(deviceIP), sanitizeErr(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 {
host, port, err := net.SplitHostPort(deviceIP)
if err != nil {
// deviceIP has no port component — use the standard ZeroConf port.
host = deviceIP
port = "8200"
}
return spotify.PushSpotifyCredentials(host, port, 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 {
host, port, err := net.SplitHostPort(deviceIP)
if err != nil {
// deviceIP has no port component — use the standard ZeroConf port.
host = deviceIP
port = "8200"
}
return amazon.PushAmazonCredentials(host, port, 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: %s", sanitizeLog(d.Name), sanitizeLog(d.Host), sanitizeErr(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: %s", sanitizeLog(deviceID), sanitizeErr(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: %s", sanitizeLog(deviceID), sanitizeErr(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: %s", sanitizeLog(deviceID), sanitizeErr(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: %s", sanitizeLog(deviceID), sanitizeErr(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)
}