Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a65376784 | ||
|
|
44d04a2b41 | ||
|
|
0f802e65c6 | ||
|
|
01d702c745 | ||
|
|
7823b68bdd | ||
|
|
e1f3fc36c8 | ||
|
|
d68599896d | ||
|
|
d18b67d80f | ||
|
|
8642ecfc5c | ||
|
|
c37e94b5f8 | ||
|
|
4e33f6948f | ||
|
|
743ff5e061 | ||
|
|
ec8bbb2f86 | ||
|
|
e75e2bea0c | ||
|
|
dd5aa2ad53 | ||
|
|
aced0f3f81 | ||
|
|
a886518cad | ||
|
|
dc81b0aa81 | ||
|
|
c648027735 | ||
|
|
fced88a8a6 | ||
|
|
0ee673c097 | ||
|
|
395b2fec8e | ||
|
|
be7e44e14b | ||
|
|
a87783d8c6 | ||
|
|
be017440b7 | ||
|
|
10de011c18 | ||
|
|
f7b74db3ea | ||
|
|
72d75133c4 | ||
|
|
e4c12471b4 | ||
|
|
3329149282 | ||
|
|
523ff0eb17 | ||
|
|
025e15d65c | ||
|
|
7d140b3e2a | ||
|
|
69210638e5 | ||
|
|
6aef2b807d | ||
|
|
95f5e9c831 | ||
|
|
7337296ae9 | ||
|
|
92a5d3592c | ||
|
|
2f04af872b | ||
|
|
9479d6d11d | ||
|
|
f687ba0d82 | ||
|
|
ab2bf0731a | ||
|
|
cafaba1be0 | ||
|
|
93082d2cdc | ||
|
|
087006c483 | ||
|
|
b7013a5ec8 | ||
|
|
7d76b3fab2 | ||
|
|
6ca206053f | ||
|
|
090eb162fb | ||
|
|
972824e07f | ||
|
|
1e2148d53b | ||
|
|
9a070da1ef | ||
|
|
d4b518da23 | ||
|
|
89bafd97b6 | ||
|
|
d616bc09fd | ||
|
|
8af60c7e4b | ||
|
|
8a21db3517 | ||
|
|
742484568e | ||
|
|
ed2d8680e4 | ||
|
|
6dc8c23f04 | ||
|
|
fa57ee9574 | ||
|
|
e438db05d9 |
@@ -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
|
||||
|
||||
@@ -20,16 +20,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
uses: actions/configure-pages@v5
|
||||
- name: Build with Jekyll
|
||||
uses: actions/jekyll-build-pages@v1
|
||||
with:
|
||||
source: 'docs/'
|
||||
destination: '_site'
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: '_site'
|
||||
- name: Deploy to GitHub Pages
|
||||
|
||||
@@ -133,10 +133,13 @@ jobs:
|
||||
local CMD_PATH=$2
|
||||
local OUTPUT_NAME
|
||||
|
||||
# Ensure build directory exists
|
||||
mkdir -p build
|
||||
|
||||
if [[ "${{ matrix.goos }}" == "windows" ]]; then
|
||||
OUTPUT_NAME="${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}.exe"
|
||||
OUTPUT_NAME="build/${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}.exe"
|
||||
else
|
||||
OUTPUT_NAME="${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}"
|
||||
OUTPUT_NAME="build/${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}"
|
||||
fi
|
||||
|
||||
echo "Building $BINARY_NAME: $OUTPUT_NAME"
|
||||
@@ -193,8 +196,8 @@ jobs:
|
||||
with:
|
||||
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
|
||||
path: |
|
||||
soundtouch-cli-v*
|
||||
soundtouch-service-v*
|
||||
build/soundtouch-cli-v*
|
||||
build/soundtouch-service-v*
|
||||
retention-days: 1
|
||||
|
||||
checksums:
|
||||
|
||||
@@ -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
|
||||
@@ -317,8 +320,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)
|
||||
}
|
||||
@@ -481,7 +484,7 @@ This project builds upon the excellent work of several community projects:
|
||||
### SoundCork 🍾
|
||||
- **Project**: [SoundCork - SoundTouch API Intercept](https://github.com/deborahgu/soundcork)
|
||||
- **Authors**: Deborah Kaplan and contributors
|
||||
- **Our Implementation**: The `soundtouch-service` in this project is heavily inspired by and based on SoundCork's Python implementation. SoundCork pioneered the approach of intercepting and emulating Bose's cloud services, providing the foundation for offline SoundTouch operation.
|
||||
- **Our Implementation**: The `soundtouch-service` in this project is heavily inspired by SoundCork's Python implementation. SoundCork pioneered the approach of intercepting and emulating Bose's cloud services, providing the foundation for offline SoundTouch operation.
|
||||
- **Key Contributions**: Service emulation architecture, BMX/Marge endpoint discovery, device migration strategies
|
||||
- **License**: MIT License
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -81,10 +82,15 @@ func main() {
|
||||
EnvVars: []string{"BIND_ADDR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "target-url",
|
||||
Usage: "URL for Python-based service components (legacy)",
|
||||
Name: "soundcork-url",
|
||||
Usage: "URL for Soundcork-based service components (legacy)",
|
||||
Value: "http://localhost:8001",
|
||||
EnvVars: []string{"PYTHON_BACKEND_URL", "TARGET_URL"},
|
||||
EnvVars: []string{"SOUNDCORK_BACKEND_URL", "TARGET_URL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "enable-soundcork-proxy",
|
||||
Usage: "Enable proxying unknown requests to the Soundcork backend",
|
||||
EnvVars: []string{"ENABLE_SOUNDCORK_PROXY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "data-dir",
|
||||
@@ -133,52 +139,66 @@ 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(s) for non-Bose queries (comma-separated). If empty, /etc/resolv.conf is used.",
|
||||
Value: "",
|
||||
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)
|
||||
ds := initDataStore(config.dataDir)
|
||||
|
||||
// Load settings from datastore
|
||||
persisted, err := ds.GetSettings()
|
||||
persisted := applyPersistedSettings(ds, &config)
|
||||
|
||||
settingsExist := err == nil && persisted.ServerURL != ""
|
||||
if persisted.ServerURL != "" {
|
||||
config.serverURL = persisted.ServerURL
|
||||
}
|
||||
|
||||
if persisted.ProxyURL != "" {
|
||||
config.targetURL = persisted.ProxyURL
|
||||
}
|
||||
|
||||
if persisted.HTTPServerURL != "" {
|
||||
config.httpsServerURL = persisted.HTTPServerURL
|
||||
}
|
||||
|
||||
if persisted.DiscoveryInterval != "" {
|
||||
if d, durErr := time.ParseDuration(persisted.DiscoveryInterval); durErr == nil {
|
||||
config.discoveryInterval = d
|
||||
}
|
||||
}
|
||||
|
||||
config.redact = persisted.RedactLogs || config.redact
|
||||
config.logBody = persisted.LogBodies || config.logBody
|
||||
config.record = persisted.RecordInteractions || config.record
|
||||
|
||||
if !settingsExist {
|
||||
if persisted.ServerURL == "" {
|
||||
log.Printf("Creating default settings.json in %s", config.dataDir)
|
||||
persisted.ServerURL = config.serverURL
|
||||
persisted.ProxyURL = config.targetURL
|
||||
persisted.HTTPServerURL = config.httpsServerURL
|
||||
persisted.RedactLogs = config.redact
|
||||
persisted.LogBodies = config.logBody
|
||||
persisted.RecordInteractions = config.record
|
||||
persisted.DiscoveryInterval = config.discoveryInterval.String()
|
||||
persisted.DiscoveryEnabled = true
|
||||
persisted.Shortcuts = map[string]int{
|
||||
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
|
||||
"/sw.js": http.StatusNotFound,
|
||||
}
|
||||
_ = ds.SaveSettings(persisted)
|
||||
persisted = createDefaultSettings(ds, config)
|
||||
}
|
||||
|
||||
// Recalculate domains if settings changed
|
||||
@@ -191,10 +211,54 @@ 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)
|
||||
sm.MgmtUsername = config.mgmtUsername
|
||||
sm.MgmtPassword = config.mgmtPassword
|
||||
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, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
|
||||
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
|
||||
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
|
||||
|
||||
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 {
|
||||
@@ -234,13 +298,11 @@ func main() {
|
||||
log.Printf("Warning: Failed to setup TLS: %v", err)
|
||||
}
|
||||
|
||||
pyProxy := setupPythonProxy(config.targetURL, config.redact, config.logBody, recorder, server)
|
||||
|
||||
startDeviceDiscovery(server)
|
||||
|
||||
r := setupRouter(server, pyProxy)
|
||||
r := setupRouter(server)
|
||||
|
||||
log.Printf("Go service starting on %s, proxying to %s", config.serverURL, config.targetURL)
|
||||
log.Printf("Go service starting on %s, proxying to %s", config.serverURL, config.soundcorkURL)
|
||||
|
||||
if tlsConfig != nil {
|
||||
startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL)
|
||||
@@ -274,19 +336,28 @@ func showVersionInfo(_ *cli.Context) error {
|
||||
}
|
||||
|
||||
type serviceConfig struct {
|
||||
port string
|
||||
bindAddr string
|
||||
addr string
|
||||
targetURL string
|
||||
dataDir string
|
||||
serverURL string
|
||||
httpsServerURL string
|
||||
httpsAddr string
|
||||
redact bool
|
||||
logBody bool
|
||||
record bool
|
||||
discoveryInterval time.Duration
|
||||
domains []string
|
||||
port string
|
||||
bindAddr string
|
||||
addr string
|
||||
soundcorkURL string
|
||||
dataDir string
|
||||
serverURL string
|
||||
httpsServerURL string
|
||||
httpsAddr string
|
||||
redact bool
|
||||
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
|
||||
}
|
||||
|
||||
func loadConfig(c *cli.Context) serviceConfig {
|
||||
@@ -298,7 +369,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
addr = ":" + port
|
||||
}
|
||||
|
||||
targetURL := c.String("target-url")
|
||||
soundcorkURL := c.String("soundcork-url")
|
||||
dataDir := c.String("data-dir")
|
||||
|
||||
hostname, _ := os.Hostname()
|
||||
@@ -330,6 +401,11 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
redact := c.Bool("redact-logs")
|
||||
logBody := c.Bool("log-bodies")
|
||||
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")
|
||||
|
||||
@@ -340,20 +416,35 @@ 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")
|
||||
|
||||
return serviceConfig{
|
||||
port: port,
|
||||
bindAddr: bindAddr,
|
||||
addr: addr,
|
||||
targetURL: targetURL,
|
||||
dataDir: dataDir,
|
||||
serverURL: serverURL,
|
||||
httpsServerURL: httpsServerURL,
|
||||
httpsAddr: httpsAddr,
|
||||
redact: redact,
|
||||
logBody: logBody,
|
||||
record: record,
|
||||
discoveryInterval: discoveryInterval,
|
||||
domains: domains,
|
||||
port: port,
|
||||
bindAddr: bindAddr,
|
||||
addr: addr,
|
||||
soundcorkURL: soundcorkURL,
|
||||
dataDir: dataDir,
|
||||
serverURL: serverURL,
|
||||
httpsServerURL: httpsServerURL,
|
||||
httpsAddr: httpsAddr,
|
||||
redact: redact,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,6 +477,71 @@ func getDomains(serverURL, httpsServerURL, hostname string) []string {
|
||||
return domains
|
||||
}
|
||||
|
||||
func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) datastore.Settings {
|
||||
persisted, err := ds.GetSettings()
|
||||
if err != nil {
|
||||
return datastore.Settings{}
|
||||
}
|
||||
|
||||
if persisted.ServerURL != "" {
|
||||
config.serverURL = persisted.ServerURL
|
||||
}
|
||||
|
||||
if persisted.SoundcorkURL != "" {
|
||||
config.soundcorkURL = persisted.SoundcorkURL
|
||||
}
|
||||
|
||||
if persisted.HTTPServerURL != "" {
|
||||
config.httpsServerURL = persisted.HTTPServerURL
|
||||
}
|
||||
|
||||
if persisted.DiscoveryInterval != "" {
|
||||
if d, durErr := time.ParseDuration(persisted.DiscoveryInterval); durErr == nil {
|
||||
config.discoveryInterval = d
|
||||
}
|
||||
}
|
||||
|
||||
config.redact = persisted.RedactLogs
|
||||
config.logBody = persisted.LogBodies
|
||||
config.record = persisted.RecordInteractions
|
||||
config.enableSoundcorkProxy = persisted.EnableSoundcorkProxy
|
||||
|
||||
config.dnsEnabled = persisted.DNSEnabled
|
||||
if len(persisted.DNSUpstream) > 0 {
|
||||
config.dnsUpstream = strings.Join(persisted.DNSUpstream, ",")
|
||||
}
|
||||
|
||||
if persisted.DNSBindAddr != "" {
|
||||
config.dnsBind = persisted.DNSBindAddr
|
||||
}
|
||||
|
||||
return persisted
|
||||
}
|
||||
|
||||
func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings {
|
||||
settings := datastore.Settings{
|
||||
ServerURL: config.serverURL,
|
||||
SoundcorkURL: config.soundcorkURL,
|
||||
HTTPServerURL: config.httpsServerURL,
|
||||
RedactLogs: config.redact,
|
||||
LogBodies: config.logBody,
|
||||
RecordInteractions: config.record,
|
||||
DiscoveryInterval: config.discoveryInterval.String(),
|
||||
DiscoveryEnabled: true,
|
||||
EnableSoundcorkProxy: config.enableSoundcorkProxy,
|
||||
DNSEnabled: config.dnsEnabled,
|
||||
DNSUpstream: strings.Split(config.dnsUpstream, ","),
|
||||
DNSBindAddr: config.dnsBind,
|
||||
Shortcuts: map[string]int{
|
||||
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
|
||||
"/sw.js": http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
_ = ds.SaveSettings(settings)
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
func initDataStore(dataDir string) *datastore.DataStore {
|
||||
ds := datastore.NewDataStore(dataDir)
|
||||
if err := ds.Initialize(); err != nil {
|
||||
@@ -404,42 +560,6 @@ func initCertificateManager(dataDir string) *certmanager.CertificateManager {
|
||||
return cm
|
||||
}
|
||||
|
||||
func setupPythonProxy(targetURL string, redact, logBody bool, recorder *proxy.Recorder, server *handlers.Server) *httputil.ReverseProxy {
|
||||
target, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse target URL: %v", err)
|
||||
}
|
||||
|
||||
pyProxy := httputil.NewSingleHostReverseProxy(target)
|
||||
pyProxy.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
|
||||
}
|
||||
|
||||
originalPyDirector := pyProxy.Director
|
||||
pyProxy.Director = func(req *http.Request) {
|
||||
originalPyDirector(req)
|
||||
|
||||
currentLp := proxy.NewLoggingProxy(target.String(), redact)
|
||||
currentLp.LogBody = logBody
|
||||
currentLp.RecordEnabled = server.GetRecordEnabled()
|
||||
currentLp.SetRecorder(recorder)
|
||||
currentLp.LogRequest(req)
|
||||
}
|
||||
|
||||
return pyProxy
|
||||
}
|
||||
|
||||
func startDeviceDiscovery(server *handlers.Server) {
|
||||
go func() {
|
||||
for {
|
||||
@@ -453,9 +573,9 @@ func startDeviceDiscovery(server *handlers.Server) {
|
||||
}()
|
||||
}
|
||||
|
||||
func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *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)
|
||||
@@ -479,6 +599,13 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
|
||||
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)
|
||||
@@ -492,6 +619,37 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
|
||||
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
})
|
||||
|
||||
// Legacy or direct domain calls without /marge prefix
|
||||
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Post("/streaming/support/power_on", server.HandleMargePowerOn)
|
||||
r.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)
|
||||
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
|
||||
})
|
||||
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Post("/stapp/{deviceId}", server.HandleAppEvents)
|
||||
r.Post("/scmudc/{deviceId}", server.HandleAppEvents)
|
||||
})
|
||||
|
||||
r.Route("/streaming/stats", func(r chi.Router) {
|
||||
@@ -499,6 +657,26 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
|
||||
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.Post("/spotify/prime", server.HandleMgmtPrimeDevice)
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/proxy/*", server.HandleProxyRequest)
|
||||
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
@@ -509,18 +687,19 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
|
||||
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("/info/{deviceId}", server.HandleGetDeviceInfo)
|
||||
r.Get("/summary/{deviceId}", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
|
||||
r.Post("/revert/{deviceId}", server.HandleRevertMigration)
|
||||
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
|
||||
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services/{deviceId}", server.HandleRemoveRemoteServices)
|
||||
r.Post("/backup/{deviceId}", server.HandleBackupConfig)
|
||||
r.Post("/sync/{deviceId}", server.HandleInitialSync)
|
||||
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
|
||||
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
|
||||
r.Post("/test-dns/{deviceId}", server.HandleTestDNSRedirection)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
r.Get("/proxy-settings", server.HandleGetProxySettings)
|
||||
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
|
||||
@@ -528,14 +707,18 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
|
||||
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.Get("/dns-discoveries/download", server.HandleDownloadDNSDiscoveries)
|
||||
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
|
||||
|
||||
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
|
||||
})
|
||||
|
||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||
pyProxy.ServeHTTP(w, r)
|
||||
})
|
||||
r.NotFound(server.HandleNotFound)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
accounts/
|
||||
certs/
|
||||
default/
|
||||
dns/
|
||||
interactions/
|
||||
patterns.json
|
||||
settings.json
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
## Technical Reference
|
||||
* [API Cookbook](reference/API-COOKBOOK.md)
|
||||
* [API Endpoints](reference/API-ENDPOINTS.md)
|
||||
* [Cloud API Emulation](reference/CLOUD-API.md)
|
||||
* [System Endpoints](reference/SYSTEM-ENDPOINTS.md)
|
||||
* [Speaker Endpoint](reference/SPEAKER-ENDPOINT.md)
|
||||
* [WebSocket Events](reference/WEBSOCKET-EVENTS.md)
|
||||
@@ -32,10 +33,15 @@
|
||||
* [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)
|
||||
|
||||
## Concepts
|
||||
* [Spotify Priming Strategy](concepts/spotify-priming-strategy.md)
|
||||
* [Spotify OAuth](concepts/spotify-oauth.md)
|
||||
|
||||
## Analysis & Research
|
||||
* [API Coverage Analysis](analysis/API-COVERAGE.md)
|
||||
* [Supported URLs](analysis/SUPPORTED-URLS.md)
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -9,6 +9,9 @@ SoundTouch devices primarily communicate with the following domains:
|
||||
- `updates.bose.com`: Software updates
|
||||
- `stats.bose.com`: Telemetry and analytics
|
||||
- `bmx.bose.com`: Bose Media eXchange registry
|
||||
- `events.api.bosecm.com`: Stockholm app analytics
|
||||
- `bose-prod.apigee.net`: Apigee gateway (used by some services)
|
||||
- `worldwide.bose.com`: Software update metadata and secondary services
|
||||
|
||||
---
|
||||
|
||||
@@ -153,7 +156,7 @@ For developers creating a completely isolated "dark" environment (no internet at
|
||||
1. **XML**: Point all URLs to local services.
|
||||
2. **Binary Patch**: Neutralize `IsItBose` to allow non-Bose domains/IPs.
|
||||
3. **`/etc/hosts`**: Redirect hardcoded domains that aren't exposed in the XML (like analytics or NTP) to prevent leakage to the real Bose cloud.
|
||||
4. **Process Instrumentation**: Use [SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook) to monitor and override internal behavior in real-time.
|
||||
4. **Process Instrumentation**: Use [SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook) to monitor and override internal behavior in real-time. This is particularly useful for handling unknown hostnames or deep-hooking into service discovery logic that might bypass standard DNS lookups.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Spotify OAuth Integration
|
||||
|
||||
The SoundTouch service supports Spotify OAuth integration to broker access tokens for SoundTouch speakers. This is particularly useful for maintaining Spotify Connect functionality after the Bose cloud shutdown (scheduled for May 2026).
|
||||
|
||||
## OAuth Flows
|
||||
|
||||
The service supports two primary OAuth flows: a browser-based flow and a mobile app-based flow (specifically for the [ueberboese](https://github.com/julius-d/ueberboese-app) app).
|
||||
|
||||
### 1. Browser-based Flow
|
||||
|
||||
The user initiates the flow, completes authorization in their browser, and is redirected back to the service.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as Client (curl/app)
|
||||
participant Service as Service
|
||||
participant Spotify as Spotify Auth Server
|
||||
participant Browser as User's Browser
|
||||
|
||||
Client->>Service: POST /mgmt/spotify/init [Basic Auth]
|
||||
Service-->>Client: {"redirectUrl": "https://accounts.spotify.com/authorize?..."}
|
||||
|
||||
Client->>Browser: User opens URL
|
||||
Browser->>Spotify: User logs in & grants access
|
||||
Spotify-->>Browser: Redirect to /mgmt/spotify/callback?code=abc
|
||||
|
||||
Browser->>Service: GET /mgmt/spotify/callback?code=abc
|
||||
Note over Service: No auth needed for callback
|
||||
|
||||
Service->>Spotify: POST /api/token (exchange code)
|
||||
Spotify-->>Service: {access_token, refresh_token}
|
||||
|
||||
Service->>Spotify: GET /v1/me (fetch profile)
|
||||
Spotify-->>Service: {id, display_name, email}
|
||||
|
||||
Note over Service: Store account to disk
|
||||
|
||||
Service-->>Browser: HTML: "Spotify Connected. You can close this window."
|
||||
```
|
||||
|
||||
### 2. Mobile App Flow (ueberboese)
|
||||
|
||||
The mobile app handles the redirect via a deep link and then confirms the authorization with the service.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as ueberboese Flutter App
|
||||
participant Service as Service
|
||||
participant Spotify as Spotify Auth Server
|
||||
|
||||
App->>Service: POST /mgmt/spotify/init [Basic Auth]
|
||||
Service-->>App: {"redirectUrl": "https://..."}
|
||||
|
||||
App->>Spotify: Open in-app browser (User authorizes)
|
||||
Spotify-->>App: Deep link redirect: ueberboese-login://spotify?code=abc
|
||||
|
||||
App->>Service: POST /mgmt/spotify/confirm?code=abc [Basic Auth]
|
||||
|
||||
Service->>Spotify: POST /api/token (exchange code)
|
||||
Spotify-->>Service: {access_token, refresh_token}
|
||||
|
||||
Service->>Spotify: GET /v1/me (fetch profile)
|
||||
Spotify-->>Service: {profile}
|
||||
|
||||
Service-->>App: {"ok": true}
|
||||
```
|
||||
|
||||
### 3. Token Retrieval (Boot Primer / Speaker Setup)
|
||||
|
||||
Once an account is linked, access tokens can be retrieved for use with speakers (e.g., via the `addUser` ZeroConf command).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Primer as Boot Primer Script
|
||||
participant Service as Service
|
||||
participant Spotify as Spotify Token API
|
||||
participant Speaker as Speaker (Bose ST 20)
|
||||
|
||||
Primer->>Service: GET /mgmt/spotify/token [Basic Auth]
|
||||
|
||||
alt Token expired
|
||||
Service->>Spotify: POST /api/token (refresh)
|
||||
Spotify-->>Service: new tokens
|
||||
end
|
||||
|
||||
Service-->>Primer: {"access_token": "...", "username": "..."}
|
||||
|
||||
Note over Primer: Spotify Connect ZeroConf
|
||||
Primer->>Speaker: POST /SpotifyConnect (addUser with token)
|
||||
Speaker-->>Primer: OK
|
||||
Note over Speaker: Speaker now has Spotify access
|
||||
```
|
||||
|
||||
## Boot Primer Script
|
||||
|
||||
A boot primer script that uses these endpoints to feed Spotify tokens to speakers via ZeroConf is available in the `scripts/spotify/` directory: [spotify-boot-primer.sh](../../scripts/spotify/spotify-boot-primer.sh).
|
||||
|
||||
This script can be installed on the speaker itself (which runs embedded Linux) to automatically prime Spotify Connect at boot time. See [README.md](../../scripts/spotify/README.md) and [INSTALL.md](../../scripts/spotify/INSTALL.md) for instructions.
|
||||
|
||||
### Automated Installation via Service
|
||||
|
||||
The SoundTouch service provides a dedicated management endpoint to automatically handle the installation of the Spotify boot primer on the speaker:
|
||||
`POST /mgmt/devices/{deviceId}/spotify/install-primer`
|
||||
|
||||
### Automated Installation Steps
|
||||
When you run the Spotify primer installation, the service performs the following:
|
||||
1. **Directories**: Creates `/mnt/nv/bin` and `/mnt/nv/BoseApp-Persistence/1` on the speaker.
|
||||
2. **Binary**: Uploads the `spotify-boot-primer` script to the speaker.
|
||||
3. **Configuration**: Automatically generates and uploads `spotify-primer.conf` containing the service's URL and management credentials.
|
||||
4. **Boot Hook**: Injects a call to the primer in the speaker's `/mnt/nv/rc.local` using idempotent markers.
|
||||
5. **Environment**: Updates `/mnt/nv/.profile` to include `/mnt/nv/bin` in the `PATH` for easier manual troubleshooting via SSH.
|
||||
|
||||
- **Idempotent Patching**: The service uses explicit markers to inject the hook, ensuring it doesn't corrupt existing content.
|
||||
- **Coexistence**: The service-injected hook is designed to coexist with a manually installed `rc.local` (e.g., from the community gist). It only adds a call to `/mnt/nv/bin/spotify-boot-primer` if it's not already managed by a service-controlled block.
|
||||
- **Markers**: Look for the following markers in your speaker's `/mnt/nv/rc.local`:
|
||||
- `# --- Aftertouch Spotify hook START ---`
|
||||
- `# --- Aftertouch Spotify hook END ---`
|
||||
- **Cleanup**: Reverting a migration via the service will cleanly remove these marker-delimited blocks.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|---------------------------------------------------|-------|-----------------------------------------------------------------------|
|
||||
| POST | `/mgmt/devices/{deviceId}/spotify/install-primer` | Basic | Install Spotify boot primer on speaker (deviceId or IP) |
|
||||
| GET | `/mgmt/spotify/callback` | None | Browser OAuth callback (redirect from Spotify, returns HTML) |
|
||||
| POST | `/mgmt/spotify/init` | Basic | Start OAuth flow, returns authorization URL |
|
||||
| POST | `/mgmt/spotify/confirm` | Basic | Mobile app confirm (ueberboese deep link delivers code, returns JSON) |
|
||||
| GET | `/mgmt/spotify/accounts` | Basic | List linked Spotify accounts (tokens stripped) |
|
||||
| GET | `/mgmt/spotify/token` | Basic | Get fresh access token (auto-refreshes if expired) |
|
||||
| POST | `/mgmt/spotify/entity` | Basic | Resolve Spotify URI to name + image URL |
|
||||
|
||||
## Security
|
||||
|
||||
- `/mgmt/spotify/callback` is intentionally outside Basic Auth to allow direct redirects from Spotify's authorization server.
|
||||
- All other `/mgmt/*` endpoints require Basic Auth as configured by `--mgmt-username` and `--mgmt-password`.
|
||||
- Tokens are persisted to disk as JSON with restricted file permissions (`0600`).
|
||||
- The `GetAccounts` endpoint strips sensitive tokens from the response.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Spotify Priming Strategy
|
||||
|
||||
This document outlines the strategy for ensuring Bose SoundTouch devices are correctly "primed" for Spotify Connect integration within the AfterTouch ecosystem.
|
||||
|
||||
## Overview
|
||||
|
||||
To enable Spotify Connect for SoundTouch devices, especially for remote availability outside the local network, the speaker must be associated with a Spotify account via a process called "priming." This involves sending an `addUser` command to the speaker's ZeroConf API (port 8200) containing a valid Spotify username and OAuth access token.
|
||||
|
||||
AfterTouch adopts a **Server-Centric Hybrid Model** that prioritizes device cleanliness and user intent while providing automated self-healing.
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. User Intent (Opt-in)
|
||||
AfterTouch replicates the native Bose "Add Source" experience. No Spotify priming occurs until a user explicitly links their Spotify account through the AfterTouch Management Dashboard. This ensures privacy and respects users who do not wish to use Spotify.
|
||||
|
||||
### 2. Device Cleanliness (Minimalist Footprint)
|
||||
We avoid invasive modifications to the speaker's filesystem.
|
||||
- **No On-Device Scripts:** We deprecate the use of internal boot-primer scripts.
|
||||
- **Native Communication:** We rely on the speaker's native ability to talk to Bose services, which are intercepted via DNS to point to the AfterTouch server.
|
||||
|
||||
### 3. Triggers for Priming
|
||||
Priming is triggered when the speaker signals it is active and ready, specifically:
|
||||
|
||||
- **Power On:** When the speaker calls the `/marge/streaming/support/power_on` endpoint, AfterTouch ensures the device's ZeroConf state is correctly primed. This is the primary trigger.
|
||||
- **Manual Override:** Users can manually trigger a "Prime Spotify" from the device list in the UI if needed.
|
||||
|
||||
During any of these events, the server:
|
||||
1. Checks if a Spotify account is linked in AfterTouch.
|
||||
2. Checks the device's current priming status (via ZeroConf).
|
||||
3. If unprimed and an account is linked, it pushes the priming command.
|
||||
|
||||
### 4. Automated Recovery
|
||||
AfterTouch ensures that if a speaker loses its session (due to a crash or power loss), it is re-primed when it next powers on and reaches out to the service.
|
||||
|
||||
### 5. Decoupling
|
||||
The logic for account management and device interaction remains decoupled:
|
||||
- **Spotify Service:** Manages OAuth tokens and account state.
|
||||
- **Discovery Service:** Finds devices and tracks their network presence.
|
||||
- **Orchestrator:** Connects the two, deciding when to push tokens to discovered devices based on the current link status.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Initial Setup (The "Add Source" UX)
|
||||
1. User opens the AfterTouch Dashboard.
|
||||
2. User selects "Link Spotify Account."
|
||||
3. OAuth flow completes; AfterTouch stores the token.
|
||||
4. AfterTouch immediately triggers a discovery run to find and prime all compatible speakers.
|
||||
|
||||
### Maintenance (The "Watchdog" UX)
|
||||
1. A speaker reboots or loses its token.
|
||||
2. A discovery event occurs (periodic or triggered by UI).
|
||||
3. AfterTouch detects the "Empty" user state on the speaker.
|
||||
4. AfterTouch pushes a fresh token from the Spotify Service.
|
||||
5. UI reflects that the device is "Managed by AfterTouch" and healthy.
|
||||
|
||||
### Manual Override
|
||||
Users can manually trigger a "Re-prime" or "Refresh Link" from the device list in the UI if they suspect the automated self-healing is delayed or if they want to force a specific account onto a device.
|
||||
|
||||
## Network Topology & Deployment Scenarios
|
||||
|
||||
The strategy adapts based on where the AfterTouch server is deployed:
|
||||
|
||||
### Local Deployment (Home Server / Docker)
|
||||
- **Mechanism:** Both "Pull" (Marge) and "Push" (ZeroConf side-channel) are used.
|
||||
- **Advantage:** The server can proactively fix the speaker's state via port 8200 as soon as it sees a "Liveness Signal."
|
||||
|
||||
### External Deployment (Cloud VPS)
|
||||
- **Mechanism:** Primarily relies on "Pull" (Marge).
|
||||
- **Constraint:** The server cannot reach port 8200 on the speaker due to NAT/Firewall.
|
||||
- **Strategy:** In this scenario, AfterTouch acts as a passive token provider. The speaker must initiate the connection to our intercepted Bose endpoints to receive its Spotify configuration. If the speaker completely loses its user state and stops "pulling," a manual re-prime from a local machine or a temporary local discovery run might be required.
|
||||
|
||||
## Transition & Cleanup
|
||||
|
||||
As AfterTouch moves to the Server-Centric model, we will:
|
||||
1. **Revert On-Device Migration:** Update the Setup Manager to remove legacy `spotify-boot-primer` scripts and `rc.local` hooks from the speakers.
|
||||
2. **Consolidated Directory:** We maintain the `/mnt/nv/soundtouch-service/` base directory for other configuration needs (e.g., `aftertouch.resolv.conf`), but it will no longer contain Spotify-specific credentials or scripts.
|
||||
3. **No On-Device Credentials:** The `/mnt/nv/soundtouch-service/spotify-primer.conf` will be removed, ensuring that no sensitive AfterTouch login details are stored on the speaker in plain text.
|
||||
|
||||
## Implementation Roadmap (Conceptual)
|
||||
|
||||
1. **Revert On-Device Migration:** Update the Setup Manager to remove legacy scripts and `rc.local` hooks.
|
||||
2. **Server-Side Priming Logic:** Implement a `PrimeDevice(ip)` method in the server that fetches a fresh token and calls the ZeroConf API.
|
||||
3. **Discovery Hook:** Integrate `PrimeDevice` into the discovery handler (`handleDiscoveredDevice`) with a check for unprimed state.
|
||||
4. **UI Enhancements:** Update the Speaker List to show "Spotify Linked" status and provide manual refresh buttons.
|
||||
@@ -1,6 +1,6 @@
|
||||
# HTTPS Setup & Custom CA Certificate
|
||||
|
||||
To use the `/etc/hosts` redirection method safely, SoundTouch devices must communicate over HTTPS. This requires the device to trust the Root CA certificate used by the local `soundtouch-service`.
|
||||
To use the `/etc/hosts` redirection method safely, SoundTouch devices must communicate over HTTPS. This requires the device to trust the AfterTouch Root CA certificate used by the local service.
|
||||
|
||||
## 1. Automated Migration (Hosts Method)
|
||||
|
||||
@@ -13,12 +13,12 @@ curl -X POST "http://localhost:8000/setup/migrate/{deviceIP}?method=hosts"
|
||||
This command will:
|
||||
1. Connect to the device via SSH.
|
||||
2. Update `/etc/hosts` to point Bose domains to the service IP.
|
||||
3. Inject the auto-generated Root CA into the device's trust store (`/etc/pki/tls/certs/ca-bundle.crt`).
|
||||
3. Inject the auto-generated AfterTouch Root CA into the device's trust store (`/etc/pki/tls/certs/ca-bundle.crt`).
|
||||
4. Reboot the device.
|
||||
|
||||
## 2. Managing the Root CA
|
||||
|
||||
The `soundtouch-service` automatically generates a Root CA when it first starts.
|
||||
The AfterTouch service automatically generates a Root CA when it first starts.
|
||||
|
||||
- **CA Certificate**: `data/certs/ca.crt`
|
||||
- **CA Private Key**: `data/certs/ca.key`
|
||||
@@ -34,7 +34,7 @@ The `soundtouch-service` now includes a built-in HTTPS listener. This simplifies
|
||||
- **HTTPS Port**: Configurable via `HTTPS_PORT` environment variable (defaults to `8443`).
|
||||
- **HTTPS Server URL**: Configurable via `HTTPS_SERVER_URL` (e.g., `https://mysoundtouch.local:8443`). If not set, the service attempts to guess it using the system hostname.
|
||||
- **Domain Coverage**: Automatically presents a certificate for `streaming.bose.com`, `updates.bose.com`, `stats.bose.com`, `bmx.bose.com`, and `content.api.bose.io`.
|
||||
- **Automatic Setup**: On first start, it generates a server certificate signed by your local Root CA.
|
||||
- **Automatic Setup**: On first start, it generates a server certificate signed by your AfterTouch local Root CA.
|
||||
|
||||
#### TLS Security
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -235,6 +241,75 @@ curl "http://192.168.1.100:8090/presets"
|
||||
curl "http://localhost:8000/events/192.168.1.100"
|
||||
```
|
||||
|
||||
#### 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
|
||||
@@ -381,6 +456,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 +487,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 +538,8 @@ data/
|
||||
│ │ └── {PATH}/
|
||||
│ │ └── {SEQ}-{TIME}-{METHOD}.http
|
||||
│ └── http-client.env.json
|
||||
├── dns/
|
||||
│ └── discoveries.json
|
||||
├── stats/
|
||||
│ ├── usage/
|
||||
│ │ └── *.json
|
||||
@@ -480,6 +561,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
|
||||
@@ -559,6 +643,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.
|
||||
|
||||
@@ -43,7 +43,7 @@ To migrate your speakers, the service needs SSH access. You can enable it by:
|
||||
3. Rebooting the speaker (unplug/replug).
|
||||
|
||||
**Verify SSH Access:**
|
||||
- Confirm the device responds to SSH without a password: `ssh -oHostKeyAlgorithms=+ssh-rsa root@<IP>`
|
||||
- Confirm the device responds to SSH without a password: `ssh -o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa root@<IP>`
|
||||
- Or use the **Migration** tab in the Web UI to see if the device shows a "✅ Success" status for SSH.
|
||||
Once enabled, you can log in as `root` (no password).
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Bose SoundTouch Cloud API Emulation (Marge/BMX/Stats)
|
||||
|
||||
This document describes the cloud-emulation APIs provided by the SoundTouch service. These APIs mimic the Bose cloud services (Marge, BMX, Stats) that SoundTouch devices and the SoundTouch controller application (Stockholm) interact with.
|
||||
|
||||
## Marge API (Account & Configuration)
|
||||
|
||||
Base path: `/marge`
|
||||
|
||||
### GET /streaming/sourceproviders
|
||||
Retrieves a list of available streaming source providers.
|
||||
|
||||
### GET /accounts/{accountId}/full
|
||||
Retrieves the full account configuration including sources, presets, and devices.
|
||||
|
||||
### GET /streaming/account/{accountId}/emailaddress
|
||||
Retrieves the email address associated with the account.
|
||||
|
||||
### GET /streaming/device_setting/account/{accountId}/device/{deviceId}/device_settings
|
||||
Retrieves settings for a specific device (e.g., clock format).
|
||||
|
||||
### POST /streaming/device_setting/account/{accountId}/device/{deviceId}/device_settings
|
||||
Updates settings for a specific device.
|
||||
|
||||
### POST /accounts/{accountId}/devices/{deviceId}/presets/{presetNumber}
|
||||
Updates a preset for a device.
|
||||
|
||||
### POST /accounts/{accountId}/devices/{deviceId}/recents
|
||||
Adds an item to the device's recently played history.
|
||||
|
||||
### POST /accounts/{accountId}/devices
|
||||
Adds a device to the account.
|
||||
|
||||
### DELETE /accounts/{accountId}/devices/{deviceId}
|
||||
Removes a device from the account.
|
||||
|
||||
## Customer API (Profile & Password)
|
||||
|
||||
Base path: `/customer`
|
||||
|
||||
### GET /account/{accountId}
|
||||
Retrieves the customer account profile.
|
||||
|
||||
### POST /account/{accountId}
|
||||
Updates the customer account profile.
|
||||
|
||||
### POST /account/{accountId}/password
|
||||
Changes the account password.
|
||||
|
||||
## Analytics & Stats API
|
||||
|
||||
Base path: `/v1` (App Events) or `/streaming/stats` (Device Stats)
|
||||
|
||||
### POST /v1/stapp/{deviceId}
|
||||
Endpoint called by Bose SoundTouch mobile and web applications (Stockholm) to submit event data.
|
||||
|
||||
### POST /v1/scmudc/{deviceId}
|
||||
Endpoint equivalent to `/v1/stapp/{deviceId}` sometimes used by apps or devices.
|
||||
|
||||
### POST /streaming/stats/usage
|
||||
Endpoint used by physical devices to report usage statistics.
|
||||
|
||||
### POST /streaming/stats/error
|
||||
Endpoint used by physical devices to report error statistics.
|
||||
|
||||
## BMX API (Streaming & Registry)
|
||||
|
||||
Base path: `/bmx`
|
||||
|
||||
### GET /registry/v1/services
|
||||
Retrieves the registry of available streaming services.
|
||||
|
||||
### GET /tunein/v1/playback/station/{stationID}
|
||||
Retrieves playback information for a TuneIn station.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -6,18 +6,18 @@ 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.47.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
)
|
||||
|
||||
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.32.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
)
|
||||
|
||||
@@ -24,16 +24,16 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
@@ -44,8 +44,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -79,8 +79,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
@@ -98,6 +98,6 @@ golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
// Package discovery provides DNS-based discovery and interception for Bose SoundTouch devices.
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"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 []string, 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 len(d.upstreamDNS) == 0 {
|
||||
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 %v", 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",
|
||||
"streamingoauth.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())
|
||||
|
||||
resolvedIP := ip
|
||||
if net.ParseIP(ip) == nil {
|
||||
// Attempt resolution if it's not a numeric IP
|
||||
ips, err := net.LookupIP(ip)
|
||||
if err == nil && len(ips) > 0 {
|
||||
for _, rIP := range ips {
|
||||
if rIP.To4() != nil {
|
||||
resolvedIP = rIP.String()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if resolvedIP == ip && len(ips) > 0 {
|
||||
resolvedIP = ips[0].String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch q.Qtype {
|
||||
case dns.TypeA, dns.TypeANY:
|
||||
if net.ParseIP(resolvedIP) == nil || strings.Contains(resolvedIP, ":") {
|
||||
// If it's still not a valid IPv4 address, we can't create an A record.
|
||||
// Try CNAME as a fallback if it looks like a hostname.
|
||||
if !strings.Contains(resolvedIP, ":") {
|
||||
// Normalize hostname for CNAME
|
||||
target := resolvedIP
|
||||
if !strings.HasSuffix(target, ".") {
|
||||
target += "."
|
||||
}
|
||||
|
||||
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN CNAME %s", q.Name, target))
|
||||
if err == nil {
|
||||
m.Answer = append(m.Answer, rr)
|
||||
|
||||
log.Printf("[DNS] Returning CNAME record %s -> %s", q.Name, target)
|
||||
} else {
|
||||
log.Printf("[DNS] Error creating CNAME fallback for %s: %v", target, err)
|
||||
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
}
|
||||
} else {
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
}
|
||||
} else {
|
||||
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN A %s", q.Name, resolvedIP))
|
||||
if err == nil {
|
||||
m.Answer = append(m.Answer, rr)
|
||||
|
||||
log.Printf("[DNS] Returning A record %s -> %s", q.Name, resolvedIP)
|
||||
} else {
|
||||
log.Printf("[DNS] Error creating A record for %s: %v", resolvedIP, err)
|
||||
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
}
|
||||
}
|
||||
case dns.TypeAAAA:
|
||||
// Check if we have an IPv6 address
|
||||
if net.ParseIP(resolvedIP) != nil && strings.Contains(resolvedIP, ":") {
|
||||
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN AAAA %s", q.Name, resolvedIP))
|
||||
if err == nil {
|
||||
m.Answer = append(m.Answer, rr)
|
||||
|
||||
log.Printf("[DNS] Returning AAAA record %s -> %s", q.Name, resolvedIP)
|
||||
} else {
|
||||
log.Printf("[DNS] Error creating AAAA record for %s: %v", resolvedIP, err)
|
||||
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
}
|
||||
} else {
|
||||
// Explicitly return SUCCESS with no data for AAAA to prevent fallback issues if no IPv6
|
||||
log.Printf("[DNS] Returning empty AAAA success (NODATA) for %s", q.Name)
|
||||
}
|
||||
default:
|
||||
log.Printf("[DNS] Returning empty success for type %d", q.Qtype)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
c := new(dns.Client)
|
||||
c.Timeout = 2 * time.Second
|
||||
|
||||
for _, upstream := range d.upstreamDNS {
|
||||
// Add port 53 if not present
|
||||
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))
|
||||
continue
|
||||
}
|
||||
|
||||
in, _, err := c.Exchange(r, upstream)
|
||||
if err == nil {
|
||||
if in.Rcode == dns.RcodeSuccess {
|
||||
if writeErr := w.WriteMsg(in); writeErr != nil {
|
||||
log.Printf("[DNS ERROR] Failed to write forwarded response from %s: %v", upstream, writeErr)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
d.throttledLog(fmt.Sprintf("[DNS] Upstream %s returned %s for %s, trying next", upstream, dns.RcodeToString[in.Rcode], q.Name))
|
||||
} else {
|
||||
d.throttledLog(fmt.Sprintf("[DNS ERROR] Forward failed for %s (type %d) via %s: %v", q.Name, q.Qtype, upstream, err))
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach here, all upstreams failed
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func TestDNSDiscovery_Interception(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := []string{"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 intercepting streamingoauth.bose.com
|
||||
m3 := new(dns.Msg)
|
||||
m3.SetQuestion("streamingoauth.bose.com.", dns.TypeA)
|
||||
rw3 := &mockResponseWriter{}
|
||||
d.ServeDNS(rw3, m3)
|
||||
|
||||
if rw3.msg == nil || len(rw3.msg.Answer) == 0 {
|
||||
t.Fatal("Expected response for streamingoauth.bose.com")
|
||||
}
|
||||
|
||||
if a, ok := rw3.msg.Answer[0].(*dns.A); ok {
|
||||
if a.A.String() != serviceIP {
|
||||
t.Errorf("Expected intercepted IP %s for streamingoauth.bose.com, got %s", serviceIP, a.A.String())
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Expected A record for streamingoauth.bose.com, got %T", rw3.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 := []string{"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 := []string{"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 := []string{"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([]string{"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 := []string{"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"
|
||||
var upstreamDNS []string // 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 := []string{"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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_MultipleUpstreams(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
|
||||
// Mock server 1: returns NXDOMAIN
|
||||
mux1 := dns.NewServeMux()
|
||||
mux1.HandleFunc("test.com.", func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
m.Rcode = dns.RcodeNameError
|
||||
_ = w.WriteMsg(m)
|
||||
})
|
||||
ts1 := &dns.Server{Addr: "127.0.0.1:5356", Net: "udp", Handler: mux1}
|
||||
go func() { _ = ts1.ListenAndServe() }()
|
||||
defer func() { _ = ts1.Shutdown() }()
|
||||
|
||||
// Mock server 2: succeeds
|
||||
mux2 := dns.NewServeMux()
|
||||
mux2.HandleFunc("test.com.", func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
m.Answer = append(m.Answer, &dns.A{
|
||||
Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
|
||||
A: net.ParseIP("1.2.3.4"),
|
||||
})
|
||||
_ = w.WriteMsg(m)
|
||||
})
|
||||
ts2 := &dns.Server{Addr: "127.0.0.1:5357", Net: "udp", Handler: mux2}
|
||||
go func() { _ = ts2.ListenAndServe() }()
|
||||
defer func() { _ = ts2.Shutdown() }()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
upstreamDNS := []string{"127.0.0.1:5356", "127.0.0.1:5357"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("test.com.", dns.TypeA)
|
||||
rw := &mockResponseWriter{}
|
||||
|
||||
d.forward(rw, m)
|
||||
|
||||
if rw.msg == nil {
|
||||
t.Fatal("Expected a response message")
|
||||
}
|
||||
|
||||
// It should succeed because it falls back to the second upstream
|
||||
if rw.msg.Rcode != dns.RcodeSuccess {
|
||||
t.Errorf("Expected RcodeSuccess (0), got %d. Fallback failed.", rw.msg.Rcode)
|
||||
}
|
||||
|
||||
if len(rw.msg.Answer) == 0 {
|
||||
t.Fatal("Expected an answer from the second upstream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_HostnameServiceIP(t *testing.T) {
|
||||
// Use localhost which should resolve to 127.0.0.1
|
||||
serviceIP := "localhost"
|
||||
upstreamDNS := []string{"8.8.8.8"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("api.bose.com.", dns.TypeA)
|
||||
|
||||
rw := &mockResponseWriter{}
|
||||
d.ServeDNS(rw, m)
|
||||
|
||||
if rw.msg == nil {
|
||||
t.Fatal("Expected a response message, got nil")
|
||||
}
|
||||
|
||||
if len(rw.msg.Answer) == 0 {
|
||||
t.Fatal("Expected an answer in the response")
|
||||
}
|
||||
|
||||
if a, ok := rw.msg.Answer[0].(*dns.A); ok {
|
||||
// It should be resolved to 127.0.0.1 (or whatever localhost resolves to)
|
||||
if a.A.String() == "" {
|
||||
t.Error("Expected a non-empty IP address")
|
||||
}
|
||||
log.Printf("Resolved localhost to %s", a.A.String())
|
||||
} else if cname, ok := rw.msg.Answer[0].(*dns.CNAME); ok {
|
||||
// Fallback to CNAME is also acceptable if resolution failed but it shouldn't for localhost
|
||||
if cname.Target != "localhost." {
|
||||
t.Errorf("Expected CNAME to localhost., got %s", cname.Target)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Expected A or CNAME record, got %T", rw.msg.Answer[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_UnresolvableHostname(t *testing.T) {
|
||||
// Use a likely unresolvable hostname
|
||||
serviceIP := "this.hostname.does.not.exist.at.all.invalid"
|
||||
upstreamDNS := []string{"8.8.8.8"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("api.bose.com.", dns.TypeA)
|
||||
|
||||
rw := &mockResponseWriter{}
|
||||
d.ServeDNS(rw, m)
|
||||
|
||||
if rw.msg == nil {
|
||||
t.Fatal("Expected a response message, got nil")
|
||||
}
|
||||
|
||||
if len(rw.msg.Answer) == 0 {
|
||||
t.Fatal("Expected an answer in the response (CNAME fallback)")
|
||||
}
|
||||
|
||||
if cname, ok := rw.msg.Answer[0].(*dns.CNAME); ok {
|
||||
expected := serviceIP + "."
|
||||
if cname.Target != expected {
|
||||
t.Errorf("Expected CNAME to %s, got %s", expected, cname.Target)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Expected CNAME record for unresolvable hostname, got %T", rw.msg.Answer[0])
|
||||
}
|
||||
}
|
||||
@@ -240,3 +240,70 @@ type DeviceEvent struct {
|
||||
MonoTime int64 `json:"monoTime"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// DeviceEventsRequest represents a request containing multiple device events (stapp/scmudc).
|
||||
type DeviceEventsRequest struct {
|
||||
Envelope struct {
|
||||
MonoTime int64 `json:"monoTime"`
|
||||
PayloadProtocolVersion string `json:"payloadProtocolVersion"`
|
||||
PayloadType string `json:"payloadType"`
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
Time string `json:"time"`
|
||||
UniqueID string `json:"uniqueId"`
|
||||
} `json:"envelope"`
|
||||
Payload struct {
|
||||
DeviceInfo struct {
|
||||
BoseID string `json:"boseID"`
|
||||
DeviceID string `json:"deviceID"`
|
||||
DeviceType string `json:"deviceType"`
|
||||
SoftwareVersion string `json:"softwareVersion"`
|
||||
} `json:"deviceInfo"`
|
||||
Events []struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
Time string `json:"time"`
|
||||
Type string `json:"type"`
|
||||
} `json:"events"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
|
||||
// DeviceSettingsResponse represents device settings.
|
||||
type DeviceSettingsResponse struct {
|
||||
XMLName xml.Name `xml:"deviceSettings"`
|
||||
Settings []DeviceSetting `xml:"deviceSetting"`
|
||||
}
|
||||
|
||||
// DeviceSetting represents a single device setting.
|
||||
type DeviceSetting struct {
|
||||
Name string `xml:"name"`
|
||||
Value string `xml:"value"`
|
||||
}
|
||||
|
||||
// AccountProfileResponse represents a customer account profile.
|
||||
type AccountProfileResponse struct {
|
||||
XMLName xml.Name `xml:"customer"`
|
||||
AccountID string `xml:"accountID"`
|
||||
Email string `xml:"email"`
|
||||
FirstName string `xml:"firstName"`
|
||||
LastName string `xml:"lastName"`
|
||||
CountryCode string `xml:"countryCode"`
|
||||
LanguageCode string `xml:"languageCode"`
|
||||
Street string `xml:"street"`
|
||||
City string `xml:"city"`
|
||||
PostalCode string `xml:"postalCode"`
|
||||
State string `xml:"state"`
|
||||
Phone string `xml:"phone"`
|
||||
MarketingOptIn bool `xml:"marketingOptIn"`
|
||||
}
|
||||
|
||||
// ChangePasswordRequest represents a request to change the account password.
|
||||
type ChangePasswordRequest struct {
|
||||
XMLName xml.Name `xml:"passwordChange"`
|
||||
OldPassword string `xml:"oldPassword"`
|
||||
NewPassword string `xml:"newPassword"`
|
||||
}
|
||||
|
||||
// EmailAddressResponse represents the account email address.
|
||||
type EmailAddressResponse struct {
|
||||
XMLName xml.Name `xml:"emailAddress"`
|
||||
Email string `xml:",chardata"`
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -148,8 +148,8 @@ func (cm *CertificateManager) GenerateCA() error {
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"SoundTouch Local Service"},
|
||||
CommonName: "SoundTouch Local Root CA",
|
||||
Organization: []string{"AfterTouch"},
|
||||
CommonName: "AfterTouch Local Root CA",
|
||||
},
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
@@ -240,7 +240,7 @@ func (cm *CertificateManager) GenerateCertificate(domains []string) ([]byte, []b
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"SoundTouch Local Service"},
|
||||
Organization: []string{"AfterTouch"},
|
||||
CommonName: domains[0],
|
||||
},
|
||||
NotBefore: notBefore,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -158,8 +159,8 @@ func (ds *DataStore) getPossibleDataDirs() []string {
|
||||
dirs = append(dirs, filepath.Join(ds.DataDir, "accounts"))
|
||||
}
|
||||
|
||||
// Also check soundcork-go/data/accounts if it's different and exists
|
||||
altDir := "soundcork-go/data/accounts"
|
||||
// Also check st-go/data/accounts if it's different and exists
|
||||
altDir := "st-go/data/accounts"
|
||||
if filepath.Join(ds.DataDir, "accounts") != altDir && exists(altDir) {
|
||||
dirs = append(dirs, altDir)
|
||||
}
|
||||
@@ -698,15 +699,19 @@ func (ds *DataStore) GetETagForAccount(account, device string) int64 {
|
||||
|
||||
// Settings represents the global service settings.
|
||||
type Settings struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
ProxyURL string `json:"proxy_url"`
|
||||
HTTPServerURL string `json:"https_server_url,omitempty"`
|
||||
RedactLogs bool `json:"redact_logs"`
|
||||
LogBodies bool `json:"log_bodies"`
|
||||
RecordInteractions bool `json:"record_interactions"`
|
||||
DiscoveryInterval string `json:"discovery_interval,omitempty"`
|
||||
DiscoveryEnabled bool `json:"discovery_enabled"`
|
||||
Shortcuts map[string]int `json:"shortcuts,omitempty"`
|
||||
ServerURL string `json:"server_url"`
|
||||
SoundcorkURL string `json:"soundcork_url"`
|
||||
HTTPServerURL string `json:"https_server_url,omitempty"`
|
||||
RedactLogs bool `json:"redact_logs"`
|
||||
LogBodies bool `json:"log_bodies"`
|
||||
RecordInteractions bool `json:"record_interactions"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// GetSettings retrieves the global service settings.
|
||||
@@ -821,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)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func TestDataStore(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func TestDataStore(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestListAllDevices_Empty(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-empty-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-empty-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -134,7 +134,7 @@ func TestListAllDevices_Empty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestListAllDevices(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-list-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-list-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -175,7 +175,7 @@ func TestListAllDevices(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestListAllDevices_EmptyDeviceID(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-empty-id-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-empty-id-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -218,7 +218,7 @@ func TestListAllDevices_EmptyDeviceID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestListAllDevices_MultipleEmptyIDs(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-multi-empty-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-multi-empty-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -264,7 +264,7 @@ func TestListAllDevices_MultipleEmptyIDs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestListAllDevices_MalformedXML(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-malformed-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-malformed-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -382,7 +382,7 @@ func TestSettingsPersistence(t *testing.T) {
|
||||
|
||||
settings := Settings{
|
||||
ServerURL: "http://myserver:8000",
|
||||
ProxyURL: "http://myproxy:8001",
|
||||
SoundcorkURL: "http://myproxy:8001",
|
||||
LogBodies: true,
|
||||
DiscoveryInterval: "10m",
|
||||
DiscoveryEnabled: true,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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 (should fallback to system DNS)
|
||||
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.StatusOK {
|
||||
t.Errorf("Expected status 200 when enabling DNS without upstream (fallback to system), got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify DNS state in server
|
||||
if !server.dnsEnabled {
|
||||
t.Error("DNS should be enabled in server state")
|
||||
}
|
||||
|
||||
// Verify it TRIED to start (either it is running, or it failed due to port conflict but state is enabled)
|
||||
if !server.dnsEnabled {
|
||||
t.Error("DNS state should be enabled")
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ const normalizedEtag = "Etag"
|
||||
const caseSensitiveETag = "ETag"
|
||||
|
||||
func TestMargeETags(t *testing.T) {
|
||||
tempDir, _ := os.MkdirTemp("", "soundcork-etag-test-*")
|
||||
tempDir, _ := os.MkdirTemp("", "st-etag-test-*")
|
||||
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -56,7 +57,122 @@ func (s *Server) HandleMargeAccountFull(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// HandleMargePowerOn handles the Marge power on request.
|
||||
func (s *Server) HandleMargePowerOn(w http.ResponseWriter, _ *http.Request) {
|
||||
func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Printf("[Marge] Failed to read power_on body: %v", err)
|
||||
w.WriteHeader(http.StatusOK) // Silent failure is usually better for device requests
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CustomerSupportRequest
|
||||
if err := xml.Unmarshal(body, &req); err != nil {
|
||||
log.Printf("[Marge] Failed to parse power_on body: %v", err)
|
||||
|
||||
// Fallback to remote address if body parsing fails
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
go s.PrimeDeviceWithSpotify(host)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := req.Device.ID
|
||||
deviceIP := req.DiagnosticData.DeviceLandscape.IPAddress
|
||||
|
||||
log.Printf("[Marge] Device %s powered on (IP: %s)", deviceID, deviceIP)
|
||||
|
||||
if deviceIP != "" {
|
||||
go s.PrimeDeviceWithSpotify(deviceIP)
|
||||
} else {
|
||||
// Fallback to remote address if IP is missing from XML
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
go s.PrimeDeviceWithSpotify(host)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// HandleMargeAccountProfile returns the account profile.
|
||||
func (s *Server) HandleMargeAccountProfile(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := chi.URLParam(r, "account")
|
||||
|
||||
// Mock profile data
|
||||
profile := models.AccountProfileResponse{
|
||||
AccountID: accountID,
|
||||
Email: "user@example.com",
|
||||
FirstName: "SoundTouch",
|
||||
LastName: "User",
|
||||
CountryCode: "US",
|
||||
LanguageCode: "en",
|
||||
}
|
||||
|
||||
data, err := xml.MarshalIndent(profile, "", " ")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(xml.Header))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeUpdateAccountProfile updates the account profile.
|
||||
func (s *Server) HandleMargeUpdateAccountProfile(w http.ResponseWriter, _ *http.Request) {
|
||||
// Stub implementation
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// HandleMargeChangePassword changes the account password.
|
||||
func (s *Server) HandleMargeChangePassword(w http.ResponseWriter, _ *http.Request) {
|
||||
// Stub implementation
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// HandleMargeGetEmailAddress returns the account email address.
|
||||
func (s *Server) HandleMargeGetEmailAddress(w http.ResponseWriter, _ *http.Request) {
|
||||
resp := models.EmailAddressResponse{
|
||||
Email: "user@example.com",
|
||||
}
|
||||
|
||||
data, err := xml.MarshalIndent(resp, "", " ")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(xml.Header))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeGetDeviceSettings returns device settings.
|
||||
func (s *Server) HandleMargeGetDeviceSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
resp := models.DeviceSettingsResponse{
|
||||
Settings: []models.DeviceSetting{
|
||||
{Name: "CLOCK_FORMAT", Value: "24HR"},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := xml.MarshalIndent(resp, "", " ")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(xml.Header))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeUpdateDeviceSettings updates device settings.
|
||||
func (s *Server) HandleMargeUpdateDeviceSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
// Stub implementation
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -202,11 +318,22 @@ func (s *Server) HandleMargeProviderSettings(w http.ResponseWriter, r *http.Requ
|
||||
func (s *Server) HandleMargeStreamingToken(w http.ResponseWriter, _ *http.Request) {
|
||||
// Simple mock token for offline use.
|
||||
// In a real production environment, this would be a JWT or similar signed token.
|
||||
// Some speakers might expect a specific format; soundcork uses a distinctive prefix
|
||||
// Some speakers might expect a specific format; we use a distinctive prefix
|
||||
// to indicate it's a locally generated token.
|
||||
token := "soundcork-local-token-" + strconv.FormatInt(time.Now().Unix(), 10)
|
||||
w.Header().Set("Authorization", "Bearer "+token)
|
||||
tokenValue := "st-local-token-" + strconv.FormatInt(time.Now().Unix(), 10)
|
||||
bearerToken := models.NewBearerToken(tokenValue)
|
||||
|
||||
data, err := xml.Marshal(bearerToken)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.Header().Set("Authorization", bearerToken.GetAuthHeader())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(xml.Header))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeCustomerSupport handles Marge customer support uploads.
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMargeStockholmHandlers(t *testing.T) {
|
||||
r, _ := setupRouter("http://localhost:8001", nil)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
t.Run("HandleMargeAccountProfile GET", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/customer/account/12345")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(body), "<accountID>12345</accountID>") {
|
||||
t.Errorf("Response missing account ID: %s", string(body))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HandleMargeUpdateAccountProfile POST", func(t *testing.T) {
|
||||
res, err := http.Post(ts.URL+"/customer/account/12345", "application/xml", strings.NewReader("<profile/>"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HandleMargeChangePassword POST", func(t *testing.T) {
|
||||
res, err := http.Post(ts.URL+"/customer/account/12345/password", "application/xml", strings.NewReader("<password/>"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HandleMargeGetEmailAddress GET", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/marge/streaming/account/12345/emailaddress")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(body), "user@example.com") {
|
||||
t.Errorf("Response missing email: %s", string(body))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HandleMargeGetDeviceSettings GET", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/marge/streaming/device_setting/account/123/device/DEV1/device_settings")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(body), "CLOCK_FORMAT") {
|
||||
t.Errorf("Response missing settings: %s", string(body))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HandleMargeUpdateDeviceSettings POST", func(t *testing.T) {
|
||||
res, err := http.Post(ts.URL+"/marge/streaming/device_setting/account/123/device/DEV1/device_settings", "application/xml", strings.NewReader("<settings/>"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func TestMargeSoftwareUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMargeAccountFull(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func TestMargeAccountFull(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMargePresets(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
@@ -196,7 +196,7 @@ func TestMargePresets(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMargeUpdatePreset(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
@@ -265,7 +265,7 @@ func TestMargeUpdatePreset(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMargeDeviceInfo(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
@@ -332,7 +332,7 @@ func TestMargeDeviceInfo(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMargeAddRemoveDevice(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
@@ -414,20 +414,32 @@ func TestMargePowerOn(t *testing.T) {
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
res, err := http.Post(ts.URL+"/marge/streaming/support/power_on", "application/xml", bytes.NewReader([]byte("<powerOn/>")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Run("EmptyBody", func(t *testing.T) {
|
||||
res, err := http.Post(ts.URL+"/marge/streaming/support/power_on", "application/xml", bytes.NewReader([]byte("<powerOn/>")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
})
|
||||
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
t.Run("FullBody", func(t *testing.T) {
|
||||
payload := `<?xml version="1.0" encoding="UTF-8" ?><device-data><device id="A81B6A536A98"><serialnumber>I6332527703739342000020</serialnumber><firmware-version>27.0.6.46330</firmware-version><product product_code="SoundTouch 10 sm2" type="5"><serialnumber>069231P63364828AE</serialnumber></product></device><diagnostic-data><device-landscape><rssi>Excellent</rssi><gateway-ip-address>192.168.1.1</gateway-ip-address><macaddresses><macaddress>A81B6A536A98</macaddress></macaddresses><ip-address>192.168.1.100</ip-address><network-connection-type>Wireless</network-connection-type></device-landscape></diagnostic-data></device-data>`
|
||||
res, err := http.Post(ts.URL+"/marge/streaming/support/power_on", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMargeAdvancedFeatures(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
@@ -471,10 +483,23 @@ func TestMargeAdvancedFeatures(t *testing.T) {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
|
||||
contentType := res.Header.Get("Content-Type")
|
||||
if contentType != "application/vnd.bose.streaming-v1.2+xml" {
|
||||
t.Errorf("Invalid content type: %s", contentType)
|
||||
}
|
||||
|
||||
token := res.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(token, "Bearer soundcork-local-token-") {
|
||||
if !strings.HasPrefix(token, "Bearer st-local-token-") {
|
||||
t.Errorf("Invalid token header: %s", token)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(body), "<bearertoken") {
|
||||
t.Errorf("Response body missing <bearertoken: %s", body)
|
||||
}
|
||||
if !strings.Contains(string(body), token) {
|
||||
t.Errorf("Response body missing token value: %s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CustomerSupport", func(t *testing.T) {
|
||||
|
||||
@@ -14,13 +14,13 @@ var indexHTML []byte
|
||||
//go:embed web/css/* web/js/*
|
||||
var webFS embed.FS
|
||||
|
||||
//go:embed soundcork/media/*
|
||||
//go:embed static/media/*
|
||||
var mediaFS embed.FS
|
||||
|
||||
//go:embed soundcork/bmx_services.json
|
||||
//go:embed static/bmx_services.json
|
||||
var bmxServicesJSON []byte
|
||||
|
||||
//go:embed soundcork/swupdate.xml
|
||||
//go:embed static/swupdate.xml
|
||||
var swUpdateXML []byte
|
||||
|
||||
// HandleRoot returns the root endpoint response.
|
||||
@@ -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
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func (s *Server) HandleWeb() http.HandlerFunc {
|
||||
|
||||
// HandleMedia returns a handler for serving media files.
|
||||
func (s *Server) HandleMedia() http.HandlerFunc {
|
||||
subFS, _ := fs.Sub(mediaFS, "soundcork/media")
|
||||
subFS, _ := fs.Sub(mediaFS, "static/media")
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
fs := http.StripPrefix("/media/", http.FileServer(http.FS(subFS)))
|
||||
|
||||
@@ -35,8 +35,8 @@ func TestRootEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(body), "Bose SoundTouch Toolkit") {
|
||||
t.Errorf("Expected body to contain 'Bose SoundTouch Toolkit', got %s", string(body))
|
||||
if !strings.Contains(string(body), "AfterTouch") {
|
||||
t.Errorf("Expected body to contain 'AfterTouch', got %s", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -80,7 +79,7 @@ func TestStaticMedia(t *testing.T) {
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// Use a known file from soundcork/media
|
||||
// Use a known file from static/media
|
||||
res, err := http.Get(ts.URL + "/media/SiriusXM_Logo_Color.svg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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 for the linked account.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMgmtPrimeDevice triggers a Spotify priming for a specific device.
|
||||
func (s *Server) HandleMgmtPrimeDevice(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := r.URL.Query().Get("deviceId")
|
||||
|
||||
if deviceID == "" {
|
||||
http.Error(w, `{"error":"missing deviceId"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
if err != nil {
|
||||
log.Printf("[Mgmt] Prime failed: %v", err)
|
||||
http.Error(w, fmt.Sprintf(`{"error":"%v"}`, err), http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger priming
|
||||
go s.PrimeDeviceWithSpotify(deviceIP)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"Priming triggered"}`))
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestHandleMgmtSpotifyInit(t *testing.T) {
|
||||
s := &Server{}
|
||||
// No spotify service configured
|
||||
req := httptest.NewRequest("POST", "/mgmt/spotify/init", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.HandleMgmtSpotifyInit(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d", w.Code)
|
||||
}
|
||||
|
||||
// With spotify service
|
||||
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
|
||||
s.SetSpotifyService(svc)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
s.HandleMgmtSpotifyInit(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !strings.Contains(resp["redirectUrl"], "client_id=cid") {
|
||||
t.Errorf("expected redirectUrl to contain client_id=cid, got %s", resp["redirectUrl"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMgmtSpotifyAccounts(t *testing.T) {
|
||||
s := &Server{}
|
||||
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
|
||||
s.SetSpotifyService(svc)
|
||||
|
||||
req := httptest.NewRequest("GET", "/mgmt/spotify/accounts", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.HandleMgmtSpotifyAccounts(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string][]spotify.Account
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(resp["accounts"]) != 0 {
|
||||
t.Errorf("expected 0 accounts, got %d", len(resp["accounts"]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMgmtListSpeakers(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
_, s := setupRouter("http://localhost:8000", ds)
|
||||
|
||||
req := httptest.NewRequest("GET", "/mgmt/accounts/default/speakers", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.HandleMgmtListSpeakers(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, ok := resp["speakers"]; !ok {
|
||||
t.Error("expected 'speakers' in response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMgmtSpotifyCallback(t *testing.T) {
|
||||
s := &Server{}
|
||||
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
|
||||
s.SetSpotifyService(svc)
|
||||
|
||||
// Mock Spotify token and profile endpoints
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"access_token": "at",
|
||||
"refresh_token": "rt",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
profileServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": "user123",
|
||||
"display_name": "Test User",
|
||||
})
|
||||
}))
|
||||
defer profileServer.Close()
|
||||
|
||||
// Use internal members to override URLs (available because we are in the same package)
|
||||
// Actually we need to reach through s.spotifyService which is private.
|
||||
// But s.spotifyService is *spotify.Service, which we have a handle to (svc).
|
||||
// We can't access private fields of spotify.Service from handlers package.
|
||||
// Wait, I can't override tokenURL from here if it's unexported in spotify package.
|
||||
// Let's check service.go again. Yes, tokenURL and apiBase are unexported.
|
||||
|
||||
// Since I can't easily mock the external Spotify API here without exported fields,
|
||||
// I will test the error paths.
|
||||
|
||||
t.Run("Missing code", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/mgmt/spotify/callback", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.HandleMgmtSpotifyCallback(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "Missing authorization code") {
|
||||
t.Errorf("expected missing code error message, got %s", w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Spotify error", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/mgmt/spotify/callback?error=access_denied", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.HandleMgmtSpotifyCallback(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "access_denied") {
|
||||
t.Errorf("expected access_denied error message, got %s", w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleMgmtSpotifyConfirm(t *testing.T) {
|
||||
s := &Server{}
|
||||
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
|
||||
s.SetSpotifyService(svc)
|
||||
|
||||
t.Run("Missing code", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/mgmt/spotify/confirm", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.HandleMgmtSpotifyConfirm(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleMgmtDeviceEvents(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
_, s := setupRouter("http://localhost:8000", ds)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/mgmt/devices/{deviceId}/events", s.HandleMgmtDeviceEvents)
|
||||
|
||||
req := httptest.NewRequest("GET", "/mgmt/devices/device123/events", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, ok := resp["events"]; !ok {
|
||||
t.Error("expected 'events' in response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthMgmt(t *testing.T) {
|
||||
s := &Server{}
|
||||
s.SetMgmtConfig("admin", "secret123")
|
||||
|
||||
handler := s.BasicAuthMgmt()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("OK"))
|
||||
}))
|
||||
|
||||
t.Run("Valid credentials", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/mgmt/test", nil)
|
||||
req.SetBasicAuth("admin", "secret123")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d", http.StatusOK, rr.Code)
|
||||
}
|
||||
if rr.Body.String() != "OK" {
|
||||
t.Errorf("expected body 'OK', got %q", rr.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Wrong username", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/mgmt/test", nil)
|
||||
req.SetBasicAuth("wrong", "secret123")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
if rr.Header().Get("WWW-Authenticate") == "" {
|
||||
t.Error("expected WWW-Authenticate header to be set")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Wrong password", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/mgmt/test", nil)
|
||||
req.SetBasicAuth("admin", "wrongpass")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Missing auth header", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/mgmt/test", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Empty credentials", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/mgmt/test", nil)
|
||||
req.SetBasicAuth("", "")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,14 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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"
|
||||
@@ -143,17 +148,37 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
s.mu.RLock()
|
||||
serverURL, proxyURL, httpsServerURL := s.serverURL, s.proxyURL, s.httpsServerURL
|
||||
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
|
||||
spotifyConfigured := s.spotifyService != nil
|
||||
s.mu.RUnlock()
|
||||
|
||||
dnsRunning, actualBind := s.GetDNSRunning()
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"server_url": serverURL,
|
||||
"proxy_url": proxyURL,
|
||||
"https_server_url": httpsServerURL,
|
||||
"discovery_interval": discoveryInterval,
|
||||
"discovery_enabled": discoveryEnabled,
|
||||
"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": strings.Join(dnsUpstream, ","),
|
||||
"dns_bind_addr": dnsBindAddr,
|
||||
"enable_soundcork_proxy": enableSoundcorkProxy,
|
||||
"redact_logs": redact,
|
||||
"log_bodies": logBody,
|
||||
"record_interactions": record,
|
||||
"shortcuts": shortcuts,
|
||||
"spotify_configured": spotifyConfigured,
|
||||
}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -163,16 +188,27 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
// HandleUpdateSettings updates the service settings.
|
||||
func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var settings struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
ProxyURL string `json:"proxy_url"`
|
||||
DiscoveryInterval string `json:"discovery_interval"`
|
||||
DiscoveryEnabled bool `json:"discovery_enabled"`
|
||||
ServerURL string `json:"server_url"`
|
||||
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"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if settings.DNSEnabled && settings.DNSUpstream == "" {
|
||||
// No strict requirement for DNSUpstream here as SetDNSSettings will
|
||||
// try to fall back to system DNS. We only log it if both are empty later.
|
||||
log.Printf("[DNS] DNS Discovery enabled without explicit upstreams, will try system DNS.")
|
||||
}
|
||||
|
||||
interval, err := time.ParseDuration(settings.DiscoveryInterval)
|
||||
if err != nil && settings.DiscoveryInterval != "" {
|
||||
http.Error(w, "Invalid discovery interval: "+err.Error(), http.StatusBadRequest)
|
||||
@@ -182,12 +218,33 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
s.serverURL = settings.ServerURL
|
||||
|
||||
s.proxyURL = settings.ProxyURL
|
||||
s.soundcorkURL = settings.SoundcorkURL
|
||||
if settings.DiscoveryInterval != "" {
|
||||
s.discoveryInterval = interval
|
||||
}
|
||||
|
||||
s.discoveryEnabled = settings.DiscoveryEnabled
|
||||
s.dnsEnabled = settings.DNSEnabled
|
||||
|
||||
// Handle comma-separated upstream DNS servers
|
||||
var upstreamList []string
|
||||
|
||||
if settings.DNSUpstream != "" {
|
||||
for _, u := range strings.Split(settings.DNSUpstream, ",") {
|
||||
u = strings.TrimSpace(u)
|
||||
if u != "" {
|
||||
upstreamList = append(upstreamList, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.dnsUpstream = upstreamList
|
||||
s.dnsBindAddr = settings.DNSBindAddr
|
||||
|
||||
s.enableSoundcorkProxy = settings.EnableSoundcorkProxy
|
||||
if settings.Shortcuts != nil {
|
||||
s.shortcuts = settings.Shortcuts
|
||||
}
|
||||
|
||||
if s.sm != nil {
|
||||
s.sm.ServerURL = settings.ServerURL
|
||||
@@ -202,17 +259,29 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
log.Printf("Saving updated settings to %s/settings.json", s.ds.DataDir)
|
||||
err = s.ds.SaveSettings(datastore.Settings{
|
||||
ServerURL: s.serverURL,
|
||||
ProxyURL: s.proxyURL,
|
||||
HTTPServerURL: currentHTTPS,
|
||||
RedactLogs: currentRedact,
|
||||
LogBodies: currentLogBody,
|
||||
RecordInteractions: currentRecord,
|
||||
DiscoveryInterval: s.discoveryInterval.String(),
|
||||
DiscoveryEnabled: s.discoveryEnabled,
|
||||
ServerURL: s.serverURL,
|
||||
SoundcorkURL: s.soundcorkURL,
|
||||
HTTPServerURL: currentHTTPS,
|
||||
RedactLogs: currentRedact,
|
||||
LogBodies: currentLogBody,
|
||||
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
|
||||
dnsUpstreamStr := strings.Join(s.dnsUpstream, ",")
|
||||
dnsBindAddr := s.dnsBindAddr
|
||||
|
||||
s.mu.Unlock()
|
||||
|
||||
s.SetDNSSettings(dnsEnabled, dnsUpstreamStr, dnsBindAddr)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to save settings: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -228,9 +297,15 @@ 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)
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -250,9 +325,15 @@ func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// 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.resolveDeviceIDToIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -283,12 +364,25 @@ 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
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -331,12 +425,25 @@ 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
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -365,14 +472,127 @@ func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGetDNSDiscoveries returns recorded DNS discoveries.
|
||||
func (s *Server) HandleGetDNSDiscoveries(w http.ResponseWriter, _ *http.Request) {
|
||||
result := s.getMergedDNSDiscoveries()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDownloadDNSDiscoveries returns recorded DNS discoveries as a downloadable JSON file.
|
||||
func (s *Server) HandleDownloadDNSDiscoveries(w http.ResponseWriter, _ *http.Request) {
|
||||
result := s.getMergedDNSDiscoveries()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\"dns-discoveries.json\"")
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetIndent("", " ")
|
||||
|
||||
if err := encoder.Encode(result); err != nil {
|
||||
log.Printf("Error encoding DNS discoveries for download: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) getMergedDNSDiscoveries() []datastore.DNSDiscoveryEntry {
|
||||
// 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)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -403,12 +623,25 @@ 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
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -439,12 +672,25 @@ 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
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -475,12 +721,25 @@ 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
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -513,12 +772,13 @@ func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) HandleGetProxySettings(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
redact, logBody, record := s.GetProxySettings()
|
||||
redact, logBody, record, enableSoundcorkProxy := s.GetProxySettings()
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]bool{
|
||||
"redact": redact,
|
||||
"log_body": logBody,
|
||||
"record": record,
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"redact": redact,
|
||||
"log_body": logBody,
|
||||
"record": record,
|
||||
"enable_soundcork_proxy": enableSoundcorkProxy,
|
||||
}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -543,9 +803,10 @@ func (s *Server) HandleGetCACert(w http.ResponseWriter, _ *http.Request) {
|
||||
// HandleUpdateProxySettings updates the proxy settings.
|
||||
func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Request) {
|
||||
var settings struct {
|
||||
Redact bool `json:"redact"`
|
||||
LogBody bool `json:"log_body"`
|
||||
Record bool `json:"record"`
|
||||
Redact bool `json:"redact"`
|
||||
LogBody bool `json:"log_body"`
|
||||
Record bool `json:"record"`
|
||||
EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
@@ -556,23 +817,30 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques
|
||||
s.proxyRedact = settings.Redact
|
||||
s.proxyLogBody = settings.LogBody
|
||||
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, proxyURL, httpsServerURL := s.serverURL, s.proxyURL, s.httpsServerURL
|
||||
serverURL, soundcorkURL, httpsServerURL := s.serverURL, s.soundcorkURL, s.httpsServerURL
|
||||
discoveryInterval := s.discoveryInterval.String()
|
||||
discoveryEnabled := s.discoveryEnabled
|
||||
|
||||
log.Printf("Saving updated proxy settings to %s/settings.json", s.ds.DataDir)
|
||||
err := s.ds.SaveSettings(datastore.Settings{
|
||||
ServerURL: serverURL,
|
||||
ProxyURL: proxyURL,
|
||||
HTTPServerURL: httpsServerURL,
|
||||
RedactLogs: s.proxyRedact,
|
||||
LogBodies: s.proxyLogBody,
|
||||
RecordInteractions: s.recordEnabled,
|
||||
DiscoveryInterval: discoveryInterval,
|
||||
DiscoveryEnabled: discoveryEnabled,
|
||||
ServerURL: serverURL,
|
||||
SoundcorkURL: soundcorkURL,
|
||||
HTTPServerURL: httpsServerURL,
|
||||
RedactLogs: s.proxyRedact,
|
||||
LogBodies: s.proxyLogBody,
|
||||
RecordInteractions: s.recordEnabled,
|
||||
DiscoveryInterval: discoveryInterval,
|
||||
DiscoveryEnabled: discoveryEnabled,
|
||||
EnableSoundcorkProxy: s.enableSoundcorkProxy,
|
||||
Shortcuts: s.shortcuts,
|
||||
})
|
||||
s.mu.Unlock()
|
||||
|
||||
@@ -591,9 +859,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.resolveDeviceIDToIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -629,11 +903,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.resolveDeviceIDToIP(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, "Missing deviceId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -648,12 +974,25 @@ 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
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -684,9 +1023,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.resolveDeviceIDToIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -857,3 +1202,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -98,8 +100,8 @@ func TestProxySettingsAPI(t *testing.T) {
|
||||
|
||||
// 3. Test System Settings POST
|
||||
sysUpdate := map[string]string{
|
||||
"server_url": "http://new-server:8000",
|
||||
"proxy_url": "http://new-proxy:8001",
|
||||
"server_url": "http://new-server:8000",
|
||||
"soundcork_url": "http://new-proxy:8001",
|
||||
}
|
||||
|
||||
sysBody, err := json.Marshal(sysUpdate)
|
||||
@@ -121,7 +123,7 @@ func TestProxySettingsAPI(t *testing.T) {
|
||||
// Verify server state
|
||||
sURL, pURL, _ := server.GetSettings()
|
||||
if sURL != "http://new-server:8000" || pURL != "http://new-proxy:8001" {
|
||||
t.Errorf("POST /setup/settings: Server state did not update: serverURL=%s, proxyURL=%s", sURL, pURL)
|
||||
t.Errorf("POST /setup/settings: Server state did not update: serverURL=%s, soundcorkURL=%s", sURL, pURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +142,7 @@ 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}
|
||||
}
|
||||
|
||||
r, server := setupRouter("http://localhost:8001", ds)
|
||||
@@ -149,6 +151,13 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// Add device to datastore for resolution
|
||||
_ = ds.SaveDeviceInfo("default", "192.168.1.10", &models.ServiceDeviceInfo{
|
||||
DeviceID: "192.168.1.10",
|
||||
IPAddress: "192.168.1.10",
|
||||
AccountID: "default",
|
||||
})
|
||||
|
||||
// 1. Test GET /setup/ca.crt
|
||||
res, err := http.Get(ts.URL + "/setup/ca.crt")
|
||||
if err != nil {
|
||||
@@ -338,12 +347,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 }
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// HandleUsageStats handles Marge usage stats uploads.
|
||||
@@ -49,6 +50,42 @@ func (s *Server) HandleUsageStats(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// HandleAppEvents handles events from the Bose SoundTouch app (stapp/scmudc).
|
||||
func (s *Server) HandleAppEvents(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req models.DeviceEventsRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, "Invalid app events format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := req.Envelope.UniqueID
|
||||
if deviceID == "" {
|
||||
deviceID = chi.URLParam(r, "deviceId")
|
||||
}
|
||||
|
||||
for _, e := range req.Payload.Events {
|
||||
event := models.DeviceEvent{
|
||||
Type: e.Type,
|
||||
Time: e.Time,
|
||||
MonoTime: req.Envelope.MonoTime,
|
||||
Data: e.Data,
|
||||
}
|
||||
if event.Time == "" {
|
||||
event.Time = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
s.ds.AddDeviceEvent(deviceID, event)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// HandleErrorStats handles Marge error stats uploads.
|
||||
func (s *Server) HandleErrorStats(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
func TestStatsHandlers(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -63,4 +63,44 @@ func TestStatsHandlers(t *testing.T) {
|
||||
t.Error("Error stats file was not created")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HandleAppEvents", func(t *testing.T) {
|
||||
jsonData := `{
|
||||
"envelope": {
|
||||
"monoTime": 12345,
|
||||
"payloadProtocolVersion": "3.1",
|
||||
"payloadType": "stapp",
|
||||
"protocolVersion": "1.0",
|
||||
"time": "2023-10-27T10:00:00Z",
|
||||
"uniqueId": "device789"
|
||||
},
|
||||
"payload": {
|
||||
"deviceInfo": {
|
||||
"deviceID": "device789"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"type": "APP_OPEN",
|
||||
"time": "2023-10-27T10:00:01Z",
|
||||
"data": {"foo": "bar"}
|
||||
}
|
||||
]
|
||||
}
|
||||
}`
|
||||
req := httptest.NewRequest("POST", "/v1/stapp/device789", bytes.NewBufferString(jsonData))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.HandleAppEvents(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %d", w.Code)
|
||||
}
|
||||
|
||||
events := ds.GetDeviceEvents("device789")
|
||||
if len(events) == 0 {
|
||||
t.Error("App events were not recorded")
|
||||
} else if events[0].Type != "APP_OPEN" {
|
||||
t.Errorf("Expected event type APP_OPEN, got %s", events[0].Type)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func TestInteractionHandlers(t *testing.T) {
|
||||
t.Setenv("RECORDER_ASYNC", "false")
|
||||
tmpDir, err := os.MkdirTemp("", "interaction-handlers-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
@@ -113,6 +114,7 @@ func TestInteractionHandlers(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordMiddleware(t *testing.T) {
|
||||
t.Setenv("RECORDER_ASYNC", "false")
|
||||
tmpDir, err := os.MkdirTemp("", "record-middleware-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
|
||||
@@ -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)
|
||||
@@ -43,6 +50,33 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
})
|
||||
|
||||
// Legacy or direct domain calls without /marge prefix
|
||||
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Post("/streaming/support/power_on", server.HandleMargePowerOn)
|
||||
r.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)
|
||||
r.Post("/account/{account}", server.HandleMargeUpdateAccountProfile)
|
||||
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
|
||||
})
|
||||
|
||||
// Setup Setup for tests
|
||||
@@ -53,30 +87,23 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
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.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services/{deviceId}", server.HandleRemoveRemoteServices)
|
||||
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
|
||||
r.Post("/revert/{deviceId}", server.HandleRevertMigration)
|
||||
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
|
||||
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
|
||||
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
|
||||
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
})
|
||||
|
||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||
proxy.ServeHTTP(w, r)
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -2,7 +2,13 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -11,41 +17,58 @@ 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"
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// Server handles HTTP requests for the SoundTouch service.
|
||||
type Server struct {
|
||||
ds *datastore.DataStore
|
||||
sm *setup.Manager
|
||||
mu sync.RWMutex
|
||||
serverURL string
|
||||
proxyURL string
|
||||
httpsServerURL string
|
||||
discovering bool
|
||||
proxyRedact bool
|
||||
proxyLogBody bool
|
||||
recordEnabled bool
|
||||
discoveryInterval time.Duration
|
||||
discoveryEnabled bool
|
||||
shortcuts map[string]int
|
||||
recorder *proxy.Recorder
|
||||
Version string
|
||||
Commit string
|
||||
Date string
|
||||
ds *datastore.DataStore
|
||||
sm *setup.Manager
|
||||
mu sync.RWMutex
|
||||
serverURL string
|
||||
soundcorkURL string
|
||||
httpsServerURL string
|
||||
discovering bool
|
||||
proxyRedact bool
|
||||
proxyLogBody bool
|
||||
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
|
||||
spotifyService *spotify.Service
|
||||
}
|
||||
|
||||
// NewServer creates a new SoundTouch service server.
|
||||
func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact, proxyLogBody, recordEnabled bool) *Server {
|
||||
return &Server{
|
||||
ds: ds,
|
||||
sm: sm,
|
||||
serverURL: serverURL,
|
||||
proxyURL: serverURL,
|
||||
proxyRedact: proxyRedact,
|
||||
proxyLogBody: proxyLogBody,
|
||||
recordEnabled: recordEnabled,
|
||||
discoveryInterval: 5 * time.Minute,
|
||||
func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact, proxyLogBody, recordEnabled, enableSoundcorkProxy bool) *Server {
|
||||
s := &Server{
|
||||
ds: ds,
|
||||
sm: sm,
|
||||
serverURL: 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.
|
||||
@@ -67,6 +90,149 @@ func (s *Server) SetDiscoverySettings(interval time.Duration, enabled bool) {
|
||||
s.discoveryEnabled = enabled
|
||||
}
|
||||
|
||||
// parseUpstreamDNS splits a comma-separated string of DNS servers.
|
||||
func parseUpstreamDNS(upstream string) []string {
|
||||
var upstreamList []string
|
||||
|
||||
if upstream != "" {
|
||||
for _, u := range strings.Split(upstream, ",") {
|
||||
u = strings.TrimSpace(u)
|
||||
if u != "" {
|
||||
upstreamList = append(upstreamList, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return upstreamList
|
||||
}
|
||||
|
||||
// getSystemDNS returns the DNS servers from /etc/resolv.conf.
|
||||
func getSystemDNS() []string {
|
||||
config, _ := dns.ClientConfigFromFile("/etc/resolv.conf")
|
||||
if config != nil && len(config.Servers) > 0 {
|
||||
return config.Servers
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// areUpstreamsEqual compares two slices of DNS server addresses.
|
||||
func areUpstreamsEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// SetDNSSettings sets the DNS discovery settings for the server.
|
||||
func (s *Server) SetDNSSettings(enabled bool, upstream, bind string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
oldBind := s.dnsBindAddr
|
||||
oldUpstream := s.dnsUpstream
|
||||
|
||||
s.dnsEnabled = enabled
|
||||
s.dnsBindAddr = bind
|
||||
|
||||
upstreamList := parseUpstreamDNS(upstream)
|
||||
|
||||
// Try to get system DNS if none provided
|
||||
if enabled && len(upstreamList) == 0 {
|
||||
upstreamList = getSystemDNS()
|
||||
if len(upstreamList) > 0 {
|
||||
log.Printf("[DNS] Using system DNS servers from /etc/resolv.conf: %v", upstreamList)
|
||||
}
|
||||
}
|
||||
|
||||
s.dnsUpstream = upstreamList
|
||||
upstreamChanged := !areUpstreamsEqual(upstreamList, oldUpstream)
|
||||
|
||||
if s.dnsDiscovery != nil {
|
||||
if !enabled || bind != oldBind || upstreamChanged {
|
||||
log.Printf("[DNS] Settings changed, stopping DNS discovery server")
|
||||
|
||||
_ = s.dnsDiscovery.Shutdown()
|
||||
s.dnsDiscovery = nil
|
||||
}
|
||||
}
|
||||
|
||||
if enabled && len(upstreamList) == 0 {
|
||||
log.Printf("[DNS] Cannot start DNS discovery server: upstream DNS is empty and no system DNS found")
|
||||
|
||||
s.dnsEnabled = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if enabled && s.dnsDiscovery == nil {
|
||||
s.startDNSDiscovery(bind, upstreamList)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) startDNSDiscovery(bind string, upstreamList []string) {
|
||||
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(upstreamList, serviceIP)
|
||||
go func(d *discovery.DNSDiscovery, addr string) {
|
||||
if err := d.Start(addr); err != nil {
|
||||
log.Printf("Warning: DNS discovery server error: %v", err)
|
||||
}
|
||||
}(s.dnsDiscovery, bind)
|
||||
}
|
||||
|
||||
// GetDNSRunning returns whether DNS discovery is active and its bind address.
|
||||
func (s *Server) GetDNSRunning() (bool, string) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if s.dnsDiscovery == nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
return s.dnsDiscovery.IsRunning(s.dnsBindAddr), s.dnsBindAddr
|
||||
}
|
||||
|
||||
// SetDNSDiscoveries sets the initial DNS discoveries for the server.
|
||||
func (s *Server) SetDNSDiscoveries(discoveries map[string]*discovery.DiscoveredHost) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.dnsDiscovery != nil {
|
||||
s.dnsDiscovery.SetDiscovered(discoveries)
|
||||
}
|
||||
}
|
||||
|
||||
// GetDNSDiscovery returns the current DNS discoveries.
|
||||
func (s *Server) GetDNSDiscovery() map[string]*discovery.DiscoveredHost {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if s.dnsDiscovery == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.dnsDiscovery.GetDiscovered()
|
||||
}
|
||||
|
||||
// SetShortcuts sets the request shortcuts for the server.
|
||||
func (s *Server) SetShortcuts(shortcuts map[string]int) {
|
||||
s.mu.Lock()
|
||||
@@ -99,9 +265,50 @@ 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
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -117,15 +324,23 @@ func (s *Server) GetSettings() (string, string, string) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.serverURL, s.proxyURL, s.httpsServerURL
|
||||
return s.serverURL, s.soundcorkURL, s.httpsServerURL
|
||||
}
|
||||
|
||||
// GetProxySettings returns the current proxy settings.
|
||||
func (s *Server) GetProxySettings() (bool, bool, bool) {
|
||||
// IsSpotifyConfigured returns whether Spotify integration is configured.
|
||||
func (s *Server) IsSpotifyConfigured() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.proxyRedact, s.proxyLogBody, s.recordEnabled
|
||||
return s.spotifyService != nil
|
||||
}
|
||||
|
||||
// GetProxySettings returns the current proxy settings.
|
||||
func (s *Server) GetProxySettings() (bool, bool, bool, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.proxyRedact, s.proxyLogBody, s.recordEnabled, s.enableSoundcorkProxy
|
||||
}
|
||||
|
||||
// DiscoverDevices starts a background device discovery process.
|
||||
@@ -163,6 +378,75 @@ func (s *Server) DiscoverDevices(ctx context.Context) {
|
||||
s.mergeOverlappingDevices()
|
||||
}
|
||||
|
||||
// PrimeDeviceWithSpotify triggers a Spotify priming of the speaker if a Spotify account is linked.
|
||||
func (s *Server) PrimeDeviceWithSpotify(deviceIP string) {
|
||||
s.mu.RLock()
|
||||
svc := s.spotifyService
|
||||
s.mu.RUnlock()
|
||||
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
accounts := svc.GetAccounts()
|
||||
if len(accounts) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// We'll use the first linked account. In the future, we might want to let the user
|
||||
// pick or map accounts to speakers, but for now, we follow the "One linked account" model.
|
||||
accessToken, username, err := svc.GetFreshToken()
|
||||
if err != nil {
|
||||
log.Printf("[Spotify Watchdog] Failed to get fresh token for %s: %v", deviceIP, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[Spotify Watchdog] Proactively priming %s with Spotify user %s", deviceIP, username)
|
||||
|
||||
if err := s.pushSpotifyTokenToDevice(deviceIP, username, accessToken); err != nil {
|
||||
log.Printf("[Spotify Watchdog] Failed to prime %s: %v", deviceIP, err)
|
||||
} else {
|
||||
log.Printf("[Spotify Watchdog] Successfully primed %s", deviceIP)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) pushSpotifyTokenToDevice(deviceIP, username, accessToken string) error {
|
||||
// ZeroConf API endpoint on the speaker
|
||||
var zcURL string
|
||||
if _, _, err := net.SplitHostPort(deviceIP); err == nil {
|
||||
// If port is specified (e.g. in tests), keep it but usually it's just IP
|
||||
zcURL = fmt.Sprintf("http://%s/zc", deviceIP)
|
||||
} else {
|
||||
// If no port specified, default to 8200
|
||||
zcURL = fmt.Sprintf("http://%s:8200/zc", deviceIP)
|
||||
}
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("action", "addUser")
|
||||
data.Set("userName", username)
|
||||
data.Set("blob", accessToken)
|
||||
data.Set("clientKey", "")
|
||||
data.Set("tokenType", "accesstoken")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.PostForm(zcURL, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("POST to %s failed: %w", zcURL, err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("POST to %s returned status %d: %s", zcURL, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) {
|
||||
log.Printf("Discovered Bose device: %s at %s (Serial: %s)", d.Name, d.Host, d.SerialNo)
|
||||
|
||||
@@ -325,3 +609,20 @@ func (s *Server) findExistingDeviceInfo(d models.DiscoveredDevice) *models.Servi
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) resolveDeviceIDToIP(deviceID string) (string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
// 1. Try to find in Datastore
|
||||
devices, err := s.ds.ListAllDevices()
|
||||
if err == nil {
|
||||
for i := range devices {
|
||||
if devices[i].DeviceID == deviceID {
|
||||
return devices[i].IPAddress, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("device not found: %s", deviceID)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 418 B After Width: | Height: | Size: 418 B |
|
Before Width: | Height: | Size: 681 B After Width: | Height: | Size: 681 B |
|
Before Width: | Height: | Size: 859 B After Width: | Height: | Size: 859 B |
|
Before Width: | Height: | Size: 246 B After Width: | Height: | Size: 246 B |
|
Before Width: | Height: | Size: 381 B After Width: | Height: | Size: 381 B |
@@ -1,6 +1,6 @@
|
||||
# Favicon Meanings
|
||||
|
||||
This directory contains favicons for the Soundcork project in various formats (SVG, PNG, ICO). The icons use Morse code and Braille to represent the initials **S** (Sound) and **T** (Touch).
|
||||
This directory contains favicons for the service in various formats (SVG, PNG, ICO). The icons use Morse code and Braille to represent the initials **S** (Sound) and **T** (Touch).
|
||||
|
||||
## Morse Variant (`favicon-morse.*`)
|
||||
The icon represents the letters **S** and **T** in international Morse code:
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 957 B After Width: | Height: | Size: 957 B |
|
Before Width: | Height: | Size: 631 B After Width: | Height: | Size: 631 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -102,3 +102,47 @@ pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px;
|
||||
.category-upstream { background-color: #f3e5f5; color: #7b1fa2; }
|
||||
.status-success { background-color: #e8f5e9; color: #2e7d32; }
|
||||
.status-error { background-color: #ffebee; color: #c62828; }
|
||||
|
||||
.info-toggle {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
background-color: #607D8B;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
margin-left: 5px;
|
||||
font-style: normal;
|
||||
user-select: none;
|
||||
}
|
||||
.info-toggle:hover {
|
||||
background-color: #455A64;
|
||||
}
|
||||
.info-details {
|
||||
display: none;
|
||||
background-color: #f0f7ff;
|
||||
border: 1px solid #d0e0f0;
|
||||
padding: 10px;
|
||||
margin-top: 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
max-width: 400px;
|
||||
}
|
||||
.info-details code {
|
||||
background-color: #e3f2fd;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.8em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Bose SoundTouch Toolkit</title>
|
||||
<title>AfterTouch (SoundTouch Toolkit)</title>
|
||||
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/web/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Bose SoundTouch Toolkit</h1>
|
||||
<h1>AfterTouch</h1>
|
||||
<p style="margin-top: -10px; font-style: italic; color: #666;">Bose SoundTouch Toolkit</p>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab-buttons">
|
||||
@@ -16,12 +17,12 @@
|
||||
<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</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 Bose SoundTouch Toolkit</h2>
|
||||
<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>
|
||||
@@ -86,9 +87,9 @@
|
||||
<span style="font-size: 0.8em; color: #666;">(Standard services URL)</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label for="proxy-domain">Proxy Domain:</label>
|
||||
<input type="text" id="proxy-domain" placeholder="http://192.168.x.x:8000" style="width: 300px;">
|
||||
<span style="font-size: 0.8em; color: #666;">(Upstream proxy URL - usually the same as Target Domain)</span>
|
||||
<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>
|
||||
@@ -99,11 +100,41 @@
|
||||
<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="Default: system nameservers" style="width: 200px;">
|
||||
<span class="info-toggle" onclick="toggleInfo('dns-upstream-info')">ⓘ</span>
|
||||
<div id="dns-upstream-info" class="info-details">
|
||||
Optional: comma-separated list of DNS servers (e.g., <code>1.1.1.1, 8.8.8.8</code>).<br>
|
||||
If empty, AfterTouch defaults to the system nameservers (e.g. from <code>/etc/resolv.conf</code>).<br>
|
||||
<div id="dns-current-upstream" style="margin-top: 5px; font-weight: bold;"></div>
|
||||
</div>
|
||||
</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>Spotify Integration:</strong>
|
||||
<div id="spotify-config-status" style="margin-top: 5px; font-size: 0.9em;">
|
||||
Checking configuration...
|
||||
</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>
|
||||
@@ -114,7 +145,13 @@
|
||||
|
||||
<!-- 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 style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h2>Known Devices <span id="discovery-indicator" style="font-size: 0.5em; vertical-align: middle; display: none;">🔍 Scanning...</span></h2>
|
||||
<div id="spotify-status-header" style="background: #f0f0f0; padding: 5px 15px; border-radius: 20px; font-size: 0.9em; display: flex; align-items: center; gap: 10px;">
|
||||
Spotify: <span id="spotify-account-name" style="font-weight: bold;">Not Linked</span>
|
||||
<button id="link-spotify-btn" onclick="linkSpotify()" style="font-size: 0.8em; padding: 2px 8px; background: #1DB954; color: white; border: none; border-radius: 10px; cursor: pointer;">Link Account</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="device-list">Loading devices...</div>
|
||||
<div style="margin-top: 20px;">
|
||||
<button onclick="triggerDiscovery()">Scan Again</button>
|
||||
@@ -159,12 +196,14 @@
|
||||
</div>
|
||||
|
||||
<div id="migration-summary" class="summary-box" style="display: none;">
|
||||
<h3>Migration Summary for <span id="summary-ip"></span></h3>
|
||||
<h3>Migration Summary for <span id="summary-device-display"></span></h3>
|
||||
<p>Migration Status: <span id="migration-status"></span></p>
|
||||
<input type="hidden" id="summary-device-id">
|
||||
<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>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>
|
||||
<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>
|
||||
@@ -191,12 +230,31 @@
|
||||
<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 - Redirect via DNS Hook)</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;">
|
||||
@@ -213,8 +271,8 @@
|
||||
<td id="orig-marge">loading...</td>
|
||||
<td>
|
||||
<select id="opt-marge" onchange="refreshSummary()">
|
||||
<option value="soundcork">Soundcork (Go/Python)</option>
|
||||
<option value="original">Original (Proxy via soundcork-go)</option>
|
||||
<option value="self">AfterTouch (Local Service)</option>
|
||||
<option value="upstream">Upstream (Proxy via local service)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -223,8 +281,8 @@
|
||||
<td id="orig-stats">loading...</td>
|
||||
<td>
|
||||
<select id="opt-stats" onchange="refreshSummary()">
|
||||
<option value="soundcork">Soundcork (Go/Python)</option>
|
||||
<option value="original">Original (Proxy via soundcork-go)</option>
|
||||
<option value="self">AfterTouch (Local Service)</option>
|
||||
<option value="upstream">Upstream (Proxy via local service)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -233,8 +291,8 @@
|
||||
<td id="orig-sw_update">loading...</td>
|
||||
<td>
|
||||
<select id="opt-sw_update" onchange="refreshSummary()">
|
||||
<option value="soundcork">Soundcork (Go/Python)</option>
|
||||
<option value="original">Original (Proxy via soundcork-go)</option>
|
||||
<option value="self">AfterTouch (Local Service)</option>
|
||||
<option value="upstream">Upstream (Proxy via local service)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -243,8 +301,8 @@
|
||||
<td id="orig-bmx">loading...</td>
|
||||
<td>
|
||||
<select id="opt-bmx" onchange="refreshSummary()">
|
||||
<option value="soundcork">Soundcork (Go/Python)</option>
|
||||
<option value="original">Original (Proxy via soundcork-go)</option>
|
||||
<option value="self">AfterTouch (Local Service)</option>
|
||||
<option value="upstream">Upstream (Proxy via local service)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -257,14 +315,21 @@
|
||||
<pre id="current-config"></pre>
|
||||
</div>
|
||||
<div id="planned-xml-pane" class="diff-pane">
|
||||
<span class="config-header">Planned Config (Soundcork)</span>
|
||||
<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 local Root CA into <code>/etc/pki/tls/certs/ca-bundle.crt</code> to enable secure HTTPS communication.
|
||||
<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>
|
||||
@@ -279,15 +344,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 5: Interactions -->
|
||||
<!-- Tab 5: Interactions & Events -->
|
||||
<div id="tab-interactions" class="tab-content">
|
||||
<h2>Recorded Interactions</h2>
|
||||
<p>Analysis of traffic handled by this service (self) and proxied to Bose (upstream).</p>
|
||||
<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>
|
||||
@@ -308,7 +376,9 @@
|
||||
</div>
|
||||
|
||||
<div id="browse-recordings" class="summary-box" style="margin-top: 20px;">
|
||||
<h3>Browse Recordings</h3>
|
||||
<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>
|
||||
@@ -351,6 +421,34 @@
|
||||
</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>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<button onclick="downloadDNSDiscoveries()" class="btn-info">Download JSON</button>
|
||||
<button onclick="clearDNSDiscoveries()" class="btn-danger">Clear DNS Logs</button>
|
||||
</div>
|
||||
</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>
|
||||
@@ -358,12 +456,39 @@
|
||||
</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="/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;">
|
||||
<span id="version-info">SoundTouch Toolkit</span>
|
||||
<span id="version-info">AfterTouch</span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -88,6 +88,7 @@ func TestLoggingProxy_LogRequest(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoggingProxy_LogResponse(t *testing.T) {
|
||||
t.Setenv("RECORDER_ASYNC", "false")
|
||||
lp := NewLoggingProxy("http://example.com", true)
|
||||
lp.LogBody = true
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -25,6 +28,16 @@ type Recorder struct {
|
||||
counter uint64
|
||||
variables map[string]string
|
||||
mu sync.Mutex
|
||||
queue chan recordingTask
|
||||
}
|
||||
|
||||
type recordingTask struct {
|
||||
category string
|
||||
req *http.Request
|
||||
res *http.Response
|
||||
replacements map[string]string
|
||||
dir string
|
||||
path string
|
||||
}
|
||||
|
||||
// InteractionStats represents statistics for recorded interactions.
|
||||
@@ -51,15 +64,34 @@ type Interaction struct {
|
||||
func NewRecorder(baseDir string) *Recorder {
|
||||
sessionID := time.Now().Format("20060102-150405") + "-" + fmt.Sprintf("%d", os.Getpid())
|
||||
|
||||
return &Recorder{
|
||||
r := &Recorder{
|
||||
BaseDir: baseDir,
|
||||
SessionID: sessionID,
|
||||
Patterns: DefaultPatterns(),
|
||||
variables: make(map[string]string),
|
||||
}
|
||||
|
||||
// Use environment variable to control async recording, default to true for production
|
||||
// but allow disabling it for tests if needed.
|
||||
if os.Getenv("RECORDER_ASYNC") != "false" {
|
||||
r.queue = make(chan recordingTask, 100)
|
||||
go r.worker()
|
||||
} else {
|
||||
log.Println("[DEBUG_LOG] Recorder starting in synchronous mode")
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// Record persists a request and response to a .http file in the specified category (e.g., "self" or "upstream").
|
||||
// Close stops the recorder and waits for pending tasks to finish.
|
||||
func (r *Recorder) Close() {
|
||||
if r.queue != nil {
|
||||
close(r.queue)
|
||||
// We might want to wait here, but for now just closing is a start
|
||||
}
|
||||
}
|
||||
|
||||
// Record logs an interaction to the configured category.
|
||||
func (r *Recorder) Record(category string, req *http.Request, res *http.Response) error {
|
||||
if r.BaseDir == "" {
|
||||
return nil
|
||||
@@ -74,19 +106,82 @@ func (r *Recorder) Record(category string, req *http.Request, res *http.Response
|
||||
|
||||
path := r.getRecordingPath(dir, req.Method)
|
||||
|
||||
// If we are in async mode, we MUST copy the bodies now because the caller
|
||||
// might close them as soon as Record() returns.
|
||||
var (
|
||||
clonedReq *http.Request
|
||||
clonedRes *http.Response
|
||||
)
|
||||
|
||||
if r.queue != nil {
|
||||
// Clone request
|
||||
clonedReq = req.Clone(req.Context())
|
||||
if req.Body != nil {
|
||||
bodyBytes, _ := io.ReadAll(req.Body)
|
||||
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
clonedReq.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
}
|
||||
|
||||
// Clone response if present
|
||||
if res != nil {
|
||||
clonedRes = &http.Response{
|
||||
StatusCode: res.StatusCode,
|
||||
Header: res.Header.Clone(),
|
||||
Request: clonedReq,
|
||||
}
|
||||
if res.Body != nil {
|
||||
bodyBytes, _ := io.ReadAll(res.Body)
|
||||
res.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
clonedRes.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
clonedReq = req
|
||||
clonedRes = res
|
||||
}
|
||||
|
||||
task := recordingTask{
|
||||
category: category,
|
||||
req: clonedReq,
|
||||
res: clonedRes,
|
||||
replacements: replacements,
|
||||
dir: dir,
|
||||
path: path,
|
||||
}
|
||||
|
||||
// For testing purposes or if queue is nil, fallback to synchronous
|
||||
if r.queue == nil {
|
||||
r.save(task)
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case r.queue <- task:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("recording queue full, dropping interaction for %s", req.URL.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Recorder) save(task recordingTask) {
|
||||
var buf bytes.Buffer
|
||||
r.writeRequest(&buf, task.req, task.replacements)
|
||||
|
||||
r.writeRequest(&buf, req, replacements)
|
||||
|
||||
if res != nil {
|
||||
r.writeResponse(&buf, res)
|
||||
if task.res != nil {
|
||||
r.writeResponse(&buf, task.res)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
|
||||
return err
|
||||
if err := os.WriteFile(task.path, buf.Bytes(), 0644); err != nil {
|
||||
log.Printf("failed to write recording to %s: %v", task.path, err)
|
||||
}
|
||||
|
||||
return r.updateEnvFile(replacements)
|
||||
_ = r.updateEnvFile(task.replacements)
|
||||
}
|
||||
|
||||
func (r *Recorder) worker() {
|
||||
for task := range r.queue {
|
||||
r.save(task)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Recorder) getSanitizedSegments(path string) ([]string, map[string]string) {
|
||||
@@ -141,6 +236,7 @@ func (r *Recorder) writeRequest(buf *bytes.Buffer, req *http.Request, replacemen
|
||||
}
|
||||
|
||||
fmt.Fprintf(buf, "%s %s\n", req.Method, displayURL)
|
||||
fmt.Fprintf(buf, "Host: %s\n", req.Host)
|
||||
|
||||
for k, vv := range req.Header {
|
||||
if r.Redact && isSensitive(k) {
|
||||
@@ -485,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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -15,6 +17,7 @@ import (
|
||||
)
|
||||
|
||||
func TestRecorder_Record_Structure(t *testing.T) {
|
||||
t.Setenv("RECORDER_ASYNC", "false")
|
||||
tmpDir, err := os.MkdirTemp("", "recorder-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
@@ -93,12 +96,19 @@ func TestRecorder_Record_Structure(t *testing.T) {
|
||||
if len(f.Name()) < 5 || !isDigit(f.Name()[0]) || !isDigit(f.Name()[1]) || !isDigit(f.Name()[2]) || !isDigit(f.Name()[3]) || f.Name()[4] != '-' {
|
||||
t.Errorf("Filename %s does not have correct 0000- prefix", f.Name())
|
||||
}
|
||||
|
||||
// Verify Host header is present
|
||||
content, _ := os.ReadFile(filepath.Join(expectedDir, f.Name()))
|
||||
if !strings.Contains(string(content), "Host: ") {
|
||||
t.Errorf("Recorded file does not contain Host header:\n%s", string(content))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecorder_Record_Sanitization(t *testing.T) {
|
||||
t.Setenv("RECORDER_ASYNC", "false")
|
||||
tmpDir, err := os.MkdirTemp("", "recorder-sanitization-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
@@ -157,6 +167,7 @@ func TestRecorder_Record_Sanitization(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecorder_Record_Sanitization_Account(t *testing.T) {
|
||||
t.Setenv("RECORDER_ASYNC", "false")
|
||||
tmpDir, err := os.MkdirTemp("", "recorder-sanitization-account-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
@@ -211,6 +222,7 @@ func TestRecorder_Record_Sanitization_Account(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecorder_Record_Redaction(t *testing.T) {
|
||||
t.Setenv("RECORDER_ASYNC", "false")
|
||||
tmpDir, err := os.MkdirTemp("", "recorder-redaction-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
@@ -260,6 +272,7 @@ func isDigit(c byte) bool {
|
||||
}
|
||||
|
||||
func TestRecorder_IncreasingPrefix(t *testing.T) {
|
||||
t.Setenv("RECORDER_ASYNC", "false")
|
||||
tmpDir, err := os.MkdirTemp("", "recorder-prefix-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
@@ -302,6 +315,7 @@ func TestRecorder_IncreasingPrefix(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecorder_EnvFile(t *testing.T) {
|
||||
t.Setenv("RECORDER_ASYNC", "false")
|
||||
tmpDir, err := os.MkdirTemp("", "recorder-env-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
@@ -344,6 +358,7 @@ func TestRecorder_EnvFile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecorder_GetInteractionStats(t *testing.T) {
|
||||
t.Setenv("RECORDER_ASYNC", "false")
|
||||
tmpDir, err := os.MkdirTemp("", "recorder-stats-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
@@ -389,6 +404,7 @@ func TestRecorder_GetInteractionStats(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecorder_ListInteractions(t *testing.T) {
|
||||
t.Setenv("RECORDER_ASYNC", "false")
|
||||
tmpDir, err := os.MkdirTemp("", "recorder-list-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
@@ -613,7 +629,66 @@ 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")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
@@ -667,6 +742,7 @@ func TestRecorder_Record_FullExchange(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecorder_Record_BinaryResponse(t *testing.T) {
|
||||
t.Setenv("RECORDER_ASYNC", "false")
|
||||
tmpDir, err := os.MkdirTemp("", "recorder-binary-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
|
||||
@@ -27,6 +27,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 +66,9 @@ 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"`
|
||||
}
|
||||
|
||||
// SSHClient defines the interface for SSH operations.
|
||||
@@ -72,12 +77,19 @@ type SSHClient interface {
|
||||
UploadContent(content []byte, remotePath string) error
|
||||
}
|
||||
|
||||
// Manager handles the migration of speakers to the soundcork service.
|
||||
// Manager handles the migration of speakers to the service.
|
||||
type Manager struct {
|
||||
ServerURL string
|
||||
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)
|
||||
|
||||
// Spotify management credentials for the boot primer
|
||||
MgmtUsername string
|
||||
MgmtPassword string
|
||||
}
|
||||
|
||||
// NewManager creates a new Manager with the given base server URL.
|
||||
@@ -89,6 +101,8 @@ func NewManager(serverURL string, ds *datastore.DataStore, cm *certmanager.Certi
|
||||
NewSSH: func(host string) SSHClient {
|
||||
return ssh.NewClient(host)
|
||||
},
|
||||
MgmtUsername: "admin",
|
||||
MgmtPassword: "change_me!",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,12 +222,20 @@ 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",
|
||||
"stats.bose.com",
|
||||
"bmx.bose.com",
|
||||
"content.api.bose.io",
|
||||
"events.api.bosecm.com",
|
||||
"bose-prod.apigee.net",
|
||||
"worldwide.bose.com",
|
||||
"music.api.bose.com",
|
||||
}
|
||||
|
||||
var hostsLines []string
|
||||
@@ -231,6 +253,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()
|
||||
@@ -245,9 +275,87 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Check if migrated
|
||||
m.checkIsMigrated(summary, deviceIP)
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// checkIsMigrated determines if the device is already migrated to AfterTouch.
|
||||
func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) {
|
||||
if !summary.SSHSuccess {
|
||||
return
|
||||
}
|
||||
|
||||
// Case 1: XML Migration
|
||||
// Check if any URL in the current config points to our server (targetURL)
|
||||
if summary.ParsedCurrentConfig != nil {
|
||||
targetURL := m.ServerURL
|
||||
// Strip protocol for comparison if needed, or just check for substring
|
||||
parsedTarget, err := url.Parse(targetURL)
|
||||
if err == nil {
|
||||
targetHost := parsedTarget.Hostname()
|
||||
if strings.Contains(summary.ParsedCurrentConfig.MargeServerUrl, targetHost) ||
|
||||
strings.Contains(summary.ParsedCurrentConfig.StatsServerUrl, targetHost) ||
|
||||
strings.Contains(summary.ParsedCurrentConfig.SwUpdateUrl, targetHost) ||
|
||||
strings.Contains(summary.ParsedCurrentConfig.BmxRegistryUrl, targetHost) {
|
||||
summary.IsMigrated = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: /etc/hosts + Trust CA Migration
|
||||
// Check if /etc/hosts contains redirections for Bose domains
|
||||
client := m.NewSSH(deviceIP)
|
||||
|
||||
hostsContent, err := client.Run("cat /etc/hosts")
|
||||
if err == nil {
|
||||
boseDomains := []string{
|
||||
"streaming.bose.com",
|
||||
"updates.bose.com",
|
||||
"stats.bose.com",
|
||||
"bmx.bose.com",
|
||||
}
|
||||
for _, domain := range boseDomains {
|
||||
if strings.Contains(hostsContent, domain) {
|
||||
// If CA is also trusted, it's a strong indicator of migration
|
||||
if summary.CACertTrusted {
|
||||
summary.IsMigrated = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
func (m *Manager) populateDeviceInfo(summary *MigrationSummary, deviceIP string) {
|
||||
// Populate from datastore if available
|
||||
@@ -354,19 +462,19 @@ func (m *Manager) applyProxyOptions(plannedCfg *PrivateCfg, proxyURL string, opt
|
||||
return
|
||||
}
|
||||
|
||||
if options["marge"] == "original" && currentCfg.MargeServerUrl != "" {
|
||||
if options["marge"] == "upstream" && currentCfg.MargeServerUrl != "" {
|
||||
plannedCfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl)
|
||||
}
|
||||
|
||||
if options["stats"] == "original" && currentCfg.StatsServerUrl != "" {
|
||||
if options["stats"] == "upstream" && currentCfg.StatsServerUrl != "" {
|
||||
plannedCfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl)
|
||||
}
|
||||
|
||||
if options["sw_update"] == "original" && currentCfg.SwUpdateUrl != "" {
|
||||
if options["sw_update"] == "upstream" && currentCfg.SwUpdateUrl != "" {
|
||||
plannedCfg.SwUpdateUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.SwUpdateUrl)
|
||||
}
|
||||
|
||||
if options["bmx"] == "original" && currentCfg.BmxRegistryUrl != "" {
|
||||
if options["bmx"] == "upstream" && currentCfg.BmxRegistryUrl != "" {
|
||||
plannedCfg.BmxRegistryUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.BmxRegistryUrl)
|
||||
}
|
||||
}
|
||||
@@ -437,7 +545,7 @@ func (m *Manager) checkCACertTrusted(summary *MigrationSummary, deviceIP string)
|
||||
}
|
||||
}
|
||||
|
||||
// MigrateSpeaker configures the speaker at the given IP to use this soundcork service.
|
||||
// MigrateSpeaker configures the speaker at the given IP to use this service.
|
||||
func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options map[string]string, method MigrationMethod) (string, error) {
|
||||
if targetURL == "" {
|
||||
targetURL = m.ServerURL
|
||||
@@ -467,10 +575,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"
|
||||
@@ -559,6 +719,31 @@ 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)
|
||||
}
|
||||
|
||||
// 3. Inject CA Certificate (optional but recommended)
|
||||
summary := &MigrationSummary{}
|
||||
m.checkCACertTrusted(summary, deviceIP)
|
||||
|
||||
if !summary.CACertTrusted {
|
||||
out, err := m.TrustCACert(deviceIP)
|
||||
|
||||
logs += "Trusting CA:\n" + out + "\n"
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to trust CA: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
@@ -806,6 +991,9 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) {
|
||||
"stats.bose.com",
|
||||
"bmx.bose.com",
|
||||
"content.api.bose.io",
|
||||
"events.api.bosecm.com",
|
||||
"bose-prod.apigee.net",
|
||||
"worldwide.bose.com",
|
||||
}
|
||||
|
||||
hostsContent, err := client.Run("cat /etc/hosts")
|
||||
@@ -815,17 +1003,7 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) {
|
||||
return logs, fmt.Errorf("failed to read /etc/hosts: %w", err)
|
||||
}
|
||||
|
||||
for _, domain := range domains {
|
||||
if !strings.Contains(hostsContent, domain) {
|
||||
entry := fmt.Sprintf("%s\t%s", hostIP, domain)
|
||||
|
||||
if hostsContent != "" && !strings.HasSuffix(hostsContent, "\n") {
|
||||
hostsContent += "\n"
|
||||
}
|
||||
|
||||
hostsContent += entry + "\n"
|
||||
}
|
||||
}
|
||||
hostsContent = m.generateHostsContent(hostsContent, domains, hostIP)
|
||||
|
||||
// 3. Upload new /etc/hosts
|
||||
out, _ := client.Run(rwCmd)
|
||||
@@ -842,9 +1020,16 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) {
|
||||
|
||||
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)
|
||||
|
||||
// 4. Inject CA Certificate
|
||||
// 5. Inject CA Certificate
|
||||
summary := &MigrationSummary{}
|
||||
m.checkCACertTrusted(summary, deviceIP)
|
||||
|
||||
@@ -864,6 +1049,321 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) {
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func (m *Manager) generateHostsContent(currentContent string, domains []string, hostIP string) string {
|
||||
lines := strings.Split(currentContent, "\n")
|
||||
|
||||
var newLines []string
|
||||
|
||||
domainFound := make(map[string]bool)
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
newLines = append(newLines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(trimmed)
|
||||
if len(fields) >= 2 {
|
||||
domain := fields[1]
|
||||
isBoseDomain := false
|
||||
|
||||
for _, d := range domains {
|
||||
if d == domain {
|
||||
isBoseDomain = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isBoseDomain {
|
||||
// Update existing entry with new IP
|
||||
newLines = append(newLines, fmt.Sprintf("%s\t%s", hostIP, domain))
|
||||
domainFound[domain] = true
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
|
||||
// Add missing domains
|
||||
for _, domain := range domains {
|
||||
if !domainFound[domain] {
|
||||
newLines = append(newLines, fmt.Sprintf("%s\t%s", hostIP, domain))
|
||||
}
|
||||
}
|
||||
|
||||
hostsContent := strings.Join(newLines, "\n")
|
||||
if !strings.HasSuffix(hostsContent, "\n") {
|
||||
hostsContent += "\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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) migrateViaResolvConf(deviceIP, targetURL string) (string, error) {
|
||||
client := m.NewSSH(deviceIP)
|
||||
rwCmd := "(rw || mount -o remount,rw /)"
|
||||
|
||||
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/soundtouch-service/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/soundtouch-service/aftertouch.resolv.conf
|
||||
// Ensure /mnt/nv/soundtouch-service exists
|
||||
_, _ = client.Run("mkdir -p /mnt/nv/soundtouch-service")
|
||||
|
||||
if uploadErr := client.UploadContent([]byte(resolvContent), "/mnt/nv/soundtouch-service/aftertouch.resolv.conf"); uploadErr != nil {
|
||||
return logs, fmt.Errorf("failed to upload /mnt/nv/soundtouch-service/aftertouch.resolv.conf: %w", uploadErr)
|
||||
}
|
||||
|
||||
logs += "Uploaded /mnt/nv/soundtouch-service/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. Cleanup legacy file
|
||||
_, _ = client.Run("rm -f /mnt/nv/aftertouch.resolv.conf")
|
||||
|
||||
// 6. Apply patch immediately to /etc/udhcpc.d/50default
|
||||
rwOut, _ := client.Run(rwCmd)
|
||||
logs += rwCmd + ": " + rwOut + "\n"
|
||||
|
||||
hookMarker := "/mnt/nv/soundtouch-service/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)
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
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/soundtouch-service/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
|
||||
}
|
||||
|
||||
patchStartMarker := "# --- Aftertouch DNS hook START ---"
|
||||
patchEndMarker := "# --- Aftertouch DNS hook END ---"
|
||||
|
||||
patchLogic := fmt.Sprintf(`
|
||||
%s
|
||||
# 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
|
||||
%s
|
||||
`, patchStartMarker, hookMarker, targetDHCPFile, hookMarker, targetDHCPFile, targetDHCPFile, hookMarker, hookMarker, targetDHCPFile, hookMarker, hookMarker, hookMarker, patchEndMarker)
|
||||
|
||||
newRcLocal := currentRcLocal
|
||||
// Remove old-style DNS hook if it exists
|
||||
if strings.Contains(newRcLocal, "# Aftertouch DNS hook") && !strings.Contains(newRcLocal, patchStartMarker) {
|
||||
// Old removal: filter out lines between the marker and the first 'fi'
|
||||
lines := strings.Split(newRcLocal, "\n")
|
||||
|
||||
var filteredLines []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 {
|
||||
filteredLines = append(filteredLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
newRcLocal = strings.Join(filteredLines, "\n")
|
||||
}
|
||||
|
||||
// Remove existing marker-based hook if it exists (for update)
|
||||
if strings.Contains(newRcLocal, patchStartMarker) {
|
||||
startIdx := strings.Index(newRcLocal, patchStartMarker)
|
||||
|
||||
endIdx := strings.Index(newRcLocal, patchEndMarker)
|
||||
if startIdx != -1 && endIdx != -1 {
|
||||
newRcLocal = newRcLocal[:startIdx] + newRcLocal[endIdx+len(patchEndMarker):]
|
||||
}
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
// RevertMigration reverts the speaker to its original Bose cloud configuration.
|
||||
func (m *Manager) RevertMigration(deviceIP string) (string, error) {
|
||||
client := m.NewSSH(deviceIP)
|
||||
@@ -872,6 +1372,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)
|
||||
@@ -886,7 +1411,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)
|
||||
@@ -895,12 +1425,170 @@ 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/soundtouch-service/aftertouch.resolv.conf"
|
||||
legacyConfPath := "/mnt/nv/aftertouch.resolv.conf"
|
||||
rcLocalPath := "/mnt/nv/rc.local"
|
||||
targetDHCPFile := "/etc/udhcpc.d/50default"
|
||||
|
||||
for _, p := range []string{aftertouchConfPath, legacyConfPath} {
|
||||
if _, err := client.Run(fmt.Sprintf("[ -f %s ]", p)); err == nil {
|
||||
logs += fmt.Sprintf("Removing %s\n", p)
|
||||
fmt.Printf("Removing %s\n", p)
|
||||
_, _ = client.Run(fmt.Sprintf("rm %s", p))
|
||||
}
|
||||
}
|
||||
|
||||
logs += m.removeRcLocalHooks(client, rcLocalPath)
|
||||
|
||||
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) removeRcLocalHooks(client SSHClient, rcLocalPath string) string {
|
||||
var logs string
|
||||
|
||||
patchStartMarker := "# --- Aftertouch DNS hook START ---"
|
||||
patchEndMarker := "# --- Aftertouch DNS hook END ---"
|
||||
spotifyPatchStartMarker := "# --- Aftertouch Spotify hook START ---"
|
||||
spotifyPatchEndMarker := "# --- Aftertouch Spotify hook END ---"
|
||||
aftertouchConfPath := "/mnt/nv/soundtouch-service/aftertouch.resolv.conf"
|
||||
legacyAftertouchConfPath := "/mnt/nv/aftertouch.resolv.conf"
|
||||
|
||||
currentRcLocal, err := client.Run(fmt.Sprintf("cat %s", rcLocalPath))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
modified := false
|
||||
|
||||
if strings.Contains(currentRcLocal, patchStartMarker) {
|
||||
logs += fmt.Sprintf("Removing Aftertouch hook logic from %s\n", rcLocalPath)
|
||||
fmt.Printf("Removing Aftertouch hook logic from %s\n", rcLocalPath)
|
||||
|
||||
startIdx := strings.Index(currentRcLocal, patchStartMarker)
|
||||
endIdx := strings.Index(currentRcLocal, patchEndMarker)
|
||||
|
||||
if startIdx != -1 && endIdx != -1 {
|
||||
currentRcLocal = currentRcLocal[:startIdx] + currentRcLocal[endIdx+len(patchEndMarker):]
|
||||
modified = true
|
||||
}
|
||||
} else if strings.Contains(currentRcLocal, aftertouchConfPath) || strings.Contains(currentRcLocal, legacyAftertouchConfPath) || strings.Contains(currentRcLocal, "# Aftertouch DNS hook") {
|
||||
logs += fmt.Sprintf("Removing legacy Aftertouch hook logic from %s\n", rcLocalPath)
|
||||
fmt.Printf("Removing legacy Aftertouch hook logic from %s\n", rcLocalPath)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
currentRcLocal = strings.Join(newLines, "\n")
|
||||
modified = true
|
||||
}
|
||||
|
||||
if strings.Contains(currentRcLocal, spotifyPatchStartMarker) {
|
||||
logs += fmt.Sprintf("Removing Spotify hook logic from %s\n", rcLocalPath)
|
||||
fmt.Printf("Removing Spotify hook logic from %s\n", rcLocalPath)
|
||||
|
||||
startIdx := strings.Index(currentRcLocal, spotifyPatchStartMarker)
|
||||
endIdx := strings.Index(currentRcLocal, spotifyPatchEndMarker)
|
||||
|
||||
if startIdx != -1 && endIdx != -1 {
|
||||
currentRcLocal = currentRcLocal[:startIdx] + currentRcLocal[endIdx+len(spotifyPatchEndMarker):]
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
|
||||
if modified {
|
||||
if err := client.UploadContent([]byte(currentRcLocal), rcLocalPath); err != nil {
|
||||
fmt.Printf("Warning: failed to update %s: %v\n", rcLocalPath, 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)
|
||||
@@ -931,6 +1619,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)
|
||||
@@ -939,7 +1628,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.
|
||||
@@ -1000,7 +1689,7 @@ func (m *Manager) Reboot(deviceIP string) (string, error) {
|
||||
const TestDomain = "custom-test-api.bose.fake"
|
||||
|
||||
// CALabel is the label used to identify the local CA certificate in the trust store.
|
||||
const CALabel = "# Soundcork Local Root CA"
|
||||
const CALabel = "# AfterTouch"
|
||||
|
||||
// TestHostsRedirection performs a preliminary check to see if /etc/hosts redirection works.
|
||||
func (m *Manager) TestHostsRedirection(deviceIP, targetURL string) (string, error) {
|
||||
@@ -1036,6 +1725,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 {
|
||||
@@ -1192,6 +1949,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
|
||||
|
||||
@@ -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") {
|
||||
@@ -109,6 +113,65 @@ func TestMigrateViaHosts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateViaHosts_UpdateExisting(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "setup-test-update")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
|
||||
_ = cm.EnsureCA()
|
||||
|
||||
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") {
|
||||
return "", nil // Backup already exists
|
||||
}
|
||||
if strings.HasPrefix(command, "grep -F") {
|
||||
return "matched", nil // CA already trusted
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
uploadContentFunc: func(content []byte, remotePath string) error {
|
||||
if remotePath == "/etc/hosts" {
|
||||
c := string(content)
|
||||
if !strings.Contains(c, "192.168.1.100\tstreaming.bose.com") {
|
||||
t.Errorf("Expected updated IP for streaming.bose.com, got:\n%s", c)
|
||||
}
|
||||
if !strings.Contains(c, "192.168.1.100\tupdates.bose.com") {
|
||||
t.Errorf("Expected updated IP for updates.bose.com, got:\n%s", c)
|
||||
}
|
||||
if !strings.Contains(c, "192.168.1.100\tevents.api.bosecm.com") {
|
||||
t.Errorf("Expected new domain events.api.bosecm.com, got:\n%s", c)
|
||||
}
|
||||
// Ensure no duplicates
|
||||
if strings.Count(c, "streaming.bose.com") != 1 {
|
||||
t.Errorf("Expected streaming.bose.com to appear exactly once, got %d", strings.Count(c, "streaming.bose.com"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
_, err = m.migrateViaHosts("192.168.1.10", "http://192.168.1.100:8000")
|
||||
if err != nil {
|
||||
t.Fatalf("migrateViaHosts failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLiveDeviceInfo(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/info" {
|
||||
@@ -187,7 +250,7 @@ func TestGetMigrationSummary_WithProxyOptions(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
host := server.Listener.Addr().String()
|
||||
manager := NewManager("http://soundcork:8000", nil, nil)
|
||||
manager := NewManager("http://st-service:8000", nil, nil)
|
||||
|
||||
// Since we can't easily mock SSH here without a full SSH server,
|
||||
// we are testing the logic that depends on ParsedCurrentConfig being nil or not.
|
||||
@@ -195,10 +258,10 @@ func TestGetMigrationSummary_WithProxyOptions(t *testing.T) {
|
||||
// If SSH fails, ParsedCurrentConfig will be nil.
|
||||
|
||||
options := map[string]string{
|
||||
"marge": "original",
|
||||
"stats": "soundcork",
|
||||
"sw_update": "original",
|
||||
"bmx": "soundcork",
|
||||
"marge": "upstream",
|
||||
"stats": "self",
|
||||
"sw_update": "upstream",
|
||||
"bmx": "self",
|
||||
}
|
||||
|
||||
summary, err := manager.GetMigrationSummary(host, "http://target:8000", "http://proxy:8000", options)
|
||||
@@ -211,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,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") {
|
||||
@@ -647,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/soundtouch-service/aftertouch.resolv.conf") || strings.Contains(command, "/mnt/nv/aftertouch.resolv.conf") {
|
||||
return "", nil // file exists
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
@@ -668,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
|
||||
@@ -676,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 {
|
||||
@@ -687,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") {
|
||||
@@ -711,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)
|
||||
|
||||
@@ -763,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 {
|
||||
@@ -855,6 +1104,414 @@ 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/soundtouch-service/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/soundtouch-service/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/soundtouch-service/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/soundtouch-service/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/soundtouch-service/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/soundtouch-service/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/soundtouch-service/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/soundtouch-service/aftertouch.resolv.conf ]") || 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)
|
||||
}
|
||||
|
||||
func TestCheckIsMigrated(t *testing.T) {
|
||||
m := NewManager("http://aftertouch:8000", nil, nil)
|
||||
|
||||
t.Run("XML Migrated", func(t *testing.T) {
|
||||
summary := &MigrationSummary{
|
||||
SSHSuccess: true,
|
||||
ParsedCurrentConfig: &PrivateCfg{
|
||||
MargeServerUrl: "http://aftertouch:8000/marge",
|
||||
},
|
||||
}
|
||||
m.checkIsMigrated(summary, "127.0.0.1")
|
||||
if !summary.IsMigrated {
|
||||
t.Errorf("Expected IsMigrated to be true for XML migration")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Hosts Migrated", func(t *testing.T) {
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
if command == "cat /etc/hosts" {
|
||||
return "127.0.0.1\tstreaming.bose.com", nil
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
}
|
||||
summary := &MigrationSummary{
|
||||
SSHSuccess: true,
|
||||
CACertTrusted: true,
|
||||
}
|
||||
m.checkIsMigrated(summary, "127.0.0.1")
|
||||
if !summary.IsMigrated {
|
||||
t.Errorf("Expected IsMigrated to be true for hosts migration")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Not Migrated", func(t *testing.T) {
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
if command == "cat /etc/hosts" {
|
||||
return "127.0.0.1\tlocalhost", nil
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
}
|
||||
summary := &MigrationSummary{
|
||||
SSHSuccess: true,
|
||||
ParsedCurrentConfig: &PrivateCfg{
|
||||
MargeServerUrl: "http://streaming.bose.com/marge",
|
||||
},
|
||||
CACertTrusted: false,
|
||||
}
|
||||
m.checkIsMigrated(summary, "127.0.0.1")
|
||||
if summary.IsMigrated {
|
||||
t.Errorf("Expected IsMigrated to be false for non-migrated device")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ set -euo pipefail
|
||||
# - Safe to re-run; it will update binary/config/unit and restart the service.
|
||||
# ==============================================================================
|
||||
|
||||
VERSION="${1:-${VERSION:-v0.18.1}}"
|
||||
VERSION="${1:-${VERSION:-v0.24.0}}"
|
||||
# Normalize version prefix
|
||||
if [[ ! "$VERSION" =~ ^v ]]; then
|
||||
VERSION="v${VERSION}"
|
||||
@@ -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:-}"
|
||||
@@ -141,7 +150,7 @@ ensure_dirs() {
|
||||
}
|
||||
|
||||
download_binary() {
|
||||
local asset url tmp
|
||||
local asset url tmp=""
|
||||
asset="${ARCH_ASSET:-$(detect_arch_asset)}"
|
||||
url="$(download_url_for "$asset")"
|
||||
|
||||
@@ -198,31 +207,53 @@ self_update() {
|
||||
return
|
||||
fi
|
||||
|
||||
log "Newer installer found for ${VERSION}. Re-executing..."
|
||||
chmod +x "${tmp_script}"
|
||||
log "Newer installer found for ${VERSION}. Updating ${SCRIPT_PATH} and re-executing..."
|
||||
install -m 0755 "${tmp_script}" "${SCRIPT_PATH}"
|
||||
rm -f "${tmp_script}"
|
||||
|
||||
# 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}" "$@"
|
||||
exec "${SCRIPT_PATH}" "$@"
|
||||
}
|
||||
|
||||
write_env_file() {
|
||||
log "Writing env file: ${ENV_FILE}"
|
||||
cat > "${ENV_FILE}" <<EOF
|
||||
PORT=${HTTP_PORT}
|
||||
HTTPS_PORT=${HTTPS_PORT}
|
||||
DATA_DIR=${DATA_DIR}
|
||||
log "Updating env file: ${ENV_FILE}"
|
||||
|
||||
LOG_PROXY_BODY=${LOG_PROXY_BODY}
|
||||
REDACT_PROXY_LOGS=${REDACT_PROXY_LOGS}
|
||||
RECORD_INTERACTIONS=${RECORD_INTERACTIONS}
|
||||
DISCOVERY_INTERVAL=${DISCOVERY_INTERVAL}
|
||||
# 1. Start with a list of all variables we want to manage
|
||||
local vars=(
|
||||
"PORT=${HTTP_PORT}"
|
||||
"HTTPS_PORT=${HTTPS_PORT}"
|
||||
"DATA_DIR=${DATA_DIR}"
|
||||
"LOG_PROXY_BODY=${LOG_PROXY_BODY}"
|
||||
"REDACT_PROXY_LOGS=${REDACT_PROXY_LOGS}"
|
||||
"RECORD_INTERACTIONS=${RECORD_INTERACTIONS}"
|
||||
"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}"
|
||||
)
|
||||
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
for entry in "${vars[@]}"; do
|
||||
echo "${entry}" >> "${ENV_FILE}"
|
||||
done
|
||||
else
|
||||
for entry in "${vars[@]}"; do
|
||||
local key="${entry%%=*}"
|
||||
local val="${entry#*=}"
|
||||
if ! grep -q "^${key}=" "${ENV_FILE}"; then
|
||||
echo "${key}=${val}" >> "${ENV_FILE}"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
SERVER_URL=${SERVER_URL}
|
||||
HTTPS_SERVER_URL=${HTTPS_SERVER_URL}
|
||||
EOF
|
||||
chmod 0640 "${ENV_FILE}"
|
||||
# group-readable so you can add yourself to the group if desired
|
||||
chown root:"${SERVICE_GROUP}" "${ENV_FILE}" || true
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# On-Speaker Spotify Boot Primer for Bose SoundTouch
|
||||
Self-contained boot-time Spotify primer that runs directly on the speaker.
|
||||
No Spotify credentials on the device — it fetches a fresh token from a
|
||||
[Bose-SoundTouch](https://github.com/gesellix/Bose-SoundTouch) server at boot.
|
||||
No jq, no rootfs modification — just files on persistent storage.
|
||||
|
||||
## How It Works
|
||||
Bose SoundTouch speakers run embedded Linux with a persistent writable volume
|
||||
at `/mnt/nv`. The init script `shelby_local` (S97) has a built-in hook:
|
||||
```
|
||||
[ -x /mnt/nv/rc.local ] && /mnt/nv/rc.local
|
||||
```
|
||||
This runs before SoundTouch itself (S99), so we background a primer script
|
||||
that waits for the Spotify Connect ZeroConf endpoint (port 8200) to come up,
|
||||
fetches a fresh Spotify token from the service, and primes the speaker — all
|
||||
within ~30 seconds of boot.
|
||||
|
||||
## File Layout
|
||||
```
|
||||
/mnt/nv/
|
||||
rc.local boot hook (S97 checks this)
|
||||
.profile PATH setup for interactive SSH
|
||||
bin/
|
||||
spotify-boot-primer main script
|
||||
BoseApp-Persistence/1/
|
||||
spotify-primer.conf service credentials (mode 600)
|
||||
Sources.xml, Presets.xml, ... existing speaker data
|
||||
```
|
||||
Scripts live in `/mnt/nv/bin/` (added to PATH via `.profile`), config lives
|
||||
alongside the speaker's own persistence files in `/mnt/nv/BoseApp-Persistence/1/`.
|
||||
|
||||
## Speaker Environment
|
||||
Tested on SoundTouch 20. Other SoundTouch models likely similar.
|
||||
| Item | Detail |
|
||||
|------|--------|
|
||||
| OS | Linux 3.14.43+ ARM (hostname `spotty`) |
|
||||
| Root FS | Read-only ubifs (can be remounted rw) |
|
||||
| Persistent storage | `/mnt/nv` — writable ubifs, ~24M free |
|
||||
| curl | 7.50.3 with OpenSSL (HTTPS works) |
|
||||
| bash/grep/sed/awk | Available via busybox |
|
||||
| jq | **Not available** (not needed) |
|
||||
| Init | SysV, runlevel 5 |
|
||||
| Production mode | Yes — cron is disabled |
|
||||
|
||||
## Prerequisites
|
||||
1. **SSH access to the speaker**:
|
||||
```
|
||||
ssh -o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa root@SPEAKER_IP
|
||||
```
|
||||
2. **A running [Bose-SoundTouch](https://github.com/gesellix/Bose-SoundTouch) server** with:
|
||||
- A linked Spotify account (via the management API OAuth flow)
|
||||
- The `GET /mgmt/spotify/token` endpoint (returns `{accessToken, username}`)
|
||||
- Management API credentials (HTTP Basic Auth)
|
||||
|
||||
## Installation
|
||||
SSH into the speaker and run:
|
||||
```bash
|
||||
# 1. Create bin directory
|
||||
mkdir -p /mnt/nv/bin
|
||||
# 2. Create the config file with your service connection info
|
||||
cat > /mnt/nv/BoseApp-Persistence/1/spotify-primer.conf << 'EOF'
|
||||
SOUNDTOUCH_URL=https://soundtouch.example.com
|
||||
SOUNDTOUCH_USER=admin
|
||||
SOUNDTOUCH_PASS=secret
|
||||
EOF
|
||||
chmod 600 /mnt/nv/BoseApp-Persistence/1/spotify-primer.conf
|
||||
# 3. Copy spotify-boot-primer to the speaker
|
||||
# From your local machine:
|
||||
# cat scripts/spotify/spotify-boot-primer | ssh root@SPEAKER_IP "cat > /mnt/nv/bin/spotify-boot-primer"
|
||||
chmod +x /mnt/nv/bin/spotify-boot-primer
|
||||
# 4. Create the boot hook
|
||||
cat > /mnt/nv/rc.local << 'EOF'
|
||||
#!/bin/bash
|
||||
/mnt/nv/bin/spotify-boot-primer &
|
||||
EOF
|
||||
chmod +x /mnt/nv/rc.local
|
||||
# 5. Set up PATH for interactive SSH sessions (optional but convenient)
|
||||
cat > /mnt/nv/.profile << 'EOF'
|
||||
export PATH="/mnt/nv/bin:$PATH"
|
||||
EOF
|
||||
```
|
||||
|
||||
## Testing
|
||||
```bash
|
||||
# Manual test (speaker must be running):
|
||||
/mnt/nv/bin/spotify-boot-primer
|
||||
# Check logs:
|
||||
logread | grep spotify-primer
|
||||
# Full test — reboot the speaker:
|
||||
reboot
|
||||
# Wait ~30s, then SSH back in and check:
|
||||
logread | grep spotify-primer
|
||||
curl -s "http://localhost:8200/zc?action=getInfo" | grep activeUser
|
||||
```
|
||||
|
||||
## Related
|
||||
- [Bose-SoundTouch](https://github.com/gesellix/Bose-SoundTouch) — Comprehensive Go toolkit with migration automation
|
||||
@@ -0,0 +1,6 @@
|
||||
# Spotify Scripts
|
||||
|
||||
This directory contains scripts and configuration files for the Spotify OAuth integration, specifically for priming Bose SoundTouch speakers.
|
||||
|
||||
These files were adapted from the community gist:
|
||||
https://gist.github.com/timvw/84ef8768ff876ef6805012b3eb4015b0
|
||||
@@ -0,0 +1,318 @@
|
||||
# ZeroConf Analysis - Spotify Connect Integration for Bose SoundTouch
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides a comprehensive analysis of the Spotify Connect ZeroConf protocol as implemented by Bose SoundTouch speakers. ZeroConf enables seamless integration between Spotify clients and SoundTouch hardware without requiring manual configuration.
|
||||
|
||||
## What is ZeroConf in This Context?
|
||||
|
||||
ZeroConf (Zero Configuration) in the Bose SoundTouch ecosystem is a **Spotify Connect integration protocol** that allows Spotify clients (mobile apps, desktop applications) to discover and control SoundTouch speakers automatically. The speakers expose an HTTP API on **port 8200** that implements Spotify's official ZeroConf specification.
|
||||
|
||||
## Network Discovery
|
||||
|
||||
### mDNS/Bonjour Advertisement
|
||||
|
||||
SoundTouch speakers advertise themselves on the local network using:
|
||||
- **Service Type**: `_spotify-connect._tcp`
|
||||
- **Port**: 8200
|
||||
- **TXT Record**: `CPath=/zc` (points to the ZeroConf endpoint)
|
||||
|
||||
This allows Spotify applications to automatically discover available speakers without manual configuration.
|
||||
|
||||
### Endpoint Structure
|
||||
|
||||
```
|
||||
http://[SPEAKER_IP]:8200/zc?action=[ACTION]&[PARAMETERS]
|
||||
```
|
||||
|
||||
Example: `http://192.168.1.100:8200/zc?action=getInfo`
|
||||
|
||||
## The getInfo Action
|
||||
|
||||
### Purpose
|
||||
|
||||
The `getInfo` action retrieves comprehensive device information and current status. This is the most commonly used ZeroConf action for:
|
||||
- Device discovery and identification
|
||||
- Checking Spotify authentication status
|
||||
- Retrieving device capabilities
|
||||
- Monitoring multiroom configurations
|
||||
|
||||
### Request Format
|
||||
|
||||
```http
|
||||
GET http://[SPEAKER_IP]:8200/zc?action=getInfo&version=2.10.0
|
||||
```
|
||||
|
||||
The `version` parameter is optional but recommended for compatibility.
|
||||
|
||||
### Response Properties
|
||||
|
||||
#### Mandatory Fields (Present in All Responses)
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `status` | Integer | Operation result code (101 = success) |
|
||||
| `statusString` | String | Human-readable status description |
|
||||
| `spotifyError` | Integer | Last Spotify SDK error code (0 = no error) |
|
||||
| `responseSource` | String | Entity identifier (e.g., "Bose") |
|
||||
|
||||
#### Device Information Fields
|
||||
|
||||
| Property | Required | Type | Description |
|
||||
|----------|----------|------|-------------|
|
||||
| `version` | Yes | String | ZeroConf API version (e.g., "2.10.0") |
|
||||
| `deviceID` | Yes | String | Unique device identifier (MAC-based) |
|
||||
| `publicKey` | Yes | String | Device's public key for secure communication |
|
||||
| `remoteName` | Yes | String | User-friendly device name shown in Spotify |
|
||||
| `deviceType` | No | String | Device category (e.g., "SPEAKER") |
|
||||
| `brandDisplayName` | Yes | String | Brand name displayed in Spotify apps |
|
||||
| `modelDisplayName` | No | String | Model name for user display |
|
||||
| `libraryVersion` | Yes | String | Spotify Connect library version |
|
||||
| `resolverVersion` | Yes | String | DNS resolution version |
|
||||
| `groupStatus` | Yes | String | Multiroom status: "NONE", "GROUP", or "SLAVE" |
|
||||
| `tokenType` | Yes | String | Authentication token type ("accesstoken") |
|
||||
| `clientID` | Yes | String | Spotify client identifier |
|
||||
| `productID` | Yes | Integer | Spotify product identifier |
|
||||
| `scope` | Yes | String | Permission scope (typically "streaming") |
|
||||
| `availability` | Yes | String | Device availability status |
|
||||
|
||||
#### Status Fields
|
||||
|
||||
| Property | Required | Type | Description |
|
||||
|----------|----------|------|-------------|
|
||||
| `activeUser` | No | String | Currently logged-in Spotify username (if any) |
|
||||
|
||||
#### Advanced Fields (Optional)
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `aliases` | Array | Virtual devices for multiroom zones |
|
||||
| `supported_drm_media_formats` | Array | Supported audio formats with DRM capabilities |
|
||||
| `supported_capabilities` | Integer | Bitmasked device capabilities |
|
||||
|
||||
### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 101,
|
||||
"statusString": "OK",
|
||||
"spotifyError": 0,
|
||||
"responseSource": "Bose",
|
||||
"version": "2.10.0",
|
||||
"deviceID": "0007F537F5ED",
|
||||
"deviceType": "SPEAKER",
|
||||
"remoteName": "Living Room Speaker",
|
||||
"publicKey": "BgIwVfz9ZXQG...",
|
||||
"brandDisplayName": "Bose",
|
||||
"modelDisplayName": "SoundTouch 30",
|
||||
"libraryVersion": "master-v3.15.1-g7890abcd",
|
||||
"resolverVersion": "1",
|
||||
"groupStatus": "NONE",
|
||||
"tokenType": "accesstoken",
|
||||
"clientID": "65b708073fc0480ea92a077233ca87bd",
|
||||
"productID": 0,
|
||||
"scope": "streaming",
|
||||
"availability": "",
|
||||
"activeUser": "spotify_username",
|
||||
"supported_drm_media_formats": [
|
||||
{"drm": 0, "formats": 35},
|
||||
{"drm": 1, "formats": 35},
|
||||
{"drm": 3, "formats": 1168}
|
||||
],
|
||||
"supported_capabilities": 1
|
||||
}
|
||||
```
|
||||
|
||||
## Key Properties Analysis
|
||||
|
||||
### Critical Status Indicators
|
||||
|
||||
- **`activeUser`**: Most important field for determining if Spotify is active
|
||||
- Present and non-empty: Spotify is authenticated and ready
|
||||
- Empty or missing: No active Spotify session
|
||||
|
||||
- **`remoteName`**: The display name users see in Spotify Connect device lists
|
||||
- Should be descriptive and user-friendly
|
||||
- Can contain UTF-8 characters and special symbols
|
||||
|
||||
### Device Identification
|
||||
|
||||
- **`deviceID`**: Unique identifier for targeting specific speakers
|
||||
- Typically derived from MAC address
|
||||
- Used for device-specific API calls
|
||||
|
||||
- **`groupStatus`**: Critical for multiroom functionality
|
||||
- `"NONE"`: Standalone device
|
||||
- `"GROUP"`: Multiroom master/coordinator
|
||||
- `"SLAVE"`: Member of a multiroom group
|
||||
|
||||
### Display Properties
|
||||
|
||||
- **`brandDisplayName`** and **`modelDisplayName`**: Shown in Spotify client UIs
|
||||
- Should be marketing-appropriate names
|
||||
- Support UTF-8 for international markets
|
||||
|
||||
## Practical Usage Examples
|
||||
|
||||
### 1. Status Checking
|
||||
|
||||
```bash
|
||||
# Check if Spotify is active
|
||||
curl -s "http://192.168.1.100:8200/zc?action=getInfo" | \
|
||||
grep -o '"activeUser" *: *"[^"]*"' | \
|
||||
sed 's/"activeUser" *: *"//;s/"$//'
|
||||
```
|
||||
|
||||
### 2. Device Discovery
|
||||
|
||||
```bash
|
||||
# Get device name and ID
|
||||
info=$(curl -s "http://192.168.1.100:8200/zc?action=getInfo")
|
||||
device_name=$(echo "$info" | grep -o '"remoteName" *: *"[^"]*"' | sed 's/"remoteName" *: *"//;s/"$//')
|
||||
device_id=$(echo "$info" | grep -o '"deviceID" *: *"[^"]*"' | sed 's/"deviceID" *: *"//;s/"$//')
|
||||
```
|
||||
|
||||
### 3. Multiroom Detection
|
||||
|
||||
```bash
|
||||
# Check multiroom status
|
||||
group_status=$(curl -s "http://192.168.1.100:8200/zc?action=getInfo" | \
|
||||
grep -o '"groupStatus" *: *"[^"]*"' | \
|
||||
sed 's/"groupStatus" *: *"//;s/"$//')
|
||||
```
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
The ZeroConf API supports the `addUser` action for Spotify authentication:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://192.168.1.100:8200/zc" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "action=addUser&userName=${SPOTIFY_USER}&blob=${ACCESS_TOKEN}&clientKey=&tokenType=accesstoken"
|
||||
```
|
||||
|
||||
### Token Requirements
|
||||
|
||||
- **Access Token**: Valid Spotify OAuth access token
|
||||
- **Username**: Spotify username associated with the token
|
||||
- **Token Type**: Always "accesstoken" for current implementations
|
||||
- **Client Key**: Empty string for current protocol version
|
||||
|
||||
### Token Lifecycle
|
||||
|
||||
1. Tokens expire after 1 hour (3600 seconds)
|
||||
2. Speakers must be re-primed after reboot
|
||||
3. Use `getInfo` to verify successful authentication via `activeUser` field
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Communication Security
|
||||
|
||||
- **Protocol**: HTTP (plain text) is standard, HTTPS supported but optional
|
||||
- **Network Scope**: Local network only (port 8200 typically not exposed externally)
|
||||
- **Authentication**: Token-based, no permanent credentials stored
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Token Management**:
|
||||
- Never store long-lived tokens on devices
|
||||
- Implement token refresh mechanisms
|
||||
- Use centralized token servers when possible
|
||||
|
||||
2. **Network Security**:
|
||||
- Ensure port 8200 is not accessible from external networks
|
||||
- Consider HTTPS for enhanced security
|
||||
- Implement proper firewall rules
|
||||
|
||||
3. **Error Handling**:
|
||||
- Always check `status` and `spotifyError` fields
|
||||
- Implement retry mechanisms for network failures
|
||||
- Log authentication failures for debugging
|
||||
|
||||
## Integration Patterns
|
||||
|
||||
### Boot-time Automation
|
||||
|
||||
See `spotify-boot-primer.sh` for a complete example of:
|
||||
1. Waiting for ZeroConf endpoint availability
|
||||
2. Checking current authentication status
|
||||
3. Fetching fresh tokens from a management server
|
||||
4. Automatically priming speakers at startup
|
||||
|
||||
### Manual Priming
|
||||
|
||||
See `spotify-prime-speaker.sh` for standalone token injection:
|
||||
1. Validate access tokens against Spotify API
|
||||
2. Extract username from token metadata
|
||||
3. Prime individual speakers
|
||||
4. Verify successful authentication
|
||||
|
||||
### Monitoring and Health Checks
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Health check script
|
||||
SPEAKER_IP="192.168.1.100"
|
||||
info=$(curl -sf --max-time 5 "http://${SPEAKER_IP}:8200/zc?action=getInfo" 2>/dev/null)
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
active_user=$(echo "$info" | grep -o '"activeUser" *: *"[^"]*"' | sed 's/"activeUser" *: *"//;s/"$//')
|
||||
if [ -n "$active_user" ]; then
|
||||
echo "✅ Spotify active (user: $active_user)"
|
||||
else
|
||||
echo "⚠️ Speaker reachable but Spotify not active"
|
||||
fi
|
||||
else
|
||||
echo "❌ Speaker unreachable"
|
||||
fi
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Port 8200 Unreachable**
|
||||
- Check network connectivity
|
||||
- Verify speaker is powered on
|
||||
- Confirm IP address is correct
|
||||
|
||||
2. **Empty `activeUser` After Authentication**
|
||||
- Wait 2-5 seconds after `addUser` request
|
||||
- Verify access token is valid and not expired
|
||||
- Check `spotifyError` field for SDK errors
|
||||
|
||||
3. **Authentication Failures**
|
||||
- Ensure token has correct scopes
|
||||
- Verify username matches token owner
|
||||
- Check token expiration time
|
||||
|
||||
### Diagnostic Commands
|
||||
|
||||
```bash
|
||||
# Test basic connectivity
|
||||
curl -sf --max-time 5 "http://192.168.1.100:8200/zc?action=getInfo"
|
||||
|
||||
# Check detailed response
|
||||
curl -s "http://192.168.1.100:8200/zc?action=getInfo" | jq .
|
||||
|
||||
# Monitor authentication status
|
||||
while true; do
|
||||
active=$(curl -s "http://192.168.1.100:8200/zc?action=getInfo" | \
|
||||
grep -o '"activeUser" *: *"[^"]*"' | sed 's/"activeUser" *: *"//;s/"$//')
|
||||
echo "$(date): activeUser = '$active'"
|
||||
sleep 10
|
||||
done
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Spotify ZeroConf API Documentation](https://developer.spotify.com/documentation/commercial-hardware/implementation/guides/zeroconf)
|
||||
- [Bose SoundTouch Toolkit](https://github.com/gesellix/Bose-SoundTouch)
|
||||
- Scripts in this directory:
|
||||
- `spotify-boot-primer.sh`: Automated boot-time priming
|
||||
- `spotify-prime-speaker.sh`: Manual speaker priming
|
||||
- `spotify-primer.conf.example`: Configuration template
|
||||
|
||||
---
|
||||
|
||||
*This analysis is based on Spotify's official ZeroConf specification and practical implementation experience with Bose SoundTouch speakers.*
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
# /mnt/nv/rc.local — runs at boot via shelby_local (S97)
|
||||
# Launches Spotify boot primer in background since SoundTouch starts at S99
|
||||
/mnt/nv/bin/spotify-boot-primer &
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# spotify-boot-primer — Self-contained Spotify primer for Bose SoundTouch speakers
|
||||
#
|
||||
# Runs at boot (via /mnt/nv/rc.local), waits for the ZeroConf endpoint to
|
||||
# come up, fetches a fresh Spotify token from a soundtouch-service server, and
|
||||
# primes the speaker. No Spotify credentials stored on the device.
|
||||
#
|
||||
# Only needs: curl, grep, sed (all available on the speaker via busybox).
|
||||
#
|
||||
# Install:
|
||||
# 1. mkdir -p /mnt/nv/soundtouch-service
|
||||
# 2. Copy this script to /mnt/nv/soundtouch-service/spotify-boot-primer
|
||||
# 3. Create /mnt/nv/soundtouch-service/spotify-primer.conf
|
||||
# 4. Create /mnt/nv/rc.local that backgrounds this script
|
||||
# 5. chmod +x /mnt/nv/rc.local /mnt/nv/soundtouch-service/spotify-boot-primer
|
||||
#
|
||||
# Config file format (/mnt/nv/soundtouch-service/spotify-primer.conf):
|
||||
# SOUNDTOUCH_URL=https://soundtouch.example.com
|
||||
# SOUNDTOUCH_USER=admin
|
||||
# SOUNDTOUCH_PASS=secret
|
||||
#
|
||||
# Related:
|
||||
# https://github.com/gesellix/Bose-SoundTouch
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
CONF="/mnt/nv/soundtouch-service/spotify-primer.conf"
|
||||
LOG_TAG="spotify-primer[$$]"
|
||||
ZC_URL="http://localhost:8200/zc"
|
||||
MAX_WAIT=120 # max seconds to wait for port 8200
|
||||
RETRY_DELAY=3 # seconds between retries
|
||||
|
||||
# --- Logging ---
|
||||
log() {
|
||||
logger -s -t "$LOG_TAG" -p "$1" "$2"
|
||||
}
|
||||
|
||||
# --- JSON parsing without jq ---
|
||||
# Extract a string value: echo '{"key":"val"}' | json_str key
|
||||
json_str() {
|
||||
grep -o "\"$1\" *: *\"[^\"]*\"" | sed "s/\"$1\" *: *\"//;s/\"$//"
|
||||
}
|
||||
|
||||
# Extract a numeric value: echo '{"key":123}' | json_num key
|
||||
json_num() {
|
||||
grep -o "\"$1\" *: *[0-9]*" | sed "s/\"$1\" *: *//"
|
||||
}
|
||||
|
||||
# --- Load config ---
|
||||
if [ ! -f "$CONF" ]; then
|
||||
log err "Config not found: $CONF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
. "$CONF"
|
||||
|
||||
for var in SOUNDTOUCH_URL SOUNDTOUCH_USER SOUNDTOUCH_PASS; do
|
||||
if [ -z "${!var:-}" ]; then
|
||||
log err "Missing $var in $CONF"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
log info "Config loaded (server=${SOUNDTOUCH_URL})"
|
||||
|
||||
# --- Wait for ZeroConf endpoint (port 8200) ---
|
||||
log info "Waiting for ZeroConf endpoint (max ${MAX_WAIT}s)..."
|
||||
waited=0
|
||||
while true; do
|
||||
if curl -sf --max-time 2 "${ZC_URL}?action=getInfo" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
waited=$((waited + RETRY_DELAY))
|
||||
if [ $waited -ge $MAX_WAIT ]; then
|
||||
log err "ZeroConf endpoint not available after ${MAX_WAIT}s — giving up"
|
||||
exit 1
|
||||
fi
|
||||
sleep $RETRY_DELAY
|
||||
done
|
||||
log info "ZeroConf endpoint is up (waited ${waited}s)"
|
||||
|
||||
# --- Check if already primed ---
|
||||
info=$(curl -sf --max-time 5 "${ZC_URL}?action=getInfo" 2>/dev/null)
|
||||
active_user=$(echo "$info" | json_str activeUser)
|
||||
device_name=$(echo "$info" | json_str remoteName)
|
||||
|
||||
if [ -n "$active_user" ]; then
|
||||
log info "Already primed (device=$device_name, activeUser=$active_user) — nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log info "Speaker '$device_name' has no active Spotify user — priming..."
|
||||
|
||||
# --- Get token from soundtouch-service server ---
|
||||
log info "Requesting Spotify token from soundtouch-service..."
|
||||
token_response=$(curl -sf --max-time 15 \
|
||||
-u "${SOUNDTOUCH_USER}:${SOUNDTOUCH_PASS}" \
|
||||
"${SOUNDTOUCH_URL}/mgmt/spotify/token" \
|
||||
2>&1)
|
||||
|
||||
if [ $? -ne 0 ] || [ -z "$token_response" ]; then
|
||||
log err "Failed to get token from soundtouch-service (is the server reachable?)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
access_token=$(echo "$token_response" | json_str accessToken)
|
||||
user=$(echo "$token_response" | json_str username)
|
||||
|
||||
if [ -z "$access_token" ] || [ -z "$user" ]; then
|
||||
error_msg=$(echo "$token_response" | json_str detail)
|
||||
log err "soundtouch-service returned error: ${error_msg:-no token/username in response}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log info "Got token for user $user (${access_token:0:10}...)"
|
||||
|
||||
# --- Prime the speaker ---
|
||||
result=$(curl -sf --max-time 10 -X POST "$ZC_URL" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "action=addUser&userName=${user}&blob=${access_token}&clientKey=&tokenType=accesstoken" \
|
||||
2>&1)
|
||||
|
||||
status=$(echo "$result" | json_num status)
|
||||
status_str=$(echo "$result" | json_str statusString)
|
||||
|
||||
if [ "$status" != "101" ]; then
|
||||
log err "addUser failed: status=$status ($status_str)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Verify (retry — speaker needs a few seconds after cold boot) ---
|
||||
log info "addUser accepted (status 101) — verifying..."
|
||||
for i in 1 2 3 4 5; do
|
||||
sleep $((i * 2))
|
||||
active_user=$(curl -sf --max-time 5 "${ZC_URL}?action=getInfo" | json_str activeUser)
|
||||
if [ -n "$active_user" ]; then
|
||||
log info "Speaker primed successfully (activeUser=$active_user)"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
log warning "Speaker accepted addUser but activeUser still empty after 30s"
|
||||
exit 1
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# spotify-prime-speaker — Prime a Bose SoundTouch speaker for Spotify playback
|
||||
#
|
||||
# Activates Spotify on a SoundTouch speaker by sending an access token
|
||||
# via the Spotify Connect ZeroConf endpoint (port 8200). This is the
|
||||
# same mechanism the Spotify desktop app uses internally.
|
||||
#
|
||||
# Works standalone — no soundtouch-service, ueberboese, or other server required.
|
||||
#
|
||||
# Requirements: curl, jq
|
||||
#
|
||||
# Usage:
|
||||
# ./spotify-prime-speaker SPEAKER_IP ACCESS_TOKEN
|
||||
#
|
||||
# Example:
|
||||
# ./spotify-prime-speaker 192.168.1.143 BQDj...your_token...
|
||||
#
|
||||
# How to get an access token:
|
||||
# - Spotify Developer Console: https://developer.spotify.com
|
||||
# (create an app, use the "Get Token" button)
|
||||
# - Via soundtouch-service management API: POST /mgmt/spotify/auth/init
|
||||
# - Via ueberboese management API: POST /mgmt/spotify/init
|
||||
# - Any Spotify OAuth Authorization Code flow with user-read-email scope
|
||||
#
|
||||
# Notes:
|
||||
# - Access tokens expire after 1 hour (3600 seconds)
|
||||
# - The speaker must be on the same network and reachable on port 8200
|
||||
# - After priming, Spotify presets on the speaker should work immediately
|
||||
# - Re-run after each speaker reboot (or use a server like soundtouch-service
|
||||
# to automate this)
|
||||
#
|
||||
# How it works:
|
||||
# The Bose SoundTouch speaker exposes a Spotify Connect ZeroConf API
|
||||
# on port 8200. By sending an addUser request with a valid Spotify
|
||||
# access token, the speaker activates its built-in Spotify Connect
|
||||
# client. No encryption is needed — the token is sent as plain text,
|
||||
# exactly like the Spotify desktop app does it.
|
||||
#
|
||||
# Related:
|
||||
# - https://github.com/gesellix/Bose-SoundTouch (comprehensive toolkit)
|
||||
set -euo pipefail
|
||||
|
||||
# --- Argument parsing ---
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Usage: $0 SPEAKER_IP ACCESS_TOKEN"
|
||||
echo ""
|
||||
echo "Prime a Bose SoundTouch speaker for Spotify playback."
|
||||
echo ""
|
||||
echo "Arguments:"
|
||||
echo " SPEAKER_IP IP address of the SoundTouch speaker"
|
||||
echo " ACCESS_TOKEN Spotify access token (starts with BQ...)"
|
||||
echo ""
|
||||
echo "Get a token at https://developer.spotify.com or via a server's OAuth flow."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SPEAKER_IP="$1"
|
||||
TOKEN="$2"
|
||||
ZC_URL="http://${SPEAKER_IP}:8200/zc"
|
||||
|
||||
# --- Dependency check ---
|
||||
for cmd in curl jq; do
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
echo "Error: $cmd is required but not installed." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# --- Step 1: Discover Spotify username from token ---
|
||||
echo "Discovering Spotify user from token..."
|
||||
ME_RESPONSE=$(curl -sf -H "Authorization: Bearer ${TOKEN}" \
|
||||
https://api.spotify.com/v1/me 2>&1) || {
|
||||
echo "Error: Failed to call Spotify /me API. Is the token valid?" >&2
|
||||
echo " (tokens expire after 1 hour)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
USER=$(echo "$ME_RESPONSE" | jq -r '.id // empty')
|
||||
if [ -z "$USER" ]; then
|
||||
echo "Error: Could not extract user ID from Spotify response." >&2
|
||||
echo "$ME_RESPONSE" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " Spotify user: $USER"
|
||||
|
||||
# --- Step 2: Check current speaker status ---
|
||||
echo "Checking speaker at ${SPEAKER_IP}:8200..."
|
||||
INFO=$(curl -sf "${ZC_URL}?action=getInfo" 2>&1) || {
|
||||
echo "Error: Could not reach speaker at ${SPEAKER_IP}:8200." >&2
|
||||
echo " Is the speaker on and on the same network?" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ACTIVE=$(echo "$INFO" | jq -r '.activeUser // empty')
|
||||
DEVICE_NAME=$(echo "$INFO" | jq -r '.remoteName // empty')
|
||||
|
||||
if [ -n "$DEVICE_NAME" ]; then
|
||||
echo " Speaker: $DEVICE_NAME"
|
||||
fi
|
||||
|
||||
if [ -n "$ACTIVE" ]; then
|
||||
echo " Already primed (activeUser=$ACTIVE)"
|
||||
echo "Done — speaker is ready for Spotify playback."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo " No active Spotify user — priming now..."
|
||||
|
||||
# --- Step 3: Send addUser ---
|
||||
RESULT=$(curl -sf -X POST "${ZC_URL}" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "action=addUser&userName=${USER}&blob=${TOKEN}&clientKey=&tokenType=accesstoken" \
|
||||
2>&1) || {
|
||||
echo "Error: addUser request failed." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
STATUS=$(echo "$RESULT" | jq -r '.status // -1')
|
||||
STATUS_STR=$(echo "$RESULT" | jq -r '.statusString // empty')
|
||||
|
||||
if [ "$STATUS" != "101" ]; then
|
||||
echo "Error: Speaker returned status $STATUS ($STATUS_STR)" >&2
|
||||
echo "$RESULT" | jq . >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " Speaker accepted the token (status 101)."
|
||||
|
||||
# --- Step 4: Verify ---
|
||||
echo " Verifying (waiting 2 seconds)..."
|
||||
sleep 2
|
||||
ACTIVE=$(curl -sf "${ZC_URL}?action=getInfo" | jq -r '.activeUser // empty')
|
||||
|
||||
if [ -n "$ACTIVE" ]; then
|
||||
echo "Done — speaker primed for Spotify (activeUser=$ACTIVE)"
|
||||
else
|
||||
echo "Warning: Speaker returned 101 but activeUser is still empty."
|
||||
echo " The speaker may need more time. Try pressing a Spotify preset."
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,6 @@
|
||||
# /mnt/nv/BoseApp-Persistence/1/spotify-primer.conf — service connection for boot primer
|
||||
# The speaker fetches a fresh Spotify token from the service at boot.
|
||||
# No Spotify credentials needed on the device.
|
||||
SOUNDTOUCH_URL=https://soundtouch.example.com
|
||||
SOUNDTOUCH_USER=admin
|
||||
SOUNDTOUCH_PASS=secret
|
||||