mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
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>
1292 lines
38 KiB
Go
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)
|
|
}
|