mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
Speakers connect to Bose hostnames over implicit HTTPS (:443) while AfterTouch's listener defaults to :8443. Without iptables / setcap / reverse-proxy in front, the speaker side sees Curl 7 / connection refused and AfterTouch's HTTP log stays silent — a recurring source of confusion (see #214, #269). Add a server-side probe (Check443Reachability) that dials both localhost:443 and the DNS-resolved LAN IP on :443. Run it once at service startup with a 2s timeout and emit a [WARN] log with the exact iptables/setcap commands keyed to the configured listener port. Expose the result via GET /setup/settings (with a shorter inline timeout) so the web UI renders a ✅/❌ line next to Target Domain and a complementary browser-side fetch probe — the browser sits on the LAN exactly where speakers do, and timing-to-error distinguishes TCP refused from TLS handshake started even with an untrusted CA. Both the startup WARN and the UI row are gated on dns_enabled, since :443 only matters for the DNS migration path; SDK-override migration uses the port from the configured URL. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
966 lines
26 KiB
Go
966 lines
26 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"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/datastore"
|
|
"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
|
|
proxyRedact bool
|
|
proxyLogBody bool
|
|
recordEnabled bool
|
|
discoveryInterval time.Duration
|
|
discoveryEnabled bool
|
|
dnsEnabled bool
|
|
dnsUpstream []string
|
|
dnsBindAddr string
|
|
mirrorEnabled bool
|
|
mirrorEndpoints []string
|
|
skipMirrorEndpoints []string
|
|
preferredSource string
|
|
internalPaths []string
|
|
shortcuts map[string]int
|
|
recorder *proxy.Recorder
|
|
dnsDiscovery *discovery.DNSDiscovery
|
|
UpstreamProxy http.Handler
|
|
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
|
|
}
|
|
|
|
// 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, proxyRedact, proxyLogBody, recordEnabled bool) *Server {
|
|
s := &Server{
|
|
ds: ds,
|
|
sm: sm,
|
|
serverURL: serverURL,
|
|
proxyRedact: proxyRedact,
|
|
proxyLogBody: proxyLogBody,
|
|
recordEnabled: recordEnabled,
|
|
discoveryInterval: 5 * time.Minute,
|
|
discoveryEnabled: true,
|
|
peerObserver: newPeerObserver(),
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
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.proxyRedact
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// SetMirrorSettings sets the mirroring settings for the server.
|
|
func (s *Server) SetMirrorSettings(enabled bool, endpoints, skipEndpoints []string, preferredSource string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.mirrorEnabled = enabled
|
|
s.mirrorEndpoints = endpoints
|
|
s.skipMirrorEndpoints = skipEndpoints
|
|
s.preferredSource = preferredSource
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// GetProxySettings returns the current proxy settings.
|
|
func (s *Server) GetProxySettings() (bool, bool, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
return s.proxyRedact, s.proxyLogBody, 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", deviceIP, err)
|
|
return
|
|
}
|
|
|
|
log.Printf("[Spotify Watchdog] Proactively priming %s with Spotify user %s", deviceIP, username)
|
|
|
|
if err := s.pushSpotifyTokenToDevice(deviceIP, username, accessToken); err != nil {
|
|
log.Printf("[Spotify Watchdog] Failed to prime %s: %v", deviceIP, err)
|
|
} else {
|
|
log.Printf("[Spotify Watchdog] Successfully primed %s", deviceIP)
|
|
}
|
|
}
|
|
|
|
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", deviceIP, err)
|
|
return
|
|
}
|
|
|
|
log.Printf("[Amazon Watchdog] Proactively priming %s with Amazon user %s", deviceIP, username)
|
|
|
|
if err := s.pushAmazonTokenToDevice(deviceIP, username, accessToken); err != nil {
|
|
log.Printf("[Amazon Watchdog] Failed to prime %s: %v", deviceIP, err)
|
|
} else {
|
|
log.Printf("[Amazon Watchdog] Successfully primed %s", 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)", d.Name, d.Host, 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", d.Name, 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", d.Name, d.Host)
|
|
s.handleDiscoveredDeviceFallback(d)
|
|
|
|
return
|
|
}
|
|
|
|
log.Printf("Using deviceID '%s' from /info for device %s at %s", deviceID, d.Name, d.Host)
|
|
|
|
// 3. Get account ID from live info or fallback to existing/default
|
|
accountID := liveInfo.MargeAccountUUID
|
|
if accountID == "" {
|
|
// Try to find account ID from existing device entries
|
|
if existing := s.findExistingDeviceInfoByDeviceID(deviceID); existing != nil {
|
|
accountID = existing.AccountID
|
|
}
|
|
}
|
|
|
|
if accountID == "" {
|
|
accountID = "default"
|
|
}
|
|
|
|
// 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", deviceID, err)
|
|
return
|
|
}
|
|
|
|
// 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", deviceID)
|
|
|
|
if err := s.ds.SaveConfiguredSources(accountID, deviceID, sources); err != nil {
|
|
log.Printf("Failed to save default sources for %s: %v", deviceID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
log.Printf("Successfully saved device %s (%s) with MAC-based deviceID: %s", info.Name, d.Host, 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", d.Name, 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", d.Name, existingID, 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", 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)", deviceID)
|
|
|
|
if err := s.ds.SaveConfiguredSources(accountID, deviceID, sources); err != nil {
|
|
log.Printf("Failed to save default sources for %s: %v", deviceID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
log.Printf("Successfully saved device %s (%s) with fallback deviceID: %s", info.Name, d.Host, 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)", devID, masterID, 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)
|
|
}
|