mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-31 14:57:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b71a3830ec | ||
|
|
f50ee1131e | ||
|
|
6a65376784 |
@@ -606,40 +606,57 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
|
||||
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
|
||||
|
||||
streamingRoutes := func(r chi.Router) {
|
||||
r.Get("/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/account/{account}/device/{device}/recent", server.HandleMargeRecents)
|
||||
r.Post("/account/{account}/device/{device}/recent", server.HandleMargeAddRecent)
|
||||
r.Get("/account/{account}/device/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/account/{account}/device/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/support/power_on", server.HandleMargePowerOn)
|
||||
r.Get("/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Get("/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
r.Get("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
r.Get("/account/{account}/device/{device}/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/account/{account}/device/{device}/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/account/{account}/device/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/account/{account}/device/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
r.Post("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
r.Get("/account/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Get("/software/update/account/{account}", server.HandleMargeSoftwareUpdate)
|
||||
|
||||
r.Route("/stats", func(r chi.Router) {
|
||||
r.Post("/usage", server.HandleUsageStats)
|
||||
r.Post("/error", server.HandleErrorStats)
|
||||
})
|
||||
}
|
||||
|
||||
accountsRoutes := func(r chi.Router) {
|
||||
r.Get("/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Get("/{account}/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Get("/{account}/devices/{device}/recents", server.HandleMargeRecents)
|
||||
r.Post("/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
r.Post("/{account}/devices", server.HandleMargeAddDevice)
|
||||
r.Delete("/{account}/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/{account}/devices/{device}/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/{account}/devices/{device}/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/{account}/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/{account}/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
}
|
||||
|
||||
r.Route("/marge", func(r chi.Router) {
|
||||
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Post("/streaming/support/power_on", server.HandleMargePowerOn)
|
||||
r.Route("/streaming", streamingRoutes)
|
||||
r.Route("/accounts", accountsRoutes)
|
||||
|
||||
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
|
||||
r.Get("/accounts/{account}/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/accounts/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/accounts/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
r.Post("/accounts/{account}/devices", server.HandleMargeAddDevice)
|
||||
r.Delete("/accounts/{account}/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
})
|
||||
|
||||
// Legacy or direct domain calls without /marge prefix
|
||||
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Post("/streaming/support/power_on", server.HandleMargePowerOn)
|
||||
r.Route("/streaming", streamingRoutes)
|
||||
r.Route("/accounts", accountsRoutes)
|
||||
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
|
||||
r.Get("/accounts/{account}/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/accounts/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/accounts/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
r.Post("/accounts/{account}/devices", server.HandleMargeAddDevice)
|
||||
r.Delete("/accounts/{account}/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
|
||||
r.Route("/customer", func(r chi.Router) {
|
||||
r.Get("/account/{account}", server.HandleMargeAccountProfile)
|
||||
@@ -652,11 +669,6 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Post("/scmudc/{deviceId}", server.HandleAppEvents)
|
||||
})
|
||||
|
||||
r.Route("/streaming/stats", func(r chi.Router) {
|
||||
r.Post("/usage", server.HandleUsageStats)
|
||||
r.Post("/error", server.HandleErrorStats)
|
||||
})
|
||||
|
||||
r.Route("/mgmt", func(r chi.Router) {
|
||||
// Browser OAuth callback — no auth required (Spotify redirects the
|
||||
// user's browser here directly). The authorization code is single-use,
|
||||
|
||||
+67
-7
@@ -4,6 +4,7 @@ package discovery
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -192,19 +193,78 @@ func (d *DNSDiscovery) respondWithIP(w dns.ResponseWriter, r *dns.Msg, ip string
|
||||
q := r.Question[0]
|
||||
log.Printf("[DNS] Intercepted query for %s (type %d) from %s", q.Name, q.Qtype, w.RemoteAddr())
|
||||
|
||||
resolvedIP := ip
|
||||
if net.ParseIP(ip) == nil {
|
||||
// Attempt resolution if it's not a numeric IP
|
||||
ips, err := net.LookupIP(ip)
|
||||
if err == nil && len(ips) > 0 {
|
||||
for _, rIP := range ips {
|
||||
if rIP.To4() != nil {
|
||||
resolvedIP = rIP.String()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if resolvedIP == ip && len(ips) > 0 {
|
||||
resolvedIP = ips[0].String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch q.Qtype {
|
||||
case dns.TypeA, dns.TypeANY:
|
||||
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN A %s", q.Name, ip))
|
||||
if err == nil {
|
||||
m.Answer = append(m.Answer, rr)
|
||||
if net.ParseIP(resolvedIP) == nil || strings.Contains(resolvedIP, ":") {
|
||||
// If it's still not a valid IPv4 address, we can't create an A record.
|
||||
// Try CNAME as a fallback if it looks like a hostname.
|
||||
if !strings.Contains(resolvedIP, ":") {
|
||||
// Normalize hostname for CNAME
|
||||
target := resolvedIP
|
||||
if !strings.HasSuffix(target, ".") {
|
||||
target += "."
|
||||
}
|
||||
|
||||
log.Printf("[DNS] Returning A record %s -> %s", q.Name, ip)
|
||||
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN CNAME %s", q.Name, target))
|
||||
if err == nil {
|
||||
m.Answer = append(m.Answer, rr)
|
||||
|
||||
log.Printf("[DNS] Returning CNAME record %s -> %s", q.Name, target)
|
||||
} else {
|
||||
log.Printf("[DNS] Error creating CNAME fallback for %s: %v", target, err)
|
||||
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
}
|
||||
} else {
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
}
|
||||
} else {
|
||||
log.Printf("[DNS] Error creating A record: %v", err)
|
||||
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN A %s", q.Name, resolvedIP))
|
||||
if err == nil {
|
||||
m.Answer = append(m.Answer, rr)
|
||||
|
||||
log.Printf("[DNS] Returning A record %s -> %s", q.Name, resolvedIP)
|
||||
} else {
|
||||
log.Printf("[DNS] Error creating A record for %s: %v", resolvedIP, err)
|
||||
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
}
|
||||
}
|
||||
case dns.TypeAAAA:
|
||||
// Explicitly return SUCCESS with no data for AAAA to prevent fallback issues
|
||||
log.Printf("[DNS] Returning empty AAAA success (NODATA) for %s", q.Name)
|
||||
// Check if we have an IPv6 address
|
||||
if net.ParseIP(resolvedIP) != nil && strings.Contains(resolvedIP, ":") {
|
||||
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN AAAA %s", q.Name, resolvedIP))
|
||||
if err == nil {
|
||||
m.Answer = append(m.Answer, rr)
|
||||
|
||||
log.Printf("[DNS] Returning AAAA record %s -> %s", q.Name, resolvedIP)
|
||||
} else {
|
||||
log.Printf("[DNS] Error creating AAAA record for %s: %v", resolvedIP, err)
|
||||
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
}
|
||||
} else {
|
||||
// Explicitly return SUCCESS with no data for AAAA to prevent fallback issues if no IPv6
|
||||
log.Printf("[DNS] Returning empty AAAA success (NODATA) for %s", q.Name)
|
||||
}
|
||||
default:
|
||||
log.Printf("[DNS] Returning empty success for type %d", q.Qtype)
|
||||
}
|
||||
|
||||
@@ -167,6 +167,106 @@ func TestDNSDiscovery_StartTCP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_SelfForwarding(t *testing.T) {
|
||||
serviceIP := "soundtouch.local"
|
||||
upstreamDNS := []string{"127.0.0.1:5357"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
// Mock upstream DNS server for soundtouch.local
|
||||
mux := dns.NewServeMux()
|
||||
mux.HandleFunc("soundtouch.local.", func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
rr, _ := dns.NewRR("soundtouch.local. 60 IN A 192.168.178.10")
|
||||
m.Answer = append(m.Answer, rr)
|
||||
_ = w.WriteMsg(m)
|
||||
})
|
||||
ts := &dns.Server{Addr: "127.0.0.1:5357", Net: "udp", Handler: mux, ReadTimeout: 100 * time.Millisecond, WriteTimeout: 100 * time.Millisecond}
|
||||
go func() {
|
||||
_ = ts.ListenAndServe()
|
||||
}()
|
||||
defer func() { _ = ts.Shutdown() }()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("soundtouch.local.", dns.TypeA)
|
||||
rw := &mockResponseWriter{}
|
||||
d.ServeDNS(rw, m)
|
||||
|
||||
if rw.msg == nil {
|
||||
t.Fatal("Expected a response for soundtouch.local")
|
||||
}
|
||||
|
||||
if rw.msg.Rcode != dns.RcodeSuccess {
|
||||
t.Errorf("Expected Success (0) for soundtouch.local being forwarded, got %d", rw.msg.Rcode)
|
||||
}
|
||||
|
||||
if len(rw.msg.Answer) == 0 {
|
||||
t.Fatal("Expected an answer in the response")
|
||||
}
|
||||
|
||||
if a, ok := rw.msg.Answer[0].(*dns.A); ok {
|
||||
if a.A.String() != "192.168.178.10" {
|
||||
t.Errorf("Expected IP 192.168.178.10, got %s", a.A.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Check if d.recordQuery logged it correctly.
|
||||
d.mu.RLock()
|
||||
host, exists := d.discovered["soundtouch.local"]
|
||||
d.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
t.Error("Expected soundtouch.local to be recorded")
|
||||
}
|
||||
// It should NOT be intercepted anymore
|
||||
if host != nil && host.IsIntercepted {
|
||||
t.Error("Expected soundtouch.local NOT to be intercepted anymore, but forwarded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_ForwardLocal(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := []string{"127.0.0.1:5356"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("someone-else.local.", dns.TypeA)
|
||||
rw := &mockResponseWriter{}
|
||||
|
||||
// Start a mock upstream DNS server that returns SUCCESS for .local
|
||||
mux := dns.NewServeMux()
|
||||
mux.HandleFunc("someone-else.local.", func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
rr, _ := dns.NewRR("someone-else.local. 60 IN A 192.168.1.50")
|
||||
m.Answer = append(m.Answer, rr)
|
||||
_ = w.WriteMsg(m)
|
||||
})
|
||||
ts := &dns.Server{Addr: "127.0.0.1:5356", Net: "udp", Handler: mux, ReadTimeout: 100 * time.Millisecond, WriteTimeout: 100 * time.Millisecond}
|
||||
go func() {
|
||||
_ = ts.ListenAndServe()
|
||||
}()
|
||||
defer func() { _ = ts.Shutdown() }()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
d.ServeDNS(rw, m)
|
||||
|
||||
if rw.msg == nil {
|
||||
t.Fatal("Expected a response message")
|
||||
}
|
||||
|
||||
if rw.msg.Rcode != dns.RcodeSuccess {
|
||||
t.Errorf("Expected Success (0) for .local being forwarded, got %d", rw.msg.Rcode)
|
||||
}
|
||||
|
||||
if len(rw.msg.Answer) == 0 {
|
||||
t.Fatal("Expected an answer in the response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_IsRunning(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := []string{"8.8.8.8"}
|
||||
@@ -373,3 +473,69 @@ func TestDNSDiscovery_MultipleUpstreams(t *testing.T) {
|
||||
t.Fatal("Expected an answer from the second upstream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_HostnameServiceIP(t *testing.T) {
|
||||
// Use localhost which should resolve to 127.0.0.1
|
||||
serviceIP := "localhost"
|
||||
upstreamDNS := []string{"8.8.8.8"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("api.bose.com.", dns.TypeA)
|
||||
|
||||
rw := &mockResponseWriter{}
|
||||
d.ServeDNS(rw, m)
|
||||
|
||||
if rw.msg == nil {
|
||||
t.Fatal("Expected a response message, got nil")
|
||||
}
|
||||
|
||||
if len(rw.msg.Answer) == 0 {
|
||||
t.Fatal("Expected an answer in the response")
|
||||
}
|
||||
|
||||
if a, ok := rw.msg.Answer[0].(*dns.A); ok {
|
||||
// It should be resolved to 127.0.0.1 (or whatever localhost resolves to)
|
||||
if a.A.String() == "" {
|
||||
t.Error("Expected a non-empty IP address")
|
||||
}
|
||||
log.Printf("Resolved localhost to %s", a.A.String())
|
||||
} else if cname, ok := rw.msg.Answer[0].(*dns.CNAME); ok {
|
||||
// Fallback to CNAME is also acceptable if resolution failed but it shouldn't for localhost
|
||||
if cname.Target != "localhost." {
|
||||
t.Errorf("Expected CNAME to localhost., got %s", cname.Target)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Expected A or CNAME record, got %T", rw.msg.Answer[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_UnresolvableHostname(t *testing.T) {
|
||||
// Use a likely unresolvable hostname
|
||||
serviceIP := "this.hostname.does.not.exist.at.all.invalid"
|
||||
upstreamDNS := []string{"8.8.8.8"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("api.bose.com.", dns.TypeA)
|
||||
|
||||
rw := &mockResponseWriter{}
|
||||
d.ServeDNS(rw, m)
|
||||
|
||||
if rw.msg == nil {
|
||||
t.Fatal("Expected a response message, got nil")
|
||||
}
|
||||
|
||||
if len(rw.msg.Answer) == 0 {
|
||||
t.Fatal("Expected an answer in the response (CNAME fallback)")
|
||||
}
|
||||
|
||||
if cname, ok := rw.msg.Answer[0].(*dns.CNAME); ok {
|
||||
expected := serviceIP + "."
|
||||
if cname.Target != expected {
|
||||
t.Errorf("Expected CNAME to %s, got %s", expected, cname.Target)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Expected CNAME record for unresolvable hostname, got %T", rw.msg.Answer[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func (s *Server) HandleMargeSourceProviders(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.Header()["ETag"] = []string{etag}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func (s *Server) HandleMargeAccountFull(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.Header()["ETag"] = []string{etag}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
@@ -146,7 +146,7 @@ func (s *Server) HandleMargeGetEmailAddress(w http.ResponseWriter, _ *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
_, _ = w.Write([]byte(xml.Header))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
@@ -165,7 +165,7 @@ func (s *Server) HandleMargeGetDeviceSettings(w http.ResponseWriter, _ *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
_, _ = w.Write([]byte(xml.Header))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
@@ -184,9 +184,16 @@ func (s *Server) HandleMargeSoftwareUpdate(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.Header()["ETag"] = []string{etag}
|
||||
|
||||
// For the account-specific firmware route, always return the software_update tag.
|
||||
// This route is specifically used by firmware like Bose_Lisa/27.0.6.
|
||||
if chi.URLParam(r, "account") != "" {
|
||||
_, _ = w.Write([]byte(marge.SoftwareUpdateToXML()))
|
||||
return
|
||||
}
|
||||
|
||||
if len(swUpdateXML) > 0 {
|
||||
_, _ = w.Write(swUpdateXML)
|
||||
} else {
|
||||
@@ -211,7 +218,7 @@ func (s *Server) HandleMargePresets(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.Header()["ETag"] = []string{etag}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
@@ -244,7 +251,29 @@ func (s *Server) HandleMargeUpdatePreset(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeRecents returns the Marge recents for a device.
|
||||
func (s *Server) HandleMargeRecents(w http.ResponseWriter, r *http.Request) {
|
||||
account := chi.URLParam(r, "account")
|
||||
device := chi.URLParam(r, "device")
|
||||
|
||||
etag := strconv.FormatInt(s.ds.GetETagForRecents(account, device), 10)
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := marge.RecentsToXML(s.ds, account, device)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.Header()["ETag"] = []string{etag}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
@@ -268,7 +297,7 @@ func (s *Server) HandleMargeAddRecent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
@@ -288,7 +317,7 @@ func (s *Server) HandleMargeAddDevice(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
@@ -310,7 +339,7 @@ func (s *Server) HandleMargeRemoveDevice(w http.ResponseWriter, r *http.Request)
|
||||
func (s *Server) HandleMargeProviderSettings(w http.ResponseWriter, r *http.Request) {
|
||||
account := chi.URLParam(r, "account")
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
_, _ = w.Write([]byte(marge.ProviderSettingsToXML(account)))
|
||||
}
|
||||
|
||||
@@ -336,6 +365,26 @@ func (s *Server) HandleMargeStreamingToken(w http.ResponseWriter, _ *http.Reques
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeDeviceGroup returns grouping information for a device (empty group by default).
|
||||
func (s *Server) HandleMargeDeviceGroup(w http.ResponseWriter, _ *http.Request) {
|
||||
// Native firmware expects vnd.bose.streaming content type
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><group/>`))
|
||||
}
|
||||
|
||||
// HandleMargeDeviceGroupServer returns grouping server information (404 by default if not a server).
|
||||
func (s *Server) HandleMargeDeviceGroupServer(w http.ResponseWriter, r *http.Request) {
|
||||
// Not in a group as server
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
// HandleMargeDeviceGroupMember returns grouping member information (404 by default if not a member).
|
||||
func (s *Server) HandleMargeDeviceGroupMember(w http.ResponseWriter, r *http.Request) {
|
||||
// Not in a group as member
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
// HandleMargeCustomerSupport handles Marge customer support uploads.
|
||||
func (s *Server) HandleMargeCustomerSupport(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
|
||||
@@ -264,7 +264,7 @@ func TestMargeUpdatePreset(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeDeviceInfo(t *testing.T) {
|
||||
func TestMargeAddRecentRoute(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
@@ -331,6 +331,298 @@ func TestMargeDeviceInfo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeNativeStreamingRoutes(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-test-native-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
account := "12345"
|
||||
deviceID := "DEV1"
|
||||
|
||||
accountDir := filepath.Join(tempDir, "accounts", account)
|
||||
deviceDir := filepath.Join(accountDir, "devices", deviceID)
|
||||
err = os.MkdirAll(deviceDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create device dir: %v", err)
|
||||
}
|
||||
|
||||
// Mock Sources.xml for recent tests
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(`
|
||||
<sources>
|
||||
<source id="SRC1" displayName="TUNEIN" secret="" secretType="Audio">
|
||||
<sourceKey type="TUNEIN" account=""/>
|
||||
</source>
|
||||
</sources>
|
||||
`), 0644); err != nil {
|
||||
t.Fatalf("Failed to write Sources.xml: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(`<recents></recents>`), 0644); err != nil {
|
||||
t.Fatalf("Failed to write Recents.xml: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(`<presets></presets>`), 0644); err != nil {
|
||||
t.Fatalf("Failed to write Presets.xml: %v", err)
|
||||
}
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
t.Run("POST /streaming/account/{account}/device/{device}/recent", func(t *testing.T) {
|
||||
payload := `
|
||||
<recent>
|
||||
<name>New Route Recent</name>
|
||||
<sourceid>SRC1</sourceid>
|
||||
<location>/station/s999</location>
|
||||
<contentItemType>station</contentItemType>
|
||||
</recent>`
|
||||
|
||||
res, err := http.Post(ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", "application/xml", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
if ct := res.Header.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.2+xml" {
|
||||
t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", ct)
|
||||
}
|
||||
|
||||
// Verify file was saved
|
||||
recentData, _ := os.ReadFile(filepath.Join(deviceDir, "Recents.xml"))
|
||||
if !strings.Contains(string(recentData), "New Route Recent") {
|
||||
t.Error("Recent from native route was not saved to datastore")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /streaming/account/{account}/full", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/streaming/account/" + account + "/full")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
if ct := res.Header.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.2+xml" {
|
||||
t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", ct)
|
||||
}
|
||||
|
||||
fullData, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(fullData), account) {
|
||||
t.Error("Account full response does not contain account ID")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /streaming/software/update/account/{account}", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/streaming/software/update/account/" + account)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
if ct := res.Header.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.2+xml" {
|
||||
t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", ct)
|
||||
}
|
||||
|
||||
swData, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(swData), "software_update") {
|
||||
t.Errorf("Response missing software_update tag: %s", string(swData))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /streaming/account/{account}/device/{device}/recent", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/streaming/account/" + account + "/device/" + deviceID + "/recent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
if ct := res.Header.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.2+xml" {
|
||||
t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", ct)
|
||||
}
|
||||
|
||||
etag := res.Header.Get("ETag")
|
||||
if etag == "" {
|
||||
t.Error("Expected ETag header")
|
||||
}
|
||||
|
||||
recentData, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(recentData), "recents") {
|
||||
t.Errorf("Response missing recents tag: %s", string(recentData))
|
||||
}
|
||||
|
||||
// Test 304
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", nil)
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
res2, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res2.Body.Close()
|
||||
if res2.StatusCode != http.StatusNotModified {
|
||||
t.Errorf("Expected 304 Not Modified, got %v", res2.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /streaming/account/{account}/device/{device}/presets", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/streaming/account/" + account + "/device/" + deviceID + "/presets")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
if ct := res.Header.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.2+xml" {
|
||||
t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", ct)
|
||||
}
|
||||
|
||||
etag := res.Header.Get("ETag")
|
||||
if etag == "" {
|
||||
t.Error("Expected ETag header")
|
||||
}
|
||||
|
||||
presetData, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(presetData), "presets") {
|
||||
t.Errorf("Response missing presets tag: %s", string(presetData))
|
||||
}
|
||||
|
||||
// Test 304
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/presets", nil)
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
res2, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res2.Body.Close()
|
||||
if res2.StatusCode != http.StatusNotModified {
|
||||
t.Errorf("Expected 304 Not Modified, got %v", res2.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST /streaming/account/{account}/device/{device}/presets/{presetNumber}", func(t *testing.T) {
|
||||
payload := `
|
||||
<preset>
|
||||
<name>New Native Preset</name>
|
||||
<sourceid>SRC1</sourceid>
|
||||
<location>/station/s777</location>
|
||||
<contentItemType>station</contentItemType>
|
||||
</preset>`
|
||||
|
||||
res, err := http.Post(ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/presets/1", "application/xml", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
if ct := res.Header.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.2+xml" {
|
||||
t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", ct)
|
||||
}
|
||||
|
||||
// Verify file was saved
|
||||
presetData, _ := os.ReadFile(filepath.Join(deviceDir, "Presets.xml"))
|
||||
if !strings.Contains(string(presetData), "New Native Preset") {
|
||||
t.Error("Preset from native route was not saved to datastore")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /streaming/account/{account}/device/{device}/group/", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/streaming/account/" + account + "/device/" + deviceID + "/group/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
groupData, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(groupData), "<group") {
|
||||
t.Errorf("Response missing group tag: %s", string(groupData))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /streaming/account/{account}/device/{device}/group/server", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/streaming/account/" + account + "/device/" + deviceID + "/group/server")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("Expected 404 Not Found, got %v", res.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /streaming/account/{account}/device/{device}/group/member", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/streaming/account/" + account + "/device/" + deviceID + "/group/member")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("Expected 404 Not Found, got %v", res.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /marge/accounts/{account}/devices/{device}/group", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/marge/accounts/" + account + "/devices/" + deviceID + "/group")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
if ct := res.Header.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.2+xml" {
|
||||
t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", ct)
|
||||
}
|
||||
|
||||
groupData, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(groupData), "<group") {
|
||||
t.Errorf("Response missing group tag: %s", string(groupData))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMargeAddRemoveDevice(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
|
||||
@@ -36,41 +36,53 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
|
||||
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
|
||||
|
||||
streamingRoutes := func(r chi.Router) {
|
||||
r.Get("/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/account/{account}/device/{device}/recent", server.HandleMargeRecents)
|
||||
r.Post("/account/{account}/device/{device}/recent", server.HandleMargeAddRecent)
|
||||
r.Get("/account/{account}/device/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/account/{account}/device/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/support/power_on", server.HandleMargePowerOn)
|
||||
r.Get("/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Get("/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
r.Get("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
// Native group endpoint (both with and without trailing slash)
|
||||
r.Get("/account/{account}/device/{device}/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/account/{account}/device/{device}/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/account/{account}/device/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/account/{account}/device/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
r.Post("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
r.Get("/account/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Get("/software/update/account/{account}", server.HandleMargeSoftwareUpdate)
|
||||
}
|
||||
|
||||
accountsRoutes := func(r chi.Router) {
|
||||
r.Get("/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Get("/{account}/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Get("/{account}/devices/{device}/recents", server.HandleMargeRecents)
|
||||
r.Post("/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
r.Post("/{account}/devices", server.HandleMargeAddDevice)
|
||||
r.Delete("/{account}/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/{account}/devices/{device}/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/{account}/devices/{device}/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/{account}/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/{account}/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
}
|
||||
|
||||
// Setup Marge for tests
|
||||
r.Route("/marge", func(r chi.Router) {
|
||||
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Post("/streaming/support/power_on", server.HandleMargePowerOn)
|
||||
r.Route("/streaming", streamingRoutes)
|
||||
r.Route("/accounts", accountsRoutes)
|
||||
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
|
||||
r.Get("/accounts/{account}/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/accounts/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/accounts/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
r.Post("/accounts/{account}/devices", server.HandleMargeAddDevice)
|
||||
r.Delete("/accounts/{account}/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
})
|
||||
|
||||
// Legacy or direct domain calls without /marge prefix
|
||||
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Post("/streaming/support/power_on", server.HandleMargePowerOn)
|
||||
r.Route("/streaming", streamingRoutes)
|
||||
r.Route("/accounts", accountsRoutes)
|
||||
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
|
||||
r.Get("/accounts/{account}/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/accounts/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/accounts/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
r.Post("/accounts/{account}/devices", server.HandleMargeAddDevice)
|
||||
r.Delete("/accounts/{account}/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
|
||||
// Setup Customer for tests
|
||||
r.Route("/customer", func(r chi.Router) {
|
||||
|
||||
+73
-59
@@ -287,73 +287,87 @@ func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Case 1: XML Migration
|
||||
// Check if any URL in the current config points to our server (targetURL)
|
||||
if summary.ParsedCurrentConfig != nil {
|
||||
targetURL := m.ServerURL
|
||||
// Strip protocol for comparison if needed, or just check for substring
|
||||
parsedTarget, err := url.Parse(targetURL)
|
||||
if err == nil {
|
||||
targetHost := parsedTarget.Hostname()
|
||||
if strings.Contains(summary.ParsedCurrentConfig.MargeServerUrl, targetHost) ||
|
||||
strings.Contains(summary.ParsedCurrentConfig.StatsServerUrl, targetHost) ||
|
||||
strings.Contains(summary.ParsedCurrentConfig.SwUpdateUrl, targetHost) ||
|
||||
strings.Contains(summary.ParsedCurrentConfig.BmxRegistryUrl, targetHost) {
|
||||
summary.IsMigrated = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: /etc/hosts + Trust CA Migration
|
||||
// Check if /etc/hosts contains redirections for Bose domains
|
||||
client := m.NewSSH(deviceIP)
|
||||
|
||||
if m.isXMLMigrated(summary) || m.isHostsMigrated(client, summary) || m.isResolvConfMigrated(client, summary) {
|
||||
summary.IsMigrated = true
|
||||
}
|
||||
}
|
||||
|
||||
// isXMLMigrated checks whether current XML config already points to our server.
|
||||
func (m *Manager) isXMLMigrated(summary *MigrationSummary) bool {
|
||||
if summary.ParsedCurrentConfig == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
parsedTarget, err := url.Parse(m.ServerURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
targetHost := parsedTarget.Hostname()
|
||||
|
||||
return strings.Contains(summary.ParsedCurrentConfig.MargeServerUrl, targetHost) ||
|
||||
strings.Contains(summary.ParsedCurrentConfig.StatsServerUrl, targetHost) ||
|
||||
strings.Contains(summary.ParsedCurrentConfig.SwUpdateUrl, targetHost) ||
|
||||
strings.Contains(summary.ParsedCurrentConfig.BmxRegistryUrl, targetHost)
|
||||
}
|
||||
|
||||
// isHostsMigrated checks if /etc/hosts contains Bose domain redirections and CA is trusted.
|
||||
func (m *Manager) isHostsMigrated(client SSHClient, summary *MigrationSummary) bool {
|
||||
hostsContent, err := client.Run("cat /etc/hosts")
|
||||
if err == nil {
|
||||
boseDomains := []string{
|
||||
"streaming.bose.com",
|
||||
"updates.bose.com",
|
||||
"stats.bose.com",
|
||||
"bmx.bose.com",
|
||||
}
|
||||
for _, domain := range boseDomains {
|
||||
if strings.Contains(hostsContent, domain) {
|
||||
// If CA is also trusted, it's a strong indicator of migration
|
||||
if summary.CACertTrusted {
|
||||
summary.IsMigrated = true
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
boseDomains := []string{
|
||||
"streaming.bose.com",
|
||||
"updates.bose.com",
|
||||
"stats.bose.com",
|
||||
"bmx.bose.com",
|
||||
}
|
||||
for _, domain := range boseDomains {
|
||||
if strings.Contains(hostsContent, domain) && summary.CACertTrusted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Case 3: /etc/resolv.conf Migration (including Aftertouch hook)
|
||||
// Check if /etc/resolv.conf contains our target nameserver OR if hook marker exists
|
||||
if summary.SSHSuccess {
|
||||
// Check for aftertouch.resolv.conf
|
||||
if _, err := client.Run("[ -f /mnt/nv/aftertouch.resolv.conf ]"); err == nil {
|
||||
if summary.CACertTrusted {
|
||||
summary.IsMigrated = true
|
||||
return
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if summary.CurrentResolvConf != "" {
|
||||
targetURL := m.ServerURL
|
||||
|
||||
parsedTarget, err := url.Parse(targetURL)
|
||||
if err == nil {
|
||||
targetHost := parsedTarget.Hostname()
|
||||
if strings.Contains(summary.CurrentResolvConf, targetHost) {
|
||||
if summary.CACertTrusted {
|
||||
summary.IsMigrated = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// isResolvConfMigrated checks for Aftertouch DNS migration signals and CA trust.
|
||||
func (m *Manager) isResolvConfMigrated(client SSHClient, summary *MigrationSummary) bool {
|
||||
// Hook file present
|
||||
if _, err := client.Run("[ -f /mnt/nv/aftertouch.resolv.conf ]"); err == nil {
|
||||
return summary.CACertTrusted
|
||||
}
|
||||
|
||||
if summary.CurrentResolvConf == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Marker comment present
|
||||
if strings.Contains(summary.CurrentResolvConf, "# Priority nameserver for Bose service redirection") && summary.CACertTrusted {
|
||||
return true
|
||||
}
|
||||
|
||||
// Match hostname or resolved IP
|
||||
parsedTarget, err := url.Parse(m.ServerURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
targetHost := parsedTarget.Hostname()
|
||||
if strings.Contains(summary.CurrentResolvConf, targetHost) && summary.CACertTrusted {
|
||||
return true
|
||||
}
|
||||
|
||||
resolvedIP := m.resolveIP(targetHost, client)
|
||||
if resolvedIP != "" && strings.Contains(summary.CurrentResolvConf, resolvedIP) && summary.CACertTrusted {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// populateDeviceInfo fills in device information from datastore and live info
|
||||
|
||||
@@ -1409,6 +1409,58 @@ func TestCheckIsMigrated(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ResolvConf Migrated (Marker)", func(t *testing.T) {
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
if command == "cat /etc/hosts" {
|
||||
return "127.0.0.1\tlocalhost", nil
|
||||
}
|
||||
if command == "[ -f /mnt/nv/aftertouch.resolv.conf ]" {
|
||||
return "", fmt.Errorf("not found")
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
}
|
||||
summary := &MigrationSummary{
|
||||
SSHSuccess: true,
|
||||
CACertTrusted: true,
|
||||
CurrentResolvConf: "# Priority nameserver for Bose service redirection\nnameserver 192.168.1.1\n",
|
||||
}
|
||||
m.checkIsMigrated(summary, "127.0.0.1")
|
||||
if !summary.IsMigrated {
|
||||
t.Errorf("Expected IsMigrated to be true for resolv.conf migration with marker comment")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ResolvConf Migrated (IP)", func(t *testing.T) {
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
if command == "cat /etc/hosts" {
|
||||
return "127.0.0.1\tlocalhost", nil
|
||||
}
|
||||
if command == "[ -f /mnt/nv/aftertouch.resolv.conf ]" {
|
||||
return "", fmt.Errorf("not found")
|
||||
}
|
||||
// Mock resolveIP by mocking its SSH commands if any, or just wait for it to return targetHost
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
}
|
||||
// m.ServerURL is "http://aftertouch:8000" in this test (see top of TestCheckIsMigrated)
|
||||
summary := &MigrationSummary{
|
||||
SSHSuccess: true,
|
||||
CACertTrusted: true,
|
||||
CurrentResolvConf: "nameserver aftertouch\n",
|
||||
}
|
||||
m.checkIsMigrated(summary, "127.0.0.1")
|
||||
if !summary.IsMigrated {
|
||||
t.Errorf("Expected IsMigrated to be true for resolv.conf migration with matching hostname/IP")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Not Migrated", func(t *testing.T) {
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
|
||||
Reference in New Issue
Block a user