mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
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.
1893 lines
58 KiB
Go
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)
|
|
}
|