Compare commits

...
37 Commits
Author SHA1 Message Date
Tobias Gesellchen 1288a619f7 Add Stockholm Mini
This is also a refactoring of our api paths
2026-02-21 00:49:18 +01:00
Tobias Gesellchen ec8bbb2f86 Lint: cleanup 2026-02-21 00:42:07 +01:00
Tobias Gesellchen e75e2bea0c Update Raspberry Pi installation script to include Spotify 2026-02-21 00:42:07 +01:00
Tobias Gesellchen dd5aa2ad53 Disable HTML escaping in JSON response 2026-02-21 00:42:07 +01:00
Tobias Gesellchen aced0f3f81 Use the Chi BasicAuth middleware 2026-02-21 00:42:07 +01:00
Tobias Gesellchen a886518cad Add example redirect URIs for both browser and ueberboese-app 2026-02-21 00:42:07 +01:00
Tim Van Wassenhove dc81b0aa81 feat: separate browser callback and mobile app confirm endpoints
- Add GET /mgmt/spotify/callback (no auth) for browser OAuth redirect
- Restore POST /mgmt/spotify/confirm (Basic Auth) for ueberboese mobile app
- Callback returns HTML success/error pages; confirm returns JSON
- Both call the same ExchangeCodeAndStore() logic
2026-02-21 00:21:52 +01:00
Tim Van Wassenhove c648027735 fix: OAuth callback as GET outside auth group, remove dead zeroconf flag, update .env.example
- Change /mgmt/spotify/confirm from POST to GET (Spotify redirects via GET)
- Move confirm endpoint outside Basic Auth group (code is single-use, needs client_secret)
- Remove --zeroconf-primer-enabled flag (no ZeroConf primer code on this branch)
- Add Spotify/mgmt env var documentation to .env.example
2026-02-21 00:21:52 +01:00
Tim Van Wassenhove fced88a8a6 feat: add management API endpoints matching ueberboese-app 2026-02-21 00:21:52 +01:00
Tim Van Wassenhove 0ee673c097 feat: wire Spotify service into server 2026-02-21 00:21:52 +01:00
Tim Van Wassenhove 395b2fec8e feat: add Spotify OAuth service with token management 2026-02-21 00:21:52 +01:00
Tim Van Wassenhove be7e44e14b feat: add Basic Auth middleware for management API 2026-02-21 00:21:52 +01:00
Tim Van Wassenhove a87783d8c6 feat: add Spotify, management, and ZeroConf CLI flags 2026-02-21 00:21:52 +01:00
Tobias Gesellchen be017440b7 Add userInactivity event 2026-02-20 09:18:53 +01:00
Tobias Gesellchen 10de011c18 Simplify the PlayTTS method cmd 2026-02-19 08:46:55 +01:00
Tobias Gesellchen f7b74db3ea Make the linter happy 2026-02-19 08:44:26 +01:00
Tobias Gesellchen 72d75133c4 Capture server references before releasing mutex to avoid race condition 2026-02-19 08:44:26 +01:00
Tobias Gesellchen e4c12471b4 Add more upstream domains to the intercept list 2026-02-19 08:44:26 +01:00
Tobias Gesellchen 3329149282 Add support for RADIO_BROWSER source
This implementation follows the reference from soundcork pull request #158. It adds RADIO_BROWSER to the known providers and includes the service configuration in bmx_services.json. Documentation has also been added to explain how to use the RadioBrowser feature. Credits to @gmuth (https://github.com/gmuth) for the original idea and implementation in soundcork. Reference: https://github.com/deborahgu/soundcork/pull/158
2026-02-16 22:18:33 +01:00
Tobias Gesellchen 523ff0eb17 Fix deadlock in settings update and add efficient DNS settings validation 2026-02-16 21:02:42 +01:00
Tobias Gesellchen 025e15d65c Implement log throttling, loop prevention, and empty upstream handling in DNS discovery server 2026-02-16 21:02:42 +01:00
Tobias Gesellchen 7d140b3e2a Fix TestMigrationAndCA by enhancing mock SSH client
This commit updates the mock SSH client in the handler tests to support the recently added verification steps. It now correctly handles stateful responses for /etc/hosts and properly responds to file existence and CA trust checks.
2026-02-16 20:17:25 +01:00
Tobias Gesellchen 69210638e5 Add verification steps to speaker migration process
This update adds explicit verification checks after applying changes via XML, Hosts, and ResolvConf migration methods. The service now verifies that configuration files are correctly updated on the device before considering the migration successful, preventing unreliable states.
2026-02-16 20:17:25 +01:00
Tobias Gesellchen 6aef2b807d Enhance ResolvConf migration to support multiple DHCP script variants
This update allows the service to correctly patch both /etc/udhcpc.d/50default and /opt/Bose/udhcpc.script (used in SoundTouch 10 firmware) for DNS redirection. It also improves robustness by adding file existence checks in rc.local and ensures clean state by reverting to .original backups during migration.
2026-02-16 18:52:25 +01:00
Tobias Gesellchen 95f5e9c831 fix(setup): prevent and clean up corrupted rc.local with cat error message 2026-02-16 18:20:16 +01:00
Tobias Gesellchen 7337296ae9 refactor(setup): reduce cyclomatic complexity of RevertMigration 2026-02-16 18:04:28 +01:00
Tobias Gesellchen 92a5d3592c feat(setup): replace obsolete resolv method with persistent DHCP-aware DNS hook 2026-02-16 18:04:28 +01:00
Tobias Gesellchen 2f04af872b feat(setup): implement Aftertouch Hook (DHCP-aware DNS redirection); update UI and tests; docs now use aftertouch.resolv.conf 2026-02-16 18:04:28 +01:00
Tobias Gesellchen 9479d6d11d Fix missing request body in recorded proxy interactions 2026-02-16 16:30:41 +01:00
Tobias Gesellchen f687ba0d82 go mod tidy 2026-02-16 12:45:04 +01:00
Tobias Gesellchen ab2bf0731a Add DNS-based discovery and migration via /etc/resolv.conf 2026-02-16 12:18:06 +01:00
Tobias Gesellchen cafaba1be0 Update SOUNDTOUCH-SERVICE.md with recent features (Soundcork proxy, session archiving, enhanced redaction) 2026-02-15 23:54:14 +01:00
Tobias Gesellchen 93082d2cdc Update root endpoint JSON response with AfterTouch and docs link 2026-02-15 23:47:10 +01:00
Tobias Gesellchen 087006c483 Add regression test for settings persistence 2026-02-15 23:28:45 +01:00
Tobias Gesellchen b7013a5ec8 Apply 'Redact Sensitive Headers' to recordings 2026-02-15 23:12:19 +01:00
Tobias Gesellchen 7d76b3fab2 Implement dynamic Bose proxy with detailed origin logging and Soundcork fallback 2026-02-15 22:50:13 +01:00
Tobias Gesellchen 6ca206053f Add session download feature to web UI 2026-02-15 22:20:16 +01:00
62 changed files with 7041 additions and 877 deletions
+17
View File
@@ -41,3 +41,20 @@ PREFERRED_DEVICES="Living Room@192.168.1.100:8090;Kitchen@192.168.1.101;192.168.
# Alternative format examples:
# PREFERRED_DEVICES="192.168.178.35;192.168.178.28"
# PREFERRED_DEVICES="SoundTouch 10@192.168.178.35;SoundTouch 20@192.168.178.28"
# Spotify Integration
# Create an app at https://developer.spotify.com/dashboard
# SPOTIFY_CLIENT_ID=your_client_id
# SPOTIFY_CLIENT_SECRET=your_client_secret
# Auth confirmation url using GET, works in browsers
# SPOTIFY_REDIRECT_URI=https://your-server.example.com/mgmt/spotify/callback
# Auth confirmation url using POST, works with the ueberboese-app (https://github.com/julius-d/ueberboese-app)
# SPOTIFY_REDIRECT_URI=https://your-server.example.com/mgmt/spotify/confirm
# Management API Authentication
# Protects /mgmt/* endpoints (Spotify token access, account management)
MGMT_USERNAME=admin
MGMT_PASSWORD=change_me!
# External base URL (required when behind a reverse proxy for OAuth callbacks)
# BASE_URL=https://your-server.example.com
+7 -3
View File
@@ -17,10 +17,13 @@ A comprehensive solution for controlling and preserving Bose SoundTouch devices,
-**Real-time Events**: WebSocket connection for live device state monitoring
- 🔍 **Device Discovery**: Automatic discovery via UPnP/SSDP and mDNS
- 📻 **Content Navigation**: Browse and search TuneIn, Pandora, Spotify, local music
- 📻 **RadioBrowser**: Access thousands of internet radio stations via [radio-browser.info](docs/reference/radio-browser.md)
- 🎙️ **Station Management**: Add and play radio stations without presets
- 🖥️ **CLI Tool**: Comprehensive command-line interface
- 🌐 **SoundTouch Service**: Emulate Bose cloud services for offline device operation
- 🔧 **Service Migration**: Migrate devices to use local services instead of Bose cloud
- 🔧 **Service Migration**: Migrate devices to use local services instead of Bose cloud (XML, Hosts, or DNS redirection)
- 🔍 **DNS Discovery & Interception**: Dynamic DNS server for intercepting and logging Bose service queries (requires port 53)
- 📊 **DNS Discovery Analysis**: Track and deduplicate all device DNS queries to discover hidden hostnames
- 📊 **Traffic Analysis**: Proxy and log device communications
- 📝 **HTTP Recording**: Persist interactions as re-playable `.http` files
- 🧹 **Session Management**: Manage and cleanup recorded interaction sessions
@@ -73,6 +76,7 @@ The `soundtouch-service` is a local server that emulates Bose's cloud services.
- **🔌 Easy Setup**: Activate SSH via USB stick (`remote_services` file)
- **🔧 Device Migration**: Seamlessly transition devices to local control
- **🌐 Web Management UI**: Easy browser-based setup and management
- **🎮 Stockholm Mini**: A minimal reverse-engineered UI for device control (accessible at `/web/stockholm-mini/`)
- **💾 Persistent Data**: Store presets, recents, and sources locally
- **📝 HTTP Recording**: Persist all interactions as re-playable `.http` files
- **🧹 Session Management**: Manage and cleanup recorded interaction sessions
@@ -317,8 +321,8 @@ func main() {
Port: 8090,
})
// Play Text-to-Speech message
err := c.PlayTTS("Welcome home!", "your-app-key", 70)
// Play Text-to-Speech message (language code "EN", "DE", etc.)
err := c.PlayTTS("Welcome home!", "your-app-key", "EN", 70)
if err != nil {
log.Fatal(err)
}
+10
View File
@@ -389,6 +389,10 @@ func handleSpecialMessage(message *models.SpecialMessage, filters map[string]boo
if !filters["userActivity"] {
return
}
case models.MessageTypeUserInactivity:
if !filters["userInactivity"] {
return
}
}
}
@@ -402,6 +406,12 @@ func handleSpecialMessage(message *models.SpecialMessage, filters map[string]boo
case models.MessageTypeUserActivity:
fmt.Printf("\n👤 User Activity [%s]\n", message.DeviceID)
if verbose {
fmt.Printf(" ⏰ Timestamp: %s\n", message.Timestamp.Format("15:04:05"))
}
case models.MessageTypeUserInactivity:
fmt.Printf("\n💤 User Inactivity [%s]\n", message.DeviceID)
if verbose {
fmt.Printf(" ⏰ Timestamp: %s\n", message.Timestamp.Format("15:04:05"))
}
+8 -19
View File
@@ -2,7 +2,6 @@ package main
import (
"fmt"
"net/url"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/models"
@@ -35,23 +34,12 @@ func playTTS(c *cli.Context) error {
return err
}
// URL encode the text for Google TTS
encodedText := url.QueryEscape(text)
// Build TTS URL with language support
ttsURL := fmt.Sprintf("http://translate.google.com/translate_tts?ie=UTF-8&tl=%s&client=tw-ob&q=%s", language, encodedText)
// Create PlayInfo for TTS
playInfo := &models.PlayInfo{
URL: ttsURL,
AppKey: appKey,
Service: "TTS Notification",
Message: "Google TTS",
Reason: text,
}
var playInfo *models.PlayInfo
if volume > 0 {
playInfo.SetVolume(volume)
playInfo = models.NewTTSPlayInfo(text, appKey, language, volume)
} else {
playInfo = models.NewTTSPlayInfo(text, appKey, language)
}
err = client.PlayCustom(playInfo)
@@ -121,10 +109,11 @@ func playURL(c *cli.Context) error {
}
// Create PlayInfo for URL content
playInfo := models.NewURLPlayInfo(urlStr, appKey, service, message, reason)
var playInfo *models.PlayInfo
if volume > 0 {
playInfo.SetVolume(volume)
playInfo = models.NewURLPlayInfo(urlStr, appKey, service, message, reason, volume)
} else {
playInfo = models.NewURLPlayInfo(urlStr, appKey, service, message, reason)
}
err = client.PlayCustom(playInfo)
+222 -90
View File
@@ -9,7 +9,6 @@ import (
"fmt"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
@@ -18,11 +17,13 @@ import (
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
"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/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/urfave/cli/v2"
@@ -138,6 +139,56 @@ func main() {
Value: "5m",
EnvVars: []string{"DISCOVERY_INTERVAL"},
},
&cli.BoolFlag{
Name: "dns-discovery",
Usage: "Enable DNS discovery server",
EnvVars: []string{"ENABLE_DNS_DISCOVERY"},
},
&cli.StringFlag{
Name: "dns-upstream",
Usage: "Upstream DNS server for non-Bose queries",
Value: "8.8.8.8",
EnvVars: []string{"DNS_UPSTREAM"},
},
&cli.StringFlag{
Name: "dns-bind",
Usage: "Bind address for the DNS discovery server",
Value: ":53",
EnvVars: []string{"DNS_BIND_ADDR"},
},
&cli.StringFlag{
Name: "spotify-client-id",
Usage: "Spotify OAuth client ID",
EnvVars: []string{"SPOTIFY_CLIENT_ID"},
},
&cli.StringFlag{
Name: "spotify-client-secret",
Usage: "Spotify OAuth client secret",
EnvVars: []string{"SPOTIFY_CLIENT_SECRET"},
},
&cli.StringFlag{
Name: "spotify-redirect-uri",
Usage: "Spotify OAuth redirect URI",
Value: "ueberboese-login://spotify",
EnvVars: []string{"SPOTIFY_REDIRECT_URI"},
},
&cli.StringFlag{
Name: "mgmt-username",
Usage: "Management API username for HTTP Basic Auth",
Value: "admin",
EnvVars: []string{"MGMT_USERNAME"},
},
&cli.StringFlag{
Name: "mgmt-password",
Usage: "Management API password for HTTP Basic Auth",
Value: "change_me!",
EnvVars: []string{"MGMT_PASSWORD"},
},
&cli.StringFlag{
Name: "base-url",
Usage: "External base URL for OAuth callbacks behind reverse proxy",
EnvVars: []string{"BASE_URL"},
},
},
Action: func(c *cli.Context) error {
config := loadConfig(c)
@@ -161,9 +212,52 @@ func main() {
cm := initCertificateManager(config.dataDir)
sm := setup.NewManager(config.serverURL, ds, cm)
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record, config.enableSoundcorkProxy)
sm.GetDNSRunning = server.GetDNSRunning
server.SetSoundcorkURL(config.soundcorkURL)
server.SetHTTPServerURL(config.httpsServerURL)
server.SetVersionInfo(version, commit, date)
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
server.SetDNSSettings(persisted.DNSEnabled, persisted.DNSUpstream, persisted.DNSBindAddr)
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
server.SetBaseURL(config.baseURL)
if config.spotifyClientID != "" {
spotifyService := spotify.NewSpotifyService(
config.spotifyClientID,
config.spotifyClientSecret,
config.spotifyRedirectURI,
config.dataDir,
)
server.SetSpotifyService(spotifyService)
clientIDPrefix := config.spotifyClientID
if len(clientIDPrefix) > 8 {
clientIDPrefix = clientIDPrefix[:8]
}
log.Printf("Spotify service initialized (client ID: %s...)", clientIDPrefix)
}
// Load and set initial DNS discoveries
dnsDiscoveries, err := ds.LoadDNSDiscoveries()
if err == nil && len(dnsDiscoveries) > 0 {
initial := make(map[string]*discovery.DiscoveredHost)
for _, entry := range dnsDiscoveries {
initial[entry.Hostname] = &discovery.DiscoveredHost{
Hostname: entry.Hostname,
FirstSeen: entry.FirstSeen,
LastSeen: entry.LastSeen,
QueryCount: entry.QueryCount,
IsBoseService: entry.IsBoseService,
IsIntercepted: entry.IsIntercepted,
RemoteAddr: entry.RemoteAddr,
}
}
server.SetDNSDiscoveries(initial)
}
server.SetShortcuts(persisted.Shortcuts)
for path, status := range persisted.Shortcuts {
@@ -203,11 +297,9 @@ func main() {
log.Printf("Warning: Failed to setup TLS: %v", err)
}
scProxy := setupSoundcorkProxy(config.soundcorkURL, config.redact, config.logBody, recorder, server)
startDeviceDiscovery(server)
r := setupRouter(server, scProxy, config.enableSoundcorkProxy)
r := setupRouter(server)
log.Printf("Go service starting on %s, proxying to %s", config.serverURL, config.soundcorkURL)
@@ -255,8 +347,17 @@ type serviceConfig struct {
logBody bool
record bool
enableSoundcorkProxy bool
dnsEnabled bool
dnsUpstream string
dnsBind string
discoveryInterval time.Duration
domains []string
spotifyClientID string
spotifyClientSecret string
spotifyRedirectURI string
mgmtUsername string
mgmtPassword string
baseURL string
}
func loadConfig(c *cli.Context) serviceConfig {
@@ -302,6 +403,10 @@ func loadConfig(c *cli.Context) serviceConfig {
record := c.Bool("record-interactions")
enableSoundcorkProxy := c.Bool("enable-soundcork-proxy")
dnsEnabled := c.Bool("dns-discovery")
dnsUpstream := c.String("dns-upstream")
dnsBind := c.String("dns-bind")
discoveryIntervalStr := c.String("discovery-interval")
discoveryInterval, err := time.ParseDuration(discoveryIntervalStr)
@@ -311,6 +416,13 @@ func loadConfig(c *cli.Context) serviceConfig {
discoveryInterval = 5 * time.Minute
}
spotifyClientID := c.String("spotify-client-id")
spotifyClientSecret := c.String("spotify-client-secret")
spotifyRedirectURI := c.String("spotify-redirect-uri")
mgmtUsername := c.String("mgmt-username")
mgmtPassword := c.String("mgmt-password")
baseURL := c.String("base-url")
return serviceConfig{
port: port,
bindAddr: bindAddr,
@@ -324,8 +436,17 @@ func loadConfig(c *cli.Context) serviceConfig {
logBody: logBody,
record: record,
enableSoundcorkProxy: enableSoundcorkProxy,
dnsEnabled: dnsEnabled,
dnsUpstream: dnsUpstream,
dnsBind: dnsBind,
discoveryInterval: discoveryInterval,
domains: domains,
spotifyClientID: spotifyClientID,
spotifyClientSecret: spotifyClientSecret,
spotifyRedirectURI: spotifyRedirectURI,
mgmtUsername: mgmtUsername,
mgmtPassword: mgmtPassword,
baseURL: baseURL,
}
}
@@ -382,10 +503,19 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
}
}
config.redact = persisted.RedactLogs || config.redact
config.logBody = persisted.LogBodies || config.logBody
config.record = persisted.RecordInteractions || config.record
config.enableSoundcorkProxy = persisted.EnableSoundcorkProxy || config.enableSoundcorkProxy
config.redact = persisted.RedactLogs
config.logBody = persisted.LogBodies
config.record = persisted.RecordInteractions
config.enableSoundcorkProxy = persisted.EnableSoundcorkProxy
config.dnsEnabled = persisted.DNSEnabled
if persisted.DNSUpstream != "" {
config.dnsUpstream = persisted.DNSUpstream
}
if persisted.DNSBindAddr != "" {
config.dnsBind = persisted.DNSBindAddr
}
return persisted
}
@@ -401,6 +531,9 @@ func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datast
DiscoveryInterval: config.discoveryInterval.String(),
DiscoveryEnabled: true,
EnableSoundcorkProxy: config.enableSoundcorkProxy,
DNSEnabled: config.dnsEnabled,
DNSUpstream: config.dnsUpstream,
DNSBindAddr: config.dnsBind,
Shortcuts: map[string]int{
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
"/sw.js": http.StatusNotFound,
@@ -429,64 +562,6 @@ func initCertificateManager(dataDir string) *certmanager.CertificateManager {
return cm
}
func setupSoundcorkProxy(soundcorkURL string, redact, logBody bool, recorder *proxy.Recorder, server *handlers.Server) *httputil.ReverseProxy {
target, err := url.Parse(soundcorkURL)
if err != nil {
log.Fatalf("Failed to parse Soundcork URL: %v", err)
}
scProxy := httputil.NewSingleHostReverseProxy(target)
scProxy.ModifyResponse = func(res *http.Response) error {
if etags, ok := res.Header["Etag"]; ok {
delete(res.Header, "Etag")
res.Header["ETag"] = etags
}
currentLp := proxy.NewLoggingProxy(target.String(), redact)
currentLp.LogBody = logBody
currentLp.RecordEnabled = server.GetRecordEnabled()
currentLp.SetRecorder(recorder)
currentLp.LogResponse(res)
return nil
}
originalScDirector := scProxy.Director
scProxy.Director = func(req *http.Request) {
originalScDirector(req)
// Fix X-Forwarded-For bloat by deduplicating
if xff := req.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
seen := make(map[string]bool)
unique := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" && !seen[p] {
seen[p] = true
unique = append(unique, p)
}
}
// Limit the number of entries to prevent header overflow
if len(unique) > 10 {
unique = unique[len(unique)-10:]
}
req.Header.Set("X-Forwarded-For", strings.Join(unique, ", "))
}
currentLp := proxy.NewLoggingProxy(target.String(), redact)
currentLp.LogBody = logBody
currentLp.RecordEnabled = server.GetRecordEnabled()
currentLp.SetRecorder(recorder)
currentLp.LogRequest(req)
}
return scProxy
}
func startDeviceDiscovery(server *handlers.Server) {
go func() {
for {
@@ -500,9 +575,9 @@ func startDeviceDiscovery(server *handlers.Server) {
}()
}
func setupRouter(server *handlers.Server, scProxy *httputil.ReverseProxy, enableSoundcorkProxy bool) *chi.Mux {
func setupRouter(server *handlers.Server) *chi.Mux {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(server.OriginMiddleware)
r.Use(middleware.Recoverer)
r.Use(server.ShortcutMiddleware)
r.Use(server.RecordMiddleware)
@@ -526,6 +601,13 @@ func setupRouter(server *handlers.Server, scProxy *httputil.ReverseProxy, enable
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
})
// Legacy or direct domain calls without /bmx prefix
r.Get("/registry/v1/services", server.HandleBMXRegistry)
r.Get("/tunein/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
r.Route("/marge", func(r chi.Router) {
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
@@ -544,6 +626,23 @@ func setupRouter(server *handlers.Server, scProxy *httputil.ReverseProxy, enable
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.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)
r.Post("/account/{account}", server.HandleMargeUpdateAccountProfile)
@@ -560,45 +659,78 @@ func setupRouter(server *handlers.Server, scProxy *httputil.ReverseProxy, enable
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,
// short-lived, and useless without the client_secret.
r.Get("/spotify/callback", server.HandleMgmtSpotifyCallback)
// All other management endpoints require Basic Auth.
r.Group(func(r chi.Router) {
r.Use(server.BasicAuthMgmt())
r.Get("/accounts/{accountId}/speakers", server.HandleMgmtListSpeakers)
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
r.Post("/spotify/init", server.HandleMgmtSpotifyInit)
r.Post("/spotify/confirm", server.HandleMgmtSpotifyConfirm)
r.Get("/spotify/accounts", server.HandleMgmtSpotifyAccounts)
r.Get("/spotify/token", server.HandleMgmtSpotifyToken)
r.Post("/spotify/entity", server.HandleMgmtSpotifyEntity)
})
})
r.Get("/proxy/*", server.HandleProxyRequest)
r.Route("/devices", func(r chi.Router) {
r.Get("/", server.HandleListDiscoveredDevices)
r.Post("/", server.HandleAddManualDevice)
r.Route("/{deviceId}", func(r chi.Router) {
r.Delete("/", server.HandleRemoveDevice)
r.Get("/events", server.HandleGetDeviceEvents)
r.Get("/info", server.HandleGetDeviceInfo)
r.Get("/ws", server.HandleDeviceWebSocket)
r.Post("/key/{key}", server.HandleDeviceKey)
r.Post("/volume/{level}", server.HandleDeviceVolume)
r.Post("/reboot", server.HandleRebootDevice)
})
})
r.Get("/version", server.HandleGetVersionInfo)
r.Route("/setup", func(r chi.Router) {
r.Get("/devices", server.HandleListDiscoveredDevices)
r.Post("/devices", server.HandleAddManualDevice)
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
r.Post("/discover", server.HandleTriggerDiscovery)
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
r.Get("/settings", server.HandleGetSettings)
r.Post("/settings", server.HandleUpdateSettings)
r.Get("/info/{deviceIP}", server.HandleGetDeviceInfo)
r.Get("/summary/{deviceIP}", server.HandleGetMigrationSummary)
r.Post("/migrate/{deviceIP}", server.HandleMigrateDevice)
r.Post("/revert/{deviceIP}", server.HandleRevertMigration)
r.Post("/reboot/{deviceIP}", server.HandleRebootDevice)
r.Post("/trust-ca/{deviceIP}", server.HandleTrustCACert)
r.Post("/ensure-remote-services/{deviceIP}", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services/{deviceIP}", server.HandleRemoveRemoteServices)
r.Post("/backup/{deviceIP}", server.HandleBackupConfig)
r.Post("/sync/{deviceIP}", server.HandleInitialSync)
r.Post("/test-connection/{deviceIP}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceIP}", server.HandleTestHostsRedirection)
r.Get("/ca.crt", server.HandleGetCACert)
r.Get("/proxy-settings", server.HandleGetProxySettings)
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
r.Get("/version", server.HandleGetVersionInfo)
r.Get("/interaction-stats", server.HandleGetInteractionStats)
r.Get("/interactions", server.HandleListInteractions)
r.Get("/interaction-content", server.HandleGetInteractionContent)
r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
r.Route("/devices/{deviceId}", func(r chi.Router) {
r.Get("/summary", server.HandleGetMigrationSummary)
r.Post("/migrate", server.HandleMigrateDevice)
r.Post("/revert", server.HandleRevertMigration)
r.Post("/trust-ca", server.HandleTrustCACert)
r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services", server.HandleRemoveRemoteServices)
r.Post("/backup", server.HandleBackupConfig)
r.Post("/sync", server.HandleInitialSync)
r.Post("/test-connection", server.HandleTestConnection)
r.Post("/test-hosts", server.HandleTestHostsRedirection)
r.Post("/test-dns", server.HandleTestDNSRedirection)
})
})
if enableSoundcorkProxy {
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
scProxy.ServeHTTP(w, r)
})
}
r.NotFound(server.HandleNotFound)
return r
}
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestApplyPersistedSettings(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "main-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
ds := datastore.NewDataStore(tmpDir)
t.Run("overrides true with false", func(t *testing.T) {
config := &serviceConfig{
redact: true,
logBody: true,
record: true,
enableSoundcorkProxy: true,
}
// Simulate the bug by using the old bitwise OR logic in the test,
// which should fail if we expect false.
// config.redact = config.redact || false -> stays true
settings := datastore.Settings{
RedactLogs: false,
LogBodies: false,
RecordInteractions: false,
EnableSoundcorkProxy: false,
}
err := ds.SaveSettings(settings)
if err != nil {
t.Fatalf("Failed to save settings: %v", err)
}
applyPersistedSettings(ds, config)
if config.redact != false {
t.Errorf("Expected redact to be false, got true")
}
if config.logBody != false {
t.Errorf("Expected logBody to be false, got true")
}
if config.record != false {
t.Errorf("Expected record to be false, got true")
}
if config.enableSoundcorkProxy != false {
t.Errorf("Expected enableSoundcorkProxy to be false, got true")
}
})
t.Run("retains false when settings are false", func(t *testing.T) {
settings := datastore.Settings{
RedactLogs: false,
}
err := ds.SaveSettings(settings)
if err != nil {
t.Fatalf("Failed to save settings: %v", err)
}
config := &serviceConfig{
redact: false,
}
applyPersistedSettings(ds, config)
if config.redact != false {
t.Errorf("Expected redact to be false, got true")
}
})
t.Run("overrides false with true", func(t *testing.T) {
settings := datastore.Settings{
RedactLogs: true,
}
err := ds.SaveSettings(settings)
if err != nil {
t.Fatalf("Failed to save settings: %v", err)
}
config := &serviceConfig{
redact: false,
}
applyPersistedSettings(ds, config)
if config.redact != true {
t.Errorf("Expected redact to be true, got false")
}
})
}
+1
View File
@@ -1,6 +1,7 @@
accounts/
certs/
default/
dns/
interactions/
patterns.json
settings.json
+1 -1
View File
@@ -119,7 +119,7 @@ soundtouch-service
```go
// Build custom applications on top of local services
client := &http.Client{}
resp, _ := client.Get("http://localhost:8000/setup/devices")
resp, _ := client.Get("http://localhost:8000/devices")
```
### Privacy-Conscious Users
+2
View File
@@ -33,6 +33,7 @@
* [Preset Management](reference/PRESET-MANAGEMENT.md)
* [Source Selection](reference/SOURCE-SELECTION.md)
* [Volume Controls](reference/VOLUME-CONTROLS.md)
* [RadioBrowser](reference/radio-browser.md)
* [Bass Controls](reference/BASS-CONTROLS.md)
* [Key Controls](reference/KEY-CONTROLS.md)
* [Feature Mapping](reference/FEATURE-MAPPING.md)
@@ -43,6 +44,7 @@
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
* [Stockholm App Analysis](analysis/stockholm-app-analysis.md)
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
## Appendix (Other Documents)
+2 -2
View File
@@ -370,7 +370,7 @@ soundtouch-cli speaker beep
**Go Client Usage:**
```go
// Text-to-Speech
client.PlayTTS("Hello World", "your-app-key", 70)
client.PlayTTS("Hello World", "your-app-key", "EN", 70)
// URL content
client.PlayURL("https://example.com/audio.mp3", "your-app-key", "Service", "Message", "Reason", 60)
@@ -1044,4 +1044,4 @@ The SoundTouch Plus Wiki provides comprehensive documentation for **64 additiona
This documentation provides the complete foundation for implementing all endpoints from the SoundTouch Plus Wiki, enabling this Go library to become the definitive SoundTouch integration solution for everything from basic home automation to professional audio installations.
*All examples and XML structures are verified against real SoundTouch hardware and extensively tested by the SoundTouch Plus community.*
*All examples and XML structures are verified against real SoundTouch hardware and extensively tested by the SoundTouch Plus community.*
+52
View File
@@ -0,0 +1,52 @@
### Stockholm App Analysis Report
#### 1. Overview
The Stockholm app is a CEPE MAUI SoundTouch Controller HTML5/JS UI. It is designed to run as a web-based interface for Bose SoundTouch devices, likely served by the device itself or an associated controller.
- **Technology Stack**: HTML5, CSS3, JavaScript (Minified).
- **Key Libraries**:
- **jQuery**: Core DOM manipulation and event handling.
- **iScroll**: Used for smooth scrolling in lists and carousels.
- **Forge**: Used for cryptographic operations (likely for secure communication or authentication).
- **WebSocket Polyfill**: Ensures WebSocket compatibility across environments.
#### 2. Directory Structure
- `js/`: Core application logic.
- `app/`: Main application entry point (`app.js`).
- `models/`: Data models for UI components (Presets, Favorites, Onboarding, etc.).
- `music_services/`: Implementation of various music services (Amazon, Deezer, Spotify, BMX, etc.).
- `views/`: UI view templates and logic.
- `utils/`: Utility functions for security, data analytics, and general-purpose tasks.
- `json/`: Configuration files and static data.
- `config.json`: Core application configuration including Base64 encoded Bose API endpoints (e.g., streaming, events, BMX registry).
- `sourceFeatures.json`: Capability mapping for different sources.
- `setup/`: Onboarding and initial device setup logic.
- `lang/`: Localization files for multi-language support.
#### 3. Communication Architecture
The app uses several communication channels to interact with the SoundTouch ecosystem:
- **Socket Communication (`socket_comm.js`)**: Real-time updates and low-latency commands via WebSockets.
- **BMX (`bmx.js` & `js/music_services/bmx/`)**: Interactions with the Bose Music eXperience services. Handles account management, navigation, and API response validation.
- **Marge (`marge_comm.js`)**: Likely used for interaction with the Marge service (Bose's legacy cloud/proxy service).
- **Worker-based Architecture**: Many services use Web Workers (`bmx_worker.js`, `spotify_worker.js`) to handle API requests and data processing in the background, keeping the UI responsive.
#### 4. Key Features & Functionality
- **Multi-Device Management**: Discovering and controlling multiple speakers on the network.
- **Music Service Integration**: Deep integration with Spotify, Amazon Music, Deezer, and Pandora.
- **Preset Management**: Browsing and setting presets directly from the UI.
- **Zone Control**: Creating and managing multi-room groups (Master/Slave configurations).
- **Onboarding**: A dedicated setup flow for new devices.
- **Analytics & Data Collection**: Modules like `data_analytics.js` and `dc_server.js` suggest tracking of user interactions.
#### 5. Integration Opportunities for Bose-SoundTouch Project
Based on the Stockholm app's capabilities, the following features could be enhanced or added to our Go-based `soundtouch-service`:
1. **Enhanced BMX Emulation**: Use insights from `bmx_client.js` and `bmx_navigate_response_generator.js` to improve our local BMX implementation.
2. **Spotify/Amazon Service Proxies**: Implement the backend logic required to support the same API calls the Stockholm app makes to these services.
3. **UI parity**: The Stockholm app's view templates (`views/`) can serve as a reference for our Web Management UI.
4. **WebSocket Support**: Ensure our service provides a robust WebSocket interface similar to what the Stockholm app expects for real-time state synchronization.
5. **Capability Discovery**: Better utilization of the `sourceFeatures.json` logic to dynamically show/hide features based on the device model and firmware version.
#### 6. Conclusion
The Stockholm app is a mature, full-featured controller that relies heavily on Bose's proprietary BMX and Marge services. By analyzing its client-side logic, we can better understand the expected API responses and interaction patterns needed to provide a seamless local replacement for the Bose Cloud.
+4 -1
View File
@@ -27,7 +27,10 @@ Before you proceed with the actual migration, follow these steps:
4. **Validate SSH Access**: Confirm the device responds to SSH without a password.
- In the Web UI **Migration** tab, select your speaker and verify that the "SSH Connection" status shows ✅ Success.
- This toolkit automatically handles the necessary SSH parameters (ciphers and key exchanges) required by older Bose firmware.
5. **Use XML Migration First**: The `XML` migration method is less invasive than the `Hosts` method. It only changes the application config and doesn't require modifying the system's DNS/CA trust store if you don't need full HTTPS interception initially.
5. **Migration Methods**:
- **XML Migration (Default)**: Less invasive, only changes the application config. Best for simple redirection.
- **Hosts Migration**: Modifies `/etc/hosts` on the device. Good for system-wide redirection of specific domains.
- **ResolvConf Migration**: Points the device to the AfterTouch DNS server. Best for discovering unknown Bose endpoints and dynamic interception. **Note**: This method requires the DNS Discovery Server to be running on port 53. The service includes a pre-flight check to ensure the server is properly bound before allowing this migration.
6. **Monitor Logs**: Run the `soundtouch-service` with `DEBUG` or `INFO` logging to see the step-by-step progress of the migration.
#### 🔄 Rollback Strategy
+151 -32
View File
@@ -7,13 +7,16 @@ The `soundtouch-service` is a comprehensive local server that emulates Bose's cl
The service provides:
- **🏠 Local Service Emulation**: Complete BMX (Bose Media eXchange) and Marge service implementation
- **🔧 Device Migration**: Seamlessly migrate devices from Bose cloud to local services
- **🔧 Device Migration**: Seamlessly migrate devices from Bose cloud to local services via XML config, `/etc/hosts`, or `/etc/resolv.conf`
- **🔍 DNS Discovery & Interception**: Built-in DNS server to discover unknown Bose endpoints and selectively intercept cloud traffic
- **📊 Traffic Proxying**: Inspect and log all device communications for debugging
- **🌐 Web Management UI**: Browser-based interface for device management
- **💾 Persistent Data**: Store device configurations, presets, and usage statistics
- **📝 HTTP Recording**: Persist all interactions as re-playable `.http` files
- **📥 Session Archiving**: Download entire interaction sessions as `.tar.gz` for offline analysis
- **🔍 Auto-Discovery**: Automatically detect and configure SoundTouch devices
- **🔒 Offline Operation**: Continue using full device functionality without internet
- **🔗 Bose Proxy & Soundcork Fallback**: Dynamic proxying with automatic fallback to local [SoundCork](https://github.com/deborahgu/soundcork) emulation if enabled
## Architecture
@@ -148,20 +151,23 @@ The service supports multiple ways to configure its behavior. When multiple sour
### Configuration Options
| Variable | Flag | Description | Default |
|------------------------------------|----------------------------|--------------------------------------------------|---------------------------|
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
| Variable | Flag | Description | Default |
|------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------|---------------------------|
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for `resolv.conf` migration) | `:53` |
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
### Configuration Examples
@@ -201,23 +207,23 @@ Device migration switches your SoundTouch devices from Bose's cloud services to
```bash
# Get migration summary first
curl http://localhost:8000/setup/migration-summary/192.168.1.100
curl http://localhost:8000/setup/devices/192.168.1.100/summary
# Perform migration
curl -X POST http://localhost:8000/setup/migrate/192.168.1.100
curl -X POST http://localhost:8000/setup/devices/192.168.1.100/migrate
# Verify migration status
curl http://localhost:8000/setup/devices
curl http://localhost:8000/devices
```
#### Advanced Migration Options
```bash
# Migration with proxy fallback for original services
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?proxy_url=http://localhost:8000&marge=original&stats=original"
curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?proxy_url=http://localhost:8000&marge=original&stats=original"
# Migration with custom target URL
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?target_url=https://my-server.com:8000"
curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?target_url=https://my-server.com:8000"
```
### Post-Migration Verification
@@ -226,20 +232,89 @@ After migration, verify the device is working correctly:
```bash
# Check device status
curl http://localhost:8000/setup/devices
curl http://localhost:8000/devices
# Test preset functionality
curl "http://192.168.1.100:8090/presets"
# Monitor device events (if needed)
curl "http://localhost:8000/events/192.168.1.100"
curl "http://localhost:8000/devices/08DF1F0BA325/events"
```
#### ResolvConf Migration (DHCP-Aware DNS Redirection)
The most robust and flexible DNS-based migration method. It utilizes the device's persistent `/mnt/nv/rc.local` script to inject a priority DNS hook into the system's DHCP configuration.
> **Note**: This method requires the DNS Discovery Server to be bound to **port 53** on your local IP and **actually running**. Most devices do not support custom DNS ports in `/etc/resolv.conf`. If you use a custom port for testing, remember to switch back to `:53` and ensure the server has successfully bound to it (check Settings for status) before the actual migration.
**Advantages:**
- **Discovery**: Automatically discover all Bose endpoints queried by the device.
- **Dynamic Interception**: Intercept new or unknown services without further device modifications.
- **Fail-Safe**: Falls back to the standard network DNS (provided by your router) if the Aftertouch service is unavailable.
- **DHCP Compatible**: Preserves your router's assigned search domain and secondary DNS servers.
- **Wildcard Support**: Seamlessly handles `*.bose.com` redirection via your local DNS server.
- **Persistent**: Survives reboots and DHCP renewals.
**How it works:**
1. **Configuration**: A custom file named `/mnt/nv/aftertouch.resolv.conf` is created on the device's persistent partition.
2. **Boot Hook**: On every boot, `/mnt/nv/rc.local` checks if the system's DHCP scripts (`/etc/udhcpc.d/50default` or `/opt/Bose/udhcpc.script`) have been patched.
3. **Surgical Patch**: If not patched, it injects a one-line check into the relevant DHCP scripts.
4. **Resolution**: Whenever the device acquires a DHCP lease, the scripts now read your `aftertouch.resolv.conf` first, placing your DNS server at the top of `/etc/resolv.conf` while keeping all other DHCP-provided settings.
**Setup:**
1. Enable SSH via the `remote_services` USB trick.
2. Create `/mnt/nv/aftertouch.resolv.conf` with your server details:
```text
# Created by Aftertouch/SoundTouch-Service
# Priority nameserver for Bose service redirection
nameserver 192.168.1.XXX
```
3. Update `/mnt/nv/rc.local` with the idempotent patch:
```sh
#!/bin/sh
# Aftertouch DNS hook: prioritizes our custom nameserver if it exists
HOOK_MARKER="/mnt/nv/aftertouch.resolv.conf"
if [ -f "$HOOK_MARKER" ]; then
# Patch 50default if it exists
TARGET_FILE="/etc/udhcpc.d/50default"
if [ -f "$TARGET_FILE" ] && ! grep -q "$HOOK_MARKER" "$TARGET_FILE"; then
sed -i '/echo "search \$domain"/a \ [ -f '"$HOOK_MARKER"' ] && cat '"$HOOK_MARKER"' && dns=""' "$TARGET_FILE"
fi
# Patch udhcpc.script if it exists (e.g. SoundTouch 10)
TARGET_SCRIPT="/opt/Bose/udhcpc.script"
if [ -f "$TARGET_SCRIPT" ] && ! grep -q "$HOOK_MARKER" "$TARGET_SCRIPT"; then
sed -i '/echo "search \$search_list # \$interface" >> \$RESOLV_CONF/a \ [ -f '"$HOOK_MARKER"' ] && cat '"$HOOK_MARKER"' >> '"\$RESOLV_CONF"' && dns=""' "$TARGET_SCRIPT"
fi
fi
```
4. Make the script executable: `chmod +x /mnt/nv/rc.local`.
5. Reboot the speaker.
### DNS Discovery Server
The SoundTouch service includes a built-in DNS server specifically designed for Bose devices.
#### How it Works
When enabled, the DNS server:
1. Receives DNS queries from migrated SoundTouch devices.
2. **Intercepts** known Bose domains (e.g., `api.bose.com`, `streaming.bose.com`, `bmx.bose.com`) and resolves them to the AfterTouch service IP.
3. **Logs** all other queries for discovery purposes, allowing you to identify new Bose cloud endpoints.
4. **Forwards** unknown or non-Bose queries to the configured upstream DNS server (default: `8.8.8.8`).
#### Configuration
You can enable and configure the DNS server via the Web UI or environment variables:
- `ENABLE_DNS_DISCOVERY=true`: Turns on the DNS server.
- `DNS_BIND_ADDR=:53`: The port to listen on (requires root privileges for port 53).
- `DNS_UPSTREAM=1.1.1.1`: Your preferred upstream DNS provider. **Note:** Ensure this is not set to the same address as the DNS server itself (loopback or local IP) to avoid forwarding loops. The server includes built-in loop prevention, but misconfiguration will cause forwarding to fail. DNS Discovery cannot be enabled if this setting is empty.
#### Manual Discovery via DNS
Even without migrating a device, you can use the DNS server to discover what a device is querying by manually setting your router's DNS or the device's DNS to point to the AfterTouch service.
## API Reference
### Discovery & Setup
#### `GET /setup/devices`
#### `GET /devices`
Lists all discovered SoundTouch devices with their current status.
**Response:**
@@ -260,10 +335,10 @@ Lists all discovered SoundTouch devices with their current status.
#### `POST /setup/discover`
Triggers immediate network device discovery.
#### `GET /setup/info/{deviceIP}`
#### `GET /devices/{deviceIP}/info`
Gets detailed device information and configuration.
#### `GET /setup/migration-summary/{deviceIP}`
#### `GET /setup/devices/{deviceIP}/summary`
Analyzes device configuration and provides migration preview.
**Response:**
@@ -280,7 +355,7 @@ Analyzes device configuration and provides migration preview.
}
```
#### `POST /setup/migrate/{deviceIP}`
#### `POST /setup/devices/{deviceIP}/migrate`
Migrates device to use local services.
**Query Parameters:**
@@ -291,6 +366,33 @@ Migrates device to use local services.
- `sw_update`: Set to "original" to proxy update requests (optional)
- `bmx`: Set to "original" to proxy BMX requests (optional)
#### `POST /setup/devices/{deviceIP}/revert`
Reverts device to Bose cloud defaults.
#### `POST /setup/devices/{deviceIP}/trust-ca`
Injects the AfterTouch root CA into the device's trust store.
#### `POST /setup/devices/{deviceIP}/sync`
Syncs presets and recents from the device to local storage.
#### `POST /setup/devices/{deviceIP}/backup`
Creates a backup of the current device configuration.
#### `POST /setup/devices/{deviceIP}/ensure-remote-services`
Enables persistent SSH/remote services on the device.
#### `POST /setup/devices/{deviceIP}/remove-remote-services`
Removes persistent SSH/remote services from the device.
#### `POST /setup/devices/{deviceIP}/test-connection`
Tests HTTPS connection from device to service.
#### `POST /setup/devices/{deviceIP}/test-hosts`
Tests /etc/hosts redirection on the device.
#### `POST /setup/devices/{deviceIP}/test-dns`
Tests DNS redirection on the device.
### BMX Services (Bose Media eXchange)
#### `GET /bmx/registry/v1/services`
@@ -381,6 +483,8 @@ The web management interface provides a comprehensive dashboard for managing you
- **Advanced Filtering**: Filter interactions by session, category (Self/Upstream), and timestamp.
- **Interaction Viewer**: View raw `.http` recording content directly in the browser.
- **Session Management**: Delete individual sessions or perform bulk cleanup to keep only recent sessions.
- **Session Download**: Download complete interaction sessions as `.tar.gz` archives for offline analysis or bug reports.
- **DNS Discoveries**: Real-time table of all hostnames discovered via the AfterTouch DNS server, categorized by interception status (Self/Upstream).
### Usage Tips
@@ -410,6 +514,8 @@ By default, the service redacts sensitive information from the recorded `.http`
- `Authorization` headers
- `Cookie` headers
- `X-Bose-Token` headers
- `X-Bose-Key` headers
- `Proxy-Authorization` headers
This behavior is controlled by the `--redact-logs` flag or the `REDACT_PROXY_LOGS` environment variable.
@@ -459,6 +565,8 @@ data/
│ │ └── {PATH}/
│ │ └── {SEQ}-{TIME}-{METHOD}.http
│ └── http-client.env.json
├── dns/
│ └── discoveries.json
├── stats/
│ ├── usage/
│ │ └── *.json
@@ -480,6 +588,9 @@ data/
- **Presets.xml**: Cross-device preset synchronization
- **Recents.xml**: Recent playback history
#### DNS Data (`dns/`)
- **discoveries.json**: Persisted DNS discovery logs with hostname deduplication
#### Statistics (`stats/`)
- **usage/**: Device usage analytics and patterns
- **error/**: Error logs and diagnostic information
@@ -530,13 +641,13 @@ find data/stats/ -name "*.json" -mtime +90 -delete
- **Description**: Browser-based guided flow for discovery, data sync, and migration.
### Setup API
- `GET /setup/devices`: List all known (auto-discovered and manual) devices.
- `POST /setup/devices`: Manually add a device by IP.
- `GET /devices`: List all known (auto-discovered and manual) devices.
- `POST /devices`: Manually add a device by IP.
- `POST /setup/discover`: Trigger a new network discovery scan.
- `GET /setup/discovery-status`: Check if a scan is currently in progress.
- `POST /setup/sync/{deviceIP}`: Fetch presets, recents, and sources from a device.
- `GET /setup/summary/{deviceIP}`: Get a detailed migration readiness summary.
- `POST /setup/migrate/{deviceIP}`: Migrate a device using the specified method (XML/Hosts).
- `POST /devices/{deviceIP}/sync`: Fetch presets, recents, and sources from a device.
- `GET /devices/{deviceIP}/summary`: Get a detailed migration readiness summary.
- `POST /devices/{deviceIP}/migrate`: Migrate a device using the specified method (XML/Hosts).
- `GET /setup/ca.crt`: Download the Root CA certificate for manual installation.
#### `GET /setup/interactions`
@@ -559,6 +670,14 @@ Deletes all recordings associated with a specific session.
#### `DELETE /setup/interactions/sessions?keep={N}`
Bulk cleanup: deletes all but the most recent `N` sessions.
### DNS Discovery API
#### `GET /setup/dns-discoveries`
Returns merged in-memory and persisted DNS discoveries, sorted by last seen timestamp.
#### `DELETE /setup/dns-discoveries`
Clears all recorded DNS discovery data from memory and disk.
### Emulated Services
- `/bmx/registry/v1/services`: BMX service registry.
- `/bmx/tunein/v1/*`: TuneIn radio emulation.
@@ -675,7 +794,7 @@ soundtouch:
name: "Living Room Speaker"
rest:
- resource: "http://localhost:8000/setup/devices"
- resource: "http://localhost:8000/devices"
scan_interval: 60
sensor:
- name: "SoundTouch Devices"
+1
View File
@@ -38,6 +38,7 @@ The Bose SoundTouch Go client provides comprehensive source selection functional
- `IHEARTRADIO` - iHeartRadio streaming
- `STORED_MUSIC` - Local/network stored music
- `AIRPLAY` - Apple AirPlay (device dependent)
- `RADIO_BROWSER` - [RadioBrowser](radio-browser.md) internet radio directory
## Client Library Usage
+5 -5
View File
@@ -68,14 +68,14 @@ func main() {
client := client.NewClient(config)
// Play TTS at current volume
err := client.PlayTTS("Hello, this is a test message", "YOUR_APP_KEY")
// Play TTS at current volume (language code "EN", "DE", etc.)
err := client.PlayTTS("Hello, this is a test message", "YOUR_APP_KEY", "EN")
if err != nil {
log.Fatal(err)
}
// Play TTS at specific volume (70)
err = client.PlayTTS("Volume test message", "YOUR_APP_KEY", 70)
err = client.PlayTTS("Volume test message", "YOUR_APP_KEY", "EN", 70)
if err != nil {
log.Fatal(err)
}
@@ -277,7 +277,7 @@ You'll need to provide your own application key. The format and generation metho
```go
// Doorbell notification
client.PlayTTS("Someone is at the front door", "home-automation-key", 80)
client.PlayTTS("Someone is at the front door", "home-automation-key", "EN", 80)
// Security alert
client.PlayURL(
@@ -311,4 +311,4 @@ soundtouch-cli speaker url --url "https://www.soundjay.com/misc/sounds/bell-ring
4. **URL content fails**: Ensure URL is accessible and contains valid audio
5. **Volume not restored**: May occur if device is powered off during playback
For more information, see the [SoundTouch WebServices API documentation](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API).
For more information, see the [SoundTouch WebServices API documentation](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API).
+34
View File
@@ -0,0 +1,34 @@
## radio-browser.info
- https://www.radio-browser.info is a community driven radio station database.
- It provides an API to access the data and allows users to submit new stations or update existing ones.
### Search for stations
- Go to https://www.radio-browser.info and find a station you like.
- Click on the station and copy the UUID from the URL.
- e.g. `https://www.radio-browser.info/history/d28420a4-eccf-47a2-ace1-088c7e7cb7e0`
### RADIO_BROWSER
- This project supports source type RADIO_BROWSER to play radio stations.
- Set the `location` attribute to `/stations/byuuid/{UUID}`.
```xml
<ContentItem
source="RADIO_BROWSER"
type="stationurl"
isPresetable="true"
location="/stations/byuuid/9610c454-0601-11e8-ae97-52543be04c81">
<itemName>RADIO_BROWSER</itemName>
<containerArt></containerArt>
</ContentItem>
```
### Playing the station
To start the radio stream replace `<uuid>` and `<soundtouch>` and run curl like this:
```bash
curl -d '<ContentItem source="RADIO_BROWSER" type="stationurl" location="/stations/byuuid/<uuid>"/>' <soundtouch>:8090/select
```
+1 -1
View File
@@ -6,6 +6,7 @@ require (
github.com/go-chi/chi/v5 v5.2.5
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/mdns v1.0.6
github.com/miekg/dns v1.1.72
github.com/russross/blackfriday/v2 v2.1.0
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.48.0
@@ -13,7 +14,6 @@ require (
require (
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
+2 -2
View File
@@ -1769,8 +1769,8 @@ func (c *Client) hasCapability(capabilities *models.Capabilities, capability str
}
// PlayTTS plays a Text-To-Speech message using Google TTS on the speaker
func (c *Client) PlayTTS(text, appKey string, volume ...int) error {
playInfo := models.NewTTSPlayInfo(text, appKey, volume...)
func (c *Client) PlayTTS(text, appKey, language string, volume ...int) error {
playInfo := models.NewTTSPlayInfo(text, appKey, language, volume...)
if err := playInfo.Validate(); err != nil {
return fmt.Errorf("invalid TTS request: %w", err)
+7
View File
@@ -356,6 +356,13 @@ func (ws *WebSocketClient) attemptReconnect(config *WebSocketConfig) {
}
attempt++
// Check if device is reachable before attempting full WS connection to reduce log noise
if err := ws.client.Ping(); err != nil {
ws.logger.Printf("Reconnection attempt %d skipped: device unreachable (%v)", attempt, err)
continue
}
ws.logger.Printf("Reconnection attempt %d", attempt)
if err := ws.connectWithConfig(config); err != nil {
+398
View File
@@ -0,0 +1,398 @@
// Package discovery provides DNS-based discovery and interception for Bose SoundTouch devices.
package discovery
import (
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/miekg/dns"
)
// DNSDiscovery handles DNS queries and records discovered hosts.
type DNSDiscovery struct {
// Configuration
upstreamDNS string
serviceIP string
// State
discovered map[string]*DiscoveredHost
mu sync.RWMutex
// Callbacks
onNewDiscovery func(hostname string)
// Servers for Shutdown
udpServer *dns.Server
tcpServer *dns.Server
// Address for loop prevention
bindAddr string
// Log throttling
lastLog map[string]time.Time
lastLogMu sync.Mutex
}
// DiscoveredHost represents a host discovered via DNS queries.
type DiscoveredHost struct {
Hostname string `json:"hostname"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
QueryCount int `json:"query_count"`
IsBoseService bool `json:"is_bose_service"`
IsIntercepted bool `json:"is_intercepted"`
RemoteAddr string `json:"remote_addr,omitempty"`
}
// NewDNSDiscovery creates a new DNSDiscovery instance.
func NewDNSDiscovery(upstreamDNS, serviceIP string) *DNSDiscovery {
return &DNSDiscovery{
upstreamDNS: upstreamDNS,
serviceIP: serviceIP,
discovered: make(map[string]*DiscoveredHost),
lastLog: make(map[string]time.Time),
}
}
// ServeDNS implements the dns.Handler interface.
func (d *DNSDiscovery) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
if len(r.Question) == 0 {
return
}
q := r.Question[0]
hostname := strings.TrimSuffix(q.Name, ".")
remoteAddr := ""
if w.RemoteAddr() != nil {
remoteAddr = w.RemoteAddr().String()
}
// Decide how to respond
isIntercepted := d.shouldIntercept(hostname) || hostname == "aftertouch.test"
// Record discovery
d.recordQuery(hostname, isIntercepted, remoteAddr)
if isIntercepted {
// Return your service IP
d.respondWithIP(w, r, d.serviceIP)
d.throttledLog(fmt.Sprintf("[DNS] Intercepting %s (type %d) -> %s", hostname, q.Qtype, d.serviceIP))
} else {
// Forward to real DNS
if d.upstreamDNS == "" {
d.throttledLog("[DNS ERROR] No upstream DNS configured, cannot forward")
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = dns.RcodeServerFailure
_ = w.WriteMsg(m)
return
}
d.throttledLog(fmt.Sprintf("[DNS] Forwarding %s (type %d) to %s", hostname, q.Qtype, d.upstreamDNS))
d.forward(w, r)
}
}
func (d *DNSDiscovery) throttledLog(msg string) {
d.lastLogMu.Lock()
defer d.lastLogMu.Unlock()
now := time.Now()
if last, ok := d.lastLog[msg]; ok && now.Sub(last) < 10*time.Second {
return
}
d.lastLog[msg] = now
log.Print(msg)
}
// recordQuery logs a DNS query and updates the internal state.
func (d *DNSDiscovery) recordQuery(hostname string, isIntercepted bool, remoteAddr string) {
d.mu.Lock()
defer d.mu.Unlock()
host, exists := d.discovered[hostname]
if !exists {
// New discovery!
host = &DiscoveredHost{
Hostname: hostname,
FirstSeen: time.Now(),
LastSeen: time.Now(),
QueryCount: 1,
IsBoseService: d.isBoseRelated(hostname),
IsIntercepted: isIntercepted,
RemoteAddr: remoteAddr,
}
d.discovered[hostname] = host
log.Printf("[NEW DISCOVERY] %s (Bose: %v, Intercepted: %v)",
hostname, host.IsBoseService, host.IsIntercepted)
if d.onNewDiscovery != nil {
go d.onNewDiscovery(hostname)
}
} else {
host.LastSeen = time.Now()
host.QueryCount++
host.IsIntercepted = isIntercepted
if remoteAddr != "" {
host.RemoteAddr = remoteAddr
}
}
}
func (d *DNSDiscovery) shouldIntercept(hostname string) bool {
// Intercept known Bose cloud services
interceptList := []string{
"api.bose.com",
"marge.bose.com",
"bmx.bose.com",
"streaming.bose.com",
"updates.bose.com",
"stats.bose.com",
"content.api.bose.io",
"events.api.bosecm.com",
"bose-prod.apigee.net",
"bose-test.apigee.net",
"worldwide.bose.com",
"music.api.bose.com",
"bosecm.com",
"bose.io",
}
for _, service := range interceptList {
if strings.Contains(hostname, service) {
return true
}
}
return false
}
func (d *DNSDiscovery) isBoseRelated(hostname string) bool {
return strings.Contains(hostname, "bose") ||
strings.Contains(hostname, "soundtouch")
}
func (d *DNSDiscovery) respondWithIP(w dns.ResponseWriter, r *dns.Msg, ip string) {
m := new(dns.Msg)
m.SetReply(r)
m.Compress = false // Embedded clients sometimes don't like compression
m.Authoritative = true
m.RecursionAvailable = true
q := r.Question[0]
log.Printf("[DNS] Intercepted query for %s (type %d) from %s", q.Name, q.Qtype, w.RemoteAddr())
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)
log.Printf("[DNS] Returning A record %s -> %s", q.Name, ip)
} else {
log.Printf("[DNS] Error creating A record: %v", err)
}
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)
default:
log.Printf("[DNS] Returning empty success for type %d", q.Qtype)
}
if err := w.WriteMsg(m); err != nil {
log.Printf("[DNS ERROR] Failed to write response: %v", err)
}
}
func (d *DNSDiscovery) forward(w dns.ResponseWriter, r *dns.Msg) {
if len(r.Question) == 0 {
return
}
q := r.Question[0]
// Don't forward PTR queries for our own service IP to avoid loops or slow timeouts
if q.Qtype == dns.TypePTR {
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = dns.RcodeNameError
if err := w.WriteMsg(m); err != nil {
log.Printf("[DNS ERROR] Failed to write NXDOMAIN: %v", err)
}
return
}
// Add port 53 if not present
upstream := d.upstreamDNS
if !strings.Contains(upstream, ":") {
upstream += ":53"
}
// Loop prevention: don't forward to ourselves
if upstream == d.bindAddr || (strings.HasPrefix(upstream, "127.0.0.1:") && strings.HasSuffix(d.bindAddr, upstream[9:])) {
d.throttledLog(fmt.Sprintf("[DNS ERROR] Refusing to forward %s to ourselves (%s)", q.Name, upstream))
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = dns.RcodeServerFailure
_ = w.WriteMsg(m)
return
}
c := new(dns.Client)
c.Timeout = 2 * time.Second
in, _, err := c.Exchange(r, upstream)
if err != nil {
d.throttledLog(fmt.Sprintf("[DNS ERROR] Forward failed for %s (type %d): %v", q.Name, q.Qtype, err))
// Return a failure response instead of just dropping
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = dns.RcodeServerFailure
if err := w.WriteMsg(m); err != nil {
log.Printf("[DNS ERROR] Failed to write failure response: %v", err)
}
return
}
if err := w.WriteMsg(in); err != nil {
log.Printf("[DNS ERROR] Failed to write forwarded response: %v", err)
}
}
// GetDiscovered returns a map of all discovered hosts.
func (d *DNSDiscovery) GetDiscovered() map[string]*DiscoveredHost {
d.mu.RLock()
defer d.mu.RUnlock()
// Return copy
result := make(map[string]*DiscoveredHost)
for k, v := range d.discovered {
result[k] = v
}
return result
}
// GetBoseHosts returns a slice of all discovered Bose-related hosts.
func (d *DNSDiscovery) GetBoseHosts() []*DiscoveredHost {
d.mu.RLock()
defer d.mu.RUnlock()
var result []*DiscoveredHost
for _, host := range d.discovered {
if host.IsBoseService {
result = append(result, host)
}
}
return result
}
// SetDiscovered sets the map of discovered hosts.
func (d *DNSDiscovery) SetDiscovered(discovered map[string]*DiscoveredHost) {
d.mu.Lock()
defer d.mu.Unlock()
d.discovered = discovered
}
// Start DNS server starts both UDP and TCP listeners
func (d *DNSDiscovery) Start(addr string) error {
mux := dns.NewServeMux()
mux.HandleFunc(".", d.ServeDNS)
d.mu.Lock()
d.bindAddr = addr
d.udpServer = &dns.Server{
Addr: addr,
Net: "udp",
Handler: mux,
}
d.tcpServer = &dns.Server{
Addr: addr,
Net: "tcp",
Handler: mux,
}
// Capture server references before releasing mutex to avoid race condition
udpServer := d.udpServer
tcpServer := d.tcpServer
d.mu.Unlock()
errChan := make(chan error, 2)
go func() {
log.Printf("[DNS] UDP Discovery server starting on %s", addr)
if err := udpServer.ListenAndServe(); err != nil {
errChan <- fmt.Errorf("UDP server failed: %w", err)
}
}()
go func() {
log.Printf("[DNS] TCP Discovery server starting on %s", addr)
if err := tcpServer.ListenAndServe(); err != nil {
errChan <- fmt.Errorf("TCP server failed: %w", err)
}
}()
log.Printf("[DNS] Discovery servers starting on %s (upstream: %s, intercept IP: %s)", addr, d.upstreamDNS, d.serviceIP)
// Wait for first error
return <-errChan
}
// IsRunning returns true if the DNS server is active and bound to the specified address.
func (d *DNSDiscovery) IsRunning(addr string) bool {
d.mu.RLock()
defer d.mu.RUnlock()
if d.udpServer == nil || d.tcpServer == nil {
return false
}
// We check if the address matches what we expect
return d.udpServer.Addr == addr && d.tcpServer.Addr == addr
}
// Shutdown stops the DNS server listeners
func (d *DNSDiscovery) Shutdown() error {
d.mu.Lock()
defer d.mu.Unlock()
if d.udpServer != nil {
if err := d.udpServer.Shutdown(); err != nil {
log.Printf("[DNS] Error shutting down UDP server: %v", err)
}
d.udpServer = nil
}
if d.tcpServer != nil {
if err := d.tcpServer.Shutdown(); err != nil {
log.Printf("[DNS] Error shutting down TCP server: %v", err)
}
d.tcpServer = nil
}
return nil
}
+302
View File
@@ -0,0 +1,302 @@
package discovery
import (
"log"
"net"
"strings"
"testing"
"time"
"github.com/miekg/dns"
)
func TestDNSDiscovery_Interception(t *testing.T) {
serviceIP := "192.168.1.100"
upstreamDNS := "8.8.8.8"
d := NewDNSDiscovery(upstreamDNS, serviceIP)
// Test intercepting Bose service
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 {
if a.A.String() != serviceIP {
t.Errorf("Expected intercepted IP %s, got %s", serviceIP, a.A.String())
}
} else {
t.Errorf("Expected A record, got %T", rw.msg.Answer[0])
}
// Test aftertouch.test
m2 := new(dns.Msg)
m2.SetQuestion("aftertouch.test.", dns.TypeA)
rw2 := &mockResponseWriter{}
d.ServeDNS(rw2, m2)
if rw2.msg == nil || len(rw2.msg.Answer) == 0 {
t.Fatal("Expected response for aftertouch.test")
}
if a, ok := rw2.msg.Answer[0].(*dns.A); ok {
if a.A.String() != serviceIP {
t.Errorf("Expected intercepted IP %s for aftertouch.test, got %s", serviceIP, a.A.String())
}
} else {
t.Errorf("Expected A record for aftertouch.test, got %T", rw2.msg.Answer[0])
}
}
func TestDNSDiscovery_Forwarding(t *testing.T) {
// This test is harder because it needs a real upstream or a mock.
// For now, let's just test that it calls forward and record.
serviceIP := "192.168.1.100"
upstreamDNS := "127.0.0.1:5353" // Use a port that is likely closed or we can mock
d := NewDNSDiscovery(upstreamDNS, serviceIP)
m := new(dns.Msg)
m.SetQuestion("google.com.", dns.TypeA)
rw := &mockResponseWriter{}
// Start a mock upstream DNS server
mux := dns.NewServeMux()
mux.HandleFunc("google.com.", func(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
_ = w.WriteMsg(m)
})
ts := &dns.Server{Addr: "127.0.0.1:5353", Net: "udp", Handler: mux, ReadTimeout: 100 * time.Millisecond, WriteTimeout: 100 * time.Millisecond}
go func() {
_ = ts.ListenAndServe()
}()
defer func() { _ = ts.Shutdown() }()
// Give it a moment to start
time.Sleep(100 * time.Millisecond)
// We expect forward to succeed
d.ServeDNS(rw, m)
d.mu.RLock()
host, exists := d.discovered["google.com"]
d.mu.RUnlock()
if !exists {
t.Error("Expected google.com to be recorded in discovery")
}
if host.IsBoseService {
t.Error("google.com should not be identified as a Bose service")
}
}
func TestDNSDiscovery_StartTCP(t *testing.T) {
serviceIP := "192.168.1.100"
upstreamDNS := "8.8.8.8"
d := NewDNSDiscovery(upstreamDNS, serviceIP)
addr := "127.0.0.1:5354"
go func() {
_ = d.Start(addr)
}()
// Give it a moment to start
time.Sleep(200 * time.Millisecond)
// Test TCP resolution
m := new(dns.Msg)
m.SetQuestion("api.bose.com.", dns.TypeA)
c := new(dns.Client)
c.Net = "tcp"
in, _, err := c.Exchange(m, addr)
if err != nil {
t.Fatalf("Failed to exchange via TCP: %v", err)
}
if len(in.Answer) == 0 {
t.Fatal("Expected answer in TCP response")
}
if a, ok := in.Answer[0].(*dns.A); ok {
if a.A.String() != serviceIP {
t.Errorf("Expected intercepted IP %s via TCP, got %s", serviceIP, a.A.String())
}
} else {
t.Errorf("Expected A record via TCP, got %T", in.Answer[0])
}
// Test Shutdown
err = d.Shutdown()
if err != nil {
t.Errorf("Shutdown failed: %v", err)
}
// Verify it's really shut down by trying to connect
_, _, err = c.Exchange(m, addr)
if err == nil {
t.Error("Expected error after shutdown, but could still exchange")
}
}
func TestDNSDiscovery_IsRunning(t *testing.T) {
serviceIP := "192.168.1.100"
upstreamDNS := "8.8.8.8"
d := NewDNSDiscovery(upstreamDNS, serviceIP)
addr := "127.0.0.1:5355"
if d.IsRunning(addr) {
t.Error("Expected IsRunning to be false before Start")
}
go func() {
_ = d.Start(addr)
}()
// Give it a moment to start
time.Sleep(200 * time.Millisecond)
if !d.IsRunning(addr) {
t.Error("Expected IsRunning to be true after Start")
}
if d.IsRunning("127.0.0.1:9999") {
t.Error("Expected IsRunning to be false for wrong address")
}
_ = d.Shutdown()
if d.IsRunning(addr) {
t.Error("Expected IsRunning to be false after Shutdown")
}
}
type mockResponseWriter struct {
msg *dns.Msg
}
func (m *mockResponseWriter) LocalAddr() net.Addr { return nil }
func (m *mockResponseWriter) RemoteAddr() net.Addr { return nil }
func (m *mockResponseWriter) WriteMsg(msg *dns.Msg) error { m.msg = msg; return nil }
func (m *mockResponseWriter) Write([]byte) (int, error) { return 0, nil }
func (m *mockResponseWriter) Close() error { return nil }
func (m *mockResponseWriter) TsigStatus() error { return nil }
func (m *mockResponseWriter) TsigTimersOnly(bool) {}
func (m *mockResponseWriter) Hijack() {}
func TestDNSDiscovery_LogThrottling(t *testing.T) {
d := NewDNSDiscovery("8.8.8.8", "192.168.1.100")
// Capture log output
var logBuf strings.Builder
oldOutput := log.Writer()
log.SetOutput(&logBuf)
defer log.SetOutput(oldOutput)
msg := "Test log message"
d.throttledLog(msg)
d.throttledLog(msg)
d.throttledLog(msg)
count := strings.Count(logBuf.String(), msg)
if count != 1 {
t.Errorf("Expected log message to appear once due to throttling, but appeared %d times", count)
}
// Advance time by 11 seconds to bypass throttling
d.lastLogMu.Lock()
d.lastLog[msg] = time.Now().Add(-11 * time.Second)
d.lastLogMu.Unlock()
d.throttledLog(msg)
count = strings.Count(logBuf.String(), msg)
if count != 2 {
t.Errorf("Expected log message to appear twice after advancing time, but appeared %d times", count)
}
}
func TestDNSDiscovery_LoopPrevention(t *testing.T) {
serviceIP := "192.168.1.100"
bindAddr := "127.0.0.1:53"
upstreamDNS := "127.0.0.1:53"
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d.bindAddr = bindAddr
// Capture log output to avoid panic if it's being throttled/logged
var logBuf strings.Builder
oldOutput := log.Writer()
log.SetOutput(&logBuf)
defer log.SetOutput(oldOutput)
m := new(dns.Msg)
m.SetQuestion("google.com.", dns.TypeA)
rw := &mockResponseWriter{}
d.forward(rw, m)
if rw.msg == nil {
t.Fatal("Expected a response message")
}
if rw.msg.Rcode != dns.RcodeServerFailure {
t.Errorf("Expected RcodeServerFailure (2), got %d", rw.msg.Rcode)
}
}
func TestDNSDiscovery_EmptyUpstream(t *testing.T) {
serviceIP := "192.168.1.100"
upstreamDNS := "" // Empty upstream
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d.bindAddr = ":53"
m := new(dns.Msg)
m.SetQuestion("google.com.", dns.TypeA)
rw := &mockResponseWriter{}
d.ServeDNS(rw, m)
if rw.msg == nil {
t.Fatal("Expected a response message, got nil")
}
if rw.msg.Rcode != dns.RcodeServerFailure {
t.Errorf("Expected RcodeServerFailure (2) for empty upstream, got %d", rw.msg.Rcode)
}
// Verify log message (optional, but good to check it's the simplified one)
}
func TestDNSDiscovery_ForwardTimeout(t *testing.T) {
serviceIP := "192.168.1.100"
// Use an IP that is unroutable or doesn't exist on the network to ensure timeout
upstreamDNS := "192.0.2.1:53" // TEST-NET-1, usually non-routable
d := NewDNSDiscovery(upstreamDNS, serviceIP)
m := new(dns.Msg)
m.SetQuestion("google.com.", dns.TypeA)
rw := &mockResponseWriter{}
start := time.Now()
d.forward(rw, m)
duration := time.Since(start)
if duration < 2*time.Second {
t.Errorf("Expected forward to take at least 2 seconds (timeout), but took %v", duration)
}
if rw.msg == nil || rw.msg.Rcode != dns.RcodeServerFailure {
t.Errorf("Expected RcodeServerFailure after timeout")
}
}
+4 -2
View File
@@ -3,6 +3,8 @@ package models
import (
"encoding/xml"
"errors"
"fmt"
"net/url"
)
// Error constants for speaker validation
@@ -57,9 +59,9 @@ func (p *PlayInfo) SetVolume(volume int) *PlayInfo {
}
// NewTTSPlayInfo creates a PlayInfo for Google TTS playback
func NewTTSPlayInfo(text, appKey string, volume ...int) *PlayInfo {
func NewTTSPlayInfo(text, appKey, language string, volume ...int) *PlayInfo {
// URL encode the text for Google TTS
url := "http://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=" + text
url := fmt.Sprintf("http://translate.google.com/translate_tts?ie=UTF-8&tl=%s&client=tw-ob&q=%s", language, url.QueryEscape(text))
playInfo := &PlayInfo{
XMLName: xml.Name{Local: "play_info"},
+3 -3
View File
@@ -35,9 +35,9 @@ func TestNewPlayInfo(t *testing.T) {
func TestNewTTSPlayInfo(t *testing.T) {
// Test without volume
playInfo := NewTTSPlayInfo("Hello World", "test-key")
playInfo := NewTTSPlayInfo("Hello World", "test-key", "EN")
expectedURL := "http://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=Hello World"
expectedURL := "http://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=Hello+World"
if playInfo.URL != expectedURL {
t.Errorf("Expected URL '%s', got '%s'", expectedURL, playInfo.URL)
}
@@ -63,7 +63,7 @@ func TestNewTTSPlayInfo(t *testing.T) {
}
// Test with volume
playInfoWithVolume := NewTTSPlayInfo("Hello World", "test-key", 50)
playInfoWithVolume := NewTTSPlayInfo("Hello World", "test-key", "EN", 50)
if playInfoWithVolume.Volume == nil || *playInfoWithVolume.Volume != 50 {
t.Errorf("Expected Volume to be 50, got %v", playInfoWithVolume.Volume)
}
+38 -2
View File
@@ -304,8 +304,9 @@ type SpecialMessageType string
// Constants for special message types
const (
MessageTypeSdkInfo SpecialMessageType = "sdkInfo"
MessageTypeUserActivity SpecialMessageType = "userActivity"
MessageTypeSdkInfo SpecialMessageType = "sdkInfo"
MessageTypeUserActivity SpecialMessageType = "userActivity"
MessageTypeUserInactivity SpecialMessageType = "userInactivity"
)
// SoundTouchSdkInfo represents the SDK info message sent on connection
@@ -321,6 +322,12 @@ type UserActivityUpdate struct {
DeviceID string `xml:"deviceID,attr"`
}
// UserInactivityUpdate represents user inactivity notifications
type UserInactivityUpdate struct {
XMLName xml.Name `xml:"userInactivityUpdate"`
DeviceID string `xml:"deviceID,attr"`
}
// SpecialMessage represents non-updates WebSocket messages
type SpecialMessage struct {
Type SpecialMessageType
@@ -604,6 +611,22 @@ func ParseSpecialMessage(data []byte) (*SpecialMessage, error) {
}, nil
}
// Check for userInactivityUpdate
if strings.Contains(dataStr, "<userInactivityUpdate") {
var userInactivity UserInactivityUpdate
if err := xml.Unmarshal(data, &userInactivity); err != nil {
return nil, fmt.Errorf("failed to parse userInactivityUpdate: %w", err)
}
return &SpecialMessage{
Type: MessageTypeUserInactivity,
DeviceID: userInactivity.DeviceID,
Data: &userInactivity,
RawData: data,
Timestamp: time.Now(),
}, nil
}
return nil, fmt.Errorf("unknown special message type: %s", dataStr)
}
@@ -629,6 +652,17 @@ func (sm *SpecialMessage) GetUserActivity() *UserActivityUpdate {
return nil
}
// GetUserInactivity returns the parsed UserInactivity data if the message is of that type
func (sm *SpecialMessage) GetUserInactivity() *UserInactivityUpdate {
if sm.Type == MessageTypeUserInactivity {
if userInactivity, ok := sm.Data.(*UserInactivityUpdate); ok {
return userInactivity
}
}
return nil
}
// String returns a string representation of the special message
func (sm *SpecialMessage) String() string {
switch sm.Type {
@@ -638,6 +672,8 @@ func (sm *SpecialMessage) String() string {
}
case MessageTypeUserActivity:
return fmt.Sprintf("User Activity [Device: %s]", sm.DeviceID)
case MessageTypeUserInactivity:
return fmt.Sprintf("User Inactivity [Device: %s]", sm.DeviceID)
}
return fmt.Sprintf("Unknown Special Message - Type: %s", sm.Type)
+1
View File
@@ -41,6 +41,7 @@ var Providers = []string{
"RADIO.COM",
"RADIO_COM",
"SIRIUSXM_EVEREST",
"RADIO_BROWSER",
}
// Common file and path constants used by the datastore and setup logic.
+79
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"sync"
"time"
@@ -707,6 +708,9 @@ type Settings struct {
DiscoveryInterval string `json:"discovery_interval,omitempty"`
DiscoveryEnabled bool `json:"discovery_enabled"`
EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream string `json:"dns_upstream,omitempty"`
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
Shortcuts map[string]int `json:"shortcuts,omitempty"`
}
@@ -822,3 +826,78 @@ func (ds *DataStore) GetDeviceEvents(deviceID string) []models.DeviceEvent {
return copiedEvents
}
// DNSDiscoveryEntry represents a persisted DNS discovery.
type DNSDiscoveryEntry struct {
Hostname string `json:"hostname"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
QueryCount int `json:"query_count"`
IsBoseService bool `json:"is_bose_service"`
IsIntercepted bool `json:"is_intercepted"`
RemoteAddr string `json:"remote_addr,omitempty"`
}
// SaveDNSDiscoveries saves DNS discoveries to the datastore.
func (ds *DataStore) SaveDNSDiscoveries(discoveries []DNSDiscoveryEntry) error {
if ds == nil || ds.DataDir == "" {
return nil
}
dir := filepath.Join(ds.DataDir, "dns")
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create dns directory: %w", err)
}
path := filepath.Join(dir, "discoveries.json")
// Sort by last seen descending
sort.Slice(discoveries, func(i, j int) bool {
return discoveries[i].LastSeen.After(discoveries[j].LastSeen)
})
data, err := json.MarshalIndent(discoveries, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// LoadDNSDiscoveries loads DNS discoveries from the datastore.
func (ds *DataStore) LoadDNSDiscoveries() ([]DNSDiscoveryEntry, error) {
if ds == nil || ds.DataDir == "" {
return []DNSDiscoveryEntry{}, nil
}
path := filepath.Join(ds.DataDir, "dns", "discoveries.json")
if !exists(path) {
return []DNSDiscoveryEntry{}, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var discoveries []DNSDiscoveryEntry
if err := json.Unmarshal(data, &discoveries); err != nil {
return nil, err
}
return discoveries, nil
}
// ClearDNSDiscoveries removes all DNS discoveries from the datastore.
func (ds *DataStore) ClearDNSDiscoveries() error {
if ds == nil || ds.DataDir == "" {
return nil
}
path := filepath.Join(ds.DataDir, "dns", "discoveries.json")
if !exists(path) {
return nil
}
return os.Remove(path)
}
@@ -0,0 +1,75 @@
package datastore
import (
"os"
"testing"
"time"
)
func TestDNSDiscoveryPersistence(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore-dns-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
now := time.Now().Round(time.Second)
discoveries := []DNSDiscoveryEntry{
{
Hostname: "api.bose.com",
FirstSeen: now.Add(-1 * time.Hour),
LastSeen: now,
QueryCount: 10,
IsBoseService: true,
IsIntercepted: true,
RemoteAddr: "192.168.1.100",
},
{
Hostname: "google.com",
FirstSeen: now.Add(-2 * time.Hour),
LastSeen: now.Add(-1 * time.Hour),
QueryCount: 5,
IsBoseService: false,
IsIntercepted: false,
RemoteAddr: "192.168.1.101",
},
}
// Test Save
err = ds.SaveDNSDiscoveries(discoveries)
if err != nil {
t.Fatalf("SaveDNSDiscoveries failed: %v", err)
}
// Test Load
loaded, err := ds.LoadDNSDiscoveries()
if err != nil {
t.Fatalf("LoadDNSDiscoveries failed: %v", err)
}
if len(loaded) != 2 {
t.Errorf("Expected 2 discoveries, got %d", len(loaded))
}
// Check if sorted by LastSeen (SaveDNSDiscoveries sorts them)
if loaded[0].Hostname != "api.bose.com" {
t.Errorf("Expected api.bose.com to be first, got %s", loaded[0].Hostname)
}
// Test Clear
err = ds.ClearDNSDiscoveries()
if err != nil {
t.Fatalf("ClearDNSDiscoveries failed: %v", err)
}
loadedAfterClear, err := ds.LoadDNSDiscoveries()
if err != nil {
t.Fatalf("LoadDNSDiscoveries after clear failed: %v", err)
}
if len(loadedAfterClear) != 0 {
t.Errorf("Expected 0 discoveries after clear, got %d", len(loadedAfterClear))
}
}
+80
View File
@@ -0,0 +1,80 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestDNSSettingsValidation(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dns-validation-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
r, server := setupRouter("http://localhost:8001", ds)
// Test Case 1: Enable DNS with empty upstream
update := map[string]interface{}{
"dns_enabled": true,
"dns_upstream": "",
"dns_bind_addr": ":5353",
}
body, err := json.Marshal(update)
if err != nil {
t.Fatalf("Failed to marshal update: %v", err)
}
req := httptest.NewRequest("POST", "/setup/settings", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400 when enabling DNS without upstream, got %d", w.Code)
}
// Verify DNS server is NOT running
running, _ := server.GetDNSRunning()
if running {
t.Error("DNS server should not be running after invalid config attempt")
}
// Test Case 2: Enable DNS with valid upstream
// Using a random port to avoid conflicts and ensure it's fast
updateValid := map[string]interface{}{
"dns_enabled": true,
"dns_upstream": "8.8.8.8",
"dns_bind_addr": "127.0.0.1:0", // Random port
}
bodyValid, err := json.Marshal(updateValid)
if err != nil {
t.Fatalf("Failed to marshal updateValid: %v", err)
}
reqValid := httptest.NewRequest("POST", "/setup/settings", bytes.NewBuffer(bodyValid))
wValid := httptest.NewRecorder()
r.ServeHTTP(wValid, reqValid)
if wValid.Code != http.StatusOK {
t.Errorf("Expected status 200 when enabling DNS with valid upstream, got %d. Body: %s", wValid.Code, wValid.Body.String())
}
// Verify DNS state in server
if !server.dnsEnabled {
t.Error("DNS should be enabled in server state")
}
// Shutdown server to clean up
if server.dnsDiscovery != nil {
_ = server.dnsDiscovery.Shutdown()
}
}
+2 -2
View File
@@ -18,7 +18,7 @@ func TestEventLog(t *testing.T) {
r := chi.NewRouter()
r.Post("/streaming/stats/usage", s.HandleUsageStats)
r.Get("/setup/devices/{deviceId}/events", s.HandleGetDeviceEvents)
r.Get("/devices/{deviceId}/events", s.HandleGetDeviceEvents)
t.Run("Record and Retrieve Events", func(t *testing.T) {
// 1. Post a usage stat
@@ -36,7 +36,7 @@ func TestEventLog(t *testing.T) {
}
// 2. Retrieve events
req, _ = http.NewRequest("GET", "/setup/devices/SPEAKER1/events", nil)
req, _ = http.NewRequest("GET", "/devices/SPEAKER1/events", nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
+2 -2
View File
@@ -11,7 +11,7 @@ import (
//go:embed web/index.html
var indexHTML []byte
//go:embed web/css/* web/js/*
//go:embed web/migration/* web/stockholm-mini/* web/shared/*
var webFS embed.FS
//go:embed static/media/*
@@ -28,7 +28,7 @@ func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) {
accept := r.Header.Get("Accept")
if !strings.Contains(accept, "text/html") && (strings.Contains(accept, "application/json") || accept == "*/*" || accept == "") {
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"Bose": "Can't Brick Us", "service": "Go/Chi"}`)
_, _ = fmt.Fprintf(w, `{"Bose": "AfterTouch", "service": "Go/Chi", "docs": "https://gesellix.github.io/Bose-SoundTouch/"}`)
return
}
+80 -10
View File
@@ -67,8 +67,7 @@ func TestRootEndpointJSON(t *testing.T) {
}
body, _ := io.ReadAll(res.Body)
expected := `{"Bose": "Can't Brick Us", "service": "Go/Chi"}`
expected := `{"Bose": "AfterTouch", "service": "Go/Chi", "docs": "https://gesellix.github.io/Bose-SoundTouch/"}`
if strings.TrimSpace(string(body)) != expected {
t.Errorf("Expected body %s, got %s", expected, string(body))
}
@@ -104,32 +103,103 @@ func TestStaticWeb(t *testing.T) {
ts := httptest.NewServer(r)
defer ts.Close()
// 1. Test CSS
res, err := http.Get(ts.URL + "/web/css/style.css")
// 1. Test Migration UI CSS
res, err := http.Get(ts.URL + "/web/migration/style.css")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("CSS: Expected status OK, got %v", res.Status)
t.Errorf("Migration CSS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
t.Errorf("CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
t.Errorf("Migration CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
}
// 2. Test JS
res, err = http.Get(ts.URL + "/web/js/script.js")
// 2. Test Migration UI JS
res, err = http.Get(ts.URL + "/web/migration/script.js")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("JS: Expected status OK, got %v", res.Status)
t.Errorf("Migration JS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
t.Errorf("JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
t.Errorf("Migration JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
}
// 3. Test Migration UI Index
res, err = http.Get(ts.URL + "/web/migration/index.html")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Migration Index: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "text/html") {
t.Errorf("Migration Index: Expected text/html content type, got %s", res.Header.Get("Content-Type"))
}
// 4. Test Stockholm Mini
res, err = http.Get(ts.URL + "/web/stockholm-mini/index.html")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Stockholm Mini: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "text/html") {
t.Errorf("Stockholm Mini: Expected text/html content type, got %s", res.Header.Get("Content-Type"))
}
// 5. Test Stockholm Mini CSS
res, err = http.Get(ts.URL + "/web/stockholm-mini/style.css")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Stockholm Mini CSS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
t.Errorf("Stockholm Mini CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
}
// 6. Test Shared CSS
res, err = http.Get(ts.URL + "/web/shared/common.css")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Shared CSS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
t.Errorf("Shared CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
}
// 7. Test Shared JS
res, err = http.Get(ts.URL + "/web/shared/common.js")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Shared JS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
t.Errorf("Shared JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
}
}
+288
View File
@@ -0,0 +1,288 @@
package handlers
import (
"encoding/json"
"io"
"log"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// BasicAuthMgmt returns a Basic Auth middleware using the server's management credentials.
func (s *Server) BasicAuthMgmt() func(http.Handler) http.Handler {
s.mu.RLock()
username := s.mgmtUsername
password := s.mgmtPassword
s.mu.RUnlock()
return middleware.BasicAuth("Management API", map[string]string{username: password})
}
// HandleMgmtListSpeakers returns discovered speakers for the given account.
func (s *Server) HandleMgmtListSpeakers(w http.ResponseWriter, r *http.Request) {
_ = chi.URLParam(r, "accountId")
allDevices, err := s.ds.ListAllDevices()
if err != nil {
log.Printf("[Mgmt] Failed to list devices: %v", err)
allDevices = nil
}
type speaker struct {
IPAddress string `json:"ipAddress"`
Name string `json:"name"`
DeviceID string `json:"deviceId"`
Type string `json:"type"`
}
speakers := make([]speaker, 0, len(allDevices))
for i := range allDevices {
d := &allDevices[i]
speakers = append(speakers, speaker{
IPAddress: d.IPAddress,
Name: d.Name,
DeviceID: d.DeviceID,
Type: d.ProductCode,
})
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"speakers": speakers,
}); err != nil {
log.Printf("[Mgmt] Failed to encode speakers: %v", err)
}
}
// HandleMgmtDeviceEvents returns events for a device (currently a placeholder).
func (s *Server) HandleMgmtDeviceEvents(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
events := s.ds.GetDeviceEvents(deviceID)
if events == nil {
events = nil // will marshal as empty array via wrapper
}
w.Header().Set("Content-Type", "application/json")
// Return the events in the structure the Flutter app expects.
// Use an explicit empty slice to ensure JSON "[]" instead of "null".
type eventEntry struct {
Type string `json:"type"`
Time string `json:"time"`
Data map[string]interface{} `json:"data"`
}
result := make([]eventEntry, 0, len(events))
for _, e := range events {
result = append(result, eventEntry{
Type: e.Type,
Time: e.Time,
Data: e.Data,
})
}
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"events": result,
}); err != nil {
log.Printf("[Mgmt] Failed to encode events: %v", err)
}
}
// HandleMgmtSpotifyInit starts the Spotify OAuth flow by returning an authorization URL.
func (s *Server) HandleMgmtSpotifyInit(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock()
svc := s.spotifyService
s.mu.RUnlock()
if svc == nil {
http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable)
return
}
redirectURL := svc.BuildAuthorizeURL()
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
if err := enc.Encode(map[string]string{
"redirectUrl": redirectURL,
}); err != nil {
log.Printf("[Mgmt] Failed to encode redirect URL: %v", err)
}
}
// HandleMgmtSpotifyCallback is the browser OAuth callback from Spotify.
// Not protected by Basic Auth — Spotify redirects the user's browser here directly.
// Returns an HTML page the user can close.
func (s *Server) HandleMgmtSpotifyCallback(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
svc := s.spotifyService
s.mu.RUnlock()
if svc == nil {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`<html><body><h1>Error</h1><p>Spotify integration not configured</p></body></html>`))
return
}
if errMsg := r.URL.Query().Get("error"); errMsg != "" {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`<html><body><h1>Spotify Authorization Failed</h1><p>Error: ` + errMsg + `</p></body></html>`))
return
}
code := r.URL.Query().Get("code")
if code == "" {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`<html><body><h1>Missing authorization code</h1></body></html>`))
return
}
if err := svc.ExchangeCodeAndStore(code); err != nil {
log.Printf("[Mgmt] Spotify callback failed: %v", err)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`<html><body><h1>Error</h1><p>Token exchange failed</p></body></html>`))
return
}
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`<html><body><h1>Spotify Connected</h1><p>You can close this window.</p></body></html>`))
}
// HandleMgmtSpotifyConfirm exchanges an authorization code for tokens.
// Used by the ueberboese mobile app after the deep link callback delivers the code.
// Protected by Basic Auth.
func (s *Server) HandleMgmtSpotifyConfirm(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
svc := s.spotifyService
s.mu.RUnlock()
if svc == nil {
http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable)
return
}
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, `{"error":"missing code parameter"}`, http.StatusBadRequest)
return
}
if err := svc.ExchangeCodeAndStore(code); err != nil {
log.Printf("[Mgmt] Spotify confirm failed: %v", err)
http.Error(w, `{"error":"token exchange failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}
// HandleMgmtSpotifyAccounts returns linked Spotify accounts (tokens stripped).
func (s *Server) HandleMgmtSpotifyAccounts(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock()
svc := s.spotifyService
s.mu.RUnlock()
if svc == nil {
http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable)
return
}
accounts := svc.GetAccounts()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"accounts": accounts,
}); err != nil {
log.Printf("[Mgmt] Failed to encode accounts: %v", err)
}
}
// HandleMgmtSpotifyToken returns a fresh Spotify access token and username.
func (s *Server) HandleMgmtSpotifyToken(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock()
svc := s.spotifyService
s.mu.RUnlock()
if svc == nil {
http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable)
return
}
accessToken, username, err := svc.GetFreshToken()
if err != nil {
log.Printf("[Mgmt] Spotify token error: %v", err)
http.Error(w, `{"error":"no token available"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]string{
"access_token": accessToken,
"username": username,
}); err != nil {
log.Printf("[Mgmt] Failed to encode token: %v", err)
}
}
// HandleMgmtSpotifyEntity resolves a Spotify URI to name and image URL.
func (s *Server) HandleMgmtSpotifyEntity(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
svc := s.spotifyService
s.mu.RUnlock()
if svc == nil {
http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, `{"error":"failed to read body"}`, http.StatusBadRequest)
return
}
var request struct {
URI string `json:"uri"`
}
if unmarshalErr := json.Unmarshal(body, &request); unmarshalErr != nil || request.URI == "" {
http.Error(w, `{"error":"missing or invalid uri"}`, http.StatusBadRequest)
return
}
name, imageURL, err := svc.ResolveEntity(request.URI)
if err != nil {
log.Printf("[Mgmt] Spotify entity resolve error: %v", err)
http.Error(w, `{"error":"entity resolution failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]string{
"name": name,
"imageUrl": imageURL,
}); err != nil {
log.Printf("[Mgmt] Failed to encode entity: %v", err)
}
}
+160 -22
View File
@@ -1,6 +1,10 @@
package handlers
import (
"bytes"
"crypto/tls"
"io"
"log"
"net/http"
"net/http/httputil"
"net/url"
@@ -33,33 +37,167 @@ func (s *Server) HandleProxyRequest(w http.ResponseWriter, r *http.Request) {
return
}
lp := proxy.NewLoggingProxy(target.String(), s.proxyRedact)
lp.LogBody = s.proxyLogBody
lp.RecordEnabled = s.recordEnabled
lp.SetRecorder(s.recorder)
s.ServeProxy(target)(w, r)
}
proxy := httputil.NewSingleHostReverseProxy(target)
// Update director to set the correct host and path
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
req.Host = target.Host
req.URL.Path = target.Path
req.URL.RawQuery = r.URL.RawQuery
lp.LogRequest(req)
}
// ServeProxy returns a handler that proxies to the given target.
func (s *Server) ServeProxy(target *url.URL) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
lp := proxy.NewLoggingProxy(target.String(), s.proxyRedact)
lp.LogBody = s.proxyLogBody
lp.RecordEnabled = s.recordEnabled
lp.SetRecorder(s.recorder)
proxy.ModifyResponse = func(res *http.Response) error {
// Generic Header Preservation
if etags, ok := res.Header["Etag"]; ok {
delete(res.Header, "Etag")
res.Header["ETag"] = etags
// Capture request body for recording, as it will be consumed by the proxy
var reqBody []byte
if r.Body != nil {
reqBody, _ = io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
}
lp.LogResponse(res)
rp := httputil.NewSingleHostReverseProxy(target)
rp.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
return nil
// Update director to set the correct host and path
originalDirector := rp.Director
rp.Director = func(req *http.Request) {
originalDirector(req)
req.Host = target.Host
// If target has a path, we should probably append or replace.
// For Bose upstream, it's usually just the domain.
if target.Path != "" && target.Path != "/" {
req.URL.Path = target.Path
}
lp.LogRequest(req)
}
rp.ModifyResponse = func(res *http.Response) error {
res.Header.Set("X-Proxy-Origin", "upstream")
// Generic Header Preservation
if etags, ok := res.Header["Etag"]; ok {
delete(res.Header, "Etag")
res.Header["ETag"] = etags
}
// Restore captured request body for the recorder
if reqBody != nil {
res.Request.Body = io.NopCloser(bytes.NewBuffer(reqBody))
}
lp.LogResponse(res)
return nil
}
rp.ServeHTTP(w, r)
}
}
// HandleNotFound handles requests that don't match any route.
func (s *Server) HandleNotFound(w http.ResponseWriter, r *http.Request) {
if s.enableSoundcorkProxy {
s.HandleSoundcorkWithFallback(w, r)
return
}
proxy.ServeHTTP(w, r)
s.HandleBoseProxy(w, r)
}
// HandleSoundcorkWithFallback tries Soundcork first, then Bose if Soundcork returns 404 or fails.
func (s *Server) HandleSoundcorkWithFallback(w http.ResponseWriter, r *http.Request) {
target, _ := url.Parse(s.soundcorkURL)
// Buffer request body if any, to allow multiple proxy attempts
var bodyBytes []byte
if r.Body != nil {
bodyBytes, _ = io.ReadAll(r.Body)
_ = r.Body.Close()
}
// We use a custom response writer to catch 404s
rw := &fallbackResponseWriter{
ResponseWriter: w,
statusCode: http.StatusOK,
buffer: &bytes.Buffer{},
}
// Create a shallow copy of the request to avoid side effects between attempts
r2 := r.Clone(r.Context())
if bodyBytes != nil {
r2.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
} else {
r2.Body = nil
}
// Remove RequestURI as it's not allowed in client requests
r2.RequestURI = ""
s.ServeProxy(target)(rw, r2)
if rw.statusCode == http.StatusNotFound || rw.statusCode == http.StatusBadGateway || rw.statusCode == http.StatusServiceUnavailable {
log.Printf("[PROXY] Soundcork returned %d for %s, falling back to Bose", rw.statusCode, r.URL.Path)
if !rw.wroteHeader {
// Restore original body if any
if bodyBytes != nil {
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
s.HandleBoseProxy(w, r)
}
}
}
type fallbackResponseWriter struct {
http.ResponseWriter
statusCode int
wroteHeader bool
buffer *bytes.Buffer
}
func (rw *fallbackResponseWriter) WriteHeader(code int) {
rw.statusCode = code
if code != http.StatusNotFound && code != http.StatusBadGateway && code != http.StatusServiceUnavailable {
rw.wroteHeader = true
rw.ResponseWriter.WriteHeader(code)
}
}
func (rw *fallbackResponseWriter) Write(b []byte) (int, error) {
if rw.statusCode == http.StatusNotFound || rw.statusCode == http.StatusBadGateway || rw.statusCode == http.StatusServiceUnavailable {
return len(b), nil // Drop the body
}
rw.wroteHeader = true
return rw.ResponseWriter.Write(b)
}
// HandleBoseProxy proxies the request to the Bose upstream.
func (s *Server) HandleBoseProxy(w http.ResponseWriter, r *http.Request) {
host := r.Host
if host == "" {
host = "streaming.bose.com"
}
// Default to HTTPS for Bose services
scheme := "https"
if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.0.0.1") || strings.HasPrefix(host, "::1") {
scheme = "http"
}
targetURL := scheme + "://" + host
target, err := url.Parse(targetURL)
if err != nil {
log.Printf("[PROXY_ERR] Failed to parse target URL %s: %v", targetURL, err)
http.Error(w, "Invalid upstream host", http.StatusBadGateway)
return
}
s.ServeProxy(target)(w, r)
}
@@ -0,0 +1,96 @@
package handlers
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
)
func TestHandleProxyRequest_RequestBodyRecording(t *testing.T) {
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "proxy-request-body-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Start a backend server to receive the proxied request
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Read the body to ensure it's consumed
_, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
w.Write([]byte("<response>ok</response>"))
}))
defer backend.Close()
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
server := NewServer(ds, nil, "http://localhost:8000", false, false, false, false)
server.recordEnabled = true
server.proxyLogBody = true
recorder := proxy.NewRecorder(tmpDir)
server.SetRecorder(recorder)
// Create a proxy request to the backend
requestBody := "<request>data</request>"
targetURL := backend.URL
proxyPath := "/proxy/" + targetURL
req := httptest.NewRequest("POST", proxyPath, bytes.NewBufferString(requestBody))
req.Header.Set("Content-Type", "application/xml")
w := httptest.NewRecorder()
server.HandleProxyRequest(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Verify that the interaction was recorded and contains the request body
sessionID := recorder.SessionID
// The recorder uses sanitized segments for the directory.
// Since the target URL is http://127.0.0.1:PORT, the path is empty,
// so it should be in the "root" directory under the category.
// We'll search recursively to be sure
foundBody := false
err = filepath.Walk(filepath.Join(tmpDir, "interactions", sessionID), func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(path, ".http") {
content, err := os.ReadFile(path)
if err != nil {
return err
}
if strings.Contains(string(content), requestBody) {
foundBody = true
}
}
return nil
})
if err != nil {
t.Fatalf("failed to walk interactions dir: %v", err)
}
if !foundBody {
t.Errorf("request body %q not found in any recorded interaction file", requestBody)
// List all files found for debugging
_ = filepath.Walk(filepath.Join(tmpDir, "interactions", sessionID), func(path string, info os.FileInfo, err error) error {
if !info.IsDir() {
content, _ := os.ReadFile(path)
t.Logf("Found file %s with content:\n%s", path, string(content))
}
return nil
})
}
}
+287 -55
View File
@@ -6,9 +6,13 @@ import (
"log"
"net/http"
"os"
"sort"
"strconv"
"time"
"fmt"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
@@ -146,17 +150,27 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
serverURL, soundcorkURL, httpsServerURL := s.serverURL, s.soundcorkURL, s.httpsServerURL
discoveryInterval := s.discoveryInterval.String()
discoveryEnabled := s.discoveryEnabled
dnsEnabled := s.dnsEnabled
dnsUpstream := s.dnsUpstream
dnsBindAddr := s.dnsBindAddr
enableSoundcorkProxy := s.enableSoundcorkProxy
redact, logBody, record := s.proxyRedact, s.proxyLogBody, s.recordEnabled
shortcuts := s.shortcuts
s.mu.RUnlock()
dnsRunning, actualBind := s.GetDNSRunning()
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"server_url": serverURL,
"soundcork_url": soundcorkURL,
"https_server_url": httpsServerURL,
"discovery_interval": discoveryInterval,
"discovery_enabled": discoveryEnabled,
"dns_enabled": dnsEnabled,
"dns_running": dnsRunning,
"dns_actual_bind": actualBind,
"dns_upstream": dnsUpstream,
"dns_bind_addr": dnsBindAddr,
"enable_soundcork_proxy": enableSoundcorkProxy,
"redact_logs": redact,
"log_bodies": logBody,
@@ -175,6 +189,9 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
SoundcorkURL string `json:"soundcork_url"`
DiscoveryInterval string `json:"discovery_interval"`
DiscoveryEnabled bool `json:"discovery_enabled"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream string `json:"dns_upstream"`
DNSBindAddr string `json:"dns_bind_addr"`
EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"`
Shortcuts map[string]int `json:"shortcuts"`
}
@@ -183,6 +200,11 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
return
}
if settings.DNSEnabled && settings.DNSUpstream == "" {
http.Error(w, "DNS Upstream is required when DNS Discovery is enabled", http.StatusBadRequest)
return
}
interval, err := time.ParseDuration(settings.DiscoveryInterval)
if err != nil && settings.DiscoveryInterval != "" {
http.Error(w, "Invalid discovery interval: "+err.Error(), http.StatusBadRequest)
@@ -198,6 +220,9 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
}
s.discoveryEnabled = settings.DiscoveryEnabled
s.dnsEnabled = settings.DNSEnabled
s.dnsUpstream = settings.DNSUpstream
s.dnsBindAddr = settings.DNSBindAddr
s.enableSoundcorkProxy = settings.EnableSoundcorkProxy
if settings.Shortcuts != nil {
@@ -225,11 +250,21 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
RecordInteractions: currentRecord,
DiscoveryInterval: s.discoveryInterval.String(),
DiscoveryEnabled: s.discoveryEnabled,
DNSEnabled: s.dnsEnabled,
DNSUpstream: s.dnsUpstream,
DNSBindAddr: s.dnsBindAddr,
EnableSoundcorkProxy: s.enableSoundcorkProxy,
Shortcuts: s.shortcuts,
})
dnsEnabled := s.dnsEnabled
dnsUpstream := s.dnsUpstream
dnsBindAddr := s.dnsBindAddr
s.mu.Unlock()
s.SetDNSSettings(dnsEnabled, dnsUpstream, dnsBindAddr)
if err != nil {
http.Error(w, "Failed to save settings: "+err.Error(), http.StatusInternalServerError)
return
@@ -243,33 +278,17 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
}
}
// HandleGetDeviceInfo returns live information for a device.
func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
http.Error(w, "Device IP is required", http.StatusBadRequest)
return
}
info, err := s.sm.GetLiveDeviceInfo(deviceIP)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(info); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// HandleGetMigrationSummary returns a summary of the migration plan for a device.
func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
http.Error(w, "Device IP is required", http.StatusBadRequest)
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
http.Error(w, "Device ID is required", http.StatusBadRequest)
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
@@ -300,12 +319,12 @@ func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Reques
// HandleMigrateDevice starts the migration process for a device.
func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -313,6 +332,12 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
targetURL := r.URL.Query().Get("target_url")
proxyURL := r.URL.Query().Get("proxy_url")
method := setup.MigrationMethod(r.URL.Query().Get("method"))
@@ -348,12 +373,12 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
// HandleRevertMigration reverts the migration for a device.
func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -361,6 +386,12 @@ func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
output, err := s.sm.RevertMigration(deviceIP)
if err != nil {
w.Header().Set("Content-Type", "application/json")
@@ -382,14 +413,93 @@ func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
}
}
// HandleGetDNSDiscoveries returns recorded DNS discoveries.
func (s *Server) HandleGetDNSDiscoveries(w http.ResponseWriter, _ *http.Request) {
// 1. Get current in-memory discoveries
inMemory := s.GetDNSDiscovery()
// 2. Load persisted discoveries
persisted, err := s.ds.LoadDNSDiscoveries()
if err != nil {
log.Printf("Warning: Failed to load DNS discoveries: %v", err)
}
// 3. Merge them
merged := make(map[string]datastore.DNSDiscoveryEntry)
for _, p := range persisted {
merged[p.Hostname] = p
}
for hostname, h := range inMemory {
m, exists := merged[hostname]
if !exists || h.LastSeen.After(m.LastSeen) {
merged[hostname] = datastore.DNSDiscoveryEntry{
Hostname: h.Hostname,
FirstSeen: h.FirstSeen,
LastSeen: h.LastSeen,
QueryCount: h.QueryCount,
IsBoseService: h.IsBoseService,
IsIntercepted: h.IsIntercepted,
RemoteAddr: h.RemoteAddr,
}
} else if h.QueryCount > m.QueryCount {
// If exists and persisted is newer (rare but possible), update query count if higher
m.QueryCount = h.QueryCount
merged[hostname] = m
}
}
// Convert to slice
result := make([]datastore.DNSDiscoveryEntry, 0, len(merged))
for _, entry := range merged {
result = append(result, entry)
}
// Sort by last seen descending
sort.Slice(result, func(i, j int) bool {
return result[i].LastSeen.After(result[j].LastSeen)
})
// 4. Update persistence with merged results
if err := s.ds.SaveDNSDiscoveries(result); err != nil {
log.Printf("Warning: Failed to persist merged DNS discoveries: %v", err)
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(result); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// HandleClearDNSDiscoveries clears recorded DNS discoveries.
func (s *Server) HandleClearDNSDiscoveries(w http.ResponseWriter, _ *http.Request) {
// 1. Clear in-memory
s.SetDNSDiscoveries(make(map[string]*discovery.DiscoveredHost))
// 2. Clear persistence
if err := s.ds.ClearDNSDiscoveries(); err != nil {
http.Error(w, "Failed to clear DNS discoveries: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]bool{"ok": true}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// HandleTrustCACert injects the local Root CA into the device's shared trust store.
func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -397,6 +507,12 @@ func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) {
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
output, err := s.sm.TrustCACert(deviceIP)
if err != nil {
w.Header().Set("Content-Type", "application/json")
@@ -420,12 +536,12 @@ func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) {
// HandleEnsureRemoteServices ensures that remote services are configured on a device.
func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -433,6 +549,12 @@ func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Reque
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
output, err := s.sm.EnsureRemoteServices(deviceIP)
if err != nil {
w.Header().Set("Content-Type", "application/json")
@@ -456,12 +578,12 @@ func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Reque
// HandleRemoveRemoteServices removes remote services configuration from a device.
func (s *Server) HandleRemoveRemoteServices(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -469,6 +591,12 @@ func (s *Server) HandleRemoveRemoteServices(w http.ResponseWriter, r *http.Reque
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
output, err := s.sm.RemoveRemoteServices(deviceIP)
if err != nil {
w.Header().Set("Content-Type", "application/json")
@@ -492,12 +620,12 @@ func (s *Server) HandleRemoveRemoteServices(w http.ResponseWriter, r *http.Reque
// HandleBackupConfig creates a backup of the device configuration.
func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -505,6 +633,12 @@ func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) {
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
output, err := s.sm.BackupConfig(deviceIP)
if err != nil {
w.Header().Set("Content-Type", "application/json")
@@ -577,6 +711,10 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques
s.recordEnabled = settings.Record
s.enableSoundcorkProxy = settings.EnableSoundcorkProxy
if s.recorder != nil {
s.recorder.Redact = settings.Redact
}
// Persist to datastore
// Access fields directly since we already hold the lock
serverURL, soundcorkURL, httpsServerURL := s.serverURL, s.soundcorkURL, s.httpsServerURL
@@ -613,9 +751,15 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques
// HandleTestHostsRedirection performs a preliminary check for /etc/hosts redirection.
func (s *Server) HandleTestHostsRedirection(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
http.Error(w, "Device IP is required", http.StatusBadRequest)
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
http.Error(w, "Device ID is required", http.StatusBadRequest)
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
@@ -651,11 +795,63 @@ func (s *Server) HandleTestHostsRedirection(w http.ResponseWriter, r *http.Reque
}
}
// HandleTestDNSRedirection performs a check for DNS redirection to the AfterTouch service.
func (s *Server) HandleTestDNSRedirection(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
http.Error(w, "Device ID is required", http.StatusBadRequest)
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
targetURL := r.URL.Query().Get("target_url")
if targetURL == "" {
targetURL = s.serverURL
}
output, err := s.sm.TestDNSRedirection(deviceIP, targetURL)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) // Return 200 but ok: false so UI can show the output
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{
"ok": false,
"message": err.Error(),
"output": output,
}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"message": "DNS redirection test successful",
"output": output,
}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleInitialSync fetches presets, recents and sources from the device and saves them to the datastore.
func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
http.Error(w, "Missing deviceIP", http.StatusBadRequest)
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
http.Error(w, "Device ID is required", http.StatusBadRequest)
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
@@ -670,12 +866,12 @@ func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) {
// HandleRebootDevice reboots a device.
func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
@@ -683,6 +879,12 @@ func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) {
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
output, err := s.sm.Reboot(deviceIP)
if err != nil {
w.Header().Set("Content-Type", "application/json")
@@ -706,9 +908,15 @@ func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) {
// HandleTestConnection performs a connection check from the device to the server.
func (s *Server) HandleTestConnection(w http.ResponseWriter, r *http.Request) {
deviceIP := chi.URLParam(r, "deviceIP")
if deviceIP == "" {
http.Error(w, "Device IP is required", http.StatusBadRequest)
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
http.Error(w, "Device ID is required", http.StatusBadRequest)
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
@@ -879,3 +1087,27 @@ func (s *Server) HandleCleanupSessions(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok": true}`))
}
// HandleDownloadSession returns a .tar.gz archive of a recorded interaction session.
func (s *Server) HandleDownloadSession(w http.ResponseWriter, r *http.Request) {
if s.recorder == nil {
http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable)
return
}
session := chi.URLParam(r, "session")
if session == "" {
http.Error(w, "Session ID is required", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.tar.gz\"", session))
if err := s.recorder.ArchiveSession(session, w); err != nil {
log.Printf("Error archiving session %s: %v", session, err)
// Since we already set headers, if we have an error here it might be partially written.
// But for now, simple error handling.
return
}
}
+49 -23
View File
@@ -7,8 +7,10 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
@@ -140,9 +142,15 @@ func TestMigrationAndCA(t *testing.T) {
sm := setup.NewManager("http://localhost:8000", ds, cm)
// Mock SSH to avoid real connections
sm.NewSSH = func(host string) setup.SSHClient {
return &mockSSH{}
return &mockSSH{host: host}
}
// Register device in datastore so lookupIP works
_ = ds.SaveDeviceInfo("default", "192.168.1.10", &models.ServiceDeviceInfo{
DeviceID: "192.168.1.10",
IPAddress: "192.168.1.10",
})
r, server := setupRouter("http://localhost:8001", ds)
server.sm = sm // Inject our manager with mock SSH
@@ -163,8 +171,8 @@ func TestMigrationAndCA(t *testing.T) {
t.Errorf("CA: Unexpected content type: %s", res.Header.Get("Content-Type"))
}
// 2. Test POST /setup/migrate/{deviceIP}?method=hosts
res, err = http.Post(ts.URL+"/setup/migrate/192.168.1.10?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
// 2. Test POST /setup/devices/{deviceIP}/migrate?method=hosts
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/migrate?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
if err != nil {
t.Fatal(err)
}
@@ -185,8 +193,8 @@ func TestMigrationAndCA(t *testing.T) {
t.Errorf("Migrate: Expected output field in response")
}
// 3. Test POST /setup/trust-ca/{deviceIP}
res, err = http.Post(ts.URL+"/setup/trust-ca/192.168.1.10", "application/json", nil)
// 3. Test POST /setup/devices/{deviceIP}/trust-ca
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/trust-ca", "application/json", nil)
if err != nil {
t.Fatal(err)
}
@@ -206,8 +214,8 @@ func TestMigrationAndCA(t *testing.T) {
t.Errorf("TrustCA: Expected output field in response")
}
// 4. Test POST /setup/reboot/{deviceIP}
res, err = http.Post(ts.URL+"/setup/reboot/192.168.1.10", "application/json", nil)
// 4. Test POST /devices/{deviceIP}/reboot
res, err = http.Post(ts.URL+"/devices/192.168.1.10/reboot", "application/json", nil)
if err != nil {
t.Fatal(err)
}
@@ -227,8 +235,8 @@ func TestMigrationAndCA(t *testing.T) {
t.Errorf("Reboot: Expected output field in response")
}
// 5. Test POST /setup/remove-remote-services/{deviceIP}
res, err = http.Post(ts.URL+"/setup/remove-remote-services/192.168.1.10", "application/json", nil)
// 5. Test POST /setup/devices/{deviceIP}/remove-remote-services
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/remove-remote-services", "application/json", nil)
if err != nil {
t.Fatal(err)
}
@@ -260,17 +268,20 @@ func TestRemoveDevice(t *testing.T) {
_ = ds.Initialize()
// Setup a dummy device in the datastore
account := "test-account"
account := "acc1"
deviceID := "TEST-DEVICE-ID"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("Failed to create device dir: %v", err)
}
infoFile := filepath.Join(deviceDir, "DeviceInfo.xml")
infoXML := `<?xml version="1.0" encoding="UTF-8" ?><info deviceID="TEST-DEVICE-ID"><name>Test Device</name><type>SoundTouch 10</type></info>`
if err := os.WriteFile(infoFile, []byte(infoXML), 0644); err != nil {
t.Fatalf("Failed to create device info file: %v", err)
// Register device in datastore so HandleRemoveDevice works
_ = ds.SaveDeviceInfo(account, deviceID, &models.ServiceDeviceInfo{
DeviceID: deviceID,
AccountID: account,
IPAddress: "192.168.1.100",
})
// Verify directory exists where datastore expects it
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
if _, err := os.Stat(deviceDir); err != nil {
t.Fatalf("Device directory was not created by SaveDeviceInfo: %v", err)
}
r, _ := setupRouter("http://localhost:8001", ds)
@@ -278,7 +289,7 @@ func TestRemoveDevice(t *testing.T) {
defer ts.Close()
// 1. Verify device exists
res, err := http.Get(ts.URL + "/setup/devices")
res, err := http.Get(ts.URL + "/devices")
if err != nil {
t.Fatal(err)
}
@@ -301,7 +312,7 @@ func TestRemoveDevice(t *testing.T) {
}
// 2. Remove device
req, err := http.NewRequest(http.MethodDelete, ts.URL+"/setup/devices/"+deviceID, nil)
req, err := http.NewRequest(http.MethodDelete, ts.URL+"/devices/"+deviceID, nil)
if err != nil {
t.Fatal(err)
}
@@ -316,7 +327,7 @@ func TestRemoveDevice(t *testing.T) {
}
// 3. Verify device is gone
res, err = http.Get(ts.URL + "/setup/devices")
res, err = http.Get(ts.URL + "/devices")
if err != nil {
t.Fatal(err)
}
@@ -338,12 +349,27 @@ func TestRemoveDevice(t *testing.T) {
}
}
type mockSSH struct{}
type mockSSH struct {
host string
runCount int
}
func (m *mockSSH) Run(command string) (string, error) {
if command == "cat /etc/hosts" {
if strings.Contains(command, "cat /etc/hosts") {
m.runCount++
if m.runCount > 1 {
// Return updated hosts for verification
return "127.0.0.1 localhost\n192.168.1.100\tstreaming.bose.com\n192.168.1.100\tupdates.bose.com\n192.168.1.100\tstats.bose.com\n192.168.1.100\tbmx.bose.com\n192.168.1.100\tcontent.api.bose.io\n192.168.1.100\tevents.api.bosecm.com\n192.168.1.100\tbose-prod.apigee.net\n192.168.1.100\tworldwide.bose.com", nil
}
return "127.0.0.1 localhost", nil
}
if strings.HasPrefix(command, "[ -f") {
return "", nil // Pretend file exists for backups
}
if strings.HasPrefix(command, "grep -F") {
return "matched", nil // CA trusted
}
return "", nil
}
func (m *mockSSH) UploadContent(content []byte, remotePath string) error { return nil }
+124
View File
@@ -0,0 +1,124 @@
package handlers
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/go-chi/chi/v5"
)
// HandleGetDeviceInfo returns live information for a device.
func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
http.Error(w, "Device ID is required", http.StatusBadRequest)
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
info, err := s.sm.GetLiveDeviceInfo(deviceIP)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
// Include IP address in both snake_case and camelCase for frontend compatibility
type deviceInfoResponse struct {
*setup.DeviceInfoXML `json:",inline"`
IPAddress string `json:"ip_address"`
IPAddressCamel string `json:"ipAddress,omitempty"`
}
resp := deviceInfoResponse{
DeviceInfoXML: info,
IPAddress: deviceIP,
IPAddressCamel: deviceIP,
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// HandleDeviceKey sends a key command to a device.
func (s *Server) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
key := chi.URLParam(r, "key")
if deviceID == "" || key == "" {
http.Error(w, "Device ID and Key are required", http.StatusBadRequest)
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
c := client.NewClientFromHost(deviceIP)
err = c.SendKey(key)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to send key %s to %s: %v", key, deviceIP, err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Key sent"}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// HandleDeviceVolume sets the volume level for a device.
func (s *Server) HandleDeviceVolume(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
levelStr := chi.URLParam(r, "level")
if deviceID == "" || levelStr == "" {
http.Error(w, "Device ID and Level are required", http.StatusBadRequest)
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
level, err := strconv.Atoi(levelStr)
if err != nil {
http.Error(w, "Invalid volume level", http.StatusBadRequest)
return
}
c := client.NewClientFromHost(deviceIP)
err = c.SetVolume(level)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to set volume to %d on %s: %v", level, deviceIP, err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Volume set"}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+203
View File
@@ -0,0 +1,203 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(_ *http.Request) bool { return true },
}
const (
pongWait = 40 * time.Second
pingPeriod = 20 * time.Second // must be less than pongWait
)
// HandleDeviceWebSocket upgrades the connection and proxies device WebSocket events to the browser.
func (s *Server) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
http.Error(w, "Device ID is required", http.StatusBadRequest)
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
// Upgrade the HTTP connection to a WebSocket for the browser
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
// Create a SoundTouch WebSocket client for the target device
c := client.NewClientFromHost(deviceIP)
wsClient := c.NewWebSocketClient(client.DefaultWebSocketConfig())
// Channel-based write pump per Gorilla best practices
sendCh := make(chan []byte, 64) // buffer to smooth bursts
closeCh := make(chan struct{})
// Helper to enqueue JSON messages; drop if buffer is full to avoid blocking
enqueue := func(v interface{}) {
b, err := json.Marshal(v)
if err != nil {
return
}
select {
case sendCh <- b:
default:
// drop to protect connection under burst
}
}
// Reader: we don't expect messages from the browser; just keep the
// connection alive by processing control frames and detect close.
_ = conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(pongWait))
})
go func() {
defer func() {
close(closeCh)
_ = wsClient.Disconnect()
_ = conn.Close()
}()
for {
mt, _, err := conn.ReadMessage()
if err != nil {
log.Printf("[WebSocket] Browser connection closed for %s: %v", deviceIP, err)
return
}
if mt == websocket.CloseMessage {
return
}
}
}()
// Writer: single writer goroutine handles JSON writes and ping keepalive
go func() {
pingTicker := time.NewTicker(pingPeriod)
defer func() {
pingTicker.Stop()
_ = conn.Close()
}()
for {
select {
case msg, ok := <-sendCh:
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
_ = conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
case <-pingTicker.C:
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
case <-closeCh:
return
}
}
}()
// Forward typed events with a simple envelope into the send queue
wsClient.SetHandlers(&models.WebSocketEventHandlers{
OnNowPlaying: func(e *models.NowPlayingUpdatedEvent) {
enqueue(map[string]interface{}{"type": "nowPlayingUpdated", "payload": e})
},
OnVolumeUpdated: func(e *models.VolumeUpdatedEvent) {
enqueue(map[string]interface{}{"type": "volumeUpdated", "payload": e})
},
OnConnectionState: func(e *models.ConnectionStateUpdatedEvent) {
enqueue(map[string]interface{}{"type": "connectionStateUpdated", "payload": e})
},
OnPresetUpdated: func(e *models.PresetUpdatedEvent) {
enqueue(map[string]interface{}{"type": "presetUpdated", "payload": e})
},
OnZoneUpdated: func(e *models.ZoneUpdatedEvent) {
enqueue(map[string]interface{}{"type": "zoneUpdated", "payload": e})
},
OnBassUpdated: func(e *models.BassUpdatedEvent) {
enqueue(map[string]interface{}{"type": "bassUpdated", "payload": e})
},
OnUnknownEvent: func(event *models.WebSocketEvent) {
bytes, _ := json.Marshal(event)
enqueue(map[string]interface{}{"type": "unknown", "payload": json.RawMessage(bytes)})
},
OnSpecialMessage: func(msg *models.SpecialMessage) {
enqueue(map[string]interface{}{"type": "special", "payload": msg})
},
})
// Add a separate goroutine to monitor the device connection status
go func() {
wsClient.Wait()
log.Printf("[WebSocket] Device %s client terminated", deviceIP)
_ = conn.Close()
}()
// Connect to the device WebSocket
if err := wsClient.Connect(); err != nil {
enqueue(map[string]interface{}{"type": "error", "message": err.Error()})
return
}
// Optional: send an initial snapshot for convenience
go func() {
info, err := s.sm.GetLiveDeviceInfo(deviceIP)
if err != nil {
return
}
// Supplement with volume and now playing
c := client.NewClientFromHost(deviceIP)
payload := map[string]interface{}{
"deviceID": info.DeviceID,
"name": info.Name,
"type": info.Type,
"maccAddress": info.MaccAddress,
"serialNumber": info.SerialNumber,
"softwareVersion": info.SoftwareVer,
// Provide IP in both styles for frontend robustness
"ip_address": deviceIP,
"ipAddress": deviceIP,
}
if vol, err := c.GetVolume(); err == nil {
payload["volume"] = vol
// Also add at top level for flatter frontend parsing
payload["actualVolume"] = vol.ActualVolume
}
if np, err := c.GetNowPlaying(); err == nil {
payload["nowPlaying"] = np
}
enqueue(map[string]interface{}{"type": "snapshotInfo", "payload": payload})
}()
}
+83 -33
View File
@@ -1,19 +1,19 @@
package handlers
import (
"net/http"
"net/url"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/go-chi/chi/v5"
)
func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) {
target, _ := url.Parse(targetURL)
proxy := &reverseProxy{target: target}
server := &Server{ds: ds}
server := NewServer(ds, nil, "http://localhost:8000", false, false, false, false)
server.SetSoundcorkURL(targetURL)
r := chi.NewRouter()
r.Use(server.OriginMiddleware)
r.Use(server.ShortcutMiddleware)
r.Use(server.RecordMiddleware)
r.Get("/", server.HandleRoot)
// Setup media and web directories for tests
@@ -29,6 +29,13 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
})
// Legacy or direct domain calls without /bmx prefix
r.Get("/registry/v1/services", server.HandleBMXRegistry)
r.Get("/tunein/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
// Setup Marge for tests
r.Route("/marge", func(r chi.Router) {
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
@@ -48,6 +55,23 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
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.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) {
r.Get("/account/{account}", server.HandleMargeAccountProfile)
@@ -55,38 +79,64 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
})
// Setup Setup for tests
r.Route("/setup", func(r chi.Router) {
r.Get("/devices", server.HandleListDiscoveredDevices)
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
r.Get("/settings", server.HandleGetSettings)
r.Post("/settings", server.HandleUpdateSettings)
r.Get("/proxy-settings", server.HandleGetProxySettings)
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
r.Post("/ensure-remote-services/{deviceIP}", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services/{deviceIP}", server.HandleRemoveRemoteServices)
r.Post("/migrate/{deviceIP}", server.HandleMigrateDevice)
r.Post("/revert/{deviceIP}", server.HandleRevertMigration)
r.Post("/reboot/{deviceIP}", server.HandleRebootDevice)
r.Post("/trust-ca/{deviceIP}", server.HandleTrustCACert)
r.Post("/test-connection/{deviceIP}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceIP}", server.HandleTestHostsRedirection)
r.Get("/ca.crt", server.HandleGetCACert)
// Setup Devices for tests
r.Route("/devices", func(r chi.Router) {
r.Get("/", server.HandleListDiscoveredDevices)
r.Post("/", server.HandleAddManualDevice)
r.Route("/{deviceId}", func(r chi.Router) {
r.Delete("/", server.HandleRemoveDevice)
r.Get("/events", server.HandleGetDeviceEvents)
r.Get("/info", server.HandleGetDeviceInfo)
r.Get("/ws", server.HandleDeviceWebSocket)
r.Post("/key/{key}", server.HandleDeviceKey)
r.Post("/volume/{level}", server.HandleDeviceVolume)
r.Post("/reboot", server.HandleRebootDevice)
})
})
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
proxy.ServeHTTP(w, r)
r.Get("/version", server.HandleGetVersionInfo)
// Setup Setup for tests
r.Route("/setup", func(r chi.Router) {
r.Post("/discover", server.HandleTriggerDiscovery)
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
r.Get("/settings", server.HandleGetSettings)
r.Post("/settings", server.HandleUpdateSettings)
r.Get("/ca.crt", server.HandleGetCACert)
r.Get("/proxy-settings", server.HandleGetProxySettings)
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
r.Get("/interaction-stats", server.HandleGetInteractionStats)
r.Get("/interactions", server.HandleListInteractions)
r.Get("/interaction-content", server.HandleGetInteractionContent)
r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
r.Route("/devices/{deviceId}", func(r chi.Router) {
r.Get("/summary", server.HandleGetMigrationSummary)
r.Post("/migrate", server.HandleMigrateDevice)
r.Post("/revert", server.HandleRevertMigration)
r.Post("/trust-ca", server.HandleTrustCACert)
r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services", server.HandleRemoveRemoteServices)
r.Post("/backup", server.HandleBackupConfig)
r.Post("/sync", server.HandleInitialSync)
r.Post("/test-connection", server.HandleTestConnection)
r.Post("/test-hosts", server.HandleTestHostsRedirection)
r.Post("/test-dns", server.HandleTestDNSRedirection)
})
})
r.NotFound(server.HandleNotFound)
return r, server
}
type reverseProxy struct {
target *url.URL
}
func (p *reverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Simplified proxy for testing
w.WriteHeader(http.StatusAccepted) // Custom status to identify proxy hit in tests
_, _ = w.Write([]byte("Proxied to " + p.target.String()))
func init() {
// Silence logger for tests
// log.SetOutput(io.Discard)
}
+27
View File
@@ -0,0 +1,27 @@
package handlers
import (
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5/middleware"
)
// OriginMiddleware returns a middleware that logs whether the request was handled "self" or "upstream".
func (s *Server) OriginMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
origin := "self"
if ww.Header().Get("X-Proxy-Origin") != "" {
origin = "upstream"
}
log.Printf("[LOG] %s %s | %d | %s | %v", r.Method, r.URL.Path, ww.Status(), origin, time.Since(start))
})
}
+20 -4
View File
@@ -5,6 +5,7 @@ import (
"bytes"
"fmt"
"io"
"log"
"net"
"net/http"
)
@@ -12,7 +13,7 @@ import (
// RecordMiddleware returns a middleware that records "self" requests and responses.
func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.recorder == nil || !s.recordEnabled {
if s.recorder == nil || !s.recordEnabled || r.Header.Get("Upgrade") == "websocket" {
next.ServeHTTP(w, r)
return
}
@@ -39,6 +40,10 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
// Create a response object for the recorder
res := rw.getRecordedResponse(r)
if res.StatusCode >= 400 {
log.Printf("[DEBUG_LOG] Recording error response: %d %s %s", res.StatusCode, r.Method, r.URL.Path)
}
if res.Body != nil {
defer func() { _ = res.Body.Close() }()
}
@@ -52,8 +57,9 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
type responseWriter struct {
http.ResponseWriter
statusCode int
body *bytes.Buffer
statusCode int
body *bytes.Buffer
wroteHeader bool
}
func (rw *responseWriter) Header() http.Header {
@@ -61,18 +67,28 @@ func (rw *responseWriter) Header() http.Header {
}
func (rw *responseWriter) WriteHeader(code int) {
if rw.wroteHeader {
return
}
rw.statusCode = code
rw.wroteHeader = true
rw.ResponseWriter.WriteHeader(code)
}
func (rw *responseWriter) Write(b []byte) (int, error) {
if !rw.wroteHeader {
rw.WriteHeader(http.StatusOK)
}
rw.body.Write(b)
return rw.ResponseWriter.Write(b)
}
func (rw *responseWriter) getRecordedResponse(r *http.Request) *http.Response {
statusCode := rw.statusCode
if statusCode == 0 {
if !rw.wroteHeader && statusCode == 0 {
statusCode = http.StatusOK
}
+175 -2
View File
@@ -2,7 +2,10 @@ package handlers
import (
"context"
"fmt"
"log"
"net/http"
"net/url"
"sync"
"time"
@@ -11,6 +14,7 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
)
// Server handles HTTP requests for the SoundTouch service.
@@ -27,27 +31,41 @@ type Server struct {
recordEnabled bool
discoveryInterval time.Duration
discoveryEnabled bool
dnsEnabled bool
dnsUpstream string
dnsBindAddr string
enableSoundcorkProxy bool
shortcuts map[string]int
recorder *proxy.Recorder
dnsDiscovery *discovery.DNSDiscovery
UpstreamProxy http.Handler
Version string
Commit string
Date string
mgmtUsername string
mgmtPassword string
spotifyClientID string
spotifyClientSecret string
spotifyRedirectURI string
baseURL string
spotifyService *spotify.Service
}
// NewServer creates a new SoundTouch service server.
func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact, proxyLogBody, recordEnabled, enableSoundcorkProxy bool) *Server {
return &Server{
s := &Server{
ds: ds,
sm: sm,
serverURL: serverURL,
soundcorkURL: serverURL,
soundcorkURL: "http://localhost:8001",
proxyRedact: proxyRedact,
proxyLogBody: proxyLogBody,
recordEnabled: recordEnabled,
enableSoundcorkProxy: enableSoundcorkProxy,
discoveryInterval: 5 * time.Minute,
}
return s
}
// SetVersionInfo sets the version information for the server.
@@ -69,6 +87,92 @@ func (s *Server) SetDiscoverySettings(interval time.Duration, enabled bool) {
s.discoveryEnabled = enabled
}
// 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.dnsUpstream = upstream
s.dnsBindAddr = bind
if s.dnsDiscovery != nil {
if !enabled || bind != oldBind || upstream != oldUpstream {
log.Printf("[DNS] Settings changed, stopping DNS discovery server")
_ = s.dnsDiscovery.Shutdown()
s.dnsDiscovery = nil
}
}
if enabled && upstream == "" {
log.Printf("[DNS] Cannot start DNS discovery server: upstream DNS is empty")
s.dnsEnabled = false
return
}
if enabled && s.dnsDiscovery == nil {
log.Printf("[DNS] Starting DNS discovery server on %s", bind)
u, _ := url.Parse(s.serverURL)
serviceIP := u.Hostname()
if serviceIP == "localhost" || serviceIP == "" {
serviceIP = "127.0.0.1"
}
if s.sm != nil {
serviceIP = s.sm.GetResolvedIP(serviceIP)
}
s.dnsDiscovery = discovery.NewDNSDiscovery(upstream, serviceIP)
go func(d *discovery.DNSDiscovery, addr string) {
if err := d.Start(addr); err != nil {
log.Printf("Warning: DNS discovery server error: %v", err)
}
}(s.dnsDiscovery, bind)
}
}
// GetDNSRunning returns whether DNS discovery is active and its bind address.
func (s *Server) GetDNSRunning() (bool, string) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.dnsDiscovery == nil {
return false, ""
}
return s.dnsDiscovery.IsRunning(s.dnsBindAddr), s.dnsBindAddr
}
// SetDNSDiscoveries sets the initial DNS discoveries for the server.
func (s *Server) SetDNSDiscoveries(discoveries map[string]*discovery.DiscoveredHost) {
s.mu.Lock()
defer s.mu.Unlock()
if s.dnsDiscovery != nil {
s.dnsDiscovery.SetDiscovered(discoveries)
}
}
// GetDNSDiscovery returns the current DNS discoveries.
func (s *Server) GetDNSDiscovery() map[string]*discovery.DiscoveredHost {
s.mu.RLock()
defer s.mu.RUnlock()
if s.dnsDiscovery == nil {
return nil
}
return s.dnsDiscovery.GetDiscovered()
}
// SetShortcuts sets the request shortcuts for the server.
func (s *Server) SetShortcuts(shortcuts map[string]int) {
s.mu.Lock()
@@ -101,9 +205,58 @@ func (s *Server) SetHTTPServerURL(url string) {
s.httpsServerURL = url
}
// SetSoundcorkURL sets the URL for the Soundcork backend.
func (s *Server) SetSoundcorkURL(url string) {
s.mu.Lock()
defer s.mu.Unlock()
s.soundcorkURL = url
}
// SetRecorder sets the recorder for the server.
func (s *Server) SetRecorder(r *proxy.Recorder) {
s.mu.Lock()
defer s.mu.Unlock()
s.recorder = r
if r != nil {
r.Redact = s.proxyRedact
}
}
// SetSpotifyConfig sets the Spotify OAuth configuration.
func (s *Server) SetSpotifyConfig(clientID, clientSecret, redirectURI string) {
s.mu.Lock()
defer s.mu.Unlock()
s.spotifyClientID = clientID
s.spotifyClientSecret = clientSecret
s.spotifyRedirectURI = redirectURI
}
// 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
}
// SetBaseURL sets the external base URL for OAuth callbacks.
func (s *Server) SetBaseURL(baseURL string) {
s.mu.Lock()
defer s.mu.Unlock()
s.baseURL = baseURL
}
// SetSpotifyService sets the Spotify OAuth service.
func (s *Server) SetSpotifyService(ss *spotify.Service) {
s.mu.Lock()
defer s.mu.Unlock()
s.spotifyService = ss
}
// GetRecordEnabled returns whether recording is enabled.
@@ -327,3 +480,23 @@ func (s *Server) findExistingDeviceInfo(d models.DiscoveredDevice) *models.Servi
return nil
}
// lookupIP resolves a deviceId to its last known device IP.
func (s *Server) lookupIP(deviceId string) (string, error) {
devices, err := s.ds.ListAllDevices()
if err != nil {
return "", err
}
for i := range devices {
if devices[i].DeviceID == deviceId {
if devices[i].IPAddress == "" {
return "", fmt.Errorf("no IP known for deviceId %s", deviceId)
}
return devices[i].IPAddress, nil
}
}
return "", fmt.Errorf("deviceId %s not found", deviceId)
}
@@ -170,6 +170,46 @@
"liveRadio",
"onDemand"
]
},
{
"_links": {
"bmx_navigate": {
"href": "/v1/navigate"
},
"bmx_token": {
"href": "/v1/token"
},
"self": {
"href": "/"
}
},
"askAdapter": false,
"assets": {
"color": "#000000",
"description": "RadioBrowser is an open source internet radio directory. It provides access to thousands of internet radio stations worldwide. RadioBrowser is community driven and relies on user contributions to keep the station database up to date.",
"icons": {
"largeSvg": "{MEDIA_SERVER}/orion-monochrome.svg",
"monochromePng": "{MEDIA_SERVER}/orion-monochrome_v2.png",
"monochromeSvg": "{MEDIA_SERVER}/orion-monochrome.svg",
"smallSvg": "{MEDIA_SERVER}/orion-monochrome.svg"
},
"name": "RadioBrowser"
},
"authenticationModel": {
"anonymousAccount": {
"autoCreate": true,
"enabled": true
}
},
"baseUrl": "https://all.api.radio-browser.info/soundtouch",
"id": {
"name": "RADIO_BROWSER",
"value": 39
},
"streamTypes": [
"liveRadio",
"onDemand"
]
}
]
}
+82 -386
View File
@@ -2,401 +2,97 @@
<html>
<head>
<meta charset="UTF-8">
<title>AfterTouch (SoundTouch Toolkit)</title>
<title>AfterTouch - Select Interface</title>
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
<link rel="stylesheet" href="/web/css/style.css">
<link rel="stylesheet" href="/web/shared/common.css">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #121212;
color: #e0e0e0;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
}
.container {
text-align: center;
max-width: 600px;
}
h1 { color: #fff; margin-bottom: 30px; }
.choices {
display: flex;
gap: 20px;
justify-content: center;
}
.choice-card {
background: #1e1e1e;
border-radius: 12px;
padding: 30px;
width: 200px;
text-decoration: none;
color: inherit;
transition: all 0.3s;
border: 2px solid transparent;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 10px 20px rgba(0,0,0,0.5);
}
.choice-card:hover {
transform: translateY(-5px);
border-color: #00bcd4;
background: #252525;
}
.icon {
font-size: 3rem;
margin-bottom: 15px;
}
.title {
font-weight: bold;
font-size: 1.2rem;
margin-bottom: 10px;
color: #00bcd4;
}
.desc {
font-size: 0.9rem;
color: #888;
}
footer {
margin-top: 50px;
}
</style>
</head>
<body>
<h1>AfterTouch</h1>
<p style="margin-top: -10px; font-style: italic; color: #666;">Bose SoundTouch Toolkit</p>
<div class="container">
<h1>AfterTouch</h1>
<p style="margin-top: -25px; font-style: italic; color: #666; margin-bottom: 30px;">Bose SoundTouch Toolkit</p>
<p style="margin-bottom: 40px; color: #aaa;">Select an interface to continue.</p>
<div class="tabs">
<div class="tab-buttons">
<button class="tab-btn active" onclick="openTab(event, 'tab-overview')">Overview</button>
<button class="tab-btn" onclick="openTab(event, 'tab-settings')">1. Settings</button>
<button class="tab-btn" onclick="openTab(event, 'tab-devices')">2. Devices</button>
<button class="tab-btn" onclick="openTab(event, 'tab-sync')">3. Data Sync</button>
<button class="tab-btn" onclick="openTab(event, 'tab-migration')">4. Migration</button>
<button class="tab-btn" onclick="openTab(event, 'tab-interactions')">5. Interactions & Events</button>
</div>
<div class="choices">
<a href="/web/stockholm-mini/" class="choice-card">
<div class="icon">📻</div>
<div class="title">Stockholm Mini</div>
<div class="desc">Lightweight device controller and player.</div>
</a>
<!-- Tab 0: Overview -->
<div id="tab-overview" class="tab-content active">
<h2>Welcome to AfterTouch</h2>
<p>This toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026. It emulates the necessary cloud services locally on your network.</p>
<h3>Migration Process at a Glance</h3>
<div class="info-box prerequisite-box">
<strong>🔌 Prerequisite: Enable SSH</strong><br>
Migration requires SSH access. To enable it:
<ol style="margin-top: 5px; margin-bottom: 5px;">
<li>Create an empty file named <code>remote_services</code> on a USB stick.</li>
<li>Insert it into the speaker's <strong>SERVICE</strong> port and reboot the speaker.</li>
</ol>
<strong>Verify connection:</strong>
<ul style="margin-top: 5px; margin-bottom: 0; padding-left: 20px;">
<li>Use the <strong>Migration</strong> tab to select your device and verify that <em>SSH Connection</em> shows ✅ Success.</li>
<li>Or manually: <code>ssh -oHostKeyAlgorithms=+ssh-rsa root@&lt;SPEAKER-IP&gt;</code> (no password).</li>
</ul>
</div>
<ol class="guide-steps">
<li>
<strong>Settings:</strong> Review the <strong>Settings</strong> tab. Ensure the "Target Domain" and "Proxy Domain" use an IP address or domain name that is <strong>accessible from your speakers</strong> (usually the IP of this server on your local network).
</li>
<li>
<strong>Discovery:</strong> Go to the <strong>Devices</strong> tab to find your speakers on the network.
Ensure your speakers are powered on and connected to the same network.
</li>
<li>
<strong>Data Sync:</strong> In the <strong>Data Sync</strong> tab, fetch your current presets, recents, and sources.
This step is critical to ensure your local service has all your personalized data before you disconnect from the Bose cloud.
</li>
<li>
<strong>Migration:</strong> In the <strong>Migration</strong> tab, redirect your speaker to this local service.
We recommend the <strong>XML Configuration</strong> method as it is surgical and easily reversible.
</li>
<li>
<strong>Verification:</strong> After migration and reboot, your speaker will communicate with this toolkit instead of Bose servers.
</li>
</ol>
<div class="info-box safety-box">
<strong>⚠️ Safety First:</strong> Before starting any migration, please read our
<a href="https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html" target="_blank">Professional Migration & Safety Guide</a>.
The toolkit automatically creates backups, but understanding the process is key to a smooth transition.
</div>
<h3>Useful Links</h3>
<ul>
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html" target="_blank">Cloud Shutdown Survival Guide</a></li>
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html" target="_blank">CLI Reference</a></li>
</ul>
</div>
<!-- Tab 1: Settings -->
<div id="tab-settings" class="tab-content">
<h2>System Settings</h2>
<p style="font-size: 0.9em; color: #555; margin-bottom: 20px;">
<strong>Note:</strong> These URLs must be <strong>accessible from your SoundTouch devices</strong>.
Use the IP address of this server on your local network (e.g., <code>http://192.168.1.100:8000</code>)
rather than <code>localhost</code>.
</p>
<div style="margin-bottom: 20px;">
<label for="target-domain">Target Domain:</label>
<input type="text" id="target-domain" placeholder="http://192.168.x.x:8000" style="width: 300px;">
<span style="font-size: 0.8em; color: #666;">(Standard services URL)</span>
</div>
<div style="margin-bottom: 20px;">
<label for="soundcork-url">Soundcork URL:</label>
<input type="text" id="soundcork-url" placeholder="http://192.168.x.x:8001" style="width: 300px;">
<span style="font-size: 0.8em; color: #666;">(Soundcork services URL)</span>
</div>
<div style="margin-bottom: 20px;">
<label for="discovery-interval">Discovery Interval:</label>
<input type="text" id="discovery-interval" placeholder="5m" style="width: 100px;">
<label style="margin-left: 15px;"><input type="checkbox" id="discovery-enabled"> Enable Automated Discovery</label>
</div>
<div style="margin-bottom: 20px;">
<button onclick="updateSettings()">Save Settings</button>
<span id="settings-status" style="margin-left: 10px; font-size: 0.9em;"></span>
</div>
<div style="margin-bottom: 20px;">
<strong>Proxy Logging:</strong>
<div style="margin-top: 5px;">
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="enable-soundcork-proxy" onchange="updateProxySettings()"> Enable Soundcork Proxy (Legacy)</label>
<label style="display: block; margin-bottom: 5px;">
<input type="checkbox" id="proxy-record" onchange="updateProxySettings()"> Record Interactions
<span style="font-size: 0.85em; color: #666; margin-left: 5px;">(View in <strong>5. Interactions</strong> tab)</span>
</label>
</div>
</div>
</div>
<!-- Tab 2: Devices -->
<div id="tab-devices" class="tab-content">
<h2>Known Devices <span id="discovery-indicator" style="font-size: 0.5em; vertical-align: middle; display: none;">🔍 Scanning...</span></h2>
<div id="device-list">Loading devices...</div>
<div style="margin-top: 20px;">
<button onclick="triggerDiscovery()">Scan Again</button>
<input type="text" id="add-manual-ip" placeholder="Manual IP (e.g. 192.168.1.100)" style="margin-left: 20px; padding: 4px;">
<button onclick="addManualDevice()">Add Device</button>
</div>
</div>
<!-- Tab 3: Data Sync -->
<div id="tab-sync" class="tab-content">
<h2>Initial Data Sync</h2>
<p>Before migrating, fetch your presets, recents, and configured sources from the device to ensure they are available locally.</p>
<div class="device-selection">
<label for="sync-device-list">Device:</label>
<select id="sync-device-list">
<option value="">-- Select a device --</option>
</select>
<button id="sync-now-btn">Start Sync</button>
</div>
<div id="sync-status" class="status"></div>
<div id="sync-results" style="margin-top: 20px; display: none;">
<h3>Sync Results</h3>
<div id="sync-log" style="font-family: monospace; background: #f4f4f4; padding: 10px; border-radius: 4px; max-height: 300px; overflow-y: auto;"></div>
</div>
</div>
<!-- Tab 4: Migration -->
<div id="tab-migration" class="tab-content">
<h2>Device Migration</h2>
<div class="device-selection">
<label for="migration-device-list">Device:</label>
<select id="migration-device-list" onchange="showSummary(this.value)">
<option value="">-- Select a device --</option>
</select>
</div>
<div id="status" class="status"></div>
<div id="command-output-box" class="summary-box" style="display: none; background-color: #f0f0f0;">
<h3>Command Output</h3>
<div id="command-output" style="font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 300px; overflow-y: auto; padding: 10px; border: 1px solid #ccc; background: #fff;"></div>
</div>
<div id="migration-summary" class="summary-box" style="display: none;">
<h3>Migration Summary for <span id="summary-ip"></span></h3>
<p>Migration Status: <span id="migration-status"></span></p>
<p>SSH Connection: <span id="ssh-status"></span></p>
<p id="original-config-status" style="display: none;">Backup: ✅ Found .original config at <code>/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original</code> <button onclick="toggleOriginalConfig()">Show Original Config</button></p>
<p id="no-original-config-status" style="display: none;">Backup: ❌ Not found <button id="backup-config-btn">Backup Config Now</button></p>
<p>Remote Services Enabled: <span id="remote-services-status"></span> <span id="remote-services-found" style="font-size: 0.8em; color: #666;"></span></p>
<p>AfterTouch Local Root CA Trusted: <span id="ca-trust-status"></span> <button id="trust-ca-btn" style="display: none; background-color: #607D8B; color: white; border: none; padding: 2px 8px; font-size: 0.8em; margin-left: 10px;">Trust CA Now</button></p>
<div id="connection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #eefbff;">
<strong>HTTPS Connection Test:</strong><br>
<span style="font-size: 0.85em; color: #555;">Verify the device can reach the server over HTTPS.</span>
<div style="margin-top: 10px;">
URL: <code id="test-url"></code>
</div>
<div style="margin-top: 10px;">
<button id="test-connection-explicit-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Explicit CA.crt</button>
<button id="test-connection-trusted-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Shared Trust Store</button>
</div>
<div id="test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div id="hosts-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #fff4e6; display: none;">
<strong>Preliminary /etc/hosts Test:</strong><br>
<span style="font-size: 0.85em; color: #555;">Verify the device's /etc/hosts mechanism before full migration.</span>
<div style="margin-top: 10px;">
Domain: <code>custom-test-api.bose.fake</code>
</div>
<div style="margin-top: 10px;">
<button id="test-hosts-btn" style="background-color: #FF9800; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test Hosts Redirection</button>
</div>
<div id="hosts-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #f9f9f9;">
<label for="migration-method"><strong>Migration Method:</strong></label>
<select id="migration-method" onchange="toggleMigrationMethod()">
<option value="xml">XML Configuration (Recommended - redirects specific services)</option>
<option value="hosts">/etc/hosts + Root CA (Advanced - global redirection)</option>
</select>
</div>
<div id="original-config-pane" style="display: none; margin-bottom: 20px;">
<span class="config-header">Original Config (Backup)</span>
<pre id="original-config-content"></pre>
</div>
<div id="service-options" style="margin-bottom: 20px; display: none;">
<h4>Service Implementations</h4>
<table>
<tr><th>Service</th><th>Original URL</th><th>Implementation</th></tr>
<tr>
<td>Marge (Streaming)</td>
<td id="orig-marge">loading...</td>
<td>
<select id="opt-marge" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
<tr>
<td>Stats</td>
<td id="orig-stats">loading...</td>
<td>
<select id="opt-stats" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
<tr>
<td>Software Update</td>
<td id="orig-sw_update">loading...</td>
<td>
<select id="opt-sw_update" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
<tr>
<td>BMX (Registry)</td>
<td id="orig-bmx">loading...</td>
<td>
<select id="opt-bmx" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
</table>
</div>
<div class="diff-container">
<div id="xml-diff-pane" class="diff-pane">
<span class="config-header">Current Config (on Speaker)</span>
<pre id="current-config"></pre>
</div>
<div id="planned-xml-pane" class="diff-pane">
<span class="config-header">Planned Config (AfterTouch)</span>
<pre id="planned-config"></pre>
</div>
<div id="planned-hosts-pane" class="diff-pane" style="display: none;">
<span class="config-header">Planned /etc/hosts Entries</span>
<pre id="planned-hosts"></pre>
<div style="margin-top: 10px; font-size: 0.9em; color: #666;">
<strong>Note:</strong> This method also injects the AfterTouch Local Root CA into <code>/etc/pki/tls/certs/ca-bundle.crt</code> to enable secure HTTPS communication.
</div>
</div>
</div>
<div style="margin-top: 15px;">
<button id="confirm-migrate-btn" style="background-color: #4CAF50; color: white; border: none; padding: 10px 20px;">Confirm Migration</button>
<button id="revert-migrate-btn" style="background-color: #FF9800; color: white; border: none; padding: 10px 20px; display: none;">Revert to Defaults</button>
<button id="reboot-speaker-btn" style="background-color: #607D8B; color: white; border: none; padding: 10px 20px;">Reboot Speaker</button>
<button id="ensure-remote-btn" style="background-color: #2196F3; color: white; border: none; padding: 10px 20px;">Enable Persistent Remote Services</button>
<button id="remove-remote-btn" style="background-color: #f44336; color: white; border: none; padding: 10px 20px;">Remove Persistent Remote Services</button>
<button onclick="document.getElementById('migration-summary').style.display='none'" style="padding: 10px 20px;">Cancel</button>
</div>
</div>
</div>
<!-- Tab 5: Interactions & Events -->
<div id="tab-interactions" class="tab-content">
<h2>Recorded Interactions & Device Events</h2>
<p>Analysis of traffic handled by this service (self), proxied to Bose (upstream), and internal device events (telemetry).</p>
<div id="interaction-stats-container" class="summary-box">
<div style="display: flex; gap: 20px; align-items: center; margin-bottom: 15px;">
<p style="margin: 0;">Total Requests: <strong id="total-requests">0</strong></p>
<button onclick="fetchInteractionStats()">Refresh Stats</button>
<div style="margin-left: 10px;">
<button onclick="showDeviceEvents()">View App/Device Events</button>
</div>
<div style="margin-left: auto; text-align: right;">
<button onclick="cleanupSessions()" class="btn-danger">Cleanup old sessions</button>
<div style="font-size: 0.75em; color: #666; margin-top: 3px;">Keeps only the 10 most recent sessions</div>
</div>
</div>
<div style="display: flex; gap: 20px;">
<div style="flex: 1; border-right: 1px solid #eee; padding-right: 20px;">
<h3>By Service</h3>
<ul id="stats-by-service" class="stats-list"></ul>
</div>
<div style="flex: 2;">
<h3>Sessions</h3>
<div id="stats-by-session-container" style="max-height: 200px; overflow-y: auto; border: 1px solid #eee; padding: 5px; border-radius: 4px;">
<ul id="stats-by-session" class="stats-list"></ul>
</div>
</div>
</div>
</div>
<div id="browse-recordings" class="summary-box" style="margin-top: 20px;">
<h3>Browse Recordings</h3>
<div style="margin-bottom: 15px; display: flex; gap: 15px; align-items: center; background: #f9f9f9; padding: 10px; border-radius: 4px;">
<div>
<label for="filter-session">Session:</label>
<select id="filter-session" onchange="fetchInteractions()">
<option value="">All Sessions</option>
</select>
</div>
<div>
<label for="filter-category">Category:</label>
<select id="filter-category" onchange="fetchInteractions()">
<option value="">All Categories</option>
<option value="self">Self (Emulated)</option>
<option value="upstream">Upstream (Bose)</option>
</select>
</div>
<div>
<label for="filter-since">Since (YYYY-MM-DD HH:mm:ss):</label>
<input type="text" id="filter-since" placeholder="e.g. 2026-02-15 15:00:00" size="25" onchange="fetchInteractions()">
</div>
<button onclick="fetchInteractions()">Apply Filters</button>
</div>
<div id="interactions-list-container" style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="text-align: left; border-bottom: 2px solid #eee;">
<th style="padding: 8px;">#</th>
<th style="padding: 8px;">Time</th>
<th style="padding: 8px;">Method</th>
<th style="padding: 8px;">Path</th>
<th style="padding: 8px;">Status</th>
<th style="padding: 8px;">Category</th>
<th style="padding: 8px;">Action</th>
</tr>
</thead>
<tbody id="interactions-list">
<tr><td colspan="7" style="padding: 20px; text-align: center; color: #666;">No interactions found.</td></tr>
</tbody>
</table>
</div>
</div>
<div id="interaction-viewer" class="summary-box" style="margin-top: 20px; display: none; background: #2b2b2b; color: #a9b7c6;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h3 style="margin: 0; color: #fff;">Recording Viewer: <span id="viewer-filename" style="font-weight: normal; font-size: 0.8em;"></span></h3>
<button onclick="document.getElementById('interaction-viewer').style.display='none'" style="background: #444; color: #fff; border: 1px solid #666;">Close</button>
</div>
<pre id="interaction-content" style="white-space: pre-wrap; font-family: 'Courier New', Courier, monospace; font-size: 0.9em; margin: 0; padding: 10px; overflow-x: auto; max-height: 600px;"></pre>
</div>
<!-- Device Events Overlay -->
<div id="device-events-overlay" class="summary-box" style="margin-top: 20px; display: none; background: #fdfdfd; border: 1px solid #ddd;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h3 style="margin: 0;">App & Device Events</h3>
<div>
<select id="event-device-selector" onchange="fetchDeviceEvents(this.value)">
<option value="">-- Select Device --</option>
</select>
<button onclick="document.getElementById('device-events-overlay').style.display='none'" style="margin-left: 10px;">Close</button>
</div>
</div>
<div id="events-list-container" style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="text-align: left; border-bottom: 2px solid #eee;">
<th style="padding: 8px;">Time</th>
<th style="padding: 8px;">Type</th>
<th style="padding: 8px;">Data</th>
</tr>
</thead>
<tbody id="events-list">
<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Select a device to view events.</td></tr>
</tbody>
</table>
</div>
</div>
<a href="/web/migration/" class="choice-card">
<div class="icon">⚙️</div>
<div class="title">Migration</div>
<div class="desc">Setup, data sync, and cloud migration toolkit.</div>
</a>
</div>
</div>
<script src="/web/js/script.js"></script>
<footer style="margin-top: 50px; padding: 20px; border-top: 1px solid #eee; font-size: 0.8em; color: #888; text-align: center;">
<footer>
<span id="version-info">AfterTouch</span>
</footer>
<script src="/web/shared/common.js"></script>
<script>
fetchVersion();
</script>
</body>
</html>
@@ -0,0 +1,476 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>AfterTouch (SoundTouch Toolkit)</title>
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
<link rel="stylesheet" href="../shared/common.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>AfterTouch</h1>
<p style="margin-top: -10px; font-style: italic; color: #666;">Bose SoundTouch Toolkit</p>
<p style="margin-bottom: 20px;"><a href="/">&larr; Back to selection</a></p>
<div class="tabs">
<div class="tab-buttons">
<button class="tab-btn active" onclick="openTab(event, 'tab-overview')">Overview</button>
<button class="tab-btn" onclick="openTab(event, 'tab-settings')">1. Settings</button>
<button class="tab-btn" onclick="openTab(event, 'tab-devices')">2. Devices</button>
<button class="tab-btn" onclick="openTab(event, 'tab-sync')">3. Data Sync</button>
<button class="tab-btn" onclick="openTab(event, 'tab-migration')">4. Migration</button>
<button class="tab-btn" onclick="openTab(event, 'tab-interactions')">5. Interactions & Events</button>
</div>
<!-- Tab 0: Overview -->
<div id="tab-overview" class="tab-content active">
<h2>Welcome to AfterTouch</h2>
<p>This toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026. It emulates the necessary cloud services locally on your network.</p>
<h3>Migration Process at a Glance</h3>
<div class="info-box prerequisite-box">
<strong>🔌 Prerequisite: Enable SSH</strong><br>
Migration requires SSH access. To enable it:
<ol style="margin-top: 5px; margin-bottom: 5px;">
<li>Create an empty file named <code>remote_services</code> on a USB stick.</li>
<li>Insert it into the speaker's <strong>SERVICE</strong> port and reboot the speaker.</li>
</ol>
<strong>Verify connection:</strong>
<ul style="margin-top: 5px; margin-bottom: 0; padding-left: 20px;">
<li>Use the <strong>Migration</strong> tab to select your device and verify that <em>SSH Connection</em> shows ✅ Success.</li>
<li>Or manually: <code>ssh -oHostKeyAlgorithms=+ssh-rsa root@&lt;SPEAKER-IP&gt;</code> (no password).</li>
</ul>
</div>
<ol class="guide-steps">
<li>
<strong>Settings:</strong> Review the <strong>Settings</strong> tab. Ensure the "Target Domain" and "Proxy Domain" use an IP address or domain name that is <strong>accessible from your speakers</strong> (usually the IP of this server on your local network).
</li>
<li>
<strong>Discovery:</strong> Go to the <strong>Devices</strong> tab to find your speakers on the network.
Ensure your speakers are powered on and connected to the same network.
</li>
<li>
<strong>Data Sync:</strong> In the <strong>Data Sync</strong> tab, fetch your current presets, recents, and sources.
This step is critical to ensure your local service has all your personalized data before you disconnect from the Bose cloud.
</li>
<li>
<strong>Migration:</strong> In the <strong>Migration</strong> tab, redirect your speaker to this local service.
We recommend the <strong>XML Configuration</strong> method as it is surgical and easily reversible.
</li>
<li>
<strong>Verification:</strong> After migration and reboot, your speaker will communicate with this toolkit instead of Bose servers.
</li>
</ol>
<div class="info-box safety-box">
<strong>⚠️ Safety First:</strong> Before starting any migration, please read our
<a href="https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html" target="_blank">Professional Migration & Safety Guide</a>.
The toolkit automatically creates backups, but understanding the process is key to a smooth transition.
</div>
<h3>Useful Links</h3>
<ul>
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html" target="_blank">Cloud Shutdown Survival Guide</a></li>
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html" target="_blank">CLI Reference</a></li>
</ul>
</div>
<!-- Tab 1: Settings -->
<div id="tab-settings" class="tab-content">
<h2>System Settings</h2>
<p style="font-size: 0.9em; color: #555; margin-bottom: 20px;">
<strong>Note:</strong> These URLs must be <strong>accessible from your SoundTouch devices</strong>.
Use the IP address of this server on your local network (e.g., <code>http://192.168.1.100:8000</code>)
rather than <code>localhost</code>.
</p>
<div style="margin-bottom: 20px;">
<label for="target-domain">Target Domain:</label>
<input type="text" id="target-domain" placeholder="http://192.168.x.x:8000" style="width: 300px;">
<span style="font-size: 0.8em; color: #666;">(Standard services URL)</span>
</div>
<div style="margin-bottom: 20px;">
<label for="soundcork-url">Soundcork URL:</label>
<input type="text" id="soundcork-url" placeholder="http://192.168.x.x:8001" style="width: 300px;">
<span style="font-size: 0.8em; color: #666;">(Soundcork services URL)</span>
</div>
<div style="margin-bottom: 20px;">
<label for="discovery-interval">Discovery Interval:</label>
<input type="text" id="discovery-interval" placeholder="5m" style="width: 100px;">
<label style="margin-left: 15px;"><input type="checkbox" id="discovery-enabled"> Enable Automated Discovery</label>
</div>
<div style="margin-bottom: 20px;">
<button onclick="updateSettings()">Save Settings</button>
<span id="settings-status" style="margin-left: 10px; font-size: 0.9em;"></span>
</div>
<div style="margin-bottom: 20px;">
<strong>DNS Discovery:</strong>
<div style="margin-top: 5px;">
<label style="display: block; margin-bottom: 5px;">
<input type="checkbox" id="dns-enabled"> Enable DNS Discovery Server
</label>
<div style="margin-left: 20px; margin-bottom: 5px;">
<label for="dns-upstream">Upstream DNS:</label>
<input type="text" id="dns-upstream" placeholder="8.8.8.8" style="width: 150px;">
<span style="font-size: 0.8em; color: #666; margin-left: 5px;">(For non-intercepted queries)</span>
</div>
<div style="margin-left: 20px;">
<label for="dns-bind">DNS Bind Address:</label>
<input type="text" id="dns-bind" placeholder=":53" style="width: 100px;">
<span style="font-size: 0.8em; color: #666; margin-left: 5px;">(e.g., :53 or 0.0.0.0:53. <strong>Port 53</strong> is required for actual migration)</span>
</div>
</div>
</div>
<div style="margin-bottom: 20px;">
<strong>Proxy Logging:</strong>
<div style="margin-top: 5px;">
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="enable-soundcork-proxy" onchange="updateProxySettings()"> Enable Soundcork Proxy (Legacy)</label>
<label style="display: block; margin-bottom: 5px;">
<input type="checkbox" id="proxy-record" onchange="updateProxySettings()"> Record Interactions
<span style="font-size: 0.85em; color: #666; margin-left: 5px;">(View in <strong>5. Interactions</strong> tab)</span>
</label>
</div>
</div>
</div>
<!-- Tab 2: Devices -->
<div id="tab-devices" class="tab-content">
<h2>Known Devices <span id="discovery-indicator" style="font-size: 0.5em; vertical-align: middle; display: none;">🔍 Scanning...</span></h2>
<div id="device-list">Loading devices...</div>
<div style="margin-top: 20px;">
<button onclick="triggerDiscovery()">Scan Again</button>
<input type="text" id="add-manual-ip" placeholder="Manual IP (e.g. 192.168.1.100)" style="margin-left: 20px; padding: 4px;">
<button onclick="addManualDevice()">Add Device</button>
</div>
</div>
<!-- Tab 3: Data Sync -->
<div id="tab-sync" class="tab-content">
<h2>Initial Data Sync</h2>
<p>Before migrating, fetch your presets, recents, and configured sources from the device to ensure they are available locally.</p>
<div class="device-selection">
<label for="sync-device-list">Device:</label>
<select id="sync-device-list">
<option value="">-- Select a device --</option>
</select>
<button id="sync-now-btn">Start Sync</button>
</div>
<div id="sync-status" class="status"></div>
<div id="sync-results" style="margin-top: 20px; display: none;">
<h3>Sync Results</h3>
<div id="sync-log" style="font-family: monospace; background: #f4f4f4; padding: 10px; border-radius: 4px; max-height: 300px; overflow-y: auto;"></div>
</div>
</div>
<!-- Tab 4: Migration -->
<div id="tab-migration" class="tab-content">
<h2>Device Migration</h2>
<div class="device-selection">
<label for="migration-device-list">Device:</label>
<select id="migration-device-list" onchange="showSummary(this.value)">
<option value="">-- Select a device --</option>
</select>
</div>
<div id="status" class="status"></div>
<div id="command-output-box" class="summary-box" style="display: none; background-color: #f0f0f0;">
<h3>Command Output</h3>
<div id="command-output" style="font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 300px; overflow-y: auto; padding: 10px; border: 1px solid #ccc; background: #fff;"></div>
</div>
<div id="migration-summary" class="summary-box" style="display: none;">
<h3>Migration Summary for <span id="summary-ip"></span></h3>
<p>Migration Status: <span id="migration-status"></span></p>
<p>SSH Connection: <span id="ssh-status"></span></p>
<p id="original-config-status" style="display: none;">Backup: ✅ Found .original config at <code>/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original</code> <button onclick="toggleOriginalConfig()">Show Original Config</button></p>
<p id="no-original-config-status" style="display: none;">Backup: ❌ Not found <button id="backup-config-btn">Backup Config Now</button></p>
<p>Remote Services Enabled: <span id="remote-services-status"></span> <span id="remote-services-found" style="font-size: 0.8em; color: #666;"></span></p>
<p>AfterTouch Local Root CA Trusted: <span id="ca-trust-status"></span> <button id="trust-ca-btn" style="display: none; background-color: #607D8B; color: white; border: none; padding: 2px 8px; font-size: 0.8em; margin-left: 10px;">Trust CA Now</button></p>
<div id="connection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #eefbff;">
<strong>HTTPS Connection Test:</strong><br>
<span style="font-size: 0.85em; color: #555;">Verify the device can reach the server over HTTPS.</span>
<div style="margin-top: 10px;">
URL: <code id="test-url"></code>
</div>
<div style="margin-top: 10px;">
<button id="test-connection-explicit-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Explicit CA.crt</button>
<button id="test-connection-trusted-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Shared Trust Store</button>
</div>
<div id="test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div id="hosts-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #fff4e6; display: none;">
<strong>Preliminary /etc/hosts Test:</strong><br>
<span style="font-size: 0.85em; color: #555;">Verify the device's /etc/hosts mechanism before full migration.</span>
<div style="margin-top: 10px;">
Domain: <code>custom-test-api.bose.fake</code>
</div>
<div style="margin-top: 10px;">
<button id="test-hosts-btn" style="background-color: #FF9800; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test Hosts Redirection</button>
</div>
<div id="hosts-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div id="dns-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #e6ffed; display: none;">
<strong>Preliminary DNS Test:</strong><br>
<span style="font-size: 0.85em; color: #555;">Verify the device can resolve domains via the AfterTouch DNS server.</span>
<div style="margin-top: 10px;">
Domain: <code>aftertouch.test</code>
</div>
<div style="margin-top: 10px;">
<button id="test-dns-btn" style="background-color: #28a745; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test DNS Redirection</button>
</div>
<div id="dns-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
</div>
<div style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #f9f9f9;">
<label for="migration-method"><strong>Migration Method:</strong></label>
<select id="migration-method" onchange="toggleMigrationMethod()">
<option value="xml">XML Configuration (Recommended - redirects specific services)</option>
<option value="hosts">/etc/hosts + Root CA (Advanced - global redirection)</option>
<option value="resolv">/etc/resolv.conf (DHCP-Aware - Most flexible)</option>
</select>
<div id="dns-port-warning" style="margin-top: 5px; color: #d32f2f; font-weight: bold; font-size: 0.9em; display: none;"></div>
</div>
<div id="current-resolv-pane" style="display: none; margin-bottom: 20px;">
<span class="config-header">Current /etc/resolv.conf</span>
<pre id="current-resolv-content"></pre>
</div>
<div id="original-config-pane" style="display: none; margin-bottom: 20px;">
<span class="config-header">Original Config (Backup)</span>
<pre id="original-config-content"></pre>
</div>
<div id="service-options" style="margin-bottom: 20px; display: none;">
<h4>Service Implementations</h4>
<table>
<tr><th>Service</th><th>Original URL</th><th>Implementation</th></tr>
<tr>
<td>Marge (Streaming)</td>
<td id="orig-marge">loading...</td>
<td>
<select id="opt-marge" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
<tr>
<td>Stats</td>
<td id="orig-stats">loading...</td>
<td>
<select id="opt-stats" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
<tr>
<td>Software Update</td>
<td id="orig-sw_update">loading...</td>
<td>
<select id="opt-sw_update" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
<tr>
<td>BMX (Registry)</td>
<td id="orig-bmx">loading...</td>
<td>
<select id="opt-bmx" onchange="refreshSummary()">
<option value="self">AfterTouch (Local Service)</option>
<option value="upstream">Upstream (Proxy via local service)</option>
</select>
</td>
</tr>
</table>
</div>
<div class="diff-container">
<div id="xml-diff-pane" class="diff-pane">
<span class="config-header">Current Config (on Speaker)</span>
<pre id="current-config"></pre>
</div>
<div id="planned-xml-pane" class="diff-pane">
<span class="config-header">Planned Config (AfterTouch)</span>
<pre id="planned-config"></pre>
</div>
<div id="planned-hosts-pane" class="diff-pane" style="display: none;">
<span class="config-header">Planned /etc/hosts Entries</span>
<pre id="planned-hosts"></pre>
<div style="margin-top: 10px; font-size: 0.9em; color: #666;">
<strong>Note:</strong> This method also injects the AfterTouch Local Root CA into <code>/etc/pki/tls/certs/ca-bundle.crt</code> to enable secure HTTPS communication.
</div>
</div>
<div id="planned-resolv-pane" class="diff-pane" style="display: none;">
<span class="config-header">Planned /etc/resolv.conf Hook</span>
<pre id="planned-resolv"></pre>
<div id="resolv-note" style="margin-top: 10px; font-size: 0.9em; color: #666;">
<strong>Note:</strong> This method injects a persistent DNS priority hook into the DHCP logic (<code>/etc/udhcpc.d/50default</code>). It preserves your router's search domain and secondary DNS servers. It also injects the Local Root CA.
</div>
</div>
</div>
<div style="margin-top: 15px;">
<button id="confirm-migrate-btn" style="background-color: #4CAF50; color: white; border: none; padding: 10px 20px;">Confirm Migration</button>
<button id="revert-migrate-btn" style="background-color: #FF9800; color: white; border: none; padding: 10px 20px; display: none;">Revert to Defaults</button>
<button id="reboot-speaker-btn" style="background-color: #607D8B; color: white; border: none; padding: 10px 20px;">Reboot Speaker</button>
<button id="ensure-remote-btn" style="background-color: #2196F3; color: white; border: none; padding: 10px 20px;">Enable Persistent Remote Services</button>
<button id="remove-remote-btn" style="background-color: #f44336; color: white; border: none; padding: 10px 20px;">Remove Persistent Remote Services</button>
<button onclick="document.getElementById('migration-summary').style.display='none'" style="padding: 10px 20px;">Cancel</button>
</div>
</div>
</div>
<!-- Tab 5: Interactions & Events -->
<div id="tab-interactions" class="tab-content">
<h2>Recorded Interactions & Device Events</h2>
<p>Analysis of traffic handled by this service (self), proxied to Bose (upstream), and internal device events (telemetry).</p>
<div id="interaction-stats-container" class="summary-box">
<div style="display: flex; gap: 20px; align-items: center; margin-bottom: 15px;">
<p style="margin: 0;">Total Requests: <strong id="total-requests">0</strong></p>
<button onclick="fetchInteractionStats()">Refresh Stats</button>
<div style="margin-left: 10px;">
<button onclick="showDeviceEvents()">View App/Device Events</button>
</div>
<div style="margin-left: auto; text-align: right;">
<button onclick="cleanupSessions()" class="btn-danger">Cleanup old sessions</button>
<div style="font-size: 0.75em; color: #666; margin-top: 3px;">Keeps only the 10 most recent sessions</div>
</div>
</div>
<div style="display: flex; gap: 20px;">
<div style="flex: 1; border-right: 1px solid #eee; padding-right: 20px;">
<h3>By Service</h3>
<ul id="stats-by-service" class="stats-list"></ul>
</div>
<div style="flex: 2;">
<h3>Sessions</h3>
<div id="stats-by-session-container" style="max-height: 200px; overflow-y: auto; border: 1px solid #eee; padding: 5px; border-radius: 4px;">
<ul id="stats-by-session" class="stats-list"></ul>
</div>
</div>
</div>
</div>
<div id="browse-recordings" class="summary-box" style="margin-top: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="margin: 0;">Browse Recordings</h3>
</div>
<div style="margin-bottom: 15px; display: flex; gap: 15px; align-items: center; background: #f9f9f9; padding: 10px; border-radius: 4px;">
<div>
<label for="filter-session">Session:</label>
<select id="filter-session" onchange="fetchInteractions()">
<option value="">All Sessions</option>
</select>
</div>
<div>
<label for="filter-category">Category:</label>
<select id="filter-category" onchange="fetchInteractions()">
<option value="">All Categories</option>
<option value="self">Self (Emulated)</option>
<option value="upstream">Upstream (Bose)</option>
</select>
</div>
<div>
<label for="filter-since">Since (YYYY-MM-DD HH:mm:ss):</label>
<input type="text" id="filter-since" placeholder="e.g. 2026-02-15 15:00:00" size="25" onchange="fetchInteractions()">
</div>
<button onclick="fetchInteractions()">Apply Filters</button>
</div>
<div id="interactions-list-container" style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="text-align: left; border-bottom: 2px solid #eee;">
<th style="padding: 8px;">#</th>
<th style="padding: 8px;">Time</th>
<th style="padding: 8px;">Method</th>
<th style="padding: 8px;">Path</th>
<th style="padding: 8px;">Status</th>
<th style="padding: 8px;">Category</th>
<th style="padding: 8px;">Action</th>
</tr>
</thead>
<tbody id="interactions-list">
<tr><td colspan="7" style="padding: 20px; text-align: center; color: #666;">No interactions found.</td></tr>
</tbody>
</table>
</div>
</div>
<div id="dns-discoveries" class="summary-box" style="margin-top: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="margin: 0;">DNS Discoveries</h3>
<button onclick="clearDNSDiscoveries()" class="btn-danger">Clear DNS Logs</button>
</div>
<p style="font-size: 0.85em; color: #666;">Hosts discovered via the AfterTouch DNS server. "Self" means the domain was intercepted and redirected to this service.</p>
<div id="dns-discoveries-list-container" style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="text-align: left; border-bottom: 2px solid #eee;">
<th style="padding: 8px;">Hostname</th>
<th style="padding: 8px;">Last Seen</th>
<th style="padding: 8px; text-align: center;">Queries</th>
<th style="padding: 8px; text-align: center;">Bose?</th>
<th style="padding: 8px;">Category</th>
<th style="padding: 8px;">Last Client IP</th>
</tr>
</thead>
<tbody id="dns-discoveries-list">
<tr><td colspan="6" style="padding: 20px; text-align: center; color: #666;">No DNS discoveries found.</td></tr>
</tbody>
</table>
</div>
</div>
<div id="interaction-viewer" class="summary-box" style="margin-top: 20px; display: none; background: #2b2b2b; color: #a9b7c6;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h3 style="margin: 0; color: #fff;">Recording Viewer: <span id="viewer-filename" style="font-weight: normal; font-size: 0.8em;"></span></h3>
<button onclick="document.getElementById('interaction-viewer').style.display='none'" style="background: #444; color: #fff; border: 1px solid #666;">Close</button>
</div>
<pre id="interaction-content" style="white-space: pre-wrap; font-family: 'Courier New', Courier, monospace; font-size: 0.9em; margin: 0; padding: 10px; overflow-x: auto; max-height: 600px;"></pre>
</div>
<!-- Device Events Overlay -->
<div id="device-events-overlay" class="summary-box" style="margin-top: 20px; display: none; background: #fdfdfd; border: 1px solid #ddd;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h3 style="margin: 0;">App & Device Events</h3>
<div>
<select id="event-device-selector" onchange="fetchDeviceEvents(this.value)">
<option value="">-- Select Device --</option>
</select>
<button onclick="document.getElementById('device-events-overlay').style.display='none'" style="margin-left: 10px;">Close</button>
</div>
</div>
<div id="events-list-container" style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="text-align: left; border-bottom: 2px solid #eee;">
<th style="padding: 8px;">Time</th>
<th style="padding: 8px;">Type</th>
<th style="padding: 8px;">Data</th>
</tr>
</thead>
<tbody id="events-list">
<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Select a device to view events.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script src="../shared/common.js"></script>
<script src="script.js"></script>
<footer style="margin-top: 50px;">
<span id="version-info">AfterTouch</span>
</footer>
</body>
</html>
@@ -14,6 +14,15 @@ async function fetchSettings() {
if (settings.discovery_enabled !== undefined) {
document.getElementById('discovery-enabled').checked = settings.discovery_enabled;
}
if (settings.dns_enabled !== undefined) {
document.getElementById('dns-enabled').checked = settings.dns_enabled;
}
if (settings.dns_upstream) {
document.getElementById('dns-upstream').value = settings.dns_upstream;
}
if (settings.dns_bind_addr) {
document.getElementById('dns-bind').value = settings.dns_bind_addr;
}
if (settings.enable_soundcork_proxy !== undefined) {
document.getElementById('enable-soundcork-proxy').checked = settings.enable_soundcork_proxy;
}
@@ -62,6 +71,9 @@ async function updateSettings() {
proxy_url: document.getElementById('soundcork-url').value,
discovery_interval: document.getElementById('discovery-interval').value,
discovery_enabled: document.getElementById('discovery-enabled').checked,
dns_enabled: document.getElementById('dns-enabled').checked,
dns_upstream: document.getElementById('dns-upstream').value,
dns_bind_addr: document.getElementById('dns-bind').value,
enable_soundcork_proxy: document.getElementById('enable-soundcork-proxy').checked
};
const status = document.getElementById('settings-status');
@@ -91,8 +103,9 @@ async function updateSettings() {
async function fetchDevices() {
try {
const response = await fetch('/setup/devices');
const response = await fetch('/devices');
const devices = await response.json();
window._knownDevices = devices; // Store globally for easy lookup
const container = document.getElementById('device-list');
const syncSelector = document.getElementById('sync-device-list');
const migrationSelector = document.getElementById('migration-device-list');
@@ -122,20 +135,20 @@ async function fetchDevices() {
<td class="col-fw-serial"><div class="col-firmware">${d.firmware_version || '0.0.0'}</div><div class="col-serial" style="font-size: 0.8em; color: #666;">${d.device_serial_number}</div></td>
<td class="col-method">${methodLabel}</td>
<td>
<button onclick="prepareSync('${d.ip_address}')">Sync Data</button>
<button onclick="prepareMigration('${d.ip_address}')">Migrate</button>
<button onclick="prepareSync('${d.device_id}')">Sync Data</button>
<button onclick="prepareMigration('${d.device_id}')">Migrate</button>
<button class="btn-danger" onclick="removeDevice('${d.device_id}', '${d.name}')">Remove</button>
</td>
</tr>
`;
const optSync = document.createElement('option');
optSync.value = d.ip_address;
optSync.value = d.device_id;
optSync.textContent = `${d.name} (${d.ip_address})`;
syncSelector.appendChild(optSync);
const optMigrate = document.createElement('option');
optMigrate.value = d.ip_address;
optMigrate.value = d.device_id;
optMigrate.textContent = `${d.name} (${d.ip_address})`;
migrationSelector.appendChild(optMigrate);
@@ -154,22 +167,22 @@ async function fetchDevices() {
if (eventSelector && currentEventVal) eventSelector.value = currentEventVal;
// Asynchronously fetch live info for each device
devices.forEach(d => updateDeviceInfo(d.ip_address));
devices.forEach(d => updateDeviceInfo(d.device_id));
}
} catch (error) {
document.getElementById('device-list').innerHTML = 'Error loading devices: ' + error;
}
}
function prepareSync(ip) {
document.getElementById('sync-device-list').value = ip;
function prepareSync(deviceId) {
document.getElementById('sync-device-list').value = deviceId;
openTab(null, 'tab-sync');
}
function prepareMigration(ip) {
document.getElementById('migration-device-list').value = ip;
function prepareMigration(deviceId) {
document.getElementById('migration-device-list').value = deviceId;
openTab(null, 'tab-migration');
showSummary(ip);
showSummary(deviceId);
}
function openTab(evt, tabId) {
@@ -191,6 +204,7 @@ function openTab(evt, tabId) {
if (tabId === 'tab-interactions') {
fetchInteractionStats();
fetchInteractions();
fetchDNSDiscoveries();
}
if (evt) {
@@ -207,25 +221,48 @@ function openTab(evt, tabId) {
}
}
function getDeviceLabel(deviceId) {
if (window._knownDevices) {
const d = window._knownDevices.find(dev => dev.device_id === deviceId);
if (d) {
return `${d.name} (${d.ip_address})`;
}
}
// Fallback to searching the UI
const rows = document.querySelectorAll('#device-list tr');
for (let r of rows) {
const deviceIdCol = r.querySelector('.col-deviceid');
if (deviceIdCol && deviceIdCol.innerText === deviceId) {
const nameEl = r.querySelector('.col-name');
const ipEl = r.querySelector('.col-ip');
if (nameEl && ipEl) {
return `${nameEl.innerText} (${ipEl.innerText})`;
}
}
}
return deviceId;
}
async function startSync() {
const ip = document.getElementById('sync-device-list').value;
if (!ip) {
const deviceId = document.getElementById('sync-device-list').value;
if (!deviceId) {
alert('Please select a device first');
return;
}
const deviceLabel = getDeviceLabel(deviceId);
const status = document.getElementById('sync-status');
const results = document.getElementById('sync-results');
const log = document.getElementById('sync-log');
status.style.display = 'block';
status.style.backgroundColor = '#eef';
status.textContent = 'Syncing data from ' + ip + '...';
status.textContent = 'Syncing data from ' + deviceLabel + '...';
results.style.display = 'none';
log.innerHTML = '';
try {
const response = await fetch('/setup/sync/' + ip, { method: 'POST' });
const response = await fetch('/setup/devices/' + deviceId + '/sync', { method: 'POST' });
if (response.ok) {
status.style.backgroundColor = '#dfd';
status.textContent = '✅ Sync completed successfully!';
@@ -241,18 +278,6 @@ async function startSync() {
}
}
async function fetchVersion() {
try {
const response = await fetch('/setup/version');
const data = await response.json();
const info = document.getElementById('version-info');
if (info && data.version) {
info.innerText = `AfterTouch ${data.version} (${data.commit}) - ${data.date}`;
}
} catch (error) {
console.error('Failed to fetch version info', error);
}
}
async function fetchInteractionStats() {
console.log('Fetching interaction stats...');
@@ -321,6 +346,7 @@ async function fetchInteractionStats() {
li.innerHTML = `
<span class="session-info"><strong>${sessionDisplay}:</strong> ${count || 0} requests</span>
<div style="display: flex; gap: 5px;">
<button onclick="downloadSession('${session || ""}')" class="btn-info" style="font-size: 0.8em; padding: 2px 5px;">Download</button>
<button onclick="filterBySession('${session || ""}')" style="font-size: 0.8em; padding: 2px 5px;">Filter</button>
<button onclick="deleteSession('${session || ""}')" class="btn-danger" style="font-size: 0.8em; padding: 2px 5px;">Delete</button>
</div>
@@ -340,6 +366,11 @@ async function fetchInteractionStats() {
}
}
function downloadSession(sessionId) {
if (!sessionId) return;
window.location.href = `/setup/interactions/sessions/${sessionId}/download`;
}
async function filterBySession(sessionId) {
document.getElementById('filter-session').value = sessionId;
fetchInteractions();
@@ -498,6 +529,74 @@ async function viewInteraction(file) {
}
}
async function fetchDNSDiscoveries() {
console.log('Fetching DNS discoveries...');
try {
const response = await fetch('/setup/dns-discoveries');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const discoveries = await response.json();
console.log('Fetched DNS discoveries:', discoveries);
const list = document.getElementById('dns-discoveries-list');
if (!list) {
console.error('Could not find dns-discoveries-list element');
return;
}
list.innerHTML = '';
if (!discoveries || discoveries.length === 0) {
list.innerHTML = '<tr><td colspan="6" style="padding: 20px; text-align: center; color: #666;">No DNS queries discovered yet.</td></tr>';
return;
}
discoveries.forEach(d => {
const tr = document.createElement('tr');
tr.style.borderBottom = '1px solid #eee';
const hostname = d.hostname || "";
const lastSeen = d.last_seen || "";
const count = d.query_count || 0;
const isBose = d.is_bose_service ? '✅' : '❌';
const category = d.is_intercepted ? 'self' : 'upstream';
const remoteAddr = d.remote_addr || 'unknown';
tr.innerHTML = `
<td style="padding: 8px; font-weight: bold;">${hostname}</td>
<td style="padding: 8px; font-size: 0.85em;">${lastSeen}</td>
<td style="padding: 8px; text-align: center;">${count}</td>
<td style="padding: 8px; text-align: center;">${isBose}</td>
<td style="padding: 8px;"><span class="badge category-${category}">${category}</span></td>
<td style="padding: 8px; font-size: 0.8em; color: #666;">${remoteAddr}</td>
`;
list.appendChild(tr);
});
} catch (error) {
console.error('Failed to fetch DNS discoveries', error);
}
}
async function clearDNSDiscoveries() {
if (!confirm('Are you sure you want to clear all DNS discovery logs?')) {
return;
}
try {
const response = await fetch('/setup/dns-discoveries', {
method: 'DELETE'
});
if (response.ok) {
fetchDNSDiscoveries();
} else {
const err = await response.text();
alert('Failed to clear DNS discoveries: ' + err);
}
} catch (error) {
alert('Error clearing DNS discoveries: ' + error.message);
}
}
async function showDeviceEvents() {
const overlay = document.getElementById('device-events-overlay');
overlay.style.display = 'block';
@@ -518,7 +617,7 @@ async function fetchDeviceEvents(deviceId) {
list.innerHTML = '<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Loading events...</td></tr>';
try {
const response = await fetch(`/setup/devices/${deviceId}/events`);
const response = await fetch(`/devices/${deviceId}/events`);
const data = await response.json();
const events = data.events;
@@ -570,7 +669,7 @@ async function addManualDevice() {
}
try {
const response = await fetch('/setup/devices', {
const response = await fetch('/devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip: ip })
@@ -594,7 +693,7 @@ async function removeDevice(deviceId, name) {
}
try {
const response = await fetch(`/setup/devices/${deviceId}`, {
const response = await fetch(`/devices/${deviceId}`, {
method: 'DELETE'
});
@@ -638,14 +737,23 @@ async function pollDiscoveryStatus() {
}
}
async function updateDeviceInfo(ip) {
async function updateDeviceInfo(deviceId) {
try {
const response = await fetch('/setup/info/' + ip);
const response = await fetch('/devices/' + deviceId + '/info');
if (!response.ok) return;
const info = await response.json();
const rowId = 'device-row-' + ip.replace(/\./g, '-');
const row = document.getElementById(rowId);
// Find the row by deviceId
const rows = document.querySelectorAll('#device-list tr');
let row = null;
for (let r of rows) {
const deviceIdCol = r.querySelector('.col-deviceid');
if (deviceIdCol && deviceIdCol.innerText === deviceId) {
row = r;
break;
}
}
if (row) {
const nameEl = row.querySelector('.col-name');
if (nameEl && info.name) nameEl.innerText = info.name;
@@ -666,12 +774,12 @@ async function updateDeviceInfo(ip) {
if (accountIdEl && info.margeAccountUUID) accountIdEl.innerText = info.margeAccountUUID;
}
} catch (error) {
console.warn('Failed to fetch live info for ' + ip, error);
console.warn('Failed to fetch live info for ' + deviceId, error);
}
}
async function showSummary(ip) {
if (!ip) {
async function showSummary(deviceId) {
if (!deviceId) {
document.getElementById('migration-summary').style.display = 'none';
return;
}
@@ -685,10 +793,11 @@ async function showSummary(ip) {
bmx: document.getElementById('opt-bmx').value
};
const deviceLabel = getDeviceLabel(deviceId);
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Fetching summary for ' + ip + '...';
statusDiv.innerHTML = 'Fetching summary for ' + deviceLabel + '...';
let query = '?target_url=' + encodeURIComponent(targetUrl) + '&proxy_url=' + encodeURIComponent(proxyUrl);
for (let k in opts) {
@@ -699,7 +808,7 @@ async function showSummary(ip) {
if (outputBox) outputBox.style.display = 'none';
try {
const response = await fetch('/setup/summary/' + ip + query);
const response = await fetch('/setup/devices/' + deviceId + '/summary' + query);
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText);
@@ -707,11 +816,18 @@ async function showSummary(ip) {
const summary = await response.json();
statusDiv.style.display = 'none';
document.getElementById('summary-ip').innerText = ip;
document.getElementById('summary-ip').innerText = summary.device_id || deviceId;
// Update table row if it exists
const rowId = 'device-row-' + ip.replace(/\./g, '-');
const row = document.getElementById(rowId);
// Find the row by deviceId
const rows = document.querySelectorAll('#device-list tr');
let row = null;
for (let r of rows) {
const deviceIdCol = r.querySelector('.col-deviceid');
if (deviceIdCol && deviceIdCol.innerText === deviceId) {
row = r;
break;
}
}
if (row) {
const nameEl = row.querySelector('.col-name');
if (nameEl && summary.device_name) nameEl.innerText = summary.device_name;
@@ -773,7 +889,7 @@ async function showSummary(ip) {
caTrustStatus.innerText = summary.ca_cert_trusted ? '✅ Yes' : '❌ No';
caTrustStatus.style.color = summary.ca_cert_trusted ? 'green' : 'red';
document.getElementById('trust-ca-btn').style.display = summary.ca_cert_trusted ? 'none' : 'inline-block';
document.getElementById('trust-ca-btn').onclick = () => trustCA(ip);
document.getElementById('trust-ca-btn').onclick = () => trustCA(deviceId);
} else {
remoteStatus.innerText = '❓ Unknown';
remoteStatus.style.color = 'gray';
@@ -790,6 +906,12 @@ async function showSummary(ip) {
document.getElementById('planned-config').innerText = summary.planned_config;
document.getElementById('planned-hosts').innerText = summary.planned_hosts || '';
document.getElementById('planned-resolv').innerText = summary.planned_resolv || '';
const currentResolvElem = document.getElementById('current-resolv-content');
if (currentResolvElem) {
currentResolvElem.innerText = summary.current_resolv_conf || 'Not available';
}
const testUrlElem = document.getElementById('test-url');
testUrlElem.innerText = summary.server_https_url || 'N/A';
@@ -797,50 +919,51 @@ async function showSummary(ip) {
testResultDiv.style.display = 'none';
testResultDiv.innerText = '';
document.getElementById('test-connection-explicit-btn').onclick = () => testConnection(ip, true);
document.getElementById('test-connection-trusted-btn').onclick = () => testConnection(ip, false);
document.getElementById('test-hosts-btn').onclick = () => testHostsRedirection(ip);
document.getElementById('test-connection-explicit-btn').onclick = () => testConnection(deviceId, true);
document.getElementById('test-connection-trusted-btn').onclick = () => testConnection(deviceId, false);
document.getElementById('test-hosts-btn').onclick = () => testHostsRedirection(deviceId);
document.getElementById('test-dns-btn').onclick = () => testDNSRedirection(deviceId);
toggleMigrationMethod();
const migrateBtn = document.getElementById('confirm-migrate-btn');
migrateBtn.onclick = () => migrate(ip);
migrateBtn.onclick = () => migrate(deviceId);
migrateBtn.disabled = !summary.ssh_success;
const revertBtn = document.getElementById('revert-migrate-btn');
revertBtn.onclick = () => revert(ip);
revertBtn.onclick = () => revert(deviceId);
revertBtn.disabled = !summary.ssh_success;
revertBtn.style.display = summary.original_config ? 'inline-block' : 'none';
const rebootBtn = document.getElementById('reboot-speaker-btn');
rebootBtn.onclick = () => reboot(ip);
rebootBtn.onclick = () => reboot(deviceId);
rebootBtn.disabled = !summary.ssh_success;
rebootBtn.style.border = 'none'; // Reset border if it was set during migration
const remoteBtn = document.getElementById('ensure-remote-btn');
remoteBtn.onclick = () => ensureRemoteServices(ip);
remoteBtn.onclick = () => ensureRemoteServices(deviceId);
remoteBtn.disabled = !summary.ssh_success;
const removeRemoteBtn = document.getElementById('remove-remote-btn');
removeRemoteBtn.onclick = () => removeRemoteServices(ip);
removeRemoteBtn.onclick = () => removeRemoteServices(deviceId);
removeRemoteBtn.disabled = !summary.ssh_success || !summary.remote_services_enabled;
const backupBtn = document.getElementById('backup-config-btn');
backupBtn.onclick = () => backupConfig(ip);
backupBtn.onclick = () => backupConfig(deviceId);
backupBtn.disabled = !summary.ssh_success || !!summary.original_config;
document.getElementById('migration-summary').style.display = 'block';
document.getElementById('migration-summary').scrollIntoView();
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error fetching summary for ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error fetching summary for ' + deviceId + ': ' + error;
}
}
function refreshSummary() {
const ip = document.getElementById('summary-ip').innerText;
if (ip) {
showSummary(ip);
const deviceId = document.getElementById('summary-ip').innerText;
if (deviceId) {
showSummary(deviceId);
}
}
@@ -855,12 +978,13 @@ function showCommandOutput(result) {
}
}
async function revert(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
async function revert(deviceId) {
if (!deviceId) {
alert('Please select a device.');
return;
}
if (!confirm('Are you sure you want to revert ' + ip + ' to Bose cloud defaults?')) {
const deviceLabel = getDeviceLabel(deviceId);
if (!confirm('Are you sure you want to revert ' + deviceLabel + ' to Bose cloud defaults?')) {
return;
}
@@ -870,61 +994,63 @@ async function revert(ip) {
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Reverting ' + ip + ' to defaults...';
statusDiv.innerHTML = 'Reverting ' + deviceLabel + ' to defaults...';
try {
const response = await fetch('/setup/revert/' + ip, { method: 'POST' });
const response = await fetch('/setup/devices/' + deviceId + '/revert', { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully started revert for ' + ip + '.';
statusDiv.innerHTML = 'Successfully started revert for ' + deviceLabel + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Revert failed for ' + ip + ': ' + (result.message || 'Unknown error');
statusDiv.innerHTML = 'Revert failed for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error reverting ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error reverting ' + deviceLabel + ': ' + error;
}
}
async function reboot(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
async function reboot(deviceId) {
if (!deviceId) {
alert('Please select a device.');
return;
}
if (!confirm('Are you sure you want to reboot the speaker at ' + ip + '?')) {
const deviceLabel = getDeviceLabel(deviceId);
if (!confirm('Are you sure you want to reboot the speaker ' + deviceLabel + '?')) {
return;
}
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Rebooting ' + ip + '...';
statusDiv.innerHTML = 'Rebooting ' + deviceLabel + '...';
try {
const response = await fetch('/setup/reboot/' + ip, { method: 'POST' });
const response = await fetch('/devices/' + deviceId + '/reboot', { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully started reboot for ' + ip + '.';
statusDiv.innerHTML = 'Successfully started reboot for ' + deviceLabel + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Reboot failed for ' + ip + ': ' + (result.message || 'Unknown error');
statusDiv.innerHTML = 'Reboot failed for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error rebooting ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error rebooting ' + deviceLabel + ': ' + error;
}
}
async function migrate(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
async function migrate(deviceId) {
if (!deviceId) {
alert('Please select a device.');
return;
}
const deviceLabel = getDeviceLabel(deviceId);
const targetUrl = document.getElementById('target-domain').value;
const proxyUrl = document.getElementById('soundcork-url').value;
const method = document.getElementById('migration-method').value;
@@ -942,7 +1068,7 @@ async function migrate(ip) {
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Migrating ' + ip + ' using ' + method + '...';
statusDiv.innerHTML = 'Migrating ' + deviceLabel + ' using ' + method + '...';
let query = '?method=' + encodeURIComponent(method) + '&target_url=' + encodeURIComponent(targetUrl) + '&proxy_url=' + encodeURIComponent(proxyUrl);
for (let k in opts) {
@@ -950,62 +1076,99 @@ async function migrate(ip) {
}
try {
const response = await fetch('/setup/migrate/' + ip + query, { method: 'POST' });
const response = await fetch('/setup/devices/' + deviceId + '/migrate' + query, { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully started migration for ' + ip + '. <strong>Please reboot the device to activate the changes.</strong>';
statusDiv.innerHTML = 'Successfully started migration for ' + deviceLabel + '. <strong>Please reboot the device to activate the changes.</strong>';
// Make reboot button available and prominent
const rebootBtn = document.getElementById('reboot-speaker-btn');
rebootBtn.style.display = 'inline-block';
rebootBtn.disabled = false;
rebootBtn.style.border = '2px solid #000';
rebootBtn.onclick = () => reboot(deviceId);
// Re-show summary but with prominence on reboot
summaryDiv.style.display = 'block';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Migration failed for ' + ip + ': ' + (result.message || 'Unknown error');
statusDiv.innerHTML = 'Migration failed for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error migrating ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error migrating ' + deviceLabel + ': ' + error;
}
}
async function trustCA(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
async function trustCA(deviceId) {
if (!deviceId) {
alert('Please select a device.');
return;
}
const deviceLabel = getDeviceLabel(deviceId);
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Injecting Root CA into shared trust store on ' + ip + '...';
statusDiv.innerHTML = 'Injecting Root CA into shared trust store on ' + deviceLabel + '...';
try {
const response = await fetch('/setup/trust-ca/' + ip, { method: 'POST' });
const response = await fetch('/setup/devices/' + deviceId + '/trust-ca', { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully injected Root CA on ' + ip + '.';
showSummary(ip); // Refresh to update status
statusDiv.innerHTML = 'Successfully injected Root CA on ' + deviceLabel + '.';
showSummary(deviceId); // Refresh to update status
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Failed to trust CA on ' + ip + ': ' + (result.message || 'Unknown error');
statusDiv.innerHTML = 'Failed to trust CA on ' + deviceLabel + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error trusting CA on ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error trusting CA on ' + deviceLabel + ': ' + error;
}
}
async function ensureRemoteServices(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
async function ensureRemoteServices(deviceId) {
if (!deviceId) {
alert('Please select a device.');
return;
}
const deviceLabel = getDeviceLabel(deviceId);
const summaryDiv = document.getElementById('migration-summary');
summaryDiv.style.display = 'none';
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Ensuring remote services for ' + deviceLabel + '...';
try {
const response = await fetch('/setup/devices/' + deviceId + '/ensure-remote-services', { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully ensured remote services for ' + deviceLabel + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Failed to ensure remote services for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error ensuring remote services for ' + deviceLabel + ': ' + error;
}
}
async function removeRemoteServices(deviceId) {
if (!deviceId) {
alert('Please select a device.');
return;
}
const deviceLabel = getDeviceLabel(deviceId);
if (!confirm('Are you sure you want to remove remote services from ' + deviceLabel + '?')) {
return;
}
const summaryDiv = document.getElementById('migration-summary');
@@ -1014,98 +1177,68 @@ async function ensureRemoteServices(ip) {
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Ensuring remote services for ' + ip + '...';
statusDiv.innerHTML = 'Removing remote services for ' + deviceLabel + '...';
try {
const response = await fetch('/setup/ensure-remote-services/' + ip, { method: 'POST' });
const response = await fetch('/setup/devices/' + deviceId + '/remove-remote-services', { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully ensured remote services for ' + ip + '.';
statusDiv.innerHTML = 'Successfully removed remote services from ' + deviceLabel + '.';
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Failed to ensure remote services for ' + ip + ': ' + (result.message || 'Unknown error');
statusDiv.innerHTML = 'Failed to remove remote services for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error ensuring remote services for ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error removing remote services for ' + deviceLabel + ': ' + error;
}
}
async function removeRemoteServices(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
async function backupConfig(deviceId) {
if (!deviceId) {
alert('Please select a device.');
return;
}
if (!confirm('Are you sure you want to remove remote services from ' + ip + '?')) {
return;
}
const summaryDiv = document.getElementById('migration-summary');
summaryDiv.style.display = 'none';
const deviceLabel = getDeviceLabel(deviceId);
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Removing remote services for ' + ip + '...';
statusDiv.innerHTML = 'Creating backup for ' + deviceLabel + '...';
try {
const response = await fetch('/setup/remove-remote-services/' + ip, { method: 'POST' });
const response = await fetch('/setup/devices/' + deviceId + '/backup', { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully removed remote services from ' + ip + '.';
statusDiv.innerHTML = 'Successfully created backup for ' + deviceLabel + '.';
showSummary(deviceId); // Refresh
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Failed to remove remote services for ' + ip + ': ' + (result.message || 'Unknown error');
statusDiv.innerHTML = 'Failed to create backup for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error removing remote services for ' + ip + ': ' + error;
statusDiv.innerHTML = 'Error creating backup for ' + deviceLabel + ': ' + error;
}
}
async function backupConfig(ip) {
if (!ip) {
alert('Please enter a valid IP address.');
return;
}
const statusDiv = document.getElementById('status');
statusDiv.style.display = 'block';
statusDiv.style.backgroundColor = '#ffffcc';
statusDiv.innerHTML = 'Creating backup for ' + ip + '...';
try {
const response = await fetch('/setup/backup/' + ip, { method: 'POST' });
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = '#ccffcc';
statusDiv.innerHTML = 'Successfully created backup for ' + ip + '.';
showSummary(ip); // Refresh
} else {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Backup failed for ' + ip + ': ' + (result.message || 'Unknown error');
}
} catch (error) {
statusDiv.style.backgroundColor = '#ffcccc';
statusDiv.innerHTML = 'Error creating backup for ' + ip + ': ' + error;
}
}
async function testConnection(ip, useExplicitCA) {
async function testConnection(deviceId, useExplicitCA) {
const testUrl = document.getElementById('test-url').innerText;
const testResultDiv = document.getElementById('test-result');
const deviceLabel = getDeviceLabel(deviceId);
testResultDiv.style.display = 'block';
testResultDiv.style.backgroundColor = '#f0f0f0';
testResultDiv.style.color = 'black';
testResultDiv.innerText = 'Running connection test from ' + ip + '...\n(This may take a few seconds)';
testResultDiv.innerText = 'Running connection test from ' + deviceLabel + '...\n(This may take a few seconds)';
try {
const query = `?target_url=${encodeURIComponent(testUrl)}&use_explicit_ca=${useExplicitCA}`;
const response = await fetch(`/setup/test-connection/${ip}${query}`, { method: 'POST' });
const response = await fetch(`/setup/devices/${deviceId}/test-connection${query}`, { method: 'POST' });
const result = await response.json();
if (result.ok) {
@@ -1121,18 +1254,49 @@ async function testConnection(ip, useExplicitCA) {
}
}
async function testHostsRedirection(ip) {
async function testHostsRedirection(deviceId) {
const targetUrl = document.getElementById('target-domain').value;
const testResultDiv = document.getElementById('hosts-test-result');
const deviceLabel = getDeviceLabel(deviceId);
testResultDiv.style.display = 'block';
testResultDiv.style.backgroundColor = '#f0f0f0';
testResultDiv.style.color = 'black';
testResultDiv.innerText = 'Running hosts redirection test from ' + ip + '...\n(This may take a few seconds)';
testResultDiv.innerText = 'Running hosts redirection test from ' + deviceLabel + '...\n(This may take a few seconds)';
try {
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
const response = await fetch(`/setup/test-hosts/${ip}${query}`, { method: 'POST' });
const response = await fetch(`/setup/devices/${deviceId}/test-hosts${query}`, { method: 'POST' });
const result = await response.json();
if (result.ok) {
testResultDiv.style.backgroundColor = '#ccffcc';
testResultDiv.innerText = '✅ ' + result.message + '\n\nOutput:\n' + result.output;
} else {
testResultDiv.style.backgroundColor = '#ffcccc';
testResultDiv.innerText = '❌ Test failed: ' + result.message + '\n\nOutput:\n' + result.output;
}
} catch (error) {
testResultDiv.style.backgroundColor = '#ffcccc';
testResultDiv.innerText = '❌ Error triggering test: ' + error;
}
}
async function testDNSRedirection(deviceId) {
const targetUrl = document.getElementById('target-domain').value;
const testResultDiv = document.getElementById('dns-test-result');
const deviceLabel = getDeviceLabel(deviceId);
testResultDiv.style.display = 'block';
testResultDiv.style.backgroundColor = '#f0f0f0';
testResultDiv.style.color = 'black';
testResultDiv.innerText = 'Running DNS redirection test from ' + deviceLabel + '...\n(This may take a few seconds)';
try {
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
const response = await fetch(`/setup/devices/${deviceId}/test-dns${query}`, { method: 'POST' });
const result = await response.json();
if (result.ok) {
@@ -1153,25 +1317,79 @@ function toggleOriginalConfig() {
pane.style.display = pane.style.display === 'none' ? 'block' : 'none';
}
function toggleMigrationMethod() {
async function toggleMigrationMethod() {
const method = document.getElementById('migration-method').value;
const xmlDiffPane = document.getElementById('xml-diff-pane');
const plannedXmlPane = document.getElementById('planned-xml-pane');
const plannedHostsPane = document.getElementById('planned-hosts-pane');
const plannedResolvPane = document.getElementById('planned-resolv-pane');
const currentResolvPane = document.getElementById('current-resolv-pane');
const serviceOptions = document.getElementById('service-options');
const hostsTestPane = document.getElementById('hosts-redirection-test');
const dnsTestPane = document.getElementById('dns-redirection-test');
const dnsWarning = document.getElementById('dns-port-warning');
if (method === 'hosts') {
xmlDiffPane.style.display = 'none';
plannedXmlPane.style.display = 'none';
plannedHostsPane.style.display = 'block';
plannedResolvPane.style.display = 'none';
currentResolvPane.style.display = 'none';
serviceOptions.style.display = 'none';
hostsTestPane.style.display = 'block';
dnsTestPane.style.display = 'none';
if (dnsWarning) dnsWarning.style.display = 'none';
} else if (method === 'resolv') {
xmlDiffPane.style.display = 'none';
plannedXmlPane.style.display = 'none';
plannedHostsPane.style.display = 'none';
plannedResolvPane.style.display = 'block';
currentResolvPane.style.display = 'none';
serviceOptions.style.display = 'none';
hostsTestPane.style.display = 'none';
dnsTestPane.style.display = 'block';
const resolvNote = document.getElementById('resolv-note');
if (resolvNote) {
resolvNote.innerHTML = '<strong>Note:</strong> This method injects a persistent DNS priority hook into the DHCP logic (<code>/etc/udhcpc.d/50default</code>). It preserves your router\'s search domain and secondary DNS servers. It also injects the Local Root CA.';
}
// Check DNS settings
try {
const response = await fetch('/setup/settings');
const settings = await response.json();
const dnsBind = settings.dns_bind_addr || '';
const isPort53 = dnsBind.endsWith(':53') || dnsBind === '53';
const isEnabled = settings.dns_enabled;
const isRunning = settings.dns_running;
const actualBind = settings.dns_actual_bind;
if (dnsWarning) {
if (!isEnabled) {
dnsWarning.innerText = '⚠️ DNS Discovery is DISABLED in Settings. Migration will fail.';
dnsWarning.style.display = 'block';
} else if (!isPort53) {
dnsWarning.innerText = `⚠️ DNS Discovery is bound to ${dnsBind}, but port 53 is required for migration.`;
dnsWarning.style.display = 'block';
} else if (!isRunning) {
dnsWarning.innerText = `⚠️ DNS Discovery server is NOT RUNNING on ${dnsBind} (check for port conflicts/permissions). Migration will fail.`;
dnsWarning.style.display = 'block';
} else {
dnsWarning.style.display = 'none';
}
}
} catch (e) {
console.error('Failed to check DNS settings', e);
}
} else {
xmlDiffPane.style.display = 'block';
plannedXmlPane.style.display = 'block';
plannedHostsPane.style.display = 'none';
plannedResolvPane.style.display = 'none';
currentResolvPane.style.display = 'none';
hostsTestPane.style.display = 'none';
dnsTestPane.style.display = 'none';
// Only show service options if we have a parsed config
const currentConfig = document.getElementById('current-config').innerText;
if (currentConfig && !currentConfig.startsWith('Error') && currentConfig !== 'loading...') {
@@ -0,0 +1,16 @@
footer {
margin-top: 50px;
padding: 20px;
font-size: 0.8em;
color: #666;
text-align: center;
}
#version-info a {
color: inherit;
text-decoration: none;
}
#version-info a:hover {
text-decoration: underline;
}
+23
View File
@@ -0,0 +1,23 @@
async function fetchVersion() {
try {
const response = await fetch('/version');
const data = await response.json();
const info = document.getElementById('version-info');
if (info && data.version) {
const version = data.version;
const commit = data.commit;
const isDirty = version.includes('dirty');
const releaseUrl = isDirty
? 'https://github.com/gesellix/Bose-SoundTouch/releases'
: `https://github.com/gesellix/Bose-SoundTouch/releases/tag/v${version}`;
const commitUrl = `https://github.com/gesellix/Bose-SoundTouch/commit/${commit}`;
const projectUrl = 'https://gesellix.github.io/Bose-SoundTouch/';
info.innerHTML = `<a href="${projectUrl}" target="_blank" style="color: inherit; text-decoration: none;">AfterTouch</a> ` +
`<a href="${releaseUrl}" target="_blank" style="color: inherit;">${version}</a> ` +
`(<a href="${commitUrl}" target="_blank" style="color: inherit;">${commit}</a>) - ${data.date}`;
}
} catch (error) {
console.error('Failed to fetch version info', error);
}
}
@@ -0,0 +1,456 @@
async function fetchDevices() {
try {
const response = await fetch('/devices');
const devices = await response.json();
const container = document.getElementById('device-list');
const seen = new Set();
if (devices.length === 0) {
container.innerHTML = '<p>No devices found. Ensure they are on the same network.</p>';
return;
}
devices.forEach(device => {
seen.add(device.device_id);
const existing = document.getElementById(`device-${device.device_id}`);
if (existing) {
// Update product code/IP if changed, but keep title if we already have a better name
const title = existing.querySelector('.device-title');
if (title && (!title.textContent || title.textContent === 'Unknown Device' || title.textContent.startsWith('SoundTouch-'))) {
title.textContent = device.name || 'Unknown Device';
}
const subtitle = existing.querySelector('.device-subtitle span');
if (subtitle) {
const currentSubtitle = subtitle.textContent || '';
const parts = currentSubtitle.split(' | ');
const currentType = parts.length > 1 ? parts[1].trim() : '';
const newType = device.product_code || 'Unknown';
// Don't downgrade type if we already have a specific one
const isGeneric = !currentType || currentType === 'Unknown' || currentType === 'N/A';
const displayType = isGeneric ? newType : currentType;
subtitle.textContent = `${device.ip_address} | ${displayType}`;
}
const details = existing.querySelector(`#details-${device.device_id}`);
if (details) {
const idField = details.querySelector('p:nth-child(1) code');
if (idField) {
const currentId = idField.textContent;
// Don't overwrite with serial if we have a real deviceID (usually hex)
if (!currentId || currentId === 'N/A' || currentId === device.device_serial_number) {
idField.textContent = device.device_id || 'N/A';
}
}
const firmwareField = details.querySelector('p:nth-child(2) code');
if (firmwareField) {
const cur = firmwareField.textContent;
if (!cur || cur === 'N/A' || cur === '0.0.0') {
firmwareField.textContent = device.firmware_version || 'N/A';
}
}
const serialField = details.querySelector('p:nth-child(3) code');
if (serialField && (!serialField.textContent || serialField.textContent === 'N/A')) {
serialField.textContent = device.device_serial_number || 'N/A';
}
}
// Ensure WS is open
openDeviceWebSocket(device.device_id);
return;
}
const card = document.createElement('div');
card.className = 'device-card';
card.id = `device-${device.device_id}`;
card.innerHTML = `
<div class="device-info">
<div class="device-header">
<div>
<div class="device-title-row">
<h2 class="device-title">${device.name || 'Unknown Device'}</h2>
<button class="info-toggle" title="More info" onclick="toggleDetails('${device.device_id}')">i</button>
</div>
<p class="device-subtitle">
<span>${device.ip_address} | ${device.product_code}</span>
</p>
</div>
<button class="power-icon" title="Power" aria-label="Power" onclick="control('${device.device_id}', 'POWER')">&#xE17E;</button>
</div>
<div class="device-details" id="details-${device.device_id}">
<p>ID: <code>${device.device_id}</code></p>
<p>Firmware: <code>${device.firmware_version || 'N/A'}</code></p>
<p>Serial: <code>${device.device_serial_number || 'N/A'}</code></p>
<p>Discovery: <code>${device.discovery_method || 'N/A'}</code></p>
</div>
</div>
<div class="now-playing" id="np-${device.device_id}">
<p><em>Loading playback status...</em></p>
</div>
<div class="controls">
<button class="primary" onclick="control('${device.device_id}', 'PLAY')">Play</button>
<button class="primary" onclick="control('${device.device_id}', 'PAUSE')">Pause</button>
<button onclick="control('${device.device_id}', 'PREV_TRACK')">Prev</button>
<button onclick="control('${device.device_id}', 'NEXT_TRACK')">Next</button>
</div>
<div class="volume-container">
<span>Vol:</span>
<input id="vol-${device.device_id}" type="range" min="0" max="100"
oninput="onVolumeInput('${device.device_id}', this)"
onmousedown="startAdjust('${device.device_id}')" ontouchstart="startAdjust('${device.device_id}')"
onmouseup="endAdjust('${device.device_id}')" ontouchend="endAdjust('${device.device_id}')">
</div>
`;
container.appendChild(card);
updateNowPlaying(device.device_id);
updateVolume(device.device_id);
openDeviceWebSocket(device.device_id);
});
// Remove cards for devices that no longer exist
Array.from(container.children).forEach(child => {
const id = child.id?.replace('device-', '');
if (id && !seen.has(id)) {
container.removeChild(child);
}
});
} catch (error) {
console.error('Failed to fetch devices', error);
document.getElementById('device-list').innerHTML = '<p>Error loading devices.</p>';
}
}
async function updateNowPlaying(deviceId) {
try {
const response = await fetch(`/devices/${deviceId}/info`);
if (!response.ok) return;
const info = await response.json();
// Update device name and type if available (live info is more accurate than discovery)
const title = document.querySelector(`#device-${deviceId} .device-title`);
if (title && info.name) {
title.textContent = info.name;
}
const subtitle = document.querySelector(`#device-${deviceId} .device-subtitle span`);
if (subtitle && info.type) {
subtitle.textContent = `${info.ipAddress || info.ip_address || 'N/A'} | ${info.type}`;
}
// Update firmware version if available
const details = document.getElementById(`details-${deviceId}`);
if (details) {
if (info.deviceID) {
const idField = details.querySelector('p:nth-child(1) code');
if (idField) idField.textContent = info.deviceID;
}
if (info.softwareVersion) {
const firmwareField = details.querySelector('p:nth-child(2) code');
if (firmwareField) firmwareField.textContent = info.softwareVersion;
}
if (info.serialNumber) {
const serialField = details.querySelector('p:nth-child(3) code');
if (serialField) serialField.textContent = info.serialNumber;
}
}
const npContainer = document.getElementById(`np-${deviceId}`);
if (npContainer && info.nowPlaying) {
const np = info.nowPlaying;
const source = np.source || np.Source;
const powerIcon = document.querySelector(`#device-${deviceId} .power-icon`);
if (powerIcon) {
if (source === 'STANDBY') {
powerIcon.classList.add('off');
powerIcon.classList.remove('on');
} else {
powerIcon.classList.remove('off');
powerIcon.classList.add('on');
}
}
if (source === 'STANDBY') {
npContainer.innerHTML = '<div class="now-playing-info"><p><em>Standby</em></p></div>';
} else {
const track = np.track || np.Track || np.stationName || np.StationName || 'Unknown Track';
const artist = np.artist || np.Artist || 'Unknown Artist';
const album = np.album || np.Album || 'Unknown Album';
const art = np.Art || np.art || {};
const artStatus = art.ArtImageStatus || art.artImageStatus;
const artUrl = artStatus === 'IMAGE_PRESENT' ? (art.URL || art.url || '') : '';
npContainer.innerHTML = `
<img class="album-art" src="${artUrl}" alt="Artwork">
<div class="now-playing-info">
<strong>${track}</strong><br>
${artist} - ${album}
</div>
`;
}
}
} catch (error) {
console.warn('Failed to fetch now playing for ' + deviceId, error);
}
}
async function updateVolume(deviceId) {
try {
const response = await fetch(`/devices/${deviceId}/info`);
if (!response.ok) return;
const info = await response.json();
const slider = document.getElementById(`vol-${deviceId}`);
if (slider && info.volume && typeof info.volume.actualvolume === 'number' && !adjusting[deviceId]) {
slider.value = String(info.volume.actualvolume);
}
} catch (error) {
console.warn('Failed to fetch volume for ' + deviceId, error);
}
}
async function control(deviceId, key) {
let deviceName = deviceId;
const title = document.querySelector(`#device-${deviceId} .device-title`);
if (title && title.textContent) {
deviceName = title.textContent;
}
try {
const res = await fetch(`/devices/${encodeURIComponent(deviceId)}/key/${encodeURIComponent(key)}`, {
method: 'POST'
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
} catch (error) {
console.error('Control failed', error);
alert(`Failed to send ${key} to ${deviceName}: ${error.message}`);
}
}
async function setVolume(deviceId, level) {
let deviceName = deviceId;
const title = document.querySelector(`#device-${deviceId} .device-title`);
if (title && title.textContent) {
deviceName = title.textContent;
}
try {
const res = await fetch(`/devices/${encodeURIComponent(deviceId)}/volume/${encodeURIComponent(level)}`, {
method: 'POST'
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
} catch (error) {
console.error('Set volume failed', error);
alert(`Failed to set volume on ${deviceName} to ${level}: ${error.message}`);
}
}
// Volume interaction helpers to avoid UI jumping while dragging
const adjusting = {};
const volumeTimers = {};
function startAdjust(deviceId) {
adjusting[deviceId] = true;
}
function endAdjust(deviceId) {
// Small delay to let the device send back its volume update
setTimeout(() => { adjusting[deviceId] = false; }, 300);
}
function onVolumeInput(deviceId, el) {
startAdjust(deviceId);
const level = el.value;
// Debounce network calls per device
if (volumeTimers[deviceId]) {
clearTimeout(volumeTimers[deviceId]);
}
volumeTimers[deviceId] = setTimeout(() => {
setVolume(deviceId, level);
endAdjust(deviceId);
}, 150);
}
let deviceSockets = {};
function openDeviceWebSocket(deviceId) {
const key = `${deviceId}`;
try {
const existing = deviceSockets[key];
if (existing) {
// Reuse an already healthy connection instead of tearing it down every refresh
if (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING) {
return;
}
try { existing.close(); } catch (_) {}
}
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const wsUrl = `${proto}://${location.host}/devices/${encodeURIComponent(deviceId)}/ws`;
const ws = new WebSocket(wsUrl);
deviceSockets[key] = ws;
ws.onopen = () => {
// console.log('WS connected for', deviceId);
};
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
const type = msg.type;
const payload = msg.payload || {};
if (type === 'nowPlayingUpdated') {
const e = payload;
const np = e.NowPlaying || e.nowPlaying || {};
const source = np.source || np.Source;
// Also try to update name/type if they are present in the event (sometimes events carry device info)
const title = document.querySelector(`#device-${deviceId} .device-title`);
if (title && e.name) {
title.textContent = e.name;
}
const subtitle = document.querySelector(`#device-${deviceId} .device-subtitle span`);
if (subtitle && e.type) {
subtitle.textContent = `${e.ipAddress || e.ip_address || 'N/A'} | ${e.type}`;
}
const powerIcon = document.querySelector(`#device-${deviceId} .power-icon`);
if (powerIcon) {
if (source === 'STANDBY') {
powerIcon.classList.add('off');
powerIcon.classList.remove('on');
} else {
powerIcon.classList.remove('off');
powerIcon.classList.add('on');
}
}
const npContainer = document.getElementById(`np-${deviceId}`);
if (npContainer) {
if (source === 'STANDBY') {
npContainer.innerHTML = '<div class="now-playing-info"><p><em>Standby</em></p></div>';
} else {
const track = np.track || np.Track || np.stationName || np.StationName || 'Unknown Track';
const artist = np.artist || np.Artist || 'Unknown Artist';
const album = np.album || np.Album || 'Unknown Album';
const art = np.Art || np.art || {};
const artStatus = art.ArtImageStatus || art.artImageStatus;
const artUrl = artStatus === 'IMAGE_PRESENT' ? (art.URL || art.url || '') : '';
npContainer.innerHTML = `
<img class="album-art" src="${artUrl}" alt="Artwork">
<div class="now-playing-info">
<strong>${track}</strong><br>
${artist} - ${album}
</div>
`;
}
}
} else if (type === 'volumeUpdated') {
const e = payload;
const vol = (e.Volume && (typeof e.Volume.actualvolume === 'number' ? e.Volume.actualvolume : (typeof e.Volume.actual === 'number' ? e.Volume.actual : e.Volume.target))) ||
(e.volume && (typeof e.volume.actualvolume === 'number' ? e.volume.actualvolume : (typeof e.volume.actual === 'number' ? e.volume.actual : e.volume.target)));
const slider = document.getElementById(`vol-${deviceId}`);
if (slider && typeof vol === 'number' && !adjusting[deviceId]) {
slider.value = String(vol);
}
} else if (type === 'snapshotInfo') {
const info = payload || {};
// Update name and type from snapshot
const title = document.querySelector(`#device-${deviceId} .device-title`);
if (title && info.name) {
title.textContent = info.name;
}
const subtitle = document.querySelector(`#device-${deviceId} .device-subtitle span`);
if (subtitle && info.type) {
subtitle.textContent = `${info.ipAddress || info.ip_address || 'N/A'} | ${info.type}`;
}
// Update firmware and ID from snapshot if available
const details = document.getElementById(`details-${deviceId}`);
if (details) {
if (info.deviceID) {
const idField = details.querySelector('p:nth-child(1) code');
if (idField) idField.textContent = info.deviceID;
}
if (info.softwareVersion) {
const firmwareField = details.querySelector('p:nth-child(2) code');
if (firmwareField) firmwareField.textContent = info.softwareVersion;
}
if (info.serialNumber) {
const serialField = details.querySelector('p:nth-child(3) code');
if (serialField) serialField.textContent = info.serialNumber;
}
}
if (info.nowPlaying) {
const np = info.nowPlaying;
const source = np.source || np.Source;
const powerIcon = document.querySelector(`#device-${deviceId} .power-icon`);
if (powerIcon) {
if (source === 'STANDBY') {
powerIcon.classList.add('off');
powerIcon.classList.remove('on');
} else {
powerIcon.classList.remove('off');
powerIcon.classList.add('on');
}
}
const npContainer = document.getElementById(`np-${deviceId}`);
if (npContainer) {
if (source === 'STANDBY') {
npContainer.innerHTML = '<div class="now-playing-info"><p><em>Standby</em></p></div>';
} else {
const track = np.track || np.Track || np.stationName || np.StationName || 'Unknown Track';
const artist = np.artist || np.Artist || 'Unknown Artist';
const album = np.album || np.Album || 'Unknown Album';
const art = np.Art || np.art || {};
const artStatus = art.ArtImageStatus || art.artImageStatus;
const artUrl = artStatus === 'IMAGE_PRESENT' ? (art.URL || art.url || '') : '';
npContainer.innerHTML = `
<img class="album-art" src="${artUrl}" alt="Artwork">
<div class="now-playing-info">
<strong>${track}</strong><br>
${artist} - ${album}
</div>
`;
}
}
}
const vol = info.actualVolume || (info.volume && (typeof info.volume.actualvolume === 'number' ? info.volume.actualvolume : (typeof info.volume.actual === 'number' ? info.volume.actual : null)));
const slider = document.getElementById(`vol-${deviceId}`);
if (slider && typeof vol === 'number' && !adjusting[deviceId]) slider.value = String(vol);
}
} catch (err) {
// console.warn('Bad WS message', err);
}
};
ws.onerror = () => {
// console.warn('WS error for', ip);
};
ws.onclose = () => {
// Try to reconnect after a delay
setTimeout(() => {
if (deviceSockets[key] === ws) {
delete deviceSockets[key];
}
openDeviceWebSocket(deviceId);
}, 3000);
};
} catch (e) {
// console.warn('Failed to open WS for', deviceId, e);
}
}
function toggleDetails(deviceId) {
const el = document.getElementById(`details-${deviceId}`);
if (el) {
el.classList.toggle('visible');
}
}
document.addEventListener('DOMContentLoaded', () => {
fetchDevices();
fetchVersion();
setInterval(fetchDevices, 30000);
});
Binary file not shown.
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Stockholm Mini - Reverse Engineered</title>
<link rel="stylesheet" href="../shared/common.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Stockholm Mini</h1>
<p style="margin-top: -10px; font-style: italic; color: #666; font-size: 0.9rem;">A minimal reverse-engineered SoundTouch controller.</p>
<p style="margin-bottom: 20px;"><a href="/" style="color: #00bcd4; text-decoration: none; font-size: 0.9rem;">&larr; Back to selection</a></p>
<div id="device-list"></div>
</div>
<footer style="margin-top: 50px;">
<span id="version-info">AfterTouch</span>
</footer>
<script src="../shared/common.js"></script>
<script src="app.js"></script>
</body>
</html>
@@ -0,0 +1,40 @@
@font-face {
font-family: 'bose';
src: url('bose.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #121212; color: #e0e0e0; margin: 0; padding: 20px; }
.container { max-width: 800px; margin: 0 auto; }
h1 { color: #fff; border-bottom: 1px solid #333; padding-bottom: 10px; }
.device-card { background: #1e1e1e; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); }
.device-info h2 { margin-top: 0; color: #00bcd4; margin-bottom: 0; }
.device-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.device-title-row { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; }
.device-title { margin: 0; font-size: 1.5rem; line-height: 1.2; }
.device-subtitle { color: #888; font-size: 0.85rem; margin: 0; display: flex; align-items: center; }
.info-toggle { background: none; color: #555; padding: 0; width: 1.15rem; height: 1.15rem; display: inline-flex; align-items: center; justify-content: center; border: 1px solid #444; border-radius: 50%; font-size: 0.7rem; font-style: italic; cursor: pointer; line-height: 1; transition: all 0.2s; flex-shrink: 0; }
.info-toggle:hover { color: #aaa; border-color: #666; background: #2a2a2a; }
.device-details { display: none; margin-top: 10px; font-size: 0.8rem; background: #252525; padding: 10px; border-radius: 4px; color: #aaa; border-left: 2px solid #00bcd4; }
.device-details.visible { display: block; }
.device-details p { margin: 4px 0; }
.device-details code { color: #ccc; }
.controls { display: flex; gap: 10px; margin-top: 20px; }
button { background: #333; color: #fff; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; transition: background 0.2s; }
button:hover { background: #444; }
button.primary { background: #00bcd4; color: #000; font-weight: bold; }
button.primary:hover { background: #00acc1; }
.power-icon { font-family: bose, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 1.25rem; line-height: 1; height: 2.25rem; width: 2.25rem; padding: 0; display: inline-flex; align-items: center; justify-content: center; background: #2a2a2a; border-radius: 50%; color: #00bcd4; border: 1px solid #00bcd4; }
.power-icon:hover { background: #3a3a3a; }
.power-icon.off { color: #666; border-color: #444; background: #1a1a1a; }
.power-icon.on { background: #00bcd4; color: #000; border-color: #00bcd4; }
.power-icon.on:hover { background: #00acc1; }
.status-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.8em; background: #333; margin-left: 10px; }
.now-playing { margin-top: 20px; padding-top: 20px; border-top: 1px solid #333; display: flex; gap: 15px; align-items: center; min-height: 80px; }
.now-playing-info { flex-grow: 1; }
.album-art { width: 80px; height: 80px; border-radius: 4px; background: #2a2a2a; flex-shrink: 0; object-fit: cover; box-shadow: 0 2px 4px rgba(0,0,0,0.5); }
.album-art[src=""] { display: none; }
.volume-container { margin-top: 15px; display: flex; align-items: center; gap: 10px; }
input[type=range] { flex-grow: 1; }
#device-list:empty::after { content: "Searching for devices..."; color: #666; font-style: italic; }
+1 -1
View File
@@ -106,7 +106,7 @@ func formatHeaders(h http.Header, redact bool) string {
val = "[REDACTED]"
}
sb.WriteString(fmt.Sprintf(" %s: %s\n", k, val))
fmt.Fprintf(&sb, " %s: %s\n", k, val)
}
return strings.TrimSuffix(sb.String(), "\n")
+68
View File
@@ -1,7 +1,9 @@
package proxy
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
@@ -579,3 +581,69 @@ func (r *Recorder) GetInteractionContent(relPath string) ([]byte, error) {
fullPath := filepath.Join(r.BaseDir, "interactions", relPath)
return os.ReadFile(fullPath)
}
// ArchiveSession creates a .tar.gz archive of the specified session and writes it to w.
func (r *Recorder) ArchiveSession(sessionID string, w io.Writer) (err error) {
sessionDir := filepath.Join(r.BaseDir, "interactions", sessionID)
info, statErr := os.Stat(sessionDir)
if statErr != nil {
return statErr
}
if !info.IsDir() {
return fmt.Errorf("%s is not a directory", sessionID)
}
gw := gzip.NewWriter(w)
defer func() {
if closeErr := gw.Close(); closeErr != nil && err == nil {
err = closeErr
}
}()
tw := tar.NewWriter(gw)
defer func() {
if closeErr := tw.Close(); closeErr != nil && err == nil {
err = closeErr
}
}()
return filepath.Walk(sessionDir, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
header, hErr := tar.FileInfoHeader(info, info.Name())
if hErr != nil {
return hErr
}
rel, rErr := filepath.Rel(sessionDir, path)
if rErr != nil {
return rErr
}
header.Name = rel
if whErr := tw.WriteHeader(header); whErr != nil {
return whErr
}
if !info.Mode().IsRegular() {
return nil
}
f, oErr := os.Open(path)
if oErr != nil {
return oErr
}
defer f.Close()
_, cErr := io.Copy(tw, f)
return cErr
})
}
+133
View File
@@ -0,0 +1,133 @@
package proxy
import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRecorder_Redaction(t *testing.T) {
// Disable async for testing
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "recorder-redact-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
r.Redact = true // Enable redaction
req := httptest.NewRequest("GET", "http://example.com/api/test", nil)
req.Header.Set("Authorization", "Bearer sensitive-token")
req.Header.Set("X-Custom", "safe-value")
w := httptest.NewRecorder()
w.Header().Set("X-Bose-Token", "sensitive-bose-token")
w.Header().Set("Content-Type", "text/plain")
_, _ = w.WriteString("hello")
res := w.Result()
res.Request = req
err = r.Record("test", req, res)
if err != nil {
t.Fatalf("Failed to record: %v", err)
}
// Find the recorded file
var recordedFile string
err = filepath.Walk(tmpDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(path, ".http") {
recordedFile = path
}
return nil
})
if err != nil {
t.Fatalf("Error walking temp dir: %v", err)
}
if recordedFile == "" {
t.Fatal("No recorded .http file found")
}
content, err := os.ReadFile(recordedFile)
if err != nil {
t.Fatalf("Failed to read recorded file: %v", err)
}
contentStr := string(content)
// Check for redaction in request headers
if strings.Contains(contentStr, "sensitive-token") {
t.Errorf("Recorded file contains sensitive Authorization header value:\n%s", contentStr)
}
if !strings.Contains(contentStr, "Authorization: [REDACTED]") {
t.Errorf("Recorded file does not contain redacted Authorization header:\n%s", contentStr)
}
// Check for redaction in response headers
if strings.Contains(contentStr, "sensitive-bose-token") {
t.Errorf("Recorded file contains sensitive X-Bose-Token header value:\n%s", contentStr)
}
if !strings.Contains(contentStr, "X-Bose-Token: [REDACTED]") {
t.Errorf("Recorded file does not contain redacted X-Bose-Token header:\n%s", contentStr)
}
// Check that non-sensitive headers are NOT redacted
if !strings.Contains(contentStr, "X-Custom: safe-value") {
t.Errorf("Recorded file missing non-sensitive header or it was incorrectly redacted:\n%s", contentStr)
}
}
func TestRecorder_NoRedaction(t *testing.T) {
// Disable async for testing
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "recorder-no-redact-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
r.Redact = false // Disable redaction
req := httptest.NewRequest("GET", "http://example.com/api/test", nil)
req.Header.Set("Authorization", "Bearer sensitive-token")
w := httptest.NewRecorder()
w.Header().Set("X-Bose-Token", "sensitive-bose-token")
_, _ = w.WriteString("hello")
res := w.Result()
res.Request = req
err = r.Record("test", req, res)
if err != nil {
t.Fatalf("Failed to record: %v", err)
}
// Find the recorded file
var recordedFile string
filepath.Walk(tmpDir, func(path string, info os.FileInfo, err error) error {
if !info.IsDir() && strings.HasSuffix(path, ".http") {
recordedFile = path
}
return nil
})
content, _ := os.ReadFile(recordedFile)
contentStr := string(content)
if !strings.Contains(contentStr, "Bearer sensitive-token") {
t.Errorf("Recorded file should contain sensitive Authorization header when Redact=false:\n%s", contentStr)
}
if !strings.Contains(contentStr, "sensitive-bose-token") {
t.Errorf("Recorded file should contain sensitive X-Bose-Token header when Redact=false:\n%s", contentStr)
}
}
+60
View File
@@ -1,7 +1,9 @@
package proxy
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
@@ -627,6 +629,64 @@ func TestRecorder_GetInteractionContent(t *testing.T) {
}
}
func TestRecorder_ArchiveSession(t *testing.T) {
tmpDir, _ := os.MkdirTemp("", "archive-test")
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
sessionID := "test-session-123"
sessionDir := filepath.Join(tmpDir, "interactions", sessionID)
os.MkdirAll(filepath.Join(sessionDir, "category1"), 0755)
os.WriteFile(filepath.Join(sessionDir, "category1", "file1.http"), []byte("content1"), 0644)
os.WriteFile(filepath.Join(sessionDir, "file2.http"), []byte("content2"), 0644)
var buf bytes.Buffer
err := r.ArchiveSession(sessionID, &buf)
if err != nil {
t.Fatalf("ArchiveSession failed: %v", err)
}
if buf.Len() == 0 {
t.Fatal("Archive buffer is empty")
}
// Verify it's a valid tar.gz
gr, err := gzip.NewReader(&buf)
if err != nil {
t.Fatalf("Failed to create gzip reader: %v", err)
}
defer gr.Close()
tr := tar.NewReader(gr)
files := make(map[string]string)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("Failed to read tar: %v", err)
}
if header.Typeflag == tar.TypeReg {
var b bytes.Buffer
io.Copy(&b, tr)
files[header.Name] = b.String()
}
}
if len(files) != 2 {
t.Errorf("Expected 2 files in archive, got %d", len(files))
}
if files["category1/file1.http"] != "content1" {
t.Errorf("Unexpected content for category1/file1.http: %s", files["category1/file1.http"])
}
if files["file2.http"] != "content2" {
t.Errorf("Unexpected content for file2.http: %s", files["file2.http"])
}
}
func TestRecorder_Record_FullExchange(t *testing.T) {
t.Setenv("RECORDER_ASYNC", "false")
tmpDir, err := os.MkdirTemp("", "recorder-full-test")
+587 -20
View File
@@ -12,6 +12,7 @@ import (
"strconv"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
@@ -27,6 +28,8 @@ const (
MigrationMethodXML MigrationMethod = "xml"
// MigrationMethodHosts redirects services by modifying /etc/hosts and updating the CA trust store.
MigrationMethodHosts MigrationMethod = "hosts"
// MigrationMethodResolvConf redirects services by injecting a priority DNS hook into the DHCP logic and updating the CA trust store.
MigrationMethodResolvConf MigrationMethod = "resolv"
)
// SoundTouchSdkPrivateCfgPath is the path to the speaker's private configuration file on device.
@@ -64,6 +67,8 @@ type MigrationSummary struct {
FirmwareVersion string `json:"firmware_version,omitempty"`
CACertTrusted bool `json:"ca_cert_trusted"`
ServerHTTPSURL string `json:"server_https_url,omitempty"`
CurrentResolvConf string `json:"current_resolv_conf,omitempty"`
PlannedResolv string `json:"planned_resolv,omitempty"`
IsMigrated bool `json:"is_migrated"`
}
@@ -79,6 +84,9 @@ type Manager struct {
DataStore *datastore.DataStore
Crypto *certmanager.CertificateManager
NewSSH func(host string) SSHClient
// GetDNSRunning is an optional callback to check the actual state of the DNS server.
GetDNSRunning func() (bool, string)
}
// NewManager creates a new Manager with the given base server URL.
@@ -108,6 +116,10 @@ type DeviceInfoXML struct {
SoftwareVersion string `xml:"softwareVersion"`
SerialNumber string `xml:"serialNumber"`
} `xml:"components>component" json:"-"`
// Enriched fields (not part of device /info XML)
NowPlaying *models.NowPlaying `json:"nowPlaying,omitempty"`
Volume *models.Volume `json:"volume,omitempty"`
}
// GetLiveDeviceInfo fetches live information from the speaker's :8090/info endpoint.
@@ -145,6 +157,16 @@ func (m *Manager) GetLiveDeviceInfo(deviceIP string) (*DeviceInfoXML, error) {
}
}
// Enrich with live now playing and volume via device API (best-effort)
c := client.NewClientFromHost(deviceIP)
if vol, err := c.GetVolume(); err == nil {
infoXML.Volume = vol
}
if np, err := c.GetNowPlaying(); err == nil {
infoXML.NowPlaying = np
}
return &infoXML, nil
}
@@ -209,6 +231,10 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
if hostName != "" && hostName != "localhost" {
client := m.NewSSH(deviceIP)
hostIP := m.resolveIP(hostName, client)
// Predicted aftertouch.resolv.conf
summary.PlannedResolv = fmt.Sprintf("# Created by Aftertouch/SoundTouch-Service\n# Priority nameserver for Bose service redirection\nnameserver %s\n", hostIP)
domains := []string{
"streaming.bose.com",
"updates.bose.com",
@@ -218,6 +244,7 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
"events.api.bosecm.com",
"bose-prod.apigee.net",
"worldwide.bose.com",
"music.api.bose.com",
}
var hostsLines []string
@@ -235,6 +262,14 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
// 4. Check if CA certificate is trusted
m.checkCACertTrusted(summary, deviceIP)
// 4b. Check current /etc/resolv.conf
if summary.SSHSuccess {
client := m.NewSSH(deviceIP)
if resolvConf, err := client.Run("cat /etc/resolv.conf"); err == nil {
summary.CurrentResolvConf = resolvConf
}
}
// 5. Provide HTTPS URL for testing
if parsedURL, err := url.Parse(targetURL); err == nil {
hostIP := parsedURL.Hostname()
@@ -301,6 +336,33 @@ func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) {
}
}
}
// 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
}
}
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
}
}
}
}
}
}
// populateDeviceInfo fills in device information from datastore and live info
@@ -522,10 +584,62 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
logs += "Pre-flight: Write access verified.\n"
if method == MigrationMethodHosts {
switch method {
case MigrationMethodHosts:
out, err := m.migrateViaHosts(deviceIP, targetURL)
return logs + out, err
case MigrationMethodResolvConf:
if err := m.checkDNSPreFlight(); err != nil {
return logs, err
}
out, err := m.migrateViaResolvConf(deviceIP, targetURL)
return logs + out, err
case MigrationMethodXML:
out, err := m.migrateViaXML(deviceIP, targetURL, proxyURL, options, client, rwCmd)
return logs + out, err
default:
return logs, fmt.Errorf("unsupported migration method: %s", method)
}
}
func (m *Manager) checkDNSPreFlight() error {
// Pre-flight check: DNS server must be enabled and bound to port 53
settings, err := m.DataStore.GetSettings()
if err != nil {
return fmt.Errorf("failed to retrieve settings: %w", err)
}
if !settings.DNSEnabled {
return fmt.Errorf("DNS discovery server is not enabled. Please enable it in Settings before using /etc/resolv.conf migration")
}
if !strings.HasSuffix(settings.DNSBindAddr, ":53") && settings.DNSBindAddr != "53" {
return fmt.Errorf("DNS discovery server is bound to %s, but port 53 is required for /etc/resolv.conf migration", settings.DNSBindAddr)
}
// Also check the actual running state if callback is available
if m.GetDNSRunning != nil {
isRunning, bindAddr := m.GetDNSRunning()
if !isRunning {
return fmt.Errorf("DNS discovery server is configured but not actually running on %s. Please check logs for binding errors", bindAddr)
}
if !strings.HasSuffix(bindAddr, ":53") && bindAddr != "53" {
// This shouldn't happen based on previous check, but for completeness
return fmt.Errorf("DNS discovery server is running on %s, but port 53 is required", bindAddr)
}
}
return nil
}
func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options map[string]string, client SSHClient, rwCmd string) (string, error) {
var logs string
out, err := m.EnsureRemoteServices(deviceIP)
logs += "Ensuring remote services:\n" + out + "\n"
@@ -614,6 +728,17 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
logs += "Uploaded new configuration to " + remotePath + "\n"
// 2. Verify the configuration on device
if verification, err := client.Run(fmt.Sprintf("cat %s", remotePath)); err == nil {
if !strings.Contains(verification, cfg.MargeServerUrl) {
return logs, fmt.Errorf("verification failed: uploaded config on %s does not contain expected margeServerUrl", deviceIP)
}
logs += "Verified configuration on device\n"
} else {
logs += fmt.Sprintf("Warning: could not verify configuration on device: %v\n", err)
}
return logs, nil
}
@@ -873,7 +998,54 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) {
return logs, fmt.Errorf("failed to read /etc/hosts: %w", err)
}
lines := strings.Split(hostsContent, "\n")
hostsContent = m.generateHostsContent(hostsContent, domains, hostIP)
// 3. Upload new /etc/hosts
out, _ := client.Run(rwCmd)
logs += rwCmd + ": " + out + "\n"
// Backup /etc/hosts if it doesn't exist
if _, err := client.Run("[ -f /etc/hosts.original ]"); err != nil {
out, _ := client.Run("cp /etc/hosts /etc/hosts.original")
logs += "cp /etc/hosts /etc/hosts.original: " + out + "\n"
}
if err := client.UploadContent([]byte(hostsContent), "/etc/hosts"); err != nil {
return logs, fmt.Errorf("failed to update /etc/hosts: %w", err)
}
logs += "Uploaded updated /etc/hosts\n"
// 4. Verify /etc/hosts on device
if err := m.verifyHosts(client, domains, hostIP, deviceIP); err != nil {
return logs, err
}
logs += "Verified /etc/hosts on device\n"
fmt.Printf("Updated /etc/hosts on %s:\n%s\n", deviceIP, hostsContent)
// 5. Inject CA Certificate
summary := &MigrationSummary{}
m.checkCACertTrusted(summary, deviceIP)
if !summary.CACertTrusted {
out, err := m.TrustCACert(deviceIP)
logs += "Trusting CA:\n" + out + "\n"
if err != nil {
return logs, err
}
} else {
logs += "CA certificate already trusted, skipping injection\n"
fmt.Printf("CA certificate already trusted on %s, skipping injection\n", deviceIP)
}
return logs, nil
}
func (m *Manager) generateHostsContent(currentContent string, domains []string, hostIP string) string {
lines := strings.Split(currentContent, "\n")
var newLines []string
@@ -917,29 +1089,95 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) {
}
}
hostsContent = strings.Join(newLines, "\n")
hostsContent := strings.Join(newLines, "\n")
if !strings.HasSuffix(hostsContent, "\n") {
hostsContent += "\n"
}
// 3. Upload new /etc/hosts
out, _ := client.Run(rwCmd)
logs += rwCmd + ": " + out + "\n"
// Backup /etc/hosts if it doesn't exist
if _, err := client.Run("[ -f /etc/hosts.original ]"); err != nil {
out, _ := client.Run("cp /etc/hosts /etc/hosts.original")
logs += "cp /etc/hosts /etc/hosts.original: " + out + "\n"
return hostsContent
}
func (m *Manager) verifyHosts(client SSHClient, domains []string, hostIP, deviceIP string) error {
verification, err := client.Run("cat /etc/hosts")
if err != nil {
return fmt.Errorf("could not verify /etc/hosts on device: %w", err)
}
if err := client.UploadContent([]byte(hostsContent), "/etc/hosts"); err != nil {
return logs, fmt.Errorf("failed to update /etc/hosts: %w", err)
for _, domain := range domains {
if !strings.Contains(verification, domain) || !strings.Contains(verification, hostIP) {
return fmt.Errorf("verification failed: /etc/hosts on %s does not contain expected redirection for %s", deviceIP, domain)
}
}
logs += "Uploaded updated /etc/hosts\n"
return nil
}
fmt.Printf("Updated /etc/hosts on %s:\n%s\n", deviceIP, hostsContent)
func (m *Manager) migrateViaResolvConf(deviceIP, targetURL string) (string, error) {
client := m.NewSSH(deviceIP)
rwCmd := "(rw || mount -o remount,rw /)"
// 4. Inject CA Certificate
var logs string
// 1. Resolve target hostname to IP
parsedURL, err := url.Parse(targetURL)
if err != nil {
return "", fmt.Errorf("failed to parse target URL: %w", err)
}
hostName := parsedURL.Hostname()
if hostName == "" || hostName == "localhost" {
return "", fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
}
hostIP := m.resolveIP(hostName, client)
logs += fmt.Sprintf("Resolved %s to %s\n", hostName, hostIP)
// 2. Prepare /mnt/nv/aftertouch.resolv.conf content
resolvContent := fmt.Sprintf("# Created by Aftertouch/SoundTouch-Service\n# Priority nameserver for Bose service redirection\nnameserver %s\n", hostIP)
// 3. Upload /mnt/nv/aftertouch.resolv.conf
// Ensure /mnt/nv exists
_, _ = client.Run("mkdir -p /mnt/nv")
if uploadErr := client.UploadContent([]byte(resolvContent), "/mnt/nv/aftertouch.resolv.conf"); uploadErr != nil {
return logs, fmt.Errorf("failed to upload /mnt/nv/aftertouch.resolv.conf: %w", uploadErr)
}
logs += "Uploaded /mnt/nv/aftertouch.resolv.conf\n"
// 4. Update /mnt/nv/rc.local with idempotent patch
patchOut, err := m.updateRcLocalWithDNSHook(client)
logs += patchOut
if err != nil {
return logs, err
}
// 5. Apply patch immediately to /etc/udhcpc.d/50default
rwOut, _ := client.Run(rwCmd)
logs += rwCmd + ": " + rwOut + "\n"
hookMarker := "/mnt/nv/aftertouch.resolv.conf"
targetDHCPFile := "/etc/udhcpc.d/50default"
dhcpPatchOut, err := m.patchDHCPFile(client, targetDHCPFile, hookMarker)
logs += dhcpPatchOut
if err != nil {
logs += fmt.Sprintf("Warning: could not apply/verify patch on %s: %v\n", targetDHCPFile, err)
}
// Apply patch immediately to /opt/Bose/udhcpc.script if it exists
targetScript := "/opt/Bose/udhcpc.script"
if _, err := client.Run(fmt.Sprintf("[ -f %s ]", targetScript)); err == nil {
scriptPatchOut, err := m.patchUdhcpcScript(client, targetScript, hookMarker)
logs += scriptPatchOut
if err != nil {
logs += fmt.Sprintf("Warning: could not apply/verify patch on %s: %v\n", targetScript, err)
}
}
// 6. Inject CA Certificate
summary := &MigrationSummary{}
m.checkCACertTrusted(summary, deviceIP)
@@ -952,8 +1190,125 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) {
}
} else {
logs += "CA certificate already trusted, skipping injection\n"
}
fmt.Printf("CA certificate already trusted on %s, skipping injection\n", deviceIP)
return logs, nil
}
func (m *Manager) updateRcLocalWithDNSHook(client SSHClient) (string, error) {
var logs string
rcLocalPath := "/mnt/nv/rc.local"
targetDHCPFile := "/etc/udhcpc.d/50default"
hookMarker := "/mnt/nv/aftertouch.resolv.conf"
// Check if rc.local exists and read it
currentRcLocal, rcErr := client.Run(fmt.Sprintf("cat %s", rcLocalPath))
if rcErr != nil {
currentRcLocal = ""
}
if strings.Contains(currentRcLocal, hookMarker) {
return fmt.Sprintf("%s already contains Aftertouch hook logic\n", rcLocalPath), nil
}
patchLogic := fmt.Sprintf(`
# Aftertouch DNS hook: prioritizes our custom nameserver if it exists
if [ -f "%s" ]; then
if [ -f "%s" ] && ! grep -q "%s" "%s"; then
logger -t "aftertouch" "Patching %s with Aftertouch DNS hook"
sed -i '/echo "search \$domain"/a \ [ -f '"%s"' ] && cat '"%s"' && dns=""' "%s"
fi
targetScript="/opt/Bose/udhcpc.script"
if [ -f "$targetScript" ] && ! grep -q "%s" "$targetScript"; then
logger -t "aftertouch" "Patching $targetScript with Aftertouch DNS hook"
sed -i '/echo "search \$search_list # \$interface" >> \$RESOLV_CONF/a \ [ -f '"%s"' ] && cat '"%s"' >> '"\$RESOLV_CONF"' && dns=""' "$targetScript"
fi
fi
`, hookMarker, targetDHCPFile, hookMarker, targetDHCPFile, targetDHCPFile, hookMarker, hookMarker, targetDHCPFile, hookMarker, hookMarker, hookMarker)
newRcLocal := currentRcLocal
// Remove "cat: can't open..." error message if it was accidentally saved in the file
if strings.Contains(newRcLocal, "cat: can't open") {
newRcLocal = ""
}
if !strings.HasPrefix(newRcLocal, "#!/bin/sh") {
newRcLocal = "#!/bin/sh\n" + strings.TrimPrefix(newRcLocal, "#!/bin/sh")
}
if !strings.HasSuffix(newRcLocal, "\n") {
newRcLocal += "\n"
}
newRcLocal += patchLogic
if err := client.UploadContent([]byte(newRcLocal), rcLocalPath); err != nil {
return logs, fmt.Errorf("failed to update %s: %w", rcLocalPath, err)
}
logs += fmt.Sprintf("Updated %s with DNS hook logic\n", rcLocalPath)
// Make it executable
_, _ = client.Run(fmt.Sprintf("chmod +x %s", rcLocalPath))
return logs, nil
}
func (m *Manager) patchDHCPFile(client SSHClient, targetDHCPFile, hookMarker string) (string, error) {
var logs string
// Backup if it doesn't exist
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetDHCPFile)); err != nil {
out, _ := client.Run(fmt.Sprintf("cp %s %s.original", targetDHCPFile, targetDHCPFile))
logs += fmt.Sprintf("cp %s %s.original: %s\n", targetDHCPFile, targetDHCPFile, out)
} else {
// If backup exists, revert to it first to ensure we start from a clean state
_, _ = client.Run(fmt.Sprintf("cp %s.original %s", targetDHCPFile, targetDHCPFile))
}
// Run the patch logic via SSH to apply it now
patchCmd := fmt.Sprintf("sed -i '/echo \"search \\$domain\"/a \\ [ -f '\"%s\"' ] && cat '\"%s\"' && dns=\"\"' %s", hookMarker, hookMarker, targetDHCPFile)
if _, err := client.Run(patchCmd); err != nil {
return logs, fmt.Errorf("failed to apply patch immediately to %s: %w", targetDHCPFile, err)
}
logs += fmt.Sprintf("Applied patch to %s\n", targetDHCPFile)
// Verify patch on 50default
if verification, err := client.Run(fmt.Sprintf("grep -q \"%s\" %s && echo \"OK\"", hookMarker, targetDHCPFile)); err == nil && strings.TrimSpace(verification) == "OK" {
logs += fmt.Sprintf("Verified patch on %s\n", targetDHCPFile)
} else {
return logs, fmt.Errorf("could not verify patch on %s: %w", targetDHCPFile, err)
}
return logs, nil
}
func (m *Manager) patchUdhcpcScript(client SSHClient, targetScript, hookMarker string) (string, error) {
var logs string
// Backup if it doesn't exist
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetScript)); err != nil {
out, _ := client.Run(fmt.Sprintf("cp %s %s.original", targetScript, targetScript))
logs += fmt.Sprintf("cp %s %s.original: %s\n", targetScript, targetScript, out)
} else {
// If backup exists, revert to it first to ensure we start from a clean state
_, _ = client.Run(fmt.Sprintf("cp %s.original %s", targetScript, targetScript))
}
patchCmdScript := fmt.Sprintf("sed -i '/echo \"search \\$search_list # \\$interface\" >> \\$RESOLV_CONF/a \\ [ -f '\"%s\"' ] && cat '\"%s\"' >> '\"\\$RESOLV_CONF\"' && dns=\"\"' %s", hookMarker, hookMarker, targetScript)
if _, err := client.Run(patchCmdScript); err != nil {
return logs, fmt.Errorf("failed to apply patch immediately to %s: %w", targetScript, err)
}
logs += fmt.Sprintf("Applied patch to %s\n", targetScript)
// Verify patch on udhcpc.script
if verification, err := client.Run(fmt.Sprintf("grep -q \"%s\" %s && echo \"OK\"", hookMarker, targetScript)); err == nil && strings.TrimSpace(verification) == "OK" {
logs += fmt.Sprintf("Verified patch on %s\n", targetScript)
} else {
return logs, fmt.Errorf("could not verify patch on %s: %w", targetScript, err)
}
return logs, nil
@@ -967,6 +1322,31 @@ func (m *Manager) RevertMigration(deviceIP string) (string, error) {
var logs string
// 1. Revert SoundTouchSdkPrivateCfg.xml
out, err := m.revertXMLConfig(client, rwCmd)
logs += out
if err != nil {
return logs, err
}
// 2. Revert /etc/hosts
logs += m.revertHosts(client, rwCmd)
// 2b. Revert /etc/resolv.conf
logs += m.revertResolvConf(client, rwCmd)
// 2c. Revert Aftertouch DNS Hook
logs += m.revertAftertouchHook(client, rwCmd)
// 3. Remove CA certificate from trust store if it exists
logs += m.revertCACert(client, rwCmd)
return logs, nil
}
func (m *Manager) revertXMLConfig(client SSHClient, rwCmd string) (string, error) {
var logs string
remotePath := SoundTouchSdkPrivateCfgPath
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err == nil {
logs += fmt.Sprintf("Reverting %s from backup\n", remotePath)
@@ -981,7 +1361,12 @@ func (m *Manager) RevertMigration(deviceIP string) (string, error) {
return logs, fmt.Errorf("backup %s.original not found, cannot revert", remotePath)
}
// 2. Revert /etc/hosts
return logs, nil
}
func (m *Manager) revertHosts(client SSHClient, rwCmd string) string {
var logs string
hostsPath := "/etc/hosts"
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", hostsPath)); err == nil {
logs += fmt.Sprintf("Reverting %s from backup\n", hostsPath)
@@ -990,12 +1375,120 @@ func (m *Manager) RevertMigration(deviceIP string) (string, error) {
logs += fmt.Sprintf("cp %s.original %s: %s\n", hostsPath, hostsPath, out)
if err != nil {
// Don't return error here, try to continue with other reverts
fmt.Printf("Warning: failed to revert %s: %v\n", hostsPath, err)
}
}
// 3. Remove CA certificate from trust store if it exists
return logs
}
func (m *Manager) revertResolvConf(client SSHClient, rwCmd string) string {
var logs string
resolvPath := "/etc/resolv.conf"
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", resolvPath)); err == nil {
logs += fmt.Sprintf("Reverting %s from backup\n", resolvPath)
fmt.Printf("Reverting %s from backup\n", resolvPath)
// Try to remove immutable flag if it was set
_, _ = client.Run(fmt.Sprintf("chattr -i %s", resolvPath))
out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, resolvPath, resolvPath))
logs += fmt.Sprintf("cp %s.original %s: %s\n", resolvPath, resolvPath, out)
if err != nil {
fmt.Printf("Warning: failed to revert %s: %v\n", resolvPath, err)
}
}
return logs
}
func (m *Manager) revertAftertouchHook(client SSHClient, rwCmd string) string {
var logs string
aftertouchConfPath := "/mnt/nv/aftertouch.resolv.conf"
rcLocalPath := "/mnt/nv/rc.local"
targetDHCPFile := "/etc/udhcpc.d/50default"
if _, err := client.Run(fmt.Sprintf("[ -f %s ]", aftertouchConfPath)); err == nil {
logs += fmt.Sprintf("Removing %s\n", aftertouchConfPath)
fmt.Printf("Removing %s\n", aftertouchConfPath)
_, _ = client.Run(fmt.Sprintf("rm %s", aftertouchConfPath))
}
if currentRcLocal, err := client.Run(fmt.Sprintf("cat %s", rcLocalPath)); err == nil {
// Remove "cat: can't open..." error message if it was accidentally saved in the file
if strings.Contains(currentRcLocal, "cat: can't open") {
logs += fmt.Sprintf("Removing corrupted %s\n", rcLocalPath)
_, _ = client.Run(fmt.Sprintf("rm %s", rcLocalPath))
return logs
}
if strings.Contains(currentRcLocal, aftertouchConfPath) || strings.Contains(currentRcLocal, "# Aftertouch DNS hook") {
logs += fmt.Sprintf("Removing Aftertouch hook logic from %s\n", rcLocalPath)
fmt.Printf("Removing Aftertouch hook logic from %s\n", rcLocalPath)
// Simple removal: filter out lines between the marker and the 'fi'
lines := strings.Split(currentRcLocal, "\n")
var newLines []string
skip := false
for _, line := range lines {
if strings.Contains(line, "# Aftertouch DNS hook") {
skip = true
continue
}
if skip && strings.TrimSpace(line) == "fi" {
skip = false
continue
}
if !skip {
newLines = append(newLines, line)
}
}
newRcLocal := strings.Join(newLines, "\n")
if err := client.UploadContent([]byte(newRcLocal), rcLocalPath); err != nil {
fmt.Printf("Warning: failed to update %s: %v\n", rcLocalPath, err)
}
}
}
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetDHCPFile)); err == nil {
logs += fmt.Sprintf("Reverting %s from backup\n", targetDHCPFile)
fmt.Printf("Reverting %s from backup\n", targetDHCPFile)
out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, targetDHCPFile, targetDHCPFile))
logs += fmt.Sprintf("cp %s.original %s: %s\n", targetDHCPFile, targetDHCPFile, out)
if err != nil {
fmt.Printf("Warning: failed to revert %s: %v\n", targetDHCPFile, err)
}
}
targetScript := "/opt/Bose/udhcpc.script"
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetScript)); err == nil {
logs += fmt.Sprintf("Reverting %s from backup\n", targetScript)
fmt.Printf("Reverting %s from backup\n", targetScript)
out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, targetScript, targetScript))
logs += fmt.Sprintf("cp %s.original %s: %s\n", targetScript, targetScript, out)
if err != nil {
fmt.Printf("Warning: failed to revert %s: %v\n", targetScript, err)
}
}
return logs
}
func (m *Manager) revertCACert(client SSHClient, rwCmd string) string {
var logs string
bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
if bundleContent, err := client.Run(fmt.Sprintf("cat %s", bundlePath)); err == nil && strings.Contains(bundleContent, CALabel) {
logs += fmt.Sprintf("Removing local CA certificate from %s\n", bundlePath)
@@ -1026,6 +1519,7 @@ func (m *Manager) RevertMigration(deviceIP string) (string, error) {
out, _ := client.Run(rwCmd)
logs += rwCmd + ": " + out + "\n"
if err := client.UploadContent([]byte(bundleContent), bundlePath); err != nil {
logs += "Warning: failed to remove CA from " + bundlePath + ": " + err.Error() + "\n"
fmt.Printf("Warning: failed to remove CA from %s: %v\n", bundlePath, err)
@@ -1034,7 +1528,7 @@ func (m *Manager) RevertMigration(deviceIP string) (string, error) {
}
}
return logs, nil
return logs
}
// RemoveRemoteServices removes remote services from the device by deleting the known remote_services files.
@@ -1131,6 +1625,74 @@ func (m *Manager) TestHostsRedirection(deviceIP, targetURL string) (string, erro
return combinedOutput, nil
}
// TestDNSRedirection performs a check from the device to see if DNS queries are intercepted by the AfterTouch service.
func (m *Manager) TestDNSRedirection(deviceIP, targetURL string) (string, error) {
client := m.NewSSH(deviceIP)
hostIP, _, err := m.parseTargetURLAndResolveIP(targetURL, client)
if err != nil {
return "", err
}
// Use a raw DNS query via nc (netcat) to test DNS resolution from the device,
// because BusyBox nslookup might not support custom ports.
testDomain := "aftertouch.test"
// Fetch configured DNS port if available
dnsPort := "53"
if m.DataStore != nil {
if dsSettings, getSettingsErr := m.DataStore.GetSettings(); getSettingsErr == nil && dsSettings.DNSBindAddr != "" {
if lastColon := strings.LastIndex(dsSettings.DNSBindAddr, ":"); lastColon != -1 {
port := dsSettings.DNSBindAddr[lastColon+1:]
if _, atoiErr := strconv.Atoi(port); atoiErr == nil {
dnsPort = port
}
}
}
}
// Raw DNS query for aftertouch.test (Type A, Class IN)
// Transaction ID: 0xAAAA, Flags: 0x0100 (Standard query), Questions: 1, Answer RRs: 0, Authority RRs: 0, Additional RRs: 0
// Query: aftertouch.test, Type: A, Class: IN
// For TCP, we need a 2-byte length prefix: 0x0021 (33 bytes)
dnsQueryHex := "\\x00\\x21\\xaa\\xaa\\x01\\x00\\x00\\x01\\x00\\x00\\x00\\x00\\x00\\x00\\x0aaftertouch\\x04test\\x00\\x00\\x01\\x00\\x01"
// We use TCP (default for nc) because BusyBox nc might not support -u,
// and our DNS server listens on both TCP and UDP.
// DNS over TCP response also has a 2-byte length prefix, but tail -c 4 will still get the IP from the end.
ncCmd := fmt.Sprintf("echo -ne '%s' | nc -w 5 %s %s | tail -c 4 | od -An -tu1", dnsQueryHex, hostIP, dnsPort)
output, err := client.Run(ncCmd)
if err == nil {
// Parse the IP from od output: " 192 168 178 122"
fields := strings.Fields(output)
if len(fields) == 4 {
resolvedIP := fmt.Sprintf("%s.%s.%s.%s", fields[0], fields[1], fields[2], fields[3])
if resolvedIP == hostIP {
return fmt.Sprintf("Success: Raw DNS query for %s returned %s via nc to %s:%s", testDomain, resolvedIP, hostIP, dnsPort), nil
}
return output, fmt.Errorf("DNS redirection test failed: nc returned %s, expected %s", resolvedIP, hostIP)
}
}
// Fallback to nslookup if nc fails (maybe nc is missing or it's standard port 53)
serverAddr := hostIP
if dnsPort != "53" {
serverAddr = fmt.Sprintf("%s:%s", hostIP, dnsPort)
}
nslookupCmd := fmt.Sprintf("nslookup %s %s", testDomain, serverAddr)
nslookupOutput, nslookupErr := client.Run(nslookupCmd)
if nslookupErr == nil && strings.Contains(nslookupOutput, hostIP) {
return nslookupOutput, nil
}
return fmt.Sprintf("nc Output: %s (err: %v)\nnslookup Output: %s (err: %v)", output, err, nslookupOutput, nslookupErr),
fmt.Errorf("DNS redirection test failed: both nc and nslookup failed to resolve %s", testDomain)
}
func (m *Manager) parseTargetURLAndResolveIP(targetURL string, client SSHClient) (string, *url.URL, error) {
parsedURL, err := url.Parse(targetURL)
if err != nil {
@@ -1287,6 +1849,11 @@ func (m *Manager) TestConnection(deviceIP, targetURL string, useExplicitCA bool)
return output, nil
}
// GetResolvedIP returns the resolved IP for a hostname, attempting to resolve it from any connected device first.
func (m *Manager) GetResolvedIP(host string) string {
return m.resolveIP(host, nil)
}
func (m *Manager) resolveIP(host string, client SSHClient) string {
if net.ParseIP(host) != nil {
return host
+546 -5
View File
@@ -52,6 +52,10 @@ func TestMigrateViaHosts(t *testing.T) {
runFunc: func(command string) (string, error) {
runCalls = append(runCalls, command)
if command == "cat /etc/hosts" {
// Handle both initial read and verification read
if len(runCalls) > 2 { // Rough heuristic: verification happens after upload
return "192.168.1.100\tstreaming.bose.com\n192.168.1.100\tupdates.bose.com\n192.168.1.100\tstats.bose.com\n192.168.1.100\tbmx.bose.com\n192.168.1.100\tcontent.api.bose.io\n192.168.1.100\tevents.api.bosecm.com\n192.168.1.100\tbose-prod.apigee.net\n192.168.1.100\tworldwide.bose.com", nil
}
return "127.0.0.1 localhost", nil
}
if strings.HasPrefix(command, "[ -f") {
@@ -122,9 +126,14 @@ func TestMigrateViaHosts_UpdateExisting(t *testing.T) {
m := NewManager("http://192.168.1.100:8000", nil, cm)
m.NewSSH = func(host string) SSHClient {
runCount := 0
return &mockSSH{
runFunc: func(command string) (string, error) {
runCount++
if command == "cat /etc/hosts" {
if runCount > 1 {
return "127.0.0.1 localhost\n192.168.1.100\tstreaming.bose.com\n192.168.1.100\tupdates.bose.com\n192.168.1.100\tstats.bose.com\n192.168.1.100\tbmx.bose.com\n192.168.1.100\tcontent.api.bose.io\n192.168.1.100\tevents.api.bosecm.com\n192.168.1.100\tbose-prod.apigee.net\n192.168.1.100\tworldwide.bose.com", nil
}
return "127.0.0.1 localhost\n1.2.3.4\tstreaming.bose.com\n1.2.3.4\tupdates.bose.com", nil
}
if strings.HasPrefix(command, "[ -f") {
@@ -265,9 +274,9 @@ func TestGetMigrationSummary_WithProxyOptions(t *testing.T) {
t.Errorf("Expected default marge URL when SSH fails, got: %s", summary.PlannedConfig)
}
// Test PlannedHosts
if !contains(summary.PlannedHosts, "target\tstreaming.bose.com") {
t.Errorf("Expected PlannedHosts to contain redirect for target, got: %s", summary.PlannedHosts)
// Test PlannedResolv
if !contains(summary.PlannedResolv, "nameserver target") {
t.Errorf("Expected PlannedResolv to contain nameserver target, got: %s", summary.PlannedResolv)
}
}
@@ -598,6 +607,10 @@ func TestMigrateViaHosts_SkipCAIfTrusted(t *testing.T) {
runFunc: func(command string) (string, error) {
runCalls = append(runCalls, command)
if command == "cat /etc/hosts" {
// Handle both initial read and verification read
if len(runCalls) > 2 { // Rough heuristic: verification happens after upload
return "192.168.1.100\tstreaming.bose.com\n192.168.1.100\tupdates.bose.com\n192.168.1.100\tstats.bose.com\n192.168.1.100\tbmx.bose.com\n192.168.1.100\tcontent.api.bose.io\n192.168.1.100\tevents.api.bosecm.com\n192.168.1.100\tbose-prod.apigee.net\n192.168.1.100\tworldwide.bose.com", nil
}
return "127.0.0.1 localhost", nil
}
if strings.HasPrefix(command, "grep -F") {
@@ -701,9 +714,14 @@ func TestRevertMigration(t *testing.T) {
if command == "cat /etc/pki/tls/certs/ca-bundle.crt" {
return "existing content\n" + CALabel + "\nCERT DATA\n" + CALabel + "\nmore content", nil
}
if command == "cat /mnt/nv/rc.local" {
return "#!/bin/sh\n# Aftertouch DNS hook\nlogic\nfi\n", nil
}
// Mock file existence checks for .original files
if strings.HasPrefix(command, "[ -f") && strings.Contains(command, ".original") {
return "", nil // file exists
if strings.HasPrefix(command, "[ -f") {
if strings.Contains(command, ".original") || strings.Contains(command, "/mnt/nv/aftertouch.resolv.conf") {
return "", nil // file exists
}
}
return "", nil
},
@@ -722,7 +740,12 @@ func TestRevertMigration(t *testing.T) {
// Verify revert commands
foundXMLRevert := false
foundHostsRevert := false
foundResolvRevert := false
foundChattrRemove := false
foundReboot := false
foundAftertouchConfRemove := false
foundDHCPRevert := false
for _, call := range runCalls {
if strings.Contains(call, "cp "+SoundTouchSdkPrivateCfgPath+".original "+SoundTouchSdkPrivateCfgPath) {
foundXMLRevert = true
@@ -730,9 +753,21 @@ func TestRevertMigration(t *testing.T) {
if strings.Contains(call, "cp /etc/hosts.original /etc/hosts") {
foundHostsRevert = true
}
if strings.Contains(call, "cp /etc/resolv.conf.original /etc/resolv.conf") {
foundResolvRevert = true
}
if strings.Contains(call, "chattr -i /etc/resolv.conf") {
foundChattrRemove = true
}
if strings.Contains(call, "reboot") {
foundReboot = true
}
if strings.Contains(call, "rm /mnt/nv/aftertouch.resolv.conf") {
foundAftertouchConfRemove = true
}
if strings.Contains(call, "cp /etc/udhcpc.d/50default.original /etc/udhcpc.d/50default") {
foundDHCPRevert = true
}
}
if !foundXMLRevert {
@@ -741,10 +776,31 @@ func TestRevertMigration(t *testing.T) {
if !foundHostsRevert {
t.Errorf("Expected /etc/hosts revert")
}
if !foundResolvRevert {
t.Errorf("Expected /etc/resolv.conf revert")
}
if !foundChattrRemove {
t.Errorf("Expected chattr -i /etc/resolv.conf")
}
if !foundAftertouchConfRemove {
t.Errorf("Expected /mnt/nv/aftertouch.resolv.conf removal")
}
if !foundDHCPRevert {
t.Errorf("Expected /etc/udhcpc.d/50default revert")
}
if foundReboot {
t.Errorf("Expected reboot NOT to be called automatically during revert")
}
// Verify rc.local cleanup
if content, ok := uploadCalls["/mnt/nv/rc.local"]; ok {
if strings.Contains(content, "# Aftertouch DNS hook") {
t.Errorf("Expected Aftertouch hook to be removed from rc.local, got: %s", content)
}
} else {
t.Errorf("Expected rc.local to be updated")
}
// Verify RemoveRemoteServices was NOT called
for _, call := range runCalls {
if strings.Contains(call, "rm -f /etc/remote_services") {
@@ -765,6 +821,47 @@ func TestRevertMigration(t *testing.T) {
}
}
func TestRevertMigration_CorruptedRcLocal(t *testing.T) {
m := NewManager("http://localhost:8000", nil, nil)
runCalls := []string{}
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
runCalls = append(runCalls, command)
if command == "cat /mnt/nv/rc.local" {
return "cat: can't open '/mnt/nv/rc.local': No such file or directory", nil
}
if strings.HasPrefix(command, "[ -f") {
if strings.Contains(command, ".original") {
if strings.Contains(command, "SoundTouchSdkPrivateCfg.xml") {
return "", nil // Pretend XML backup exists to satisfy RevertMigration
}
return "", fmt.Errorf("not found")
}
}
return "", nil
},
}
}
_, err := m.RevertMigration("192.168.1.10")
if err != nil {
t.Fatalf("RevertMigration failed: %v", err)
}
foundRmRcLocal := false
for _, call := range runCalls {
if call == "rm /mnt/nv/rc.local" {
foundRmRcLocal = true
break
}
}
if !foundRmRcLocal {
t.Errorf("Expected corrupted rc.local to be removed")
}
}
func TestRevertMigration_NoBackup(t *testing.T) {
m := NewManager("http://localhost:8000", nil, nil)
@@ -817,6 +914,104 @@ func TestReboot(t *testing.T) {
}
}
func TestTestDNSRedirection(t *testing.T) {
m := NewManager("http://192.168.1.100:8000", nil, nil)
runCalls := []string{}
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
runCalls = append(runCalls, command)
if !strings.Contains(command, "-u") && strings.Contains(command, "nc") {
// Verify TCP length prefix is present: \x00\x21
if !strings.Contains(command, "\\x00\\x21") {
return "", fmt.Errorf("missing TCP length prefix in nc command")
}
// Mock od output: " 192 168 1 100"
return " 192 168 1 100", nil
}
if strings.HasPrefix(command, "nslookup aftertouch.test 192.168.1.100") {
return "Server: 192.168.1.100\nAddress 1: 192.168.1.100\n\nName: aftertouch.test\nAddress 1: 192.168.1.100", nil
}
return "", nil
},
}
}
output, err := m.TestDNSRedirection("192.168.1.10", "http://192.168.1.100:8000")
if err != nil {
t.Fatalf("TestDNSRedirection failed: %v", err)
}
if !strings.Contains(output, "192.168.1.100") {
t.Errorf("Expected output to contain service IP, got %s", output)
}
foundNc := false
for _, call := range runCalls {
if strings.Contains(call, "nc") && !strings.Contains(call, "-u") && strings.Contains(call, "192.168.1.100 53") {
foundNc = true
break
}
}
if !foundNc {
t.Errorf("Expected nc command with port 53, got calls: %v", runCalls)
}
}
func TestTestDNSRedirection_CustomPort(t *testing.T) {
tempDir, err := os.MkdirTemp("", "setup-test-dns-port")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
_ = ds.SaveSettings(datastore.Settings{
DNSBindAddr: ":1053",
})
m := NewManager("http://192.168.1.100:8000", ds, nil)
runCalls := []string{}
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
runCalls = append(runCalls, command)
if !strings.Contains(command, "-u") && strings.Contains(command, "nc") {
// Verify TCP length prefix is present: \x00\x21
if !strings.Contains(command, "\\x00\\x21") {
return "", fmt.Errorf("missing TCP length prefix in nc command")
}
return " 192 168 1 100", nil
}
return "", nil
},
}
}
output, err := m.TestDNSRedirection("192.168.1.10", "http://192.168.1.100:8000")
if err != nil {
t.Fatalf("TestDNSRedirection failed: %v", err)
}
if !strings.Contains(output, "192.168.1.100") {
t.Errorf("Expected output to contain service IP, got %s", output)
}
foundNc := false
for _, call := range runCalls {
if strings.Contains(call, "nc") && !strings.Contains(call, "-u") && strings.Contains(call, "192.168.1.100 1053") {
foundNc = true
break
}
}
if !foundNc {
t.Errorf("Expected nc command with custom port 1053, got calls: %v", runCalls)
}
}
func TestBackupConfigOffDevice(t *testing.T) {
tempDir, err := os.MkdirTemp("", "backup-test")
if err != nil {
@@ -909,6 +1104,270 @@ func TestMigrateSpeaker_PreFlightFailure(t *testing.T) {
}
}
func TestMigrateViaResolvConf(t *testing.T) {
tempDir, err := os.MkdirTemp("", "setup-test-resolv")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
if err := cm.EnsureCA(); err != nil {
t.Fatalf("Failed to ensure CA: %v", err)
}
m := NewManager("http://192.168.1.100:8000", nil, cm)
runCalls := []string{}
uploads := make(map[string]string)
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
runCalls = append(runCalls, command)
if command == "cat /mnt/nv/rc.local" {
return "#!/bin/sh\n", nil
}
if strings.HasPrefix(command, "grep -q \"/mnt/nv/aftertouch.resolv.conf\"") {
return "OK", nil
}
if strings.HasPrefix(command, "[ -f") {
return "", fmt.Errorf("file not found")
}
return "", nil
},
uploadContentFunc: func(content []byte, remotePath string) error {
uploads[remotePath] = string(content)
return nil
},
}
}
_, err = m.migrateViaResolvConf("192.168.1.10", "http://192.168.1.100:8000")
if err != nil {
t.Fatalf("migrateViaResolvConf failed: %v", err)
}
// Verify uploads
if !strings.Contains(uploads["/mnt/nv/aftertouch.resolv.conf"], "nameserver 192.168.1.100") {
t.Errorf("aftertouch.resolv.conf missing nameserver")
}
if !strings.Contains(uploads["/mnt/nv/rc.local"], "/mnt/nv/aftertouch.resolv.conf") {
t.Errorf("rc.local missing hook logic")
}
// Verify immediate patch
foundPatch := false
for _, call := range runCalls {
if strings.Contains(call, "sed -i") && strings.Contains(call, "/etc/udhcpc.d/50default") {
foundPatch = true
break
}
}
if !foundPatch {
t.Errorf("Expected immediate patch to /etc/udhcpc.d/50default")
}
}
func TestMigrateViaResolvConf_CorruptedRcLocal(t *testing.T) {
tempDir, err := os.MkdirTemp("", "setup-test-resolv-corrupted")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
if err := cm.EnsureCA(); err != nil {
t.Fatalf("Failed to ensure CA: %v", err)
}
m := NewManager("http://192.168.1.100:8000", nil, cm)
uploads := make(map[string]string)
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
if command == "cat /mnt/nv/rc.local" {
// Simulate corrupted file containing error message
return "cat: can't open '/mnt/nv/rc.local': No such file or directory", nil
}
if strings.HasPrefix(command, "grep -q \"/mnt/nv/aftertouch.resolv.conf\"") {
return "OK", nil
}
if strings.HasPrefix(command, "[ -f") {
return "", fmt.Errorf("file not found")
}
return "", nil
},
uploadContentFunc: func(content []byte, remotePath string) error {
uploads[remotePath] = string(content)
return nil
},
}
}
_, err = m.migrateViaResolvConf("192.168.1.10", "http://192.168.1.100:8000")
if err != nil {
t.Fatalf("migrateViaResolvConf failed: %v", err)
}
// Verify uploads - rc.local should have been sanitized and only contain shebang and hook
rcLocal := uploads["/mnt/nv/rc.local"]
if strings.Contains(rcLocal, "cat: can't open") {
t.Errorf("rc.local still contains corrupted content: %s", rcLocal)
}
if !strings.HasPrefix(rcLocal, "#!/bin/sh") {
t.Errorf("rc.local missing shebang: %s", rcLocal)
}
if !strings.Contains(rcLocal, "/mnt/nv/aftertouch.resolv.conf") {
t.Errorf("rc.local missing hook logic: %s", rcLocal)
}
}
func TestMigrateViaResolvConf_UdhcpcScript(t *testing.T) {
tempDir, err := os.MkdirTemp("", "setup-test-resolv-script")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
if err := cm.EnsureCA(); err != nil {
t.Fatalf("Failed to ensure CA: %v", err)
}
m := NewManager("http://192.168.1.100:8000", nil, cm)
runCalls := []string{}
uploads := make(map[string]string)
targetScript := "/opt/Bose/udhcpc.script"
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
runCalls = append(runCalls, command)
if command == "cat /mnt/nv/rc.local" {
return "#!/bin/sh\n", nil
}
if strings.HasPrefix(command, "grep -q \"/mnt/nv/aftertouch.resolv.conf\"") {
return "OK", nil
}
if command == "[ -f "+targetScript+" ]" {
return "", nil // file exists
}
if strings.HasPrefix(command, "[ -f") {
return "", fmt.Errorf("file not found")
}
return "", nil
},
uploadContentFunc: func(content []byte, remotePath string) error {
uploads[remotePath] = string(content)
return nil
},
}
}
_, err = m.migrateViaResolvConf("192.168.1.10", "http://192.168.1.100:8000")
if err != nil {
t.Fatalf("migrateViaResolvConf failed: %v", err)
}
// Verify immediate patch to udhcpc.script
foundPatch := false
for _, call := range runCalls {
if strings.Contains(call, "sed -i") && strings.Contains(call, targetScript) {
foundPatch = true
break
}
}
if !foundPatch {
t.Errorf("Expected immediate patch to %s", targetScript)
}
// Verify rc.local contains patch for udhcpc.script
rcLocal := uploads["/mnt/nv/rc.local"]
if !strings.Contains(rcLocal, "targetScript=\"/opt/Bose/udhcpc.script\"") {
t.Errorf("rc.local missing targetScript definition: %s", rcLocal)
}
if !strings.Contains(rcLocal, "sed -i '/echo \"search \\$search_list # \\$interface\" >> \\$RESOLV_CONF/a \\ [ -f '\"$HOOK_MARKER\"' ] && cat '\"$HOOK_MARKER\"' >> '\"\\$RESOLV_CONF\"' && dns=\"\"' \"$targetScript\"") {
// Note: The actual string in rcLocal might have variables expanded or escaped depending on how it was constructed.
// Let's check for the critical part: the escaped $RESOLV_CONF
if !strings.Contains(rcLocal, ">> '\"\\$RESOLV_CONF\"'") {
t.Errorf("rc.local missing correctly escaped RESOLV_CONF in sed patch for udhcpc.script: %s", rcLocal)
}
}
}
func TestRevertMigration_ResolvConf(t *testing.T) {
tempDir, err := os.MkdirTemp("", "setup-test-revert-resolv")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
m := NewManager("http://192.168.1.100:8000", nil, nil)
runCalls := []string{}
uploads := make(map[string]string)
targetDHCPFile := "/etc/udhcpc.d/50default"
targetScript := "/opt/Bose/udhcpc.script"
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
runCalls = append(runCalls, command)
if command == "cat /mnt/nv/rc.local" {
return "#!/bin/sh\n# Aftertouch DNS hook\nif [ -f \"/mnt/nv/aftertouch.resolv.conf\" ]; then\n sed ...\nfi\n", nil
}
if strings.Contains(command, ".original ]") {
return "", nil // backup exists
}
if strings.Contains(command, "[ -f /mnt/nv/aftertouch.resolv.conf ]") {
return "", nil
}
return "", nil
},
uploadContentFunc: func(content []byte, remotePath string) error {
uploads[remotePath] = string(content)
return nil
},
}
}
_, err = m.RevertMigration("192.168.1.10")
if err != nil {
t.Fatalf("RevertMigration failed: %v", err)
}
// Verify backups were restored
foundDHCPRestore := false
foundScriptRestore := false
for _, call := range runCalls {
if strings.Contains(call, "cp "+targetDHCPFile+".original "+targetDHCPFile) {
foundDHCPRestore = true
}
if strings.Contains(call, "cp "+targetScript+".original "+targetScript) {
foundScriptRestore = true
}
}
if !foundDHCPRestore {
t.Errorf("Expected %s to be restored from backup", targetDHCPFile)
}
if !foundScriptRestore {
t.Errorf("Expected %s to be restored from backup", targetScript)
}
// Verify rc.local was cleaned up
rcLocal := uploads["/mnt/nv/rc.local"]
if strings.Contains(rcLocal, "# Aftertouch DNS hook") {
t.Errorf("rc.local still contains hook logic after revert: %s", rcLocal)
}
}
func contains(s, substr string) bool {
return strings.Contains(s, substr)
}
@@ -974,3 +1433,85 @@ func TestCheckIsMigrated(t *testing.T) {
}
})
}
func TestMigrateSpeaker_ResolvBlocking(t *testing.T) {
tempDir, err := os.MkdirTemp("", "setup-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
m := NewManager("http://192.168.1.100:8000", ds, cm)
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
return "", nil
},
}
}
// 1. DNS Disabled
ds.SaveSettings(datastore.Settings{
DNSEnabled: false,
DNSBindAddr: ":53",
})
// Mock HTTP server for device info
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/info" {
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<info deviceID="12345"><name>Test Speaker</name><type>ST10</type><maccAddress>00:11:22:33:44:55</maccAddress><margeAccountUUID>acc-123</margeAccountUUID></info>`))
}
}))
defer ts.Close()
// Use the test server address as device IP
tsIP := strings.TrimPrefix(ts.URL, "http://")
_, err = m.MigrateSpeaker(tsIP, "", "", nil, MigrationMethodResolvConf)
if err == nil || !strings.Contains(err.Error(), "DNS discovery server is not enabled") {
t.Errorf("Expected error about DNS not being enabled, got %v", err)
}
// 2. DNS Enabled but wrong port
ds.SaveSettings(datastore.Settings{
DNSEnabled: true,
DNSBindAddr: ":5353",
})
_, err = m.MigrateSpeaker(tsIP, "", "", nil, MigrationMethodResolvConf)
if err == nil || !strings.Contains(err.Error(), "port 53 is required") {
t.Errorf("Expected error about port 53 required, got %v", err)
}
// 3. DNS Enabled and port 53, but not running
ds.SaveSettings(datastore.Settings{
DNSEnabled: true,
DNSBindAddr: ":53",
})
m.GetDNSRunning = func() (bool, string) {
return false, ":53"
}
_, err = m.MigrateSpeaker(tsIP, "", "", nil, MigrationMethodResolvConf)
if err == nil || !strings.Contains(err.Error(), "not actually running") {
t.Errorf("Expected error about DNS not actually running, got %v", err)
}
// 4. DNS Enabled and port 53, and running
m.GetDNSRunning = func() (bool, string) {
return true, ":53"
}
// This should now proceed to migrateViaResolvConf
_, err = m.MigrateSpeaker(tsIP, "", "", nil, MigrationMethodResolvConf)
if err != nil && (strings.Contains(err.Error(), "DNS discovery server is not enabled") ||
strings.Contains(err.Error(), "port 53 is required") ||
strings.Contains(err.Error(), "not actually running")) {
t.Errorf("Did not expect pre-flight DNS errors, got %v", err)
}
}
+478
View File
@@ -0,0 +1,478 @@
// Package spotify provides Spotify OAuth integration and token management
// for the SoundTouch service, ported from soundcork's Python implementation.
package spotify
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const (
// SpotifyAuthorizeURL is the Spotify OAuth authorization endpoint.
SpotifyAuthorizeURL = "https://accounts.spotify.com/authorize"
// SpotifyTokenURL is the Spotify OAuth token endpoint.
SpotifyTokenURL = "https://accounts.spotify.com/api/token"
// SpotifyAPIBase is the base URL for the Spotify Web API.
SpotifyAPIBase = "https://api.spotify.com/v1"
// SpotifyScopes are the OAuth scopes required for speaker playback and user info.
SpotifyScopes = "streaming user-read-private user-read-email user-read-playback-state user-modify-playback-state"
)
// Account represents a stored Spotify account with tokens.
type Account struct {
UserID string `json:"user_id"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt int64 `json:"expires_at"`
}
// Service manages Spotify OAuth flow and token lifecycle.
type Service struct {
clientID string
clientSecret string
redirectURI string
dataDir string
mu sync.RWMutex
accounts map[string]*Account
// Overridable URLs for testing
tokenURL string
apiBase string
}
// NewSpotifyService creates a new Service and loads any persisted accounts.
func NewSpotifyService(clientID, clientSecret, redirectURI, dataDir string) *Service {
s := &Service{
clientID: clientID,
clientSecret: clientSecret,
redirectURI: redirectURI,
dataDir: dataDir,
accounts: make(map[string]*Account),
tokenURL: SpotifyTokenURL,
apiBase: SpotifyAPIBase,
}
if err := s.load(); err != nil {
log.Printf("[Spotify] Failed to load accounts: %v", err)
}
return s
}
// BuildAuthorizeURL constructs the Spotify OAuth authorization URL.
func (s *Service) BuildAuthorizeURL() string {
params := url.Values{
"client_id": {s.clientID},
"response_type": {"code"},
"redirect_uri": {s.redirectURI},
"scope": {SpotifyScopes},
}
return SpotifyAuthorizeURL + "?" + params.Encode()
}
// ExchangeCodeAndStore exchanges an authorization code for tokens,
// fetches the user profile, and stores the account.
func (s *Service) ExchangeCodeAndStore(code string) error {
// Exchange code for tokens
tokenResp, err := s.exchangeCode(code)
if err != nil {
return fmt.Errorf("token exchange: %w", err)
}
accessToken, _ := tokenResp["access_token"].(string)
refreshToken, _ := tokenResp["refresh_token"].(string)
expiresIn, _ := tokenResp["expires_in"].(float64)
if expiresIn == 0 {
expiresIn = 3600
}
// Fetch user profile
profile, err := s.getUserProfile(accessToken)
if err != nil {
return fmt.Errorf("fetch profile: %w", err)
}
userID, _ := profile["id"].(string)
displayName, _ := profile["display_name"].(string)
email, _ := profile["email"].(string)
account := &Account{
UserID: userID,
DisplayName: displayName,
Email: email,
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresAt: time.Now().Unix() + int64(expiresIn),
}
s.mu.Lock()
s.accounts[userID] = account
s.mu.Unlock()
if err := s.save(); err != nil {
return fmt.Errorf("save accounts: %w", err)
}
log.Printf("[Spotify] Account linked: %s (%s)", displayName, userID)
return nil
}
func (s *Service) exchangeCode(code string) (map[string]interface{}, error) {
data := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {s.redirectURI},
}
req, err := http.NewRequest(http.MethodPost, s.tokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(s.clientID, s.clientSecret)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("token request: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token exchange failed (%d): %s", resp.StatusCode, string(body))
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
return result, nil
}
func (s *Service) getUserProfile(accessToken string) (map[string]interface{}, error) {
req, err := http.NewRequest(http.MethodGet, s.apiBase+"/me", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("profile request: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("profile fetch failed (%d): %s", resp.StatusCode, string(body))
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse profile: %w", err)
}
return result, nil
}
// RefreshAccessToken refreshes the access token for the given account.
func (s *Service) RefreshAccessToken(account *Account) error {
data := url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {account.RefreshToken},
}
req, err := http.NewRequest(http.MethodPost, s.tokenURL, strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(s.clientID, s.clientSecret)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("refresh request: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("token refresh failed (%d): %s", resp.StatusCode, string(body))
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("parse response: %w", err)
}
s.mu.Lock()
account.AccessToken, _ = result["access_token"].(string)
expiresIn, _ := result["expires_in"].(float64)
if expiresIn == 0 {
expiresIn = 3600
}
account.ExpiresAt = time.Now().Unix() + int64(expiresIn)
if newRefresh, ok := result["refresh_token"].(string); ok && newRefresh != "" {
account.RefreshToken = newRefresh
}
s.mu.Unlock()
if err := s.save(); err != nil {
return fmt.Errorf("save accounts: %w", err)
}
return nil
}
// GetFreshToken returns a valid access token and username, refreshing if needed.
func (s *Service) GetFreshToken() (accessToken, username string, err error) {
s.mu.RLock()
if len(s.accounts) == 0 {
s.mu.RUnlock()
return "", "", fmt.Errorf("no Spotify accounts linked")
}
// Get the first account
var account *Account
for _, a := range s.accounts {
account = a
break
}
s.mu.RUnlock()
// Check if token needs refresh (expired or within 60s of expiry)
if account.ExpiresAt < time.Now().Unix()+60 {
if err := s.RefreshAccessToken(account); err != nil {
return "", "", fmt.Errorf("refresh token: %w", err)
}
}
s.mu.RLock()
defer s.mu.RUnlock()
return account.AccessToken, account.UserID, nil
}
// GetAccounts returns a copy of all accounts with tokens stripped for API responses.
func (s *Service) GetAccounts() []Account {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]Account, 0, len(s.accounts))
for _, a := range s.accounts {
result = append(result, Account{
UserID: a.UserID,
DisplayName: a.DisplayName,
Email: a.Email,
ExpiresAt: a.ExpiresAt,
// AccessToken and RefreshToken deliberately omitted
})
}
return result
}
// ResolveEntity resolves a Spotify URI to a name and image URL.
func (s *Service) ResolveEntity(uri string) (name, imageURL string, err error) {
entityType, entityID, err := parseSpotifyURI(uri)
if err != nil {
return "", "", err
}
accessToken, _, err := s.GetFreshToken()
if err != nil {
return "", "", fmt.Errorf("get token: %w", err)
}
apiURL := fmt.Sprintf("%s/%s/%s", s.apiBase, entityType, entityID)
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return "", "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", fmt.Errorf("API request: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode == http.StatusNotFound {
return "", "", fmt.Errorf("spotify entity not found")
}
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("spotify API error (%d): %s", resp.StatusCode, string(body))
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return "", "", fmt.Errorf("parse response: %w", err)
}
name, _ = data["name"].(string)
if name == "" {
name = "Unknown"
}
// Extract image URL — location varies by entity type
imageURL = extractImageURL(data, entityType)
return name, imageURL, nil
}
// extractImageURL extracts the first image URL from a Spotify API response.
// For tracks, images are stored on the album object.
func extractImageURL(data map[string]interface{}, entityType string) string {
images, _ := data["images"].([]interface{})
if len(images) == 0 && entityType == "tracks" {
// Tracks store images on the album
album, _ := data["album"].(map[string]interface{})
if album != nil {
images, _ = album["images"].([]interface{})
}
}
if len(images) > 0 {
if img, ok := images[0].(map[string]interface{}); ok {
url, _ := img["url"].(string)
return url
}
}
return ""
}
// parseSpotifyURI parses a Spotify URI like "spotify:track:abc" into
// the pluralized API type ("tracks") and ID ("abc").
func parseSpotifyURI(uri string) (entityType, entityID string, err error) {
parts := strings.Split(uri, ":")
if len(parts) != 3 || parts[0] != "spotify" {
return "", "", fmt.Errorf("invalid Spotify URI format: %s", uri)
}
typ := parts[1]
id := parts[2]
validTypes := map[string]string{
"track": "tracks",
"album": "albums",
"playlist": "playlists",
"artist": "artists",
}
plural, ok := validTypes[typ]
if !ok {
return "", "", fmt.Errorf("unsupported Spotify entity type: %s", typ)
}
return plural, id, nil
}
// save persists accounts to disk as JSON.
func (s *Service) save() error {
s.mu.RLock()
data := make(map[string]*Account, len(s.accounts))
for k, v := range s.accounts {
data[k] = v
}
s.mu.RUnlock()
dir := filepath.Join(s.dataDir, "spotify")
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create directory: %w", err)
}
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("marshal accounts: %w", err)
}
path := filepath.Join(dir, "accounts.json")
if err := os.WriteFile(path, jsonData, 0600); err != nil {
return fmt.Errorf("write file: %w", err)
}
return nil
}
// load reads persisted accounts from disk.
func (s *Service) load() error {
path := filepath.Join(s.dataDir, "spotify", "accounts.json")
jsonData, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil // No accounts file yet, not an error
}
return fmt.Errorf("read file: %w", err)
}
var accounts map[string]*Account
if err := json.Unmarshal(jsonData, &accounts); err != nil {
return fmt.Errorf("unmarshal accounts: %w", err)
}
s.mu.Lock()
s.accounts = accounts
s.mu.Unlock()
log.Printf("[Spotify] Loaded %d account(s)", len(accounts))
return nil
}
+426
View File
@@ -0,0 +1,426 @@
package spotify
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestBuildAuthorizeURL(t *testing.T) {
svc := NewSpotifyService("test-client-id", "test-secret", "http://localhost/callback", t.TempDir())
url := svc.BuildAuthorizeURL()
if !strings.Contains(url, "client_id=test-client-id") {
t.Errorf("URL should contain client_id, got: %s", url)
}
if !strings.Contains(url, "redirect_uri=") {
t.Errorf("URL should contain redirect_uri, got: %s", url)
}
if !strings.Contains(url, "scope=") {
t.Errorf("URL should contain scope, got: %s", url)
}
if !strings.Contains(url, "response_type=code") {
t.Errorf("URL should contain response_type=code, got: %s", url)
}
if !strings.HasPrefix(url, SpotifyAuthorizeURL) {
t.Errorf("URL should start with %s, got: %s", SpotifyAuthorizeURL, url)
}
}
func TestGetAccountsStripsTokens(t *testing.T) {
svc := NewSpotifyService("cid", "csecret", "http://localhost/cb", t.TempDir())
// Manually add an account with tokens
svc.mu.Lock()
svc.accounts["user1"] = &Account{
UserID: "user1",
DisplayName: "Test User",
Email: "test@example.com",
AccessToken: "secret-access-token",
RefreshToken: "secret-refresh-token",
ExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
}
svc.mu.Unlock()
accounts := svc.GetAccounts()
if len(accounts) != 1 {
t.Fatalf("expected 1 account, got %d", len(accounts))
}
if accounts[0].AccessToken != "" {
t.Errorf("AccessToken should be stripped, got: %s", accounts[0].AccessToken)
}
if accounts[0].RefreshToken != "" {
t.Errorf("RefreshToken should be stripped, got: %s", accounts[0].RefreshToken)
}
if accounts[0].UserID != "user1" {
t.Errorf("UserID should be preserved, got: %s", accounts[0].UserID)
}
if accounts[0].DisplayName != "Test User" {
t.Errorf("DisplayName should be preserved, got: %s", accounts[0].DisplayName)
}
}
func TestGetFreshTokenRefreshesExpired(t *testing.T) {
// Set up a mock Spotify token endpoint
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
if r.Form.Get("grant_type") != "refresh_token" {
t.Errorf("expected grant_type=refresh_token, got %s", r.Form.Get("grant_type"))
}
if r.Form.Get("refresh_token") != "my-refresh-token" {
t.Errorf("expected refresh_token=my-refresh-token, got %s", r.Form.Get("refresh_token"))
}
// Verify Basic Auth
user, pass, ok := r.BasicAuth()
if !ok || user != "cid" || pass != "csecret" {
t.Errorf("expected Basic Auth cid:csecret, got %s:%s (ok=%v)", user, pass, ok)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": "new-access-token",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "new-refresh-token",
})
}))
defer tokenServer.Close()
svc := NewSpotifyService("cid", "csecret", "http://localhost/cb", t.TempDir())
// Override the token URL for testing
svc.tokenURL = tokenServer.URL
// Add an account with an expired token
svc.mu.Lock()
svc.accounts["user1"] = &Account{
UserID: "user1",
DisplayName: "Test User",
AccessToken: "old-expired-token",
RefreshToken: "my-refresh-token",
ExpiresAt: time.Now().Add(-1 * time.Hour).Unix(), // expired
}
svc.mu.Unlock()
accessToken, username, err := svc.GetFreshToken()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if accessToken != "new-access-token" {
t.Errorf("expected new-access-token, got %s", accessToken)
}
if username != "user1" {
t.Errorf("expected user1, got %s", username)
}
// Verify the account was updated
svc.mu.RLock()
account := svc.accounts["user1"]
svc.mu.RUnlock()
if account.RefreshToken != "new-refresh-token" {
t.Errorf("refresh token should be updated, got %s", account.RefreshToken)
}
}
func TestResolveEntityParsesURI(t *testing.T) {
tests := []struct {
uri string
expectedType string
expectedID string
shouldErr bool
}{
{"spotify:track:abc123", "tracks", "abc123", false},
{"spotify:album:xyz789", "albums", "xyz789", false},
{"spotify:playlist:pl1", "playlists", "pl1", false},
{"spotify:artist:ar1", "artists", "ar1", false},
{"invalid-uri", "", "", true},
{"spotify:invalid_type:id", "", "", true},
{"spotify:track", "", "", true},
}
for _, tc := range tests {
t.Run(tc.uri, func(t *testing.T) {
entityType, entityID, err := parseSpotifyURI(tc.uri)
if tc.shouldErr {
if err == nil {
t.Errorf("expected error for URI %s", tc.uri)
}
return
}
if err != nil {
t.Fatalf("unexpected error for URI %s: %v", tc.uri, err)
}
if entityType != tc.expectedType {
t.Errorf("expected type %s, got %s", tc.expectedType, entityType)
}
if entityID != tc.expectedID {
t.Errorf("expected id %s, got %s", tc.expectedID, entityID)
}
})
}
}
func TestResolveEntityFetchesFromAPI(t *testing.T) {
// Mock Spotify API
apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check Authorization header
auth := r.Header.Get("Authorization")
if auth != "Bearer fresh-token" {
t.Errorf("expected Bearer fresh-token, got %s", auth)
}
switch r.URL.Path {
case "/tracks/abc123":
json.NewEncoder(w).Encode(map[string]interface{}{
"name": "Test Track",
"album": map[string]interface{}{
"images": []map[string]interface{}{
{"url": "http://img.example.com/track.jpg"},
},
},
})
case "/albums/xyz789":
json.NewEncoder(w).Encode(map[string]interface{}{
"name": "Test Album",
"images": []map[string]interface{}{
{"url": "http://img.example.com/album.jpg"},
},
})
default:
http.NotFound(w, r)
}
}))
defer apiServer.Close()
svc := NewSpotifyService("cid", "csecret", "http://localhost/cb", t.TempDir())
svc.apiBase = apiServer.URL
// Add a non-expired account
svc.mu.Lock()
svc.accounts["user1"] = &Account{
UserID: "user1",
AccessToken: "fresh-token",
RefreshToken: "refresh",
ExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
}
svc.mu.Unlock()
// Test track (images come from album)
name, imageURL, err := svc.ResolveEntity("spotify:track:abc123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if name != "Test Track" {
t.Errorf("expected Test Track, got %s", name)
}
if imageURL != "http://img.example.com/track.jpg" {
t.Errorf("expected track image URL, got %s", imageURL)
}
// Test album (images at top level)
name, imageURL, err = svc.ResolveEntity("spotify:album:xyz789")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if name != "Test Album" {
t.Errorf("expected Test Album, got %s", name)
}
if imageURL != "http://img.example.com/album.jpg" {
t.Errorf("expected album image URL, got %s", imageURL)
}
}
func TestSaveAndLoad(t *testing.T) {
dir := t.TempDir()
// Create and populate
svc := NewSpotifyService("cid", "csecret", "http://localhost/cb", dir)
svc.mu.Lock()
svc.accounts["user1"] = &Account{
UserID: "user1",
DisplayName: "Test User",
Email: "test@example.com",
AccessToken: "at",
RefreshToken: "rt",
ExpiresAt: 1234567890,
}
svc.accounts["user2"] = &Account{
UserID: "user2",
DisplayName: "User Two",
Email: "two@example.com",
AccessToken: "at2",
RefreshToken: "rt2",
ExpiresAt: 9876543210,
}
svc.mu.Unlock()
// Save
if err := svc.save(); err != nil {
t.Fatalf("save failed: %v", err)
}
// Verify file exists
accountsFile := filepath.Join(dir, "spotify", "accounts.json")
if _, err := os.Stat(accountsFile); os.IsNotExist(err) {
t.Fatal("accounts.json was not created")
}
// Load into new service
svc2 := NewSpotifyService("cid", "csecret", "http://localhost/cb", dir)
svc2.mu.RLock()
defer svc2.mu.RUnlock()
if len(svc2.accounts) != 2 {
t.Fatalf("expected 2 accounts after load, got %d", len(svc2.accounts))
}
u1, ok := svc2.accounts["user1"]
if !ok {
t.Fatal("user1 not found after load")
}
if u1.DisplayName != "Test User" {
t.Errorf("expected Test User, got %s", u1.DisplayName)
}
if u1.AccessToken != "at" {
t.Errorf("expected at, got %s", u1.AccessToken)
}
if u1.ExpiresAt != 1234567890 {
t.Errorf("expected ExpiresAt 1234567890, got %d", u1.ExpiresAt)
}
}
func TestExchangeCodeAndStore(t *testing.T) {
// Mock token endpoint
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
switch r.Form.Get("grant_type") {
case "authorization_code":
if r.Form.Get("code") != "test-auth-code" {
t.Errorf("expected code=test-auth-code, got %s", r.Form.Get("code"))
}
user, pass, ok := r.BasicAuth()
if !ok || user != "cid" || pass != "csecret" {
t.Errorf("bad Basic Auth: %s:%s ok=%v", user, pass, ok)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": "new-at",
"refresh_token": "new-rt",
"expires_in": 3600,
})
default:
t.Errorf("unexpected grant_type: %s", r.Form.Get("grant_type"))
http.Error(w, "bad request", 400)
}
}))
defer tokenServer.Close()
// Mock profile endpoint
profileServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if auth != "Bearer new-at" {
t.Errorf("expected Bearer new-at, got %s", auth)
}
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "spotify-user-123",
"display_name": "Spotify User",
"email": "user@spotify.com",
})
}))
defer profileServer.Close()
dir := t.TempDir()
svc := NewSpotifyService("cid", "csecret", "http://localhost/cb", dir)
svc.tokenURL = tokenServer.URL
svc.apiBase = profileServer.URL
err := svc.ExchangeCodeAndStore("test-auth-code")
if err != nil {
t.Fatalf("ExchangeCodeAndStore failed: %v", err)
}
// Verify account stored
svc.mu.RLock()
account, ok := svc.accounts["spotify-user-123"]
svc.mu.RUnlock()
if !ok {
t.Fatal("account not found after exchange")
}
if account.DisplayName != "Spotify User" {
t.Errorf("expected Spotify User, got %s", account.DisplayName)
}
if account.Email != "user@spotify.com" {
t.Errorf("expected user@spotify.com, got %s", account.Email)
}
if account.AccessToken != "new-at" {
t.Errorf("expected new-at, got %s", account.AccessToken)
}
if account.RefreshToken != "new-rt" {
t.Errorf("expected new-rt, got %s", account.RefreshToken)
}
// Verify saved to disk
accountsFile := filepath.Join(dir, "spotify", "accounts.json")
data, err := os.ReadFile(accountsFile)
if err != nil {
t.Fatalf("failed to read accounts file: %v", err)
}
if !strings.Contains(string(data), "spotify-user-123") {
t.Error("accounts file should contain the user ID")
}
}
func TestGetFreshTokenNoAccounts(t *testing.T) {
svc := NewSpotifyService("cid", "csecret", "http://localhost/cb", t.TempDir())
_, _, err := svc.GetFreshToken()
if err == nil {
t.Error("expected error when no accounts exist")
}
}
func TestGetFreshTokenNotExpired(t *testing.T) {
svc := NewSpotifyService("cid", "csecret", "http://localhost/cb", t.TempDir())
svc.mu.Lock()
svc.accounts["user1"] = &Account{
UserID: "user1",
AccessToken: "valid-token",
RefreshToken: "rt",
ExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
}
svc.mu.Unlock()
token, username, err := svc.GetFreshToken()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token != "valid-token" {
t.Errorf("expected valid-token, got %s", token)
}
if username != "user1" {
t.Errorf("expected user1, got %s", username)
}
}
+17
View File
@@ -58,6 +58,15 @@ REDACT_PROXY_LOGS="${REDACT_PROXY_LOGS:-true}"
RECORD_INTERACTIONS="${RECORD_INTERACTIONS:-true}"
DISCOVERY_INTERVAL="${DISCOVERY_INTERVAL:-5m}"
# Spotify OAuth config (optional)
SPOTIFY_CLIENT_ID="${SPOTIFY_CLIENT_ID:-}"
SPOTIFY_CLIENT_SECRET="${SPOTIFY_CLIENT_SECRET:-}"
SPOTIFY_REDIRECT_URI="${SPOTIFY_REDIRECT_URI:-ueberboese-login://spotify}"
# Management API credentials
MGMT_USERNAME="${MGMT_USERNAME:-admin}"
MGMT_PASSWORD="${MGMT_PASSWORD:-change_me!}"
# Override if you want to force a specific asset suffix:
# ARCH_ASSET=linux-armv7|linux-arm64|linux-amd64
ARCH_ASSET="${ARCH_ASSET:-}"
@@ -204,6 +213,7 @@ self_update() {
# Export current env vars to the new script
export IS_SELF_UPDATE="true"
export VERSION HOSTNAME_FQDN HTTP_PORT HTTPS_PORT DATA_DIR BIN_PATH CONFIG_DIR ENV_FILE SERVICE_USER SERVICE_GROUP
export SPOTIFY_CLIENT_ID SPOTIFY_CLIENT_SECRET SPOTIFY_REDIRECT_URI MGMT_USERNAME MGMT_PASSWORD
exec "${tmp_script}" "$@"
}
@@ -222,6 +232,13 @@ DISCOVERY_INTERVAL=${DISCOVERY_INTERVAL}
SERVER_URL=${SERVER_URL}
HTTPS_SERVER_URL=${HTTPS_SERVER_URL}
SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}
SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}
SPOTIFY_REDIRECT_URI=${SPOTIFY_REDIRECT_URI}
MGMT_USERNAME=${MGMT_USERNAME}
MGMT_PASSWORD=${MGMT_PASSWORD}
EOF
chmod 0640 "${ENV_FILE}"
# group-readable so you can add yourself to the group if desired