Compare commits
@@ -16,12 +16,14 @@ dist/
|
||||
/soundtouch-cli
|
||||
/soundtouch-service
|
||||
/soundtouch-web
|
||||
/dummy-speaker
|
||||
/example-mdns
|
||||
/example-upnp
|
||||
/example-unified
|
||||
/mdns-scanner
|
||||
/websocket-demo
|
||||
/main
|
||||
/screenshots
|
||||
|
||||
# Environment configuration
|
||||
.env
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: all build build-cli test test-coverage check fmt vet lint clean dev help
|
||||
.PHONY: all build build-cli test test-coverage check fmt vet lint clean dev help screenshots
|
||||
|
||||
# Go parameters
|
||||
GOCMD=go
|
||||
@@ -336,6 +336,10 @@ docker-run-ports:
|
||||
@echo "Running Docker container with port mapping (discovery will be manual)..."
|
||||
docker run --rm -it -p 8000:8000 -v $$(pwd)/data:/app/data soundtouch-service
|
||||
|
||||
screenshots:
|
||||
@echo "Capturing documentation screenshots..."
|
||||
@bash scripts/screenshots/run.sh
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " build - Build the CLI tool, service, and examples"
|
||||
@@ -356,6 +360,7 @@ help:
|
||||
@echo " dev - Build and show CLI help"
|
||||
@echo " dev-service - Build and run service locally"
|
||||
@echo " dev-service-proxy - Build and run service with proxy (PROXY_URL=url required)"
|
||||
@echo " screenshots - Capture documentation screenshots (headless Chrome via chromedp)"
|
||||
@echo " dev-discover - Build and run device discovery"
|
||||
@echo " dev-info - Build and get device info (HOST=ip required)"
|
||||
@echo " dev-mdns - Build and run mDNS discovery example"
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// Command dummy-speaker runs an HTTP-only fake SoundTouch speaker and
|
||||
// optionally registers it with a running soundtouch-service so the web UI
|
||||
// has a device to display.
|
||||
//
|
||||
// Intended for documentation screenshots and local UI smoke checks. Do not
|
||||
// use against a real network — the fixture payload is synthetic and would
|
||||
// confuse other tooling that expects live device data.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// dummy-speaker --port 8090 --register http://localhost:8000
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/testing/fakespeaker"
|
||||
)
|
||||
|
||||
func main() {
|
||||
listen := flag.String("listen", "127.0.0.1:8090", "bind address for the fake speaker's HTTP API")
|
||||
telnetListen := flag.String("telnet-listen", "127.0.0.1:17000", "bind address for the fake speaker's telnet diagnostic shell (empty to disable)")
|
||||
register := flag.String("register", "", "service base URL (e.g. http://localhost:8000) to self-register with via POST /setup/devices")
|
||||
registerAs := flag.String("register-as", "", "address to send to /setup/devices (defaults to --listen)")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
s, err := fakespeaker.Start(fakespeaker.Config{
|
||||
HTTPListen: *listen,
|
||||
TelnetListen: *telnetListen,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("start fake speaker: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("fake speaker HTTP listening on http://%s", s.HTTPAddr())
|
||||
|
||||
if addr := s.TelnetAddr(); addr != "" {
|
||||
log.Printf("fake speaker telnet listening on tcp://%s", addr)
|
||||
}
|
||||
|
||||
if *register != "" {
|
||||
target := *registerAs
|
||||
if target == "" {
|
||||
target = s.HTTPAddr()
|
||||
}
|
||||
|
||||
if err := registerWithService(*register, target); err != nil {
|
||||
log.Printf("self-register failed: %v (continuing anyway)", err)
|
||||
} else {
|
||||
log.Printf("registered %s with service at %s", target, *register)
|
||||
}
|
||||
}
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sig
|
||||
|
||||
log.Printf("shutting down")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.Stop(ctx); err != nil {
|
||||
log.Printf("stop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func registerWithService(serviceURL, deviceAddr string) error {
|
||||
body, err := json.Marshal(map[string]string{"ip": deviceAddr})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, serviceURL+"/setup/devices", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("service responded %s", resp.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -23,6 +23,12 @@ func eventSubscribe(c *cli.Context) error {
|
||||
filterStr := c.String("filter")
|
||||
filters := parseEventFilters(filterStr)
|
||||
|
||||
debugMode, err := parseDebugMode(c.String("debug"))
|
||||
if err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse duration
|
||||
duration := c.Duration("duration")
|
||||
verbose := c.Bool("verbose")
|
||||
@@ -60,6 +66,10 @@ func eventSubscribe(c *cli.Context) error {
|
||||
// Set up event handlers
|
||||
setupEventHandlers(wsClient, filters, verbose)
|
||||
|
||||
if debugMode != debugOff {
|
||||
installDebugHook(wsClient, debugMode)
|
||||
}
|
||||
|
||||
// Connect to WebSocket
|
||||
fmt.Println("🔌 Connecting to WebSocket...")
|
||||
|
||||
@@ -127,11 +137,78 @@ func eventSubscribe(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// debugMode controls when the WebSocket subscribe loop prints raw frames
|
||||
// to stderr. "off" disables debug output entirely (the production default
|
||||
// when --debug is unset).
|
||||
type debugMode int
|
||||
|
||||
const (
|
||||
debugOff debugMode = iota
|
||||
debugAll
|
||||
debugUnknown
|
||||
debugErrors
|
||||
)
|
||||
|
||||
func parseDebugMode(s string) (debugMode, error) {
|
||||
switch strings.TrimSpace(s) {
|
||||
case "":
|
||||
return debugOff, nil
|
||||
case "all":
|
||||
return debugAll, nil
|
||||
case "unknown":
|
||||
return debugUnknown, nil
|
||||
case "errors":
|
||||
return debugErrors, nil
|
||||
default:
|
||||
return debugOff, fmt.Errorf("invalid --debug value %q (want one of: all, unknown, errors)", s)
|
||||
}
|
||||
}
|
||||
|
||||
// installDebugHook wires an OnRawMessage handler that prints the raw
|
||||
// frame to stderr based on the chosen mode. Stays out of stdout so
|
||||
// debug output can be filtered/grep'd independently of normal events.
|
||||
func installDebugHook(ws *client.WebSocketClient, mode debugMode) {
|
||||
ws.OnRawMessage(func(data []byte, parseErr error) {
|
||||
switch mode {
|
||||
case debugAll:
|
||||
printRawFrame(data, parseErr, "all")
|
||||
case debugErrors:
|
||||
if parseErr != nil {
|
||||
printRawFrame(data, parseErr, "errors")
|
||||
}
|
||||
case debugUnknown:
|
||||
// "Unknown" = parsed successfully but no known event types
|
||||
// matched. Parse errors also qualify, since they're frames
|
||||
// the client couldn't interpret either.
|
||||
if parseErr != nil {
|
||||
printRawFrame(data, parseErr, "unknown:parse-error")
|
||||
return
|
||||
}
|
||||
|
||||
ev, err := models.ParseWebSocketEvent(data)
|
||||
if err != nil || len(ev.GetEventTypes()) == 0 {
|
||||
printRawFrame(data, err, "unknown")
|
||||
}
|
||||
case debugOff:
|
||||
// nothing
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func printRawFrame(data []byte, parseErr error, tag string) {
|
||||
prefix := "[ws-debug:" + tag + "]"
|
||||
if parseErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s parse-error: %v\n", prefix, parseErr)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%s %s\n", prefix, string(data))
|
||||
}
|
||||
|
||||
// parseEventFilters validates and parses the filter string
|
||||
func parseEventFilters(eventFilter string) map[string]bool {
|
||||
validFilters := map[string]bool{
|
||||
"nowPlaying": true, "volume": true, "connection": true,
|
||||
"preset": true, "zone": true, "bass": true,
|
||||
"preset": true, "zone": true, "group": true, "bass": true,
|
||||
"sdkInfo": true, "userActivity": true,
|
||||
}
|
||||
|
||||
@@ -217,6 +294,13 @@ func setupEventHandlers(wsClient *client.WebSocketClient, filters map[string]boo
|
||||
})
|
||||
}
|
||||
|
||||
// Stereo-pair (group) events — ST-10 only
|
||||
if filters == nil || filters["group"] {
|
||||
wsClient.OnGroupUpdated(func(event *models.GroupUpdatedEvent) {
|
||||
handleGroupEvent(event)
|
||||
})
|
||||
}
|
||||
|
||||
// Bass events
|
||||
if filters == nil || filters["bass"] {
|
||||
wsClient.OnBassUpdated(func(event *models.BassUpdatedEvent) {
|
||||
@@ -358,6 +442,34 @@ func handleZoneEvent(event *models.ZoneUpdatedEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
func handleGroupEvent(event *models.GroupUpdatedEvent) {
|
||||
group := &event.Group
|
||||
fmt.Printf("\n🎧 Stereo-Pair Update [%s]:\n", event.DeviceID)
|
||||
|
||||
if group.IsEmpty() {
|
||||
fmt.Println(" ⛓️💥 Pair dissolved (no group configured)")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" 🆔 ID: %s\n", group.ID)
|
||||
fmt.Printf(" 📛 Name: %s\n", group.Name)
|
||||
fmt.Printf(" 👑 Master: %s\n", group.MasterDeviceID)
|
||||
|
||||
if group.Status != "" {
|
||||
fmt.Printf(" ✅ Status: %s\n", group.Status)
|
||||
}
|
||||
|
||||
for _, r := range group.Roles.Roles {
|
||||
fmt.Printf(" %-5s %s", r.Role, r.DeviceID)
|
||||
|
||||
if r.IPAddress != "" {
|
||||
fmt.Printf(" (IP: %s)", r.IPAddress)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func handleBassEvent(event *models.BassUpdatedEvent) {
|
||||
bass := &event.Bass
|
||||
fmt.Printf("\n🎵 Bass Update [%s]:\n", event.DeviceID)
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/speaker"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// getGroupStatus retrieves and prints the device's current stereo-pair state.
|
||||
func getGroupStatus(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Getting group information", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
group, err := client.GetGroup()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to get group: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if group.IsEmpty() {
|
||||
fmt.Println("Device is not in a stereo pair")
|
||||
return nil
|
||||
}
|
||||
|
||||
printGroup(group)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createGroup forms a stereo pair on the LEFT speaker, which becomes the master.
|
||||
func createGroup(c *cli.Context) error {
|
||||
leftIP := c.String("left")
|
||||
rightIP := c.String("right")
|
||||
name := c.String("name")
|
||||
|
||||
if net.ParseIP(leftIP) == nil {
|
||||
PrintError(fmt.Sprintf("Invalid left IP address: %s", leftIP))
|
||||
return fmt.Errorf("invalid left IP: %s", leftIP)
|
||||
}
|
||||
|
||||
if net.ParseIP(rightIP) == nil {
|
||||
PrintError(fmt.Sprintf("Invalid right IP address: %s", rightIP))
|
||||
return fmt.Errorf("invalid right IP: %s", rightIP)
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Creating stereo pair: LEFT=%s RIGHT=%s", leftIP, rightIP), leftIP, speaker.HTTPPort)
|
||||
|
||||
leftInfo, err := fetchDeviceInfo(c, leftIP)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to read LEFT device info: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
rightInfo, err := fetchDeviceInfo(c, rightIP)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to read RIGHT device info: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("%s + %s", leftInfo.Name, rightInfo.Name)
|
||||
}
|
||||
|
||||
req := &models.Group{
|
||||
Name: name,
|
||||
MasterDeviceID: leftInfo.DeviceID,
|
||||
Roles: models.GroupRoles{
|
||||
Roles: []models.GroupRole{
|
||||
{DeviceID: leftInfo.DeviceID, Role: "LEFT", IPAddress: leftIP},
|
||||
{DeviceID: rightInfo.DeviceID, Role: "RIGHT", IPAddress: rightIP},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
leftClient, err := clientForHost(c, leftIP)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client for LEFT: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := leftClient.AddGroup(req)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create group: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Stereo pair created (id=%s)", result.ID))
|
||||
printGroup(result)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// renameGroup updates the name of the existing stereo pair. The device
|
||||
// requires the full structure on every update, so we fetch the current
|
||||
// state first.
|
||||
func renameGroup(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
newName := c.String("name")
|
||||
|
||||
if newName == "" {
|
||||
PrintError("--name is required")
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Renaming stereo pair to %q", newName), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
stClient, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
current, err := stClient.GetGroup()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to read current group: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if current.IsEmpty() {
|
||||
PrintError("Device is not in a stereo pair — nothing to rename")
|
||||
return fmt.Errorf("no group configured")
|
||||
}
|
||||
|
||||
// Status is read-only on the device side; don't echo it back.
|
||||
current.Status = ""
|
||||
current.Name = newName
|
||||
|
||||
result, err := stClient.UpdateGroup(current)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to rename group: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Stereo pair renamed to %q", result.Name))
|
||||
printGroup(result)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeGroup tears down the device's stereo pair.
|
||||
func removeGroup(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Removing stereo pair", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
stClient, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := stClient.RemoveGroup(); err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to remove group: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess("Stereo pair removed")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchDeviceInfo builds a one-off client for the given IP and reads /info.
|
||||
// Reused for both halves of a `create` invocation so the caller doesn't have
|
||||
// to babysit two host/port pairs.
|
||||
func fetchDeviceInfo(c *cli.Context, host string) (*models.DeviceInfo, error) {
|
||||
stClient, err := clientForHost(c, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stClient.GetDeviceInfo()
|
||||
}
|
||||
|
||||
// clientForHost mirrors CreateSoundTouchClient but overrides the host so we
|
||||
// can talk to a speaker other than the one named in --host.
|
||||
func clientForHost(c *cli.Context, host string) (*client.Client, error) {
|
||||
cfg, err := loadConfig(c.Duration("timeout"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
return client.NewClient(&client.Config{
|
||||
Host: host,
|
||||
Port: speaker.HTTPPort,
|
||||
Timeout: cfg.HTTPTimeout,
|
||||
UserAgent: cfg.UserAgent,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func printGroup(g *models.Group) {
|
||||
fmt.Println("Stereo Pair Configuration:")
|
||||
fmt.Printf(" ID: %s\n", g.ID)
|
||||
fmt.Printf(" Name: %s\n", g.Name)
|
||||
fmt.Printf(" Master: %s\n", g.MasterDeviceID)
|
||||
|
||||
if g.Status != "" {
|
||||
fmt.Printf(" Status: %s\n", g.Status)
|
||||
}
|
||||
|
||||
for _, r := range g.Roles.Roles {
|
||||
fmt.Printf(" %-5s %s", r.Role, r.DeviceID)
|
||||
|
||||
if r.IPAddress != "" {
|
||||
fmt.Printf(" (IP: %s)", r.IPAddress)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
@@ -1478,6 +1478,64 @@ func main() {
|
||||
},
|
||||
},
|
||||
},
|
||||
// Stereo-pair (group) commands — ST-10 only
|
||||
{
|
||||
Name: "group",
|
||||
Aliases: []string{"g"},
|
||||
Usage: "ST-10 stereo-pair management (left/right channel pairing)",
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "status",
|
||||
Usage: "Show the device's current stereo-pair configuration",
|
||||
Action: getGroupStatus,
|
||||
Before: RequireHost,
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Usage: "Form a stereo pair (LEFT speaker becomes master)",
|
||||
Action: createGroup,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "left",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "IP address of the LEFT speaker (will be master)",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "right",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "IP address of the RIGHT speaker",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Pair name (defaults to \"<left> + <right>\")",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "rename",
|
||||
Usage: "Rename the existing stereo pair on the device",
|
||||
Action: renameGroup,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "New pair name",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
Before: RequireHost,
|
||||
},
|
||||
{
|
||||
Name: "remove",
|
||||
Usage: "Dissolve the device's stereo pair",
|
||||
Action: removeGroup,
|
||||
Before: RequireHost,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Advanced Audio commands
|
||||
{
|
||||
Name: "audio",
|
||||
@@ -2093,7 +2151,7 @@ func main() {
|
||||
&cli.StringFlag{
|
||||
Name: "filter",
|
||||
Aliases: []string{"f"},
|
||||
Usage: "Filter events by type (comma-separated): nowPlaying,volume,connection,preset,zone,bass,sdkInfo,userActivity",
|
||||
Usage: "Filter events by type (comma-separated): nowPlaying,volume,connection,preset,zone,group,bass,sdkInfo,userActivity",
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "duration",
|
||||
@@ -2105,6 +2163,10 @@ func main() {
|
||||
Name: "no-reconnect",
|
||||
Usage: "Disable automatic reconnection on connection loss",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "debug",
|
||||
Usage: "Print raw WebSocket frames to stderr — one of: all, unknown, errors",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "verbose",
|
||||
Aliases: []string{"v"},
|
||||
|
||||
@@ -852,15 +852,32 @@ func startDeviceDiscovery(server *handlers.Server) {
|
||||
|
||||
func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// TrustedRealIP must run before any handler that reads r.RemoteAddr —
|
||||
// SnapshotMiddleware captures the request, and several handlers
|
||||
// (HandleMargePowerOn, etc.) inspect the source IP. The middleware is
|
||||
// gated on Settings.TrustForwardedHeaders; when off (the safe default),
|
||||
// it returns nil and we skip Use'ing it entirely.
|
||||
if mw := server.TrustedRealIPMiddleware(); mw != nil {
|
||||
r.Use(mw)
|
||||
}
|
||||
|
||||
r.Use(server.SnapshotMiddleware)
|
||||
r.Use(server.OriginMiddleware)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(server.PeerObserverMiddleware)
|
||||
r.Use(server.ShortcutMiddleware)
|
||||
r.Use(server.MirrorMiddleware)
|
||||
r.Use(server.RecordMiddleware)
|
||||
|
||||
r.Get("/", server.HandleRoot)
|
||||
r.Get("/health", server.HandleHealth)
|
||||
// Passive peer-reachability probe. Registers a device IP with the
|
||||
// in-process observer, nudges :8090/swUpdateCheck, and waits for
|
||||
// any inbound from that IP. Used post-migration where the daemon
|
||||
// caches its swUpdateUrl at boot and the active round-trip can't
|
||||
// reach it without a reboot.
|
||||
r.Post("/setup/peer-probe/{deviceId}", server.HandlePeerProbe)
|
||||
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||
r.URL.Path = "/media/favicon-braille.svg"
|
||||
server.HandleMedia()(w, r)
|
||||
|
||||
@@ -123,6 +123,7 @@ POST /setup/discover handlers.(
|
||||
POST /setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
|
||||
POST /setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
|
||||
POST /setup/pair-account/{deviceId} handlers.(*Server).HandlePairAccount-fm
|
||||
POST /setup/peer-probe/{deviceId} handlers.(*Server).HandlePeerProbe-fm
|
||||
POST /setup/proxy-settings handlers.(*Server).HandleUpdateProxySettings-fm
|
||||
POST /setup/reboot/{deviceId} handlers.(*Server).HandleRebootDevice-fm
|
||||
POST /setup/remove-remote-services/{deviceId} handlers.(*Server).HandleRemoveRemoteServices-fm
|
||||
|
||||
@@ -335,6 +335,10 @@ this; we just add a `telnet` option next to `xml`/`resolv`.
|
||||
|
||||
---
|
||||
|
||||
> **See §9 for the as-shipped state.** Section 7 below records the
|
||||
> original forecast; the wizard grew larger during implementation and
|
||||
> §9 documents what actually landed.
|
||||
|
||||
## 7. Summary of what changes when this lands
|
||||
|
||||
- **New reusable package `pkg/telnet`** — sibling of `pkg/ssh`, line-oriented
|
||||
@@ -463,3 +467,264 @@ The most useful next verification step is touching a real ST 30 and ST 520
|
||||
— those are the two "expected to work" models with zero concrete captures.
|
||||
Beyond that, every behaviour the doc predicts is exercised by the unit
|
||||
tests in `pkg/telnet` and `pkg/service/setup`.
|
||||
|
||||
---
|
||||
|
||||
## 9. What actually shipped (post-implementation addendum)
|
||||
|
||||
§7 forecast the surface area roughly; the wizard ended up larger. This
|
||||
section is the present-day map of the migration tab and the supporting
|
||||
backend pieces — kept appended rather than rewritten in place so the
|
||||
feasibility analysis above stays a faithful design record.
|
||||
|
||||
### 9.1 Three-axis state model
|
||||
|
||||
`MigrationSummary` now exposes the four mechanism-specific booleans
|
||||
that `checkIsMigrated` writes individually:
|
||||
|
||||
- `XMLMigrated` — parsed SoundTouchSdkPrivateCfg.xml's URLs point at us.
|
||||
- `HostsMigrated` — `/etc/hosts` carries Bose-domain redirects (the
|
||||
deprecated method, kept detectable for legacy speakers).
|
||||
- `ResolvMigrated` — the `/etc/resolv.conf` priority-nameserver hook
|
||||
is in place (with CA trusted).
|
||||
- `TelnetMigrated` — `getpdo CurrentSystemConfiguration` reports the
|
||||
service hostname.
|
||||
|
||||
`IsMigrated` is the OR. Plus `IsPaired` from the live
|
||||
`:8090/info.margeAccountUUID` value.
|
||||
|
||||
The frontend opens with a state card that surfaces three orthogonal
|
||||
axes derived from these flags:
|
||||
|
||||
| Axis | Verdict semantics |
|
||||
|-------------------|-----------------------------------------------------------------------------------------------------------------------|
|
||||
| URL Configuration | URL flip active → ✅; original Bose URLs + DNS hook active → ✅ (intercepted); original + no DNS → ❌ (not intercepted). |
|
||||
| DNS Interception | None / resolv.conf hook / /etc/hosts (with deprecated badge). |
|
||||
| CA / TLS | Local root CA installed yes/no. |
|
||||
|
||||
Plus a Preconditions row: `remote_services` persistence, account
|
||||
pairing state, XML config backup presence. Action affordances
|
||||
(`Trust CA Now`, `Download CA cert`) live inline next to their verdicts.
|
||||
|
||||
### 9.2 Plan card with per-field URL editor
|
||||
|
||||
Replaces the XML method's `self/proxied/original` dropdowns and the
|
||||
duplicate URL inputs that used to live inside the telnet method pane:
|
||||
|
||||
- Target service URL input with `Save as default` (POSTs to
|
||||
`/setup/settings`, preserving the `***` secret-unchanged convention).
|
||||
- Capabilities header: detected transports (SSH / Telnet:17000) and
|
||||
the recipes AfterTouch can offer given those transports.
|
||||
- Service URLs table: four free-form URL inputs (Marge / Stats /
|
||||
SwUpdate / BmxRegistry) with on-keystroke validation
|
||||
(`validatePlanURLs`), a Soundcork-mode checkbox that flips `/marge`
|
||||
on `margeServerUrl`, and a `Reset to defaults` button.
|
||||
- Account pairing section: ID input + Generate + datastore picker;
|
||||
the implicit intent (`readPlanPairTarget`) queues a pair step at
|
||||
Apply when the input differs from the current `account_id`.
|
||||
- Suggested plan box: one-click conservative default — XML + HTTP
|
||||
when SSH works, Telnet + HTTP otherwise; "Already migrated" info
|
||||
state when `IsMigrated` is already true.
|
||||
|
||||
The per-field URLs feed both XML and Telnet migrations via the
|
||||
`marge_url` / `stats_url` / `sw_update_url` / `bmx_url` option family
|
||||
(see §9.6). Live preview rewrites `#planned-config` purely client-side
|
||||
on every keystroke — optimistic; the backend's perspective gates the
|
||||
write via §9.4's pre-flight.
|
||||
|
||||
### 9.3 Customize three-axis form
|
||||
|
||||
The `<details>` "Customize this migration" section replaces the old
|
||||
migration-method dropdown with three independent radio groups:
|
||||
|
||||
1. **URL flip transport**: XML / Telnet:17000 / Skip.
|
||||
2. **DNS interception**: None / `/etc/resolv.conf` hook.
|
||||
3. **Local CA install**: checkbox.
|
||||
|
||||
Each option carries a per-axis availability hint
|
||||
(`(SSH unreachable)`, `(already trusted)`, etc.) so users see *why*
|
||||
an option is disabled. `applyCustomPlan` orchestrates the chosen
|
||||
combination as a sequence of existing backend calls
|
||||
(`/setup/migrate?method=…` for each flip/resolv step plus
|
||||
`/setup/trust-ca` for standalone CA install, and the queued pair
|
||||
step from §9.2). Resolv already bundles a CA install, so a redundant
|
||||
standalone CA step is skipped. First failure aborts the rest.
|
||||
|
||||
### 9.4 Pre-flight panel
|
||||
|
||||
Both Apply paths run a visible pre-flight panel before any backend
|
||||
operation touches the speaker. Each check renders inline with the
|
||||
🕐 / ⟳ / ✅ / ❌ / — idiom. On all-green the panel holds for ~700ms so
|
||||
the success state registers, then auto-proceeds. On any failure the
|
||||
panel surfaces `Proceed Anyway` / `Cancel` buttons; default is to
|
||||
abort.
|
||||
|
||||
Checks:
|
||||
|
||||
| Check | When | Backend route |
|
||||
|---------------------------------------|------------------------------------------------------------|--------------------------------|
|
||||
| Backend summary re-check | always | `GET /setup/summary` |
|
||||
| HTTPS connection from device | `ssh_success && server_https_url` | `POST /setup/test-connection` |
|
||||
| Reachability check (passive observer) | `telnet_reachable && is_migrated` (see §9.8) | `POST /setup/peer-probe` |
|
||||
| Round-trip skip explainer | `telnet_reachable && !is_migrated` — runs after reboot | _none_ (UI-side skip row) |
|
||||
| DNS redirection from device | `methods.includes("resolv") && ssh_success` | `POST /setup/test-dns` |
|
||||
|
||||
The HTTPS check uses `use_explicit_ca=true` so it exercises the trust
|
||||
path even when CA install is part of the plan (i.e. forward-looking).
|
||||
The reachability skip row is explicit ("neither SSH nor Telnet:17000
|
||||
is reachable") rather than silently dropped, per the user's
|
||||
"feedback always visible" requirement.
|
||||
|
||||
### 9.5 Telnet round-trip probe — the SSH-less reachability check
|
||||
|
||||
> **REMOVED — see §9.8.** Empirical testing showed the swUpdate
|
||||
> daemon caches its target URL at boot and ignores live config
|
||||
> writes, so the active flip described below could never reach the
|
||||
> running daemon. The section is retained as a historical record of
|
||||
> what was tried; the running code uses the passive observer in §9.8.
|
||||
|
||||
The reachability gap §7 left open for USB-unlock-refusing speakers is
|
||||
closed by `Manager.RunTelnetRoundTripProbe`
|
||||
(`pkg/service/setup/telnet_probe.go`). Sequence:
|
||||
|
||||
1. Telnet `getpdo CurrentSystemConfiguration` to capture the
|
||||
speaker's current `swUpdateUrl`.
|
||||
2. Generate a random 24-hex-char token; register a one-shot signal
|
||||
channel under it on the new `probeRegistry` (sibling field on
|
||||
`handlers.Server`).
|
||||
3. Telnet `sys configuration swUpdateUrl <targetURL>/probe/<token>`
|
||||
— **runtime layer only, deliberately not `envswitch boseurls set
|
||||
…`**. The persistence layer keeps the original URL, so a reboot
|
||||
heals the device naturally if our restore step fails.
|
||||
4. `HTTP GET <deviceIP>:8090/swUpdateCheck` — the cleanest
|
||||
`:8090` endpoint that triggers exactly one outbound to the
|
||||
configured `swUpdateUrl`. Read-only on the cloud side
|
||||
(doesn't initiate an update); independent of `margeAccountUUID`
|
||||
so it works on factory-reset speakers.
|
||||
5. Wait on the registered channel up to `telnetProbeTimeout` (6s).
|
||||
6. Telnet `sys configuration swUpdateUrl <originalURL>` — deferred
|
||||
restore so it runs even on the failure path.
|
||||
|
||||
The new `/probe/{token}[/*]` catch-all on the root router signals the
|
||||
matching channel when the speaker's outbound lands. The response is
|
||||
a minimal `<swUpdateIndex/>` so the speaker's `swUpdateCheck`
|
||||
doesn't choke on a missing structure. The `/*` sub-path is
|
||||
registered because some firmware appends a path component to the
|
||||
configured `swUpdateUrl`.
|
||||
|
||||
### 9.6 Backend additions worth knowing
|
||||
|
||||
| Addition | Where | Why |
|
||||
|------------------------------------------------------------------------|----------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `applyURLOverrides(cfg, options)` | `pkg/service/setup/setup.go` | Per-field literal `marge_url` / `stats_url` / `sw_update_url` / `bmx_url` overrides win over `applyProxyOptions`. Honored by both `GetMigrationSummary` and `migrateViaXML`. |
|
||||
| `telnetURLsFromOptions(targetURL, options)` | `pkg/service/setup/telnet_migration.go` | Same option family as above, plus envswitch arg derivation rule (arg1 = final Marge verbatim; the soundcork-suffix case drops out). |
|
||||
| Per-axis booleans + `IsPaired` + `Warnings` | `MigrationSummary` | Surfaces partial-state cells and SSH-XML ⇄ telnet-getpdo cross-check disagreements. |
|
||||
| `parseGetpdoConfig` | `pkg/service/setup/preflight_crosscheck.go` | Parses the Protobuf-text-like nested-block reply (`key { text: "..." }`) FW 27.0.6 actually sends, plus the legacy `key=value` shape as a tolerance path. |
|
||||
| `peerObserver` + `RunPeerReachabilityProbe` + `/setup/peer-probe` | `pkg/service/handlers` / `pkg/service/setup` | §9.8. Replaces the removed `probeRegistry` + `RunTelnetRoundTripProbe` + `/setup/telnet-probe` from §9.5. |
|
||||
| `migrationOptionKeys` allow-list | `pkg/service/handlers/migration_options.go` | Unknown query keys never reach the manager. Both XML mode keys and `*_url` keys are recognised. |
|
||||
| Telnet client default timeouts: dial 4s, read 7s, write 3s, idle 600ms | `pkg/telnet/telnet.go` | Bumped from the original 2s/5s/2s/400ms after observing transient i/o-timeout flakes on healthy speakers that recovered on retry. |
|
||||
|
||||
### 9.7 Future probe candidates
|
||||
|
||||
- `:8090/pushCustomerSupportInfoToMarge` — flagged as a potential
|
||||
"ask the device about itself" probe that could feed a richer
|
||||
device-info pane (firmware build dates, hardware revisions). Not
|
||||
implemented.
|
||||
- Running the round-trip probe on SSH-capable speakers too (as
|
||||
additional validation alongside the curl-from-device HTTPS test),
|
||||
not just as the SSH-less fallback it is today. **Subsumed by §9.8
|
||||
— the round-trip probe is being removed; the passive observer is
|
||||
transport-agnostic and replaces it for migrated speakers.**
|
||||
|
||||
### 9.8 The swUpdate daemon-cache finding and removal of §9.5
|
||||
|
||||
The §9.5 round-trip probe was retired after empirical testing on a
|
||||
fully-migrated speaker (FW 27.0.6) revealed that the `swUpdate`
|
||||
daemon **caches its target URL at boot and ignores live config
|
||||
writes**. The diagnostic sequence:
|
||||
|
||||
1. Manual telnet flip of both layers — `sys configuration swUpdateUrl
|
||||
<probe-url>` (runtime) **and** `envswitch boseurls set <marge>
|
||||
<probe-url>` (persistence). `getpdo CurrentSystemConfiguration`
|
||||
confirmed both writes stuck.
|
||||
2. HTTP GET `:8090/swUpdateCheck` to trigger fan-out.
|
||||
3. Service access log showed the device outbound landed on
|
||||
`/updates/soundtouch` (the **previous** `swUpdateUrl` value, current
|
||||
at the last daemon boot) and `/streaming/software/update/account/<id>`
|
||||
(a separate Bose URL the daemon hits, routed to this service by DNS
|
||||
interception). The probe URL was never dialed.
|
||||
|
||||
This falsifies the original NEXT.md hypothesis that the persistence
|
||||
layer would override the runtime layer for the daemon's fan-out, and
|
||||
points instead at daemon-level URL caching. Two consequences:
|
||||
|
||||
- **The §9.5 probe cannot work on migrated speakers without a
|
||||
reboot.** The cached URL is set when the daemon starts; flipping
|
||||
config after that point has no effect on what the daemon dials.
|
||||
- **The §9.5 probe likely cannot work on unmigrated speakers
|
||||
either**, for the same reason — the daemon caches whatever URL it
|
||||
read at startup, which on an unmigrated speaker is the Bose cloud
|
||||
URL. We have no service running with the probe URL registered on
|
||||
unmigrated speakers, so the original "it worked in testing" claim
|
||||
has no empirical basis; it likely failed silently because nothing
|
||||
was watching.
|
||||
|
||||
The honest replacement is a **passive observer** (see
|
||||
`pkg/service/setup/peer_probe.go`):
|
||||
|
||||
1. Register the device IP with an in-process observer
|
||||
(`handlers.peerObserver`, wired via `PeerObserverMiddleware`).
|
||||
2. Nudge `:8090/swUpdateCheck` to make the daemon fan out *something*
|
||||
sooner than its ~5min timer.
|
||||
3. Wait up to 30s for any inbound from that IP. On a migrated
|
||||
speaker, DNS interception means the daemon's outbounds (update
|
||||
fan-out, marge polls, BMX registry calls) all funnel through this
|
||||
service regardless of which URL the daemon resolved internally —
|
||||
so reachability reduces to *"did the device dial us at all."*
|
||||
|
||||
Endpoint: `POST /setup/peer-probe/{deviceId}`. No device-state
|
||||
mutation; safe to re-run. Returns `{ok, result: {reached,
|
||||
observed_path, elapsed_ms}, error}` with the same UI keying as the
|
||||
old probe (`result.reached`).
|
||||
|
||||
#### 9.8.1 The pre-flight panel branch
|
||||
|
||||
The web UI's pre-flight orchestrator (`runApplyPreflight` in
|
||||
`script.js`) branches on `summary.is_migrated`:
|
||||
|
||||
| Migration state | Reachability row |
|
||||
|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Migrated (`is_migrated=true`) | "Reachability check (passive observer)" — calls `POST /setup/peer-probe/{deviceId}`. |
|
||||
| Not migrated (incl. partial) | Skip row "Round-trip validation runs after Apply + reboot" with the rationale "daemon caches swUpdateUrl at boot". |
|
||||
|
||||
Per-axis booleans (`xml_migrated`, `hosts_migrated`, `resolv_migrated`,
|
||||
`telnet_migrated`) remain visible in the State card, so the user can
|
||||
see which parts of the migration are already in place even when the
|
||||
overall flag is false. The skip row does not attempt the active probe
|
||||
on unmigrated speakers — the canonical telnet flow is:
|
||||
|
||||
```
|
||||
Apply telnet config → user-initiated reboot → re-run pre-flight on
|
||||
the now-migrated speaker → passive observer confirms fan-out.
|
||||
```
|
||||
|
||||
#### 9.8.2 Removal trail
|
||||
|
||||
Removed (or scheduled for removal in a follow-up commit) at the time
|
||||
of §9.8 landing:
|
||||
|
||||
- `pkg/service/setup/telnet_probe.go` — `RunTelnetRoundTripProbe`,
|
||||
`ProbeRegistrar`, `TelnetProbeResult`, `generateProbeToken`.
|
||||
- `pkg/service/handlers/handlers_telnet_probe.go` — `HandleTelnetProbe`,
|
||||
`HandleProbeInbound`, `telnetProbeTimeout`, `telnetProbeResponse`.
|
||||
- `pkg/service/handlers/probe_registry.go` — `probeRegistry` + tests.
|
||||
- `Server.probes` field.
|
||||
- Routes `/probe/{token}`, `/probe/{token}/*`, `/setup/telnet-probe/{deviceId}`.
|
||||
- The `target_url` query-param plumbing on the deprecated endpoint.
|
||||
- `script.js` — `checkTelnetRoundTrip` (orchestrator call site removed
|
||||
in the commit that added the branch; function itself removed later).
|
||||
|
||||
`isCommandNotFound` and `parseGetpdoConfig` stay — they are also used
|
||||
by the migration writer (`telnet_migration.go`), preflight reader
|
||||
(`telnet_preflight.go`), pairing path (`marge_pairing.go`), and
|
||||
cross-check (`preflight_crosscheck.go`).
|
||||
|
||||
@@ -70,6 +70,21 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
> **Tell the service to honour `X-Real-IP`/`X-Forwarded-For`.** When deploying
|
||||
> behind a reverse proxy on the same host as above, set
|
||||
> `"trust_forwarded_headers": true` in `data/settings.json`. With that flag
|
||||
> on, the service rewrites `r.RemoteAddr` from the proxy-supplied headers,
|
||||
> so handlers that act on the source IP (e.g. the Spotify priming triggered
|
||||
> by `/marge/streaming/support/power_on`) see the speaker's real address
|
||||
> instead of the proxy's loopback peer.
|
||||
>
|
||||
> By default only `127.0.0.0/8` and `::1/128` are trusted to set those
|
||||
> headers. If your reverse proxy lives on a different host, list its CIDR(s)
|
||||
> in `"trusted_proxy_cidrs"` (e.g. `["10.0.0.0/8"]`). Do **not** enable
|
||||
> `trust_forwarded_headers` on a flat LAN deployment without a proxy: a
|
||||
> malicious speaker on the LAN can send the headers itself and spoof its
|
||||
> source IP.
|
||||
|
||||
---
|
||||
|
||||
## Manual CA injection (advanced)
|
||||
|
||||
@@ -90,9 +90,13 @@ If you plan to use DNS/DHCP redirect, enable the **DNS Discovery Server** and se
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Enable SSH on each speaker
|
||||
## Step 3: Enable shell access on each speaker
|
||||
|
||||
The migration writes updated configuration to the speaker's filesystem, which requires SSH access. Enable it once per device:
|
||||
The wizard supports **two transports** for talking to the speaker. Pick whichever your device exposes:
|
||||
|
||||
### SSH (recommended — required for XML migration, DNS interception, and CA install)
|
||||
|
||||
The XML migration writes updated configuration to the speaker's filesystem, which requires SSH access. Enable it once per device:
|
||||
|
||||
1. Format a USB drive as FAT (FAT32). Some speakers require the **bootable flag** to be set on the partition — see [SoundCork issue #172](https://github.com/deborahgu/soundcork/issues/172) for details.
|
||||
2. Create an empty file named **`remote_services`** (no extension) in the root of the drive.
|
||||
@@ -102,6 +106,12 @@ The migration writes updated configuration to the speaker's filesystem, which re
|
||||
|
||||
You only need to do this once per speaker. SSH can remain enabled for future maintenance or be disabled after migration — your choice.
|
||||
|
||||
### Telnet:17000 (fallback when SSH isn't possible)
|
||||
|
||||
If the USB-stick unlock doesn't work on your speaker (some firmware revisions refuse it — notably SA-5, ST520, and recent ST Portables), the wizard falls back to the speaker's **built-in diagnostic shell on TCP port 17000**. No setup required — most SoundTouch firmware exposes it automatically. The wizard detects which transports are available and picks the right one; you don't have to choose manually.
|
||||
|
||||
Telnet-only migrations are limited to HTTP (no CA install possible without SSH). The wizard surfaces this clearly when it applies.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Add and sync your speaker
|
||||
@@ -124,44 +134,64 @@ If the Bose cloud is still running, Sync also fetches your account data from Bos
|
||||
|
||||
## Step 5: Migrate
|
||||
|
||||
Click **Migrate** next to a device on the Devices tab to open the Migration tab. It shows SSH status, CA trust status, and connection test results before letting you apply the redirect.
|
||||
Click **Migrate** next to a device on the Devices tab to open the Migration tab. The tab opens with a **Migration Summary** that shows where your speaker currently stands, then offers a one-click suggested plan and a fully customizable form underneath.
|
||||
|
||||

|
||||

|
||||
|
||||
Two redirect methods are available:
|
||||
### What you see at the top — the state card
|
||||
|
||||
### XML redirect (recommended for first-time / testing)
|
||||
Three rows tell you the speaker's current state at a glance:
|
||||
|
||||
Uploads a configuration file to the speaker via the SoundTouch Web API. This changes the application-level service URLs without touching the speaker's network configuration. It's the least invasive option.
|
||||
- **Transports** — whether SSH and Telnet:17000 are reachable. The wizard's choices are driven by these.
|
||||
- **Migration State** — three orthogonal axes:
|
||||
- *URL Configuration* — original Bose URLs or AfterTouch URLs (with a special "intercepted via DNS" verdict when the resolv.conf hook is doing the redirect).
|
||||
- *DNS Interception* — none, or `/etc/resolv.conf` hook active.
|
||||
- *CA / TLS* — local root CA installed on the device, with `Trust CA Now` and `Download CA cert` actions inline.
|
||||
- **Preconditions** — `remote_services` persistence, account pairing state, and XML config backup presence.
|
||||
|
||||
The web UI guides you through:
|
||||
1. Previewing the config change (current vs. planned XML)
|
||||
2. Optionally installing the AfterTouch CA certificate on the speaker (requires SSH; needed for HTTPS)
|
||||
3. Applying the XML redirect
|
||||
4. Verifying the speaker can reach the local service
|
||||
### The Plan card — the happy path
|
||||
|
||||
### DNS/DHCP redirect (recommended for permanent / all-device setup)
|
||||
Below the state card is the **Plan** card. For most users this is the only thing you'll touch:
|
||||
|
||||
Configures the speaker to use a custom DNS server that resolves Bose cloud hostnames to the local service. This is the most robust method — it covers all Bose endpoints automatically and survives reboots.
|
||||
1. **Target service URL** — pre-filled from your Settings. Edit inline and click *Save as default* to update Settings without bouncing tabs.
|
||||
2. **Capabilities** — what transports the speaker exposes and what AfterTouch can offer given those.
|
||||
3. **Service URLs** — four URL inputs (margeServerUrl, statsServerUrl, swUpdateUrl, bmxRegistryUrl) pre-filled with canonical defaults. Most users leave them as-is; soundcork users tick the *Soundcork mode* checkbox to append `/marge` to `margeServerUrl`. URL validation runs on every keystroke.
|
||||
4. **Account pairing** — pre-filled with the speaker's current account ID. Leave it to keep the existing pairing, change it to re-pair, or click *Generate* to assign a new 7-digit ID on a factory-reset device.
|
||||
5. **Suggested plan** — one big green button: *Apply Suggested Plan*. The wizard picks the most conservative recipe for your speaker (XML over SSH with HTTP when SSH works; telnet URL flip with HTTP when only telnet works) and runs it.
|
||||
|
||||
Requirements:
|
||||
- The AfterTouch DNS server must be running and bound to **port 53** on your network. Enable it in the **Settings** tab (`DNS Discovery` → enabled).
|
||||
- HTTPS is required. The web UI walks you through trusting the CA certificate on the speaker (via SSH).
|
||||
### What happens when you click Apply
|
||||
|
||||
The web UI guides you through:
|
||||
1. Verifying the DNS server is running and reachable
|
||||
2. Installing the CA certificate on the speaker
|
||||
3. Configuring the speaker to use the AfterTouch DNS server
|
||||
4. Verifying DNS resolution and HTTPS connectivity
|
||||
The wizard switches to a visible **Pre-flight checks** panel and runs every applicable verification before touching the speaker:
|
||||
|
||||
- **Backend summary re-check** — confirms transports, hostname resolution, and that the URLs you plan to write match what the backend would produce.
|
||||
- **HTTPS connection from device** (SSH-capable speakers) — uploads a temporary CA and runs `curl` from the speaker to your service.
|
||||
- **Reachability check (passive observer)** (already-migrated speakers) — nudges `:8090/swUpdateCheck` on the device and watches for *any* request from the speaker to land on the service. Used when the speaker is already migrated and the service is the natural target of its outbounds.
|
||||
- **"Round-trip validation runs after Apply + reboot"** (not-yet-migrated speakers) — surfaced as a skip row with a rationale. The speaker's swUpdate daemon caches its URL at boot, so there is no useful no-reboot round-trip check pre-migration; the canonical telnet flow is Apply → reboot → re-run pre-flight on the migrated speaker.
|
||||
- **DNS redirection from device** — when DNS interception is part of the plan.
|
||||
|
||||
On all-green, the wizard auto-proceeds. On any failure, it pauses with *Proceed Anyway* / *Cancel* buttons so you can override on a known-false-positive (slow DNS, etc.) or fix the underlying issue and retry.
|
||||
|
||||
### Customize this migration — for mix-and-match
|
||||
|
||||
Expand the `▸ Customize this migration` section to pick any combination of three independent axes:
|
||||
|
||||
- **URL flip transport** — XML over SSH / Telnet (Port 17000) / Skip
|
||||
- **DNS interception** — None / `/etc/resolv.conf` hook
|
||||
- **Local CA install** — checkbox (SSH-only)
|
||||
|
||||
Each option carries a per-axis availability hint (e.g. *(SSH unreachable)*, *(already trusted)*) so you see why an option is disabled before you pick. *Apply Custom Plan* runs the chosen combination as a sequence; the same pre-flight panel gates the execution.
|
||||
|
||||
> **Note**: DNS interception bundles the CA install on the backend, so a standalone CA-install step is skipped automatically when DNS is part of the plan. The wizard handles this for you.
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Reboot and verify
|
||||
|
||||
After migration, **power-cycle the speaker** (unplug and replug). This applies all configuration changes.
|
||||
After a successful Apply the wizard auto-expands the Customize section and highlights the **Reboot Speaker** button. Click it (or power-cycle the speaker manually) to apply all configuration changes. The reboot transport is picked automatically from your URL flip choice — telnet reboot for SSH-less speakers, SSH reboot otherwise.
|
||||
|
||||
After reboot:
|
||||
- The speaker should appear as **migrated** in the Devices tab
|
||||
- The state card on the Migration tab should now show ✅ for URL Configuration (or "intercepted via DNS" if you used the resolv.conf hook)
|
||||
- Presets should load and play (served from the local service)
|
||||
- TuneIn browsing should work
|
||||
- Recently played items should appear
|
||||
@@ -180,8 +210,9 @@ Each speaker is migrated independently. You can run multiple migrations in paral
|
||||
|
||||
If you need to undo a migration:
|
||||
|
||||
- **From the web UI**: Use the **Revert** action on the device — this restores the `.original` backup files created on the speaker during migration.
|
||||
- **Via SSH**: The original config is backed up on the speaker with a `.original` suffix. Restore it manually if the UI is unreachable.
|
||||
- **From the web UI**: Use the **Revert to Defaults** action on the device — this restores the `.original` backup files created on the speaker during the XML migration.
|
||||
- **Telnet-only migrations**: the wizard writes both the runtime configuration layer (`sys configuration …`) and the persistent layer (`envswitch boseurls set …`) so the migration survives reboot. If you want to revert quickly, the cleanest path is to re-run the wizard with the original Bose URLs in the URL editor.
|
||||
- **Via SSH**: The original XML config is backed up on the speaker with a `.original` suffix. Restore it manually if the UI is unreachable.
|
||||
- **Factory reset**: As a last resort, perform a factory reset (see [Device Initial Setup](DEVICE-INITIAL-SETUP.md) for button sequences). This wipes all configuration and returns the speaker to out-of-box state.
|
||||
|
||||
---
|
||||
|
||||
@@ -225,13 +225,21 @@ curl http://localhost:8000/setup/devices
|
||||
#### Advanced Migration Options
|
||||
|
||||
```bash
|
||||
# Migration with proxy fallback for original services
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?proxy_url=http://localhost:8000&marge=original&stats=original"
|
||||
|
||||
# Migration with custom target URL
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?target_url=https://my-server.com:8000"
|
||||
|
||||
# Per-field literal URL overrides (preferred — used by the web wizard)
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?method=xml&target_url=http://server:8000&marge_url=http://server:8000/marge"
|
||||
|
||||
# SSH-less migration over the device's port-17000 diagnostic shell
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?method=telnet&target_url=http://server:8000"
|
||||
|
||||
# Legacy proxy-fallback for selected fields (kept for API back-compat)
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?proxy_url=http://localhost:8000&marge=original&stats=original"
|
||||
```
|
||||
|
||||
See the full parameter reference at `POST /setup/migrate/{deviceIP}` below for `method`, `target_url`, `*_url`, and the legacy mode selectors.
|
||||
|
||||
### Post-Migration Verification
|
||||
|
||||
After migration, verify the device is working correctly:
|
||||
@@ -393,12 +401,73 @@ Analyzes device configuration and provides migration preview.
|
||||
Migrates device to use local services.
|
||||
|
||||
**Query Parameters:**
|
||||
- `target_url`: Custom service URL (optional)
|
||||
- `proxy_url`: Proxy URL for fallback (optional)
|
||||
- `marge`: Set to "original" to proxy Marge requests (optional)
|
||||
- `stats`: Set to "original" to proxy stats requests (optional)
|
||||
- `sw_update`: Set to "original" to proxy update requests (optional)
|
||||
- `bmx`: Set to "original" to proxy BMX requests (optional)
|
||||
|
||||
| Parameter | Values | Notes |
|
||||
|--------------|-----------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `method` | `xml` (default), `telnet`, `resolv`, `hosts` (deprecated) | Picks the redirect mechanism. `xml` writes `SoundTouchSdkPrivateCfg.xml` via SSH; `telnet` flips the four URLs via the device's port-17000 diagnostic shell; `resolv` installs the `/etc/resolv.conf` priority-nameserver hook and the local CA via SSH. |
|
||||
| `target_url` | Any URL, e.g. `http://soundtouch.local:8000` | Service base URL the per-field defaults derive from. Falls back to the service's configured `ServerURL` when omitted. |
|
||||
| `proxy_url` | Any URL | Proxy base used when the legacy `marge=proxied` / `stats=proxied` / `sw_update=proxied` / `bmx=proxied` modes are set. Defaults to `target_url`. |
|
||||
|
||||
**Per-field implementation mode** (XML method's legacy semantics — kept for API back-compat, UI no longer sets them):
|
||||
|
||||
| Parameter | Values | Effect on the matching `*ServerUrl` / `*RegistryUrl` field |
|
||||
|-------------|-----------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `marge` | `self` (default), `proxied`, `original` | `self`: write `target_url` (canonical). `proxied`: write `<proxy_url>/proxy/<original-marge-url>`. `original`: keep the speaker's existing value. |
|
||||
| `stats` | same | same |
|
||||
| `sw_update` | same | same |
|
||||
| `bmx` | same | same |
|
||||
|
||||
**Per-field literal URL overrides** (preferred — used by the wizard's Plan card; honored for both `xml` and `telnet` methods):
|
||||
|
||||
| Parameter | Effect |
|
||||
|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `marge_url` | Writes the exact URL to `<margeServerUrl>` regardless of `target_url` derivation or `marge` mode. Empty / missing → fall back to canonical default from `target_url`. |
|
||||
| `stats_url` | Same shape for `<statsServerUrl>`. |
|
||||
| `sw_update_url` | Same for `<swUpdateUrl>`. |
|
||||
| `bmx_url` | Same for `<bmxRegistryUrl>`. |
|
||||
|
||||
**Precedence**: `*_url` overrides win over the `marge / stats / sw_update / bmx` mode selectors. The setup package applies `applyProxyOptions` first, then `applyURLOverrides` clobbers any field where a literal `*_url` was supplied. So if you send both `marge=proxied&marge_url=http://x:8000/marge`, the literal `http://x:8000/marge` is written.
|
||||
|
||||
**Soundcork redirect**: append `/marge` to `marge_url`. The telnet method derives `envswitch boseurls set <margeServerUrl> <swUpdateUrl>` from the final URLs verbatim, so the suffix propagates to the parallel persistence layer automatically — no separate flag needed.
|
||||
|
||||
**Examples**:
|
||||
|
||||
```bash
|
||||
# Canonical XML migration over SSH to the default service URL
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?method=xml"
|
||||
|
||||
# Telnet migration with the soundcork redirect (only marge gets the /marge suffix)
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?method=telnet&target_url=http://soundcork.local:8000&marge_url=http://soundcork.local:8000/marge"
|
||||
|
||||
# DNS interception (writes /etc/resolv.conf hook + installs CA) — *_url overrides are ignored
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?method=resolv&target_url=https://my-server.com:8443"
|
||||
```
|
||||
|
||||
#### `POST /setup/telnet-probe/{deviceIP}`
|
||||
SSH-less reachability check. Temporarily flips the speaker's `swUpdateUrl` via the port-17000 diagnostic shell, triggers `:8090/swUpdateCheck` on the device, and observes whether the resulting outbound lands on this service's `/probe/{token}` handler within 6 s. Always attempts to restore the original `swUpdateUrl` even on failure.
|
||||
|
||||
**Query Parameters:**
|
||||
- `target_url` (optional): defaults to the service's configured `ServerURL`. The probe URL written to the device is `<target_url>/probe/<token>`.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"result": {
|
||||
"reached": true,
|
||||
"restored": true,
|
||||
"original_url": "https://worldwide.bose.com/updates/soundtouch",
|
||||
"probe_url": "http://soundtouch.local:8000/probe/abc123…",
|
||||
"elapsed_ms": 412,
|
||||
"logs": "…"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`reached=true` means the device's outbound landed on our `/probe/{token}` route within the timeout. `restored=true` means the runtime `swUpdateUrl` was reverted to its captured original (the envswitch persistence layer is left untouched throughout, so a reboot heals the device naturally if our restore step fails).
|
||||
|
||||
#### `GET /probe/{token}[/*]`
|
||||
Catch-all endpoint that signals the matching pre-flight probe channel. Used internally by `/setup/telnet-probe/{deviceIP}`; not intended to be called directly by API consumers. Returns a minimal `<swUpdateIndex/>` XML so the device's `swUpdateCheck` doesn't choke on a missing structure.
|
||||
|
||||
### BMX Services (Bose Media eXchange)
|
||||
|
||||
@@ -836,6 +905,7 @@ fi
|
||||
- **SSH Access**: Migration requires SSH access to devices. Ensure your network security policies allow this.
|
||||
- **Proxy Logging**: Disable `REDACT_PROXY_LOGS` only in development environments.
|
||||
- **Data Protection**: The data directory contains device configurations and usage patterns. Secure appropriately.
|
||||
- **Spotify / Amazon Music credential push (zeroconf)**: outbound credential-push requests are restricted to literal IP hosts on local-network ranges (loopback, RFC1918 private, IPv4/IPv6 link-local). Hostname-style URLs (DNS, mDNS `*.local`) are rejected at runtime; if you have a hostname, resolve it first (`getent hosts <name>` or `dig +short <name>`) and pass the resolved IP. This guards against a malicious LAN-resident speaker pointing the credential push at a non-speaker host (server-side request forgery).
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 334 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 544 KiB After Width: | Height: | Size: 512 KiB |
|
Before Width: | Height: | Size: 516 KiB After Width: | Height: | Size: 463 KiB |
|
Before Width: | Height: | Size: 266 KiB After Width: | Height: | Size: 95 KiB |
@@ -3,6 +3,7 @@ module github.com/gesellix/bose-soundtouch
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/chromedp/chromedp v0.15.1
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/google/gopacket v1.1.19
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
@@ -18,13 +19,19 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
golang.org/x/image v0.40.0 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
golang.org/x/tools v0.45.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
|
||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
|
||||
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
|
||||
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -5,6 +11,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||
@@ -16,9 +30,13 @@ github.com/hashicorp/mdns v1.0.6/go.mod h1:X4+yWh+upFECLOki1doUPaKpgNQII9gy4bUdC
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
@@ -69,8 +87,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.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
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=
|
||||
@@ -88,6 +106,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
@@ -127,8 +146,8 @@ 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.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/speaker"
|
||||
)
|
||||
|
||||
// Integration tests for bass control functionality
|
||||
@@ -426,7 +427,7 @@ func BenchmarkClient_Bass_Integration(b *testing.B) {
|
||||
// This is a simple version for test use
|
||||
func parseBassHostPort(hostPort string) (string, int) {
|
||||
if !containsSubstring(hostPort, ":") {
|
||||
return hostPort, defaultSoundTouchPort
|
||||
return hostPort, speaker.HTTPPort
|
||||
}
|
||||
|
||||
// Simple parsing - in real use, we'd use net.SplitHostPort
|
||||
@@ -448,7 +449,7 @@ func parseBassHostPort(hostPort string) (string, int) {
|
||||
|
||||
if len(parts) == 2 {
|
||||
// Try to parse port
|
||||
port := defaultSoundTouchPort
|
||||
port := speaker.HTTPPort
|
||||
portStr := parts[1]
|
||||
portInt := 0
|
||||
|
||||
@@ -468,5 +469,5 @@ func parseBassHostPort(hostPort string) (string, int) {
|
||||
return parts[0], port
|
||||
}
|
||||
|
||||
return hostPort, defaultSoundTouchPort
|
||||
return hostPort, speaker.HTTPPort
|
||||
}
|
||||
|
||||
@@ -153,11 +153,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/speaker"
|
||||
)
|
||||
|
||||
// defaultSoundTouchPort is the standard port for SoundTouch devices
|
||||
const defaultSoundTouchPort = 8090
|
||||
|
||||
// Client represents a SoundTouch API client
|
||||
type Client struct {
|
||||
baseURL string
|
||||
@@ -204,7 +202,7 @@ func NewClient(config *Config) *Client {
|
||||
// Fallback for invalid URLs
|
||||
port := config.Port
|
||||
if port == 0 {
|
||||
port = 8090
|
||||
port = speaker.HTTPPort
|
||||
}
|
||||
|
||||
return &Client{
|
||||
@@ -223,7 +221,7 @@ func NewClient(config *Config) *Client {
|
||||
// No port in the host string, use the one from config or default
|
||||
port := config.Port
|
||||
if port == 0 {
|
||||
port = 8090
|
||||
port = speaker.HTTPPort
|
||||
}
|
||||
|
||||
u.Host = net.JoinHostPort(u.Host, fmt.Sprintf("%d", port))
|
||||
@@ -231,7 +229,7 @@ func NewClient(config *Config) *Client {
|
||||
// Empty port, use config or default
|
||||
port := config.Port
|
||||
if port == 0 {
|
||||
port = 8090
|
||||
port = speaker.HTTPPort
|
||||
}
|
||||
|
||||
u.Host = net.JoinHostPort(u.Hostname(), fmt.Sprintf("%d", port))
|
||||
@@ -1380,6 +1378,58 @@ func (c *Client) GetZoneMembers() ([]string, error) {
|
||||
return zone.GetAllDeviceIDs(), nil
|
||||
}
|
||||
|
||||
// GetGroup retrieves the current stereo-pair configuration from the device.
|
||||
// An empty <group/> response is reported as a zero-value Group; callers can
|
||||
// distinguish with (*Group).IsEmpty().
|
||||
//
|
||||
// ST-10 is the only product that supports stereo pairs; on other devices
|
||||
// the call is harmless but will always return an empty group. The endpoint
|
||||
// is named /getGroup on the device (mirroring /getZone), even though some
|
||||
// third-party wikis document it as plain /group.
|
||||
func (c *Client) GetGroup() (*models.Group, error) {
|
||||
var g models.Group
|
||||
|
||||
err := c.get("/getGroup", &g)
|
||||
|
||||
return &g, err
|
||||
}
|
||||
|
||||
// AddGroup creates a new stereo pair on the device addressed by this client,
|
||||
// which becomes the master. The supplied group must contain both LEFT and
|
||||
// RIGHT roles; the device assigns the group ID and echoes the full state
|
||||
// in the response.
|
||||
func (c *Client) AddGroup(group *models.Group) (*models.Group, error) {
|
||||
var result models.Group
|
||||
if err := c.postWithResponse("/addGroup", group, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// UpdateGroup renames or otherwise updates an existing stereo pair. The
|
||||
// device requires the full group structure on every update, not just the
|
||||
// changed fields.
|
||||
func (c *Client) UpdateGroup(group *models.Group) (*models.Group, error) {
|
||||
var result models.Group
|
||||
if err := c.postWithResponse("/updateGroup", group, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// RemoveGroup tears down the device's stereo pair. The device returns an
|
||||
// empty <group/> on success — surfaced here as a non-error nil.
|
||||
//
|
||||
// Note: the wiki specifies GET (not DELETE) for this endpoint, so we honour
|
||||
// that despite the state-mutating semantics.
|
||||
func (c *Client) RemoveGroup() error {
|
||||
var g models.Group
|
||||
|
||||
return c.get("/removeGroup", &g)
|
||||
}
|
||||
|
||||
// SetName sets the device name
|
||||
func (c *Client) SetName(name string) error {
|
||||
nameRequest := models.Name{
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestClient_GetGroup_Configured(t *testing.T) {
|
||||
responseXML := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<group id="1234567">
|
||||
<name>Living Room Pair</name>
|
||||
<masterDeviceId>9070658C9D4A</masterDeviceId>
|
||||
<roles>
|
||||
<groupRole>
|
||||
<deviceId>9070658C9D4A</deviceId>
|
||||
<role>LEFT</role>
|
||||
<ipAddress>192.168.1.131</ipAddress>
|
||||
</groupRole>
|
||||
<groupRole>
|
||||
<deviceId>F45EAB3115DA</deviceId>
|
||||
<role>RIGHT</role>
|
||||
<ipAddress>192.168.1.134</ipAddress>
|
||||
</groupRole>
|
||||
</roles>
|
||||
<senderIPAddress>192.168.1.131</senderIPAddress>
|
||||
<status>GROUP_OK</status>
|
||||
</group>`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/getGroup" {
|
||||
t.Errorf("path = %q, want /getGroup", r.URL.Path)
|
||||
}
|
||||
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("method = %s, want GET", r.Method)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(responseXML))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
g, err := createTestClient(server.URL).GetGroup()
|
||||
if err != nil {
|
||||
t.Fatalf("GetGroup: %v", err)
|
||||
}
|
||||
|
||||
if g.ID != "1234567" {
|
||||
t.Errorf("ID = %q, want 1234567", g.ID)
|
||||
}
|
||||
|
||||
if g.Name != "Living Room Pair" {
|
||||
t.Errorf("Name = %q, want Living Room Pair", g.Name)
|
||||
}
|
||||
|
||||
if g.MasterDeviceID != "9070658C9D4A" {
|
||||
t.Errorf("MasterDeviceID = %q", g.MasterDeviceID)
|
||||
}
|
||||
|
||||
if g.Status != "GROUP_OK" {
|
||||
t.Errorf("Status = %q, want GROUP_OK", g.Status)
|
||||
}
|
||||
|
||||
if len(g.Roles.Roles) != 2 {
|
||||
t.Fatalf("roles = %d, want 2", len(g.Roles.Roles))
|
||||
}
|
||||
|
||||
if g.Roles.Roles[0].Role != "LEFT" || g.Roles.Roles[1].Role != "RIGHT" {
|
||||
t.Errorf("role order LEFT/RIGHT not preserved: %+v", g.Roles.Roles)
|
||||
}
|
||||
|
||||
if g.IsEmpty() {
|
||||
t.Errorf("IsEmpty = true for populated group")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetGroup_Empty(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<group />`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
g, err := createTestClient(server.URL).GetGroup()
|
||||
if err != nil {
|
||||
t.Fatalf("GetGroup: %v", err)
|
||||
}
|
||||
|
||||
if !g.IsEmpty() {
|
||||
t.Errorf("IsEmpty = false for <group/>, got %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_AddGroup(t *testing.T) {
|
||||
var capturedBody string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/addGroup" {
|
||||
t.Errorf("path = %q, want /addGroup", r.URL.Path)
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %s, want POST", r.Method)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
capturedBody = string(body)
|
||||
|
||||
// Echo the request back with an assigned ID and GROUP_OK status —
|
||||
// matches real device behaviour.
|
||||
var got models.Group
|
||||
if err := xml.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
|
||||
got.ID = "9999999"
|
||||
got.Status = "GROUP_OK"
|
||||
got.SenderIPAddress = "192.168.1.131"
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
enc, _ := xml.Marshal(&got)
|
||||
_, _ = w.Write(enc)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
req := &models.Group{
|
||||
Name: "Living Room",
|
||||
MasterDeviceID: "9070658C9D4A",
|
||||
Roles: models.GroupRoles{
|
||||
Roles: []models.GroupRole{
|
||||
{DeviceID: "9070658C9D4A", Role: "LEFT", IPAddress: "192.168.1.131"},
|
||||
{DeviceID: "F45EAB3115DA", Role: "RIGHT", IPAddress: "192.168.1.134"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := createTestClient(server.URL).AddGroup(req)
|
||||
if err != nil {
|
||||
t.Fatalf("AddGroup: %v", err)
|
||||
}
|
||||
|
||||
if resp.ID != "9999999" {
|
||||
t.Errorf("response ID = %q, want 9999999", resp.ID)
|
||||
}
|
||||
|
||||
if resp.Status != "GROUP_OK" {
|
||||
t.Errorf("response Status = %q, want GROUP_OK", resp.Status)
|
||||
}
|
||||
|
||||
// Wire-shape sanity: the request body must carry both roles and the
|
||||
// master ID (the device validates these on the wire).
|
||||
for _, want := range []string{"<role>LEFT</role>", "<role>RIGHT</role>", "9070658C9D4A"} {
|
||||
if !strings.Contains(capturedBody, want) {
|
||||
t.Errorf("request body missing %q\nbody:\n%s", want, capturedBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_UpdateGroup_RenameRoundtrip(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/updateGroup" {
|
||||
t.Errorf("path = %q, want /updateGroup", r.URL.Path)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
|
||||
var got models.Group
|
||||
if err := xml.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
got.Status = "GROUP_OK"
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
enc, _ := xml.Marshal(&got)
|
||||
_, _ = w.Write(enc)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
req := &models.Group{
|
||||
ID: "1234567",
|
||||
Name: "Kitchen Pair",
|
||||
MasterDeviceID: "AAAA",
|
||||
Roles: models.GroupRoles{
|
||||
Roles: []models.GroupRole{
|
||||
{DeviceID: "AAAA", Role: "LEFT"},
|
||||
{DeviceID: "BBBB", Role: "RIGHT"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := createTestClient(server.URL).UpdateGroup(req)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateGroup: %v", err)
|
||||
}
|
||||
|
||||
if resp.Name != "Kitchen Pair" {
|
||||
t.Errorf("Name = %q, want Kitchen Pair", resp.Name)
|
||||
}
|
||||
|
||||
if resp.ID != "1234567" {
|
||||
t.Errorf("ID = %q, want 1234567", resp.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_RemoveGroup(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/removeGroup" {
|
||||
t.Errorf("path = %q, want /removeGroup", r.URL.Path)
|
||||
}
|
||||
|
||||
// The wiki specifies GET (not DELETE) for /removeGroup. We honour
|
||||
// that, surprising as it is for a state-mutating endpoint.
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("method = %s, want GET", r.Method)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<group />`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := createTestClient(server.URL).RemoveGroup(); err != nil {
|
||||
t.Fatalf("RemoveGroup: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/speaker"
|
||||
)
|
||||
|
||||
// Integration tests for source selection functionality
|
||||
@@ -386,7 +388,7 @@ func BenchmarkClient_SelectSource_Integration(b *testing.B) {
|
||||
// This is a simple version for test use
|
||||
func parseHostPort(hostPort string) (string, int) {
|
||||
if !containsSubstring(hostPort, ":") {
|
||||
return hostPort, defaultSoundTouchPort
|
||||
return hostPort, speaker.HTTPPort
|
||||
}
|
||||
|
||||
// Simple parsing - in real use, we'd use net.SplitHostPort
|
||||
@@ -408,7 +410,7 @@ func parseHostPort(hostPort string) (string, int) {
|
||||
|
||||
if len(parts) == 2 {
|
||||
// Try to parse port
|
||||
port := defaultSoundTouchPort
|
||||
port := speaker.HTTPPort
|
||||
portStr := parts[1]
|
||||
portInt := 0
|
||||
|
||||
@@ -428,5 +430,5 @@ func parseHostPort(hostPort string) (string, int) {
|
||||
return parts[0], port
|
||||
}
|
||||
|
||||
return hostPort, defaultSoundTouchPort
|
||||
return hostPort, speaker.HTTPPort
|
||||
}
|
||||
|
||||
@@ -140,6 +140,17 @@ func (ws *WebSocketClient) OnZoneUpdated(handler models.TypedEventHandler[*model
|
||||
ws.handlers.OnZoneUpdated = handler
|
||||
}
|
||||
|
||||
// OnGroupUpdated sets a handler for ST-10 stereo-pair update events.
|
||||
// The device fans these out to both LEFT and RIGHT speakers whenever the
|
||||
// pair is created, renamed, or removed, so callers will see one event per
|
||||
// affected device.
|
||||
func (ws *WebSocketClient) OnGroupUpdated(handler models.TypedEventHandler[*models.GroupUpdatedEvent]) {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
|
||||
ws.handlers.OnGroupUpdated = handler
|
||||
}
|
||||
|
||||
// OnBassUpdated sets a handler for bass update events
|
||||
func (ws *WebSocketClient) OnBassUpdated(handler models.TypedEventHandler[*models.BassUpdatedEvent]) {
|
||||
ws.mu.Lock()
|
||||
@@ -156,6 +167,18 @@ func (ws *WebSocketClient) OnUnknownEvent(handler models.EventHandler) {
|
||||
ws.handlers.OnUnknownEvent = handler
|
||||
}
|
||||
|
||||
// OnRawMessage sets a handler that fires for every incoming frame with
|
||||
// the raw bytes and the result of attempting to XML-parse them. The
|
||||
// typed handlers (OnNowPlaying, OnGroupUpdated, ...) still run
|
||||
// afterwards on successful parses, so OnRawMessage is purely additive —
|
||||
// intended for debug/observability tooling.
|
||||
func (ws *WebSocketClient) OnRawMessage(handler models.RawMessageHandler) {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
|
||||
ws.handlers.OnRawMessage = handler
|
||||
}
|
||||
|
||||
// OnSpecialMessage sets a handler for special (non-updates) messages
|
||||
func (ws *WebSocketClient) OnSpecialMessage(handler models.SpecialMessageHandler) {
|
||||
ws.mu.Lock()
|
||||
@@ -379,26 +402,45 @@ func (ws *WebSocketClient) attemptReconnect(config *WebSocketConfig) {
|
||||
|
||||
// handleMessage processes incoming WebSocket messages
|
||||
func (ws *WebSocketClient) handleMessage(data []byte) {
|
||||
// Check if this is a SoundTouchSdkInfo or other non-updates message
|
||||
// Special (non-updates) messages take their own decode path and
|
||||
// surface raw payloads to the OnRawMessage hook from there, so
|
||||
// observers see exactly one notification per frame.
|
||||
if !ws.isUpdatesMessage(data) {
|
||||
ws.handleSpecialMessage(data)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the WebSocket event
|
||||
event, err := models.ParseWebSocketEvent(data)
|
||||
if err != nil {
|
||||
ws.logger.Printf("Failed to parse WebSocket message: %v", err)
|
||||
event, parseErr := models.ParseWebSocketEvent(data)
|
||||
|
||||
ws.fireRawMessage(data, parseErr)
|
||||
|
||||
if parseErr != nil {
|
||||
ws.logger.Printf("Failed to parse WebSocket message: %v", parseErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Process each event type in the message
|
||||
ws.handleEvent(event)
|
||||
}
|
||||
|
||||
// fireRawMessage invokes the OnRawMessage hook if one is registered.
|
||||
// Kept separate so the read path doesn't have to repeat the locking
|
||||
// dance for every frame.
|
||||
func (ws *WebSocketClient) fireRawMessage(data []byte, parseErr error) {
|
||||
ws.mu.RLock()
|
||||
handler := ws.handlers.OnRawMessage
|
||||
ws.mu.RUnlock()
|
||||
|
||||
if handler != nil {
|
||||
handler(data, parseErr)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSpecialMessage processes special (non-updates) WebSocket messages
|
||||
func (ws *WebSocketClient) handleSpecialMessage(data []byte) {
|
||||
specialMessage, err := models.ParseSpecialMessage(data)
|
||||
|
||||
ws.fireRawMessage(data, err)
|
||||
|
||||
if err != nil {
|
||||
ws.logger.Printf("Unknown special message type: %v", err)
|
||||
ws.logger.Printf("Raw message: %s", string(data))
|
||||
@@ -468,6 +510,13 @@ func (ws *WebSocketClient) dispatchTypedEventContinued(handlers *models.WebSocke
|
||||
|
||||
return true
|
||||
|
||||
case models.EventTypeGroupUpdated:
|
||||
if handlers.OnGroupUpdated != nil && event.GroupUpdated != nil {
|
||||
handlers.OnGroupUpdated(event.GroupUpdated)
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
case models.EventTypeBassUpdated:
|
||||
if handlers.OnBassUpdated != nil && event.BassUpdated != nil {
|
||||
handlers.OnBassUpdated(event.BassUpdated)
|
||||
|
||||
@@ -10,6 +10,15 @@ type Group struct {
|
||||
MasterDeviceID string `xml:"masterDeviceId"`
|
||||
Roles GroupRoles `xml:"roles"`
|
||||
SenderIPAddress string `xml:"senderIPAddress,omitempty"`
|
||||
// Status is populated by the device on GET /group (e.g. "GROUP_OK")
|
||||
// and omitted from requests we send back.
|
||||
Status string `xml:"status,omitempty"`
|
||||
}
|
||||
|
||||
// IsEmpty reports whether the device returned an empty <group/> element,
|
||||
// which is the speaker's way of saying "no stereo pair configured".
|
||||
func (g *Group) IsEmpty() bool {
|
||||
return g.ID == "" && g.MasterDeviceID == "" && len(g.Roles.Roles) == 0
|
||||
}
|
||||
|
||||
// GroupRoles contains the role assignments for devices in a group.
|
||||
|
||||
@@ -21,6 +21,10 @@ const (
|
||||
EventTypePresetUpdated WebSocketEventType = "presetsUpdated"
|
||||
// EventTypeZoneUpdated indicates a zone configuration change
|
||||
EventTypeZoneUpdated WebSocketEventType = "zoneUpdated"
|
||||
// EventTypeGroupUpdated is emitted to both ROLE devices when an ST-10
|
||||
// stereo pair is created, renamed, or removed via /addGroup,
|
||||
// /updateGroup, or /removeGroup.
|
||||
EventTypeGroupUpdated WebSocketEventType = "groupUpdated"
|
||||
// EventTypeBassUpdated indicates a bass level change
|
||||
EventTypeBassUpdated WebSocketEventType = "bassUpdated"
|
||||
// EventTypeClockTimeUpdated indicates a clock time change
|
||||
@@ -56,6 +60,8 @@ func (e WebSocketEventType) String() string {
|
||||
return "Preset Updated"
|
||||
case EventTypeZoneUpdated:
|
||||
return "Zone Updated"
|
||||
case EventTypeGroupUpdated:
|
||||
return "Stereo Pair Updated"
|
||||
case EventTypeBassUpdated:
|
||||
return "Bass Updated"
|
||||
case EventTypeClockTimeUpdated:
|
||||
@@ -88,6 +94,7 @@ type WebSocketEvent struct {
|
||||
ConnectionStateUpdated *ConnectionStateUpdatedEvent `xml:"connectionStateUpdated,omitempty"`
|
||||
PresetUpdated *PresetUpdatedEvent `xml:"presetsUpdated,omitempty"`
|
||||
ZoneUpdated *ZoneUpdatedEvent `xml:"zoneUpdated,omitempty"`
|
||||
GroupUpdated *GroupUpdatedEvent `xml:"groupUpdated,omitempty"`
|
||||
BassUpdated *BassUpdatedEvent `xml:"bassUpdated,omitempty"`
|
||||
ClockTimeUpdated *ClockTimeUpdatedEvent `xml:"clockTimeUpdated,omitempty"`
|
||||
ClockDisplayUpdated *ClockDisplayUpdatedEvent `xml:"clockDisplayUpdated,omitempty"`
|
||||
@@ -122,6 +129,10 @@ func (e *WebSocketEvent) GetEvents() []interface{} {
|
||||
events = append(events, e.ZoneUpdated)
|
||||
}
|
||||
|
||||
if e.GroupUpdated != nil {
|
||||
events = append(events, e.GroupUpdated)
|
||||
}
|
||||
|
||||
if e.BassUpdated != nil {
|
||||
events = append(events, e.BassUpdated)
|
||||
}
|
||||
@@ -215,6 +226,16 @@ type ZoneUpdatedEvent struct {
|
||||
Zone Zone `xml:"zone"`
|
||||
}
|
||||
|
||||
// GroupUpdatedEvent represents an ST-10 stereo-pair update notification.
|
||||
// The device fans this event out to both LEFT and RIGHT speakers whenever
|
||||
// the pair is created, renamed, or removed. Group will be the zero value
|
||||
// for a teardown notification — see (*Group).IsEmpty.
|
||||
type GroupUpdatedEvent struct {
|
||||
XMLName xml.Name `xml:"groupUpdated"`
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
Group Group `xml:"group"`
|
||||
}
|
||||
|
||||
// Zone represents multiroom zone information
|
||||
type Zone struct {
|
||||
XMLName xml.Name `xml:"zone"`
|
||||
@@ -373,6 +394,7 @@ type WebSocketEventHandlers struct {
|
||||
OnConnectionState TypedEventHandler[*ConnectionStateUpdatedEvent]
|
||||
OnPresetUpdated TypedEventHandler[*PresetUpdatedEvent]
|
||||
OnZoneUpdated TypedEventHandler[*ZoneUpdatedEvent]
|
||||
OnGroupUpdated TypedEventHandler[*GroupUpdatedEvent]
|
||||
OnBassUpdated TypedEventHandler[*BassUpdatedEvent]
|
||||
OnClockTimeUpdated TypedEventHandler[*ClockTimeUpdatedEvent]
|
||||
OnClockDisplayUpdated TypedEventHandler[*ClockDisplayUpdatedEvent]
|
||||
@@ -382,8 +404,19 @@ type WebSocketEventHandlers struct {
|
||||
OnLanguageUpdated TypedEventHandler[*LanguageUpdatedEvent]
|
||||
OnUnknownEvent EventHandler
|
||||
OnSpecialMessage SpecialMessageHandler
|
||||
// OnRawMessage fires for every received frame before any parsing
|
||||
// happens. Use it for debug/observability tooling that wants to see
|
||||
// exactly what the device sent on the wire — the typed handlers
|
||||
// above still run afterwards, independently. parseErr is the result
|
||||
// of the XML parse: nil for messages that decoded cleanly, non-nil
|
||||
// for malformed payloads. The slice is owned by the caller; copy
|
||||
// before retaining.
|
||||
OnRawMessage RawMessageHandler
|
||||
}
|
||||
|
||||
// RawMessageHandler defines the signature for raw-frame handlers.
|
||||
type RawMessageHandler func(data []byte, parseErr error)
|
||||
|
||||
// ParseWebSocketEvent attempts to parse a WebSocket message into a specific event type
|
||||
func ParseWebSocketEvent(data []byte) (*WebSocketEvent, error) {
|
||||
var event WebSocketEvent
|
||||
@@ -411,6 +444,8 @@ func (e *WebSocketEvent) getFieldByEventType(eventType WebSocketEventType) inter
|
||||
field = e.PresetUpdated
|
||||
case EventTypeZoneUpdated:
|
||||
field = e.ZoneUpdated
|
||||
case EventTypeGroupUpdated:
|
||||
field = e.GroupUpdated
|
||||
case EventTypeBassUpdated:
|
||||
field = e.BassUpdated
|
||||
case EventTypeClockTimeUpdated:
|
||||
@@ -462,6 +497,8 @@ func isNil(i interface{}) bool {
|
||||
return v == nil
|
||||
case *ZoneUpdatedEvent:
|
||||
return v == nil
|
||||
case *GroupUpdatedEvent:
|
||||
return v == nil
|
||||
case *BassUpdatedEvent:
|
||||
return v == nil
|
||||
case *ClockTimeUpdatedEvent:
|
||||
@@ -508,6 +545,8 @@ func (e *WebSocketEvent) HasEventType(eventType WebSocketEventType) bool {
|
||||
return e.PresetUpdated != nil
|
||||
case EventTypeZoneUpdated:
|
||||
return e.ZoneUpdated != nil
|
||||
case EventTypeGroupUpdated:
|
||||
return e.GroupUpdated != nil
|
||||
case EventTypeBassUpdated:
|
||||
return e.BassUpdated != nil
|
||||
case EventTypeClockTimeUpdated:
|
||||
@@ -551,6 +590,10 @@ func (e *WebSocketEvent) GetEventTypes() []WebSocketEventType {
|
||||
types = append(types, EventTypeZoneUpdated)
|
||||
}
|
||||
|
||||
if e.GroupUpdated != nil {
|
||||
types = append(types, EventTypeGroupUpdated)
|
||||
}
|
||||
|
||||
if e.BassUpdated != nil {
|
||||
types = append(types, EventTypeBassUpdated)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ func TestWebSocketEventType_String(t *testing.T) {
|
||||
{"ConnectionState", EventTypeConnectionState, "Connection State Updated"},
|
||||
{"PresetUpdated", EventTypePresetUpdated, "Preset Updated"},
|
||||
{"ZoneUpdated", EventTypeZoneUpdated, "Zone Updated"},
|
||||
{"GroupUpdated", EventTypeGroupUpdated, "Stereo Pair Updated"},
|
||||
{"BassUpdated", EventTypeBassUpdated, "Bass Updated"},
|
||||
{"ClockTimeUpdated", EventTypeClockTimeUpdated, "Clock Time Updated"},
|
||||
{"ClockDisplayUpdated", EventTypeClockDisplayUpdated, "Clock Display Updated"},
|
||||
@@ -187,6 +188,89 @@ func TestParseWebSocketEvent(t *testing.T) {
|
||||
t.Error("Expected error for invalid XML, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidGroupUpdatedEvent", func(t *testing.T) {
|
||||
// The device fans this out to both ROLE devices when a stereo
|
||||
// pair is created via POST /addGroup.
|
||||
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<updates deviceID="9070658C9D4A">
|
||||
<groupUpdated deviceID="9070658C9D4A">
|
||||
<group id="1234567">
|
||||
<name>Living Room Pair</name>
|
||||
<masterDeviceId>9070658C9D4A</masterDeviceId>
|
||||
<roles>
|
||||
<groupRole>
|
||||
<deviceId>9070658C9D4A</deviceId>
|
||||
<role>LEFT</role>
|
||||
<ipAddress>192.168.1.131</ipAddress>
|
||||
</groupRole>
|
||||
<groupRole>
|
||||
<deviceId>F45EAB3115DA</deviceId>
|
||||
<role>RIGHT</role>
|
||||
<ipAddress>192.168.1.134</ipAddress>
|
||||
</groupRole>
|
||||
</roles>
|
||||
<status>GROUP_OK</status>
|
||||
</group>
|
||||
</groupUpdated>
|
||||
</updates>`
|
||||
|
||||
event, err := ParseWebSocketEvent([]byte(xmlData))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseWebSocketEvent: %v", err)
|
||||
}
|
||||
|
||||
if !event.HasEventType(EventTypeGroupUpdated) {
|
||||
t.Fatal("HasEventType(EventTypeGroupUpdated) = false, want true")
|
||||
}
|
||||
|
||||
if event.GroupUpdated == nil {
|
||||
t.Fatal("GroupUpdated is nil")
|
||||
}
|
||||
|
||||
g := event.GroupUpdated.Group
|
||||
|
||||
if g.ID != "1234567" {
|
||||
t.Errorf("group ID = %q, want 1234567", g.ID)
|
||||
}
|
||||
|
||||
if g.MasterDeviceID != "9070658C9D4A" {
|
||||
t.Errorf("MasterDeviceID = %q", g.MasterDeviceID)
|
||||
}
|
||||
|
||||
if len(g.Roles.Roles) != 2 || g.Roles.Roles[0].Role != "LEFT" || g.Roles.Roles[1].Role != "RIGHT" {
|
||||
t.Errorf("roles not parsed as LEFT/RIGHT: %+v", g.Roles.Roles)
|
||||
}
|
||||
|
||||
if g.Status != "GROUP_OK" {
|
||||
t.Errorf("status = %q, want GROUP_OK", g.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GroupUpdatedTeardown", func(t *testing.T) {
|
||||
// On /removeGroup, the device emits a groupUpdated with an empty
|
||||
// <group/> body. Parsing must surface that as IsEmpty=true so the
|
||||
// UI can render "pair dissolved" cleanly.
|
||||
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<updates deviceID="9070658C9D4A">
|
||||
<groupUpdated deviceID="9070658C9D4A">
|
||||
<group/>
|
||||
</groupUpdated>
|
||||
</updates>`
|
||||
|
||||
event, err := ParseWebSocketEvent([]byte(xmlData))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseWebSocketEvent: %v", err)
|
||||
}
|
||||
|
||||
if event.GroupUpdated == nil {
|
||||
t.Fatal("GroupUpdated is nil")
|
||||
}
|
||||
|
||||
if !event.GroupUpdated.Group.IsEmpty() {
|
||||
t.Errorf("Group.IsEmpty() = false on teardown; got %+v", event.GroupUpdated.Group)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebSocketEvent_HasEventType(t *testing.T) {
|
||||
|
||||
@@ -299,11 +299,10 @@ const (
|
||||
RecentsFile = "Recents.xml"
|
||||
SourcesFile = "Sources.xml"
|
||||
|
||||
SpeakerHTTPPort = 8090
|
||||
SpeakerDeviceInfoPath = "/info"
|
||||
SpeakerRecentsPath = "/recents"
|
||||
SpeakerPresetsPath = "/presets"
|
||||
SpeakerSourcesFileLocation = "/mnt/nv/BoseApp-Persistence/1/Sources.xml"
|
||||
// Speaker-protocol constants (HTTP port, paths, on-device file
|
||||
// locations) moved to github.com/gesellix/bose-soundtouch/pkg/speaker
|
||||
// so the client library and CLI can share them without depending on
|
||||
// the service package.
|
||||
|
||||
// DateStr is the hardcoded date used in many Bose XML responses
|
||||
DateStr = "2012-09-19T12:43:00.000+00:00"
|
||||
|
||||
@@ -9,10 +9,6 @@ func TestConstants(t *testing.T) {
|
||||
t.Error("DateStr should not be empty")
|
||||
}
|
||||
|
||||
if SpeakerHTTPPort != 8090 {
|
||||
t.Errorf("Expected SpeakerHTTPPort 8090, got %d", SpeakerHTTPPort)
|
||||
}
|
||||
|
||||
if len(GetProviders()) == 0 {
|
||||
t.Error("Providers should not be empty")
|
||||
}
|
||||
|
||||
@@ -69,6 +69,15 @@ type DataStore struct {
|
||||
// baseDir is the absolute, normalized base directory used for path safety checks.
|
||||
baseDir string
|
||||
|
||||
// rootMu guards lazy initialisation of root.
|
||||
rootMu sync.Mutex
|
||||
// root is an os.Root anchored at baseDir. All filesystem operations within
|
||||
// the datastore go through it, so ".." or absolute paths in
|
||||
// caller-supplied components cannot escape the root — the Go runtime
|
||||
// enforces containment regardless of what safeJoin's output looks like.
|
||||
// Lazily opened so NewDataStore stays a pure constructor.
|
||||
root *os.Root
|
||||
|
||||
eventMutex sync.RWMutex
|
||||
deviceEvents map[string][]models.DeviceEvent
|
||||
idMutex sync.RWMutex
|
||||
@@ -113,10 +122,31 @@ func NewDataStore(dataDir string) *DataStore {
|
||||
}
|
||||
|
||||
// safeJoin joins the given path elements to the datastore baseDir and ensures
|
||||
// that the resulting absolute path stays within baseDir. If the check fails,
|
||||
// baseDir is returned to prevent directory traversal.
|
||||
// that the resulting absolute path stays within baseDir. If any element would
|
||||
// escape baseDir (absolute path, "..", or — on Windows — a drive/colon), the
|
||||
// function falls back to baseDir to prevent directory traversal.
|
||||
//
|
||||
// The validation up-front uses filepath.IsLocal, which CodeQL recognises as a
|
||||
// path-traversal sanitiser, so taint analysis at call sites that subsequently
|
||||
// hand the result to os.ReadFile / os.Open / os.Remove etc. propagates safely.
|
||||
// The post-join prefix check below stays as belt-and-suspenders for any
|
||||
// unusual platform behaviour IsLocal does not cover.
|
||||
func (ds *DataStore) safeJoin(elem ...string) string {
|
||||
// Join the base directory with the provided elements.
|
||||
for _, e := range elem {
|
||||
if e == "" {
|
||||
// filepath.Join silently skips empty elements, but IsLocal
|
||||
// returns false for "" — treat empties as a no-op.
|
||||
continue
|
||||
}
|
||||
|
||||
if !filepath.IsLocal(e) {
|
||||
// Element is absolute, contains ".." or a reserved Windows
|
||||
// component. Refuse to join.
|
||||
return ds.baseDir
|
||||
}
|
||||
}
|
||||
|
||||
// Join the base directory with the (now sanitised) elements.
|
||||
path := filepath.Join(append([]string{ds.baseDir}, elem...)...)
|
||||
|
||||
absPath, err := filepath.Abs(path)
|
||||
@@ -131,7 +161,8 @@ func (ds *DataStore) safeJoin(elem ...string) string {
|
||||
return absPath
|
||||
}
|
||||
|
||||
// Ensure the resolved path is within the base directory.
|
||||
// Belt-and-suspenders: ensure the resolved path is within the base
|
||||
// directory even if filepath.IsLocal somehow misjudged a component.
|
||||
baseWithSep := base
|
||||
if !strings.HasSuffix(baseWithSep, string(os.PathSeparator)) {
|
||||
baseWithSep += string(os.PathSeparator)
|
||||
@@ -150,6 +181,277 @@ func (ds *DataStore) SafeJoin(elem ...string) string {
|
||||
return ds.safeJoin(elem...)
|
||||
}
|
||||
|
||||
// getRoot returns the lazily-opened *os.Root anchored at baseDir. The root is
|
||||
// created on first call after MkdirAll-ing baseDir; subsequent calls return
|
||||
// the cached handle. Filesystem operations performed via the returned root
|
||||
// cannot escape baseDir even if the relative path passed to them is malicious.
|
||||
func (ds *DataStore) getRoot() (*os.Root, error) {
|
||||
ds.rootMu.Lock()
|
||||
defer ds.rootMu.Unlock()
|
||||
|
||||
if ds.root != nil {
|
||||
return ds.root, nil
|
||||
}
|
||||
|
||||
if ds.baseDir == "" {
|
||||
return nil, fmt.Errorf("datastore: baseDir not configured")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(ds.baseDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("datastore: ensure baseDir %s: %w", ds.baseDir, err)
|
||||
}
|
||||
|
||||
r, err := os.OpenRoot(ds.baseDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("datastore: open root at %s: %w", ds.baseDir, err)
|
||||
}
|
||||
|
||||
ds.root = r
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Close releases any open filesystem handles held by the datastore. Safe to
|
||||
// call on a never-used DataStore.
|
||||
func (ds *DataStore) Close() error {
|
||||
ds.rootMu.Lock()
|
||||
defer ds.rootMu.Unlock()
|
||||
|
||||
if ds.root == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := ds.root.Close()
|
||||
ds.root = nil
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// rootRel converts a path produced by safeJoin (or by filepath.Join over
|
||||
// ds.DataDir) into the form expected by *os.Root methods — relative to
|
||||
// baseDir, no leading separator. Tolerates both absolute paths and paths
|
||||
// whose root is the relative ds.DataDir.
|
||||
//
|
||||
// Returns "." for baseDir itself.
|
||||
func (ds *DataStore) rootRel(absPath string) (string, error) {
|
||||
// If the input is relative, absolutise so the comparison with baseDir
|
||||
// works regardless of how DataDir was originally configured.
|
||||
if !filepath.IsAbs(absPath) {
|
||||
a, err := filepath.Abs(absPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("datastore: absolutise %s: %w", absPath, err)
|
||||
}
|
||||
|
||||
absPath = a
|
||||
}
|
||||
|
||||
if absPath == ds.baseDir {
|
||||
return ".", nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(ds.baseDir, absPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("datastore: %s is outside baseDir: %w", absPath, err)
|
||||
}
|
||||
|
||||
if rel == "." || rel == "" {
|
||||
return ".", nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(rel, "..") {
|
||||
return "", fmt.Errorf("datastore: %s is outside baseDir", absPath)
|
||||
}
|
||||
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
// rootStat is the os.Stat equivalent for a path under baseDir.
|
||||
func (ds *DataStore) rootStat(absPath string) (os.FileInfo, error) {
|
||||
r, err := ds.getRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rel, err := ds.rootRel(absPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r.Stat(rel)
|
||||
}
|
||||
|
||||
// rootReadFile is the os.ReadFile equivalent.
|
||||
func (ds *DataStore) rootReadFile(absPath string) ([]byte, error) {
|
||||
r, err := ds.getRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rel, err := ds.rootRel(absPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r.ReadFile(rel)
|
||||
}
|
||||
|
||||
// rootWriteFile is the os.WriteFile equivalent.
|
||||
func (ds *DataStore) rootWriteFile(absPath string, data []byte, perm os.FileMode) error {
|
||||
r, err := ds.getRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := ds.rootRel(absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.WriteFile(rel, data, perm)
|
||||
}
|
||||
|
||||
// rootMkdirAll is the os.MkdirAll equivalent.
|
||||
func (ds *DataStore) rootMkdirAll(absPath string, perm os.FileMode) error {
|
||||
r, err := ds.getRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := ds.rootRel(absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
return r.MkdirAll(rel, perm)
|
||||
}
|
||||
|
||||
// rootRemove is the os.Remove equivalent.
|
||||
func (ds *DataStore) rootRemove(absPath string) error {
|
||||
r, err := ds.getRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := ds.rootRel(absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.Remove(rel)
|
||||
}
|
||||
|
||||
// rootRemoveAll is the os.RemoveAll equivalent.
|
||||
func (ds *DataStore) rootRemoveAll(absPath string) error {
|
||||
r, err := ds.getRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := ds.rootRel(absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.RemoveAll(rel)
|
||||
}
|
||||
|
||||
// rootRename is the os.Rename equivalent. Both paths must be under baseDir.
|
||||
func (ds *DataStore) rootRename(oldAbs, newAbs string) error {
|
||||
r, err := ds.getRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
oldRel, err := ds.rootRel(oldAbs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newRel, err := ds.rootRel(newAbs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.Rename(oldRel, newRel)
|
||||
}
|
||||
|
||||
// rootReadDir lists the entries in absPath. Equivalent to os.ReadDir,
|
||||
// including the same alphabetical-by-name sort order — *os.File.ReadDir(-1)
|
||||
// returns entries in directory order, but callers (and existing tests)
|
||||
// depend on the sorted contract that os.ReadDir documents.
|
||||
func (ds *DataStore) rootReadDir(absPath string) ([]os.DirEntry, error) {
|
||||
r, err := ds.getRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rel, err := ds.rootRel(absPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f, err := r.Open(rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
entries, err := f.ReadDir(-1)
|
||||
if err != nil {
|
||||
return entries, err
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// rootExists is true when absPath exists under baseDir.
|
||||
func (ds *DataStore) rootExists(absPath string) bool {
|
||||
_, err := ds.rootStat(absPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ReadDirUnderBase lists the entries in absPath, which must resolve to a
|
||||
// directory under the datastore baseDir. Cross-package callers (marge,
|
||||
// handlers, …) use this instead of os.ReadDir so that the underlying
|
||||
// *os.Root sanitises the path against traversal.
|
||||
func (ds *DataStore) ReadDirUnderBase(absPath string) ([]os.DirEntry, error) {
|
||||
return ds.rootReadDir(absPath)
|
||||
}
|
||||
|
||||
// MkdirAllUnderBase creates a directory tree under baseDir.
|
||||
func (ds *DataStore) MkdirAllUnderBase(absPath string, perm os.FileMode) error {
|
||||
return ds.rootMkdirAll(absPath, perm)
|
||||
}
|
||||
|
||||
// WriteFileUnderBase atomically writes data to absPath, which must be under
|
||||
// baseDir.
|
||||
func (ds *DataStore) WriteFileUnderBase(absPath string, data []byte, perm os.FileMode) error {
|
||||
return ds.rootWriteFile(absPath, data, perm)
|
||||
}
|
||||
|
||||
// rootOpen is the os.Open equivalent for a path under baseDir. The caller
|
||||
// owns the returned *os.File and must Close it.
|
||||
func (ds *DataStore) rootOpen(absPath string) (*os.File, error) {
|
||||
r, err := ds.getRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rel, err := ds.rootRel(absPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r.Open(rel)
|
||||
}
|
||||
|
||||
// ListAccounts returns a list of all account IDs (directories in the data root).
|
||||
func (ds *DataStore) ListAccounts() ([]string, error) {
|
||||
ds.fileMutex.RLock()
|
||||
@@ -157,11 +459,11 @@ func (ds *DataStore) ListAccounts() ([]string, error) {
|
||||
|
||||
// Account data is stored in 'accounts' subdirectory within the data root.
|
||||
accountsDir := filepath.Join(ds.baseDir, "accounts")
|
||||
if !exists(accountsDir) {
|
||||
if !ds.rootExists(accountsDir) {
|
||||
return []string{"default"}, nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(accountsDir)
|
||||
entries, err := ds.rootReadDir(accountsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -199,7 +501,7 @@ func (ds *DataStore) AccountDeviceDir(account, device string) string {
|
||||
// First, check if the device directory exists directly with the given deviceID
|
||||
// This prioritizes MAC-based deviceIDs over legacy mappings
|
||||
directPath := ds.safeJoin("accounts", account, constants.DevicesDir, device)
|
||||
if _, err := os.Stat(directPath); err == nil {
|
||||
if _, err := ds.rootStat(directPath); err == nil {
|
||||
// Directory exists, use the direct deviceID (preferred for MAC-based IDs)
|
||||
return directPath
|
||||
}
|
||||
@@ -219,7 +521,7 @@ func (ds *DataStore) AccountDeviceDir(account, device string) string {
|
||||
if ok {
|
||||
// Use the mapped device only if it exists and the direct path doesn't
|
||||
mappedPath := ds.safeJoin("accounts", account, constants.DevicesDir, mappedDevice)
|
||||
if _, err := os.Stat(mappedPath); err == nil {
|
||||
if _, err := ds.rootStat(mappedPath); err == nil {
|
||||
return mappedPath
|
||||
}
|
||||
}
|
||||
@@ -241,7 +543,7 @@ func (ds *DataStore) getDeviceInfoNoLock(account, device string) (*models.Servic
|
||||
path := ds.AccountDeviceDir(account, device)
|
||||
deviceInfoPath := filepath.Join(path, constants.DeviceInfoFile)
|
||||
|
||||
data, err := os.ReadFile(deviceInfoPath)
|
||||
data, err := ds.rootReadFile(deviceInfoPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -533,7 +835,7 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
|
||||
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
data, err := ds.rootReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []models.ServicePreset{}, nil
|
||||
@@ -597,7 +899,7 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
if err := ds.rootMkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -661,11 +963,11 @@ func (ds *DataStore) atomicWriteFile(filename string, data []byte) error {
|
||||
perm := os.FileMode(0644)
|
||||
|
||||
tempFile := filename + ".tmp"
|
||||
if err := os.WriteFile(tempFile, data, perm); err != nil {
|
||||
if err := ds.rootWriteFile(tempFile, data, perm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Rename(tempFile, filename)
|
||||
return ds.rootRename(tempFile, filename)
|
||||
}
|
||||
|
||||
// GetRecents returns the list of recently played items for the specified account and device.
|
||||
@@ -675,7 +977,7 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
|
||||
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
data, err := ds.rootReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []models.ServiceRecent{}, nil
|
||||
@@ -770,7 +1072,7 @@ func (ds *DataStore) SaveRecents(account, device string, recents []models.Servic
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
dir := ds.AccountDeviceDir(account, device)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
if err := ds.rootMkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -869,7 +1171,7 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
|
||||
ds.mergeWithExistingDeviceInfo(account, device, info)
|
||||
|
||||
dir := ds.AccountDeviceDir(account, device)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
if err := ds.rootMkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1031,7 +1333,7 @@ func (ds *DataStore) SaveAccountInfo(accountID string, info *models.ServiceAccou
|
||||
}
|
||||
|
||||
dir := ds.AccountDir(accountID)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
if err := ds.rootMkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1053,11 +1355,11 @@ func (ds *DataStore) GetAccountInfo(accountID string) (*models.ServiceAccountInf
|
||||
|
||||
// Try account root (canonical location)
|
||||
path := filepath.Join(ds.AccountDir(accountID), "account.json")
|
||||
if !exists(path) {
|
||||
if !ds.rootExists(path) {
|
||||
return &models.ServiceAccountInfo{AccountID: accountID, IsPlaceholder: true}, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
data, err := ds.rootReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1077,7 +1379,7 @@ func (ds *DataStore) RemoveDevice(account, device string) error {
|
||||
|
||||
dir := ds.AccountDeviceDir(account, device)
|
||||
|
||||
return os.RemoveAll(dir)
|
||||
return ds.rootRemoveAll(dir)
|
||||
}
|
||||
|
||||
// RemoveDeviceDir is an alias for RemoveDevice for backwards compatibility.
|
||||
@@ -1108,7 +1410,7 @@ func (ds *DataStore) collectDeducedIDs(account, device string) map[string]string
|
||||
|
||||
// Check recents and presets to find source IDs for provider IDs 2, 9, 11, 25
|
||||
for _, filename := range []string{constants.RecentsFile, constants.PresetsFile} {
|
||||
fileContent, err := os.ReadFile(filepath.Join(ds.AccountDeviceDir(account, device), filename))
|
||||
fileContent, err := ds.rootReadFile(filepath.Join(ds.AccountDeviceDir(account, device), filename))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -1214,7 +1516,7 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
|
||||
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
data, err := ds.rootReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
sources := ds.getDefaultSources()
|
||||
@@ -1366,7 +1668,7 @@ func (ds *DataStore) SaveConfiguredSources(account, device string, sources []mod
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
if err := ds.rootMkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1661,7 +1963,7 @@ func (ds *DataStore) Initialize() error {
|
||||
func (ds *DataStore) GetETagForPresets(account, device string) int64 {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
|
||||
|
||||
info, err := os.Stat(path)
|
||||
info, err := ds.rootStat(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
@@ -1672,7 +1974,7 @@ func (ds *DataStore) GetETagForPresets(account, device string) int64 {
|
||||
// HasConfiguredSources reports whether a Sources.xml file exists for the given account and device.
|
||||
func (ds *DataStore) HasConfiguredSources(account, device string) bool {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
|
||||
_, err := os.Stat(path)
|
||||
_, err := ds.rootStat(path)
|
||||
|
||||
return err == nil
|
||||
}
|
||||
@@ -1681,7 +1983,7 @@ func (ds *DataStore) HasConfiguredSources(account, device string) bool {
|
||||
func (ds *DataStore) GetETagForSources(account, device string) int64 {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
|
||||
|
||||
info, err := os.Stat(path)
|
||||
info, err := ds.rootStat(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
@@ -1693,7 +1995,7 @@ func (ds *DataStore) GetETagForSources(account, device string) int64 {
|
||||
func (ds *DataStore) GetETagForRecents(account, device string) int64 {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)
|
||||
|
||||
info, err := os.Stat(path)
|
||||
info, err := ds.rootStat(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
@@ -1717,7 +2019,7 @@ func (ds *DataStore) GetETagForAccount(account, device string) string {
|
||||
if device != "" {
|
||||
deviceDir := ds.AccountDeviceDir(account, device)
|
||||
for _, name := range []string{constants.PresetsFile, constants.SourcesFile, constants.RecentsFile} {
|
||||
f, err := os.Open(filepath.Join(deviceDir, name))
|
||||
f, err := ds.rootOpen(filepath.Join(deviceDir, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -1734,13 +2036,13 @@ func (ds *DataStore) GetETagForAccount(account, device string) string {
|
||||
// Ignore error: missing directory is treated as no devices, producing a
|
||||
// stable non-empty hash rather than "" which would false-match an absent
|
||||
// If-None-Match header and return 304 on the first request.
|
||||
entries, _ := os.ReadDir(devicesDir)
|
||||
entries, _ := ds.rootReadDir(devicesDir)
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
deviceDir := ds.AccountDeviceDir(account, entry.Name())
|
||||
for _, name := range []string{constants.PresetsFile, constants.SourcesFile, constants.RecentsFile} {
|
||||
f, err := os.Open(filepath.Join(deviceDir, name))
|
||||
f, err := ds.rootOpen(filepath.Join(deviceDir, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -1778,6 +2080,28 @@ type Settings struct {
|
||||
AmazonClientID string `json:"amazon_client_id,omitempty"`
|
||||
AmazonClientSecret string `json:"amazon_client_secret,omitempty"`
|
||||
AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"`
|
||||
|
||||
// AllowInsecureUpstreamTLS, when true, disables TLS certificate verification
|
||||
// for the upstream Bose-cloud proxy and mirror traffic. The default (false)
|
||||
// keeps verification on; opt in only when the upstream certificate chain is
|
||||
// broken (post end-of-service) and a temporary unblock is required.
|
||||
AllowInsecureUpstreamTLS bool `json:"allow_insecure_upstream_tls,omitempty"`
|
||||
|
||||
// TrustForwardedHeaders enables proxy-aware client IP resolution: when the
|
||||
// immediate TCP peer is one of the TrustedProxyCIDRs, the X-Real-IP /
|
||||
// X-Forwarded-For / True-Client-IP headers are honoured and replace
|
||||
// r.RemoteAddr. Required when the service is fronted by nginx, Caddy, or
|
||||
// any other reverse proxy. Default false — direct LAN deployments must
|
||||
// not enable this, otherwise a malicious LAN-resident client could spoof
|
||||
// its source IP via these headers.
|
||||
TrustForwardedHeaders bool `json:"trust_forwarded_headers,omitempty"`
|
||||
|
||||
// TrustedProxyCIDRs is the list of CIDR blocks whose immediate TCP peers
|
||||
// are allowed to set X-Forwarded-* headers when TrustForwardedHeaders is
|
||||
// true. Defaults to loopback (127.0.0.0/8 and ::1/128) — i.e. only a
|
||||
// reverse proxy on the same host. Override only if the proxy lives on a
|
||||
// different host within a known-good private subnet.
|
||||
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
|
||||
}
|
||||
|
||||
// GetSettings retrieves the global service settings.
|
||||
@@ -1787,11 +2111,11 @@ func (ds *DataStore) GetSettings() (Settings, error) {
|
||||
}
|
||||
|
||||
path := filepath.Join(ds.DataDir, "settings.json")
|
||||
if !exists(path) {
|
||||
if !ds.rootExists(path) {
|
||||
return Settings{}, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
data, err := ds.rootReadFile(path)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
@@ -1810,7 +2134,7 @@ func (ds *DataStore) SaveSettings(settings Settings) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(ds.DataDir, 0755); err != nil {
|
||||
if err := ds.rootMkdirAll(ds.DataDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -1827,7 +2151,7 @@ func (ds *DataStore) SaveSettings(settings Settings) error {
|
||||
// SaveUsageStats saves usage statistics to the datastore.
|
||||
func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error {
|
||||
dir := filepath.Join(ds.DataDir, "stats", "usage")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
if err := ds.rootMkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1845,7 +2169,7 @@ func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error {
|
||||
// SaveErrorStats saves error statistics to the datastore.
|
||||
func (ds *DataStore) SaveErrorStats(stats models.ErrorStats) error {
|
||||
dir := filepath.Join(ds.DataDir, "stats", "error")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
if err := ds.rootMkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1911,7 +2235,7 @@ func (ds *DataStore) SaveDNSDiscoveries(discoveries []DNSDiscoveryEntry) error {
|
||||
}
|
||||
|
||||
dir := filepath.Join(ds.DataDir, "dns")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
if err := ds.rootMkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create dns directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -1937,11 +2261,11 @@ func (ds *DataStore) LoadDNSDiscoveries() ([]DNSDiscoveryEntry, error) {
|
||||
}
|
||||
|
||||
path := filepath.Join(ds.DataDir, "dns", "discoveries.json")
|
||||
if !exists(path) {
|
||||
if !ds.rootExists(path) {
|
||||
return []DNSDiscoveryEntry{}, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
data, err := ds.rootReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1961,11 +2285,11 @@ func (ds *DataStore) ClearDNSDiscoveries() error {
|
||||
}
|
||||
|
||||
path := filepath.Join(ds.DataDir, "dns", "discoveries.json")
|
||||
if !exists(path) {
|
||||
if !ds.rootExists(path) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return os.Remove(path)
|
||||
return ds.rootRemove(path)
|
||||
}
|
||||
|
||||
// groupFilePath returns the on-disk path for a group file.
|
||||
@@ -1977,7 +2301,7 @@ func (ds *DataStore) groupFilePath(account, groupID string) string {
|
||||
func (ds *DataStore) generateGroupID(account string) string {
|
||||
for {
|
||||
id := fmt.Sprintf("%07d", rand.Int63n(10_000_000)) //nolint:gosec
|
||||
if !exists(ds.groupFilePath(account, id)) {
|
||||
if !ds.rootExists(ds.groupFilePath(account, id)) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
@@ -1990,7 +2314,7 @@ func (ds *DataStore) GetGroupForDevice(account, deviceID string) (*models.Group,
|
||||
|
||||
dir := ds.AccountDevicesDir(account)
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
entries, err := ds.rootReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, ErrGroupNotFound
|
||||
@@ -2004,7 +2328,7 @@ func (ds *DataStore) GetGroupForDevice(account, deviceID string) (*models.Group,
|
||||
continue
|
||||
}
|
||||
|
||||
data, readErr := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
data, readErr := ds.rootReadFile(filepath.Join(dir, e.Name()))
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
@@ -2030,7 +2354,7 @@ func (ds *DataStore) AddGroup(account string, group *models.Group) (string, erro
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
dir := ds.AccountDevicesDir(account)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
if err := ds.rootMkdirAll(dir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -2052,7 +2376,7 @@ func (ds *DataStore) ModifyGroup(account, groupID, newName string) (*models.Grou
|
||||
|
||||
path := ds.groupFilePath(account, groupID)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
data, err := ds.rootReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("group %s not found", groupID)
|
||||
@@ -2085,7 +2409,7 @@ func (ds *DataStore) DeleteGroup(account, groupID string) error {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
err := os.Remove(ds.groupFilePath(account, groupID))
|
||||
err := ds.rootRemove(ds.groupFilePath(account, groupID))
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("group %s not found", groupID)
|
||||
}
|
||||
@@ -2101,11 +2425,11 @@ func (ds *DataStore) SaveTuneInFavorite(stationID string) error {
|
||||
}
|
||||
|
||||
dir := ds.safeJoin("tunein", "favorites")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
if err := ds.rootMkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(ds.safeJoin("tunein", "favorites", stationID), nil, 0644)
|
||||
return ds.rootWriteFile(ds.safeJoin("tunein", "favorites", stationID), nil, 0644)
|
||||
}
|
||||
|
||||
// DeleteTuneInFavorite removes a previously saved TuneIn favorite marker file.
|
||||
@@ -2115,7 +2439,7 @@ func (ds *DataStore) DeleteTuneInFavorite(stationID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := os.Remove(ds.safeJoin("tunein", "favorites", stationID))
|
||||
err := ds.rootRemove(ds.safeJoin("tunein", "favorites", stationID))
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,14 +2,38 @@ package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/russross/blackfriday/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
docsRootOnce sync.Once
|
||||
docsRoot *os.Root
|
||||
)
|
||||
|
||||
// docsRootHandle returns a *os.Root anchored at the on-disk "docs" directory.
|
||||
// All file reads from HandleDocs go through it so the Go runtime guarantees
|
||||
// containment regardless of what HTTP path the caller sends — CodeQL also
|
||||
// recognises *os.Root.* as a path-traversal sanitiser.
|
||||
func docsRootHandle() *os.Root {
|
||||
docsRootOnce.Do(func() {
|
||||
r, err := os.OpenRoot("docs")
|
||||
if err != nil {
|
||||
// Fall back to nil; HandleDocs degrades to 404 below.
|
||||
return
|
||||
}
|
||||
|
||||
docsRoot = r
|
||||
})
|
||||
|
||||
return docsRoot
|
||||
}
|
||||
|
||||
// HandleDocs returns a handler for serving documentation files as HTML.
|
||||
func (s *Server) HandleDocs(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/docs")
|
||||
@@ -19,21 +43,23 @@ func (s *Server) HandleDocs(w http.ResponseWriter, r *http.Request) {
|
||||
path = "guides/SURVIVAL-GUIDE.md"
|
||||
}
|
||||
|
||||
// Ensure we only serve files from the docs directory
|
||||
filePath := filepath.Join("docs", path)
|
||||
if !strings.HasPrefix(filepath.Clean(filePath), "docs") {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
root := docsRootHandle()
|
||||
if root == nil {
|
||||
http.Error(w, "Documentation not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filePath)
|
||||
content, err := root.ReadFile(path)
|
||||
if err != nil {
|
||||
// *os.Root.ReadFile rejects absolute paths and ".." segments at the
|
||||
// runtime level, so any failure here is either "not found" or
|
||||
// "traversal attempt blocked" — both 404 from the user's view.
|
||||
http.Error(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Load sidebar (SUMMARY.md)
|
||||
summaryContent, _ := os.ReadFile(filepath.Join("docs", "SUMMARY.md"))
|
||||
summaryContent, _ := root.ReadFile("SUMMARY.md")
|
||||
|
||||
sidebar := ""
|
||||
if len(summaryContent) > 0 {
|
||||
@@ -52,7 +78,12 @@ func (s *Server) HandleDocs(w http.ResponseWriter, r *http.Request) {
|
||||
// Render markdown to HTML
|
||||
output := blackfriday.Run(content)
|
||||
|
||||
// Wrap in a documentation template with sidebar
|
||||
// Wrap in a documentation template with sidebar. The user-supplied path
|
||||
// is escaped before interpolation; the sidebar and rendered markdown
|
||||
// output are server-controlled (loaded from local files) and may
|
||||
// legitimately contain HTML.
|
||||
titleSafe := html.EscapeString(path)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -91,7 +122,7 @@ func (s *Server) HandleDocs(w http.ResponseWriter, r *http.Request) {
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`, path, sidebar, output)
|
||||
</html>`, titleSafe, sidebar, output)
|
||||
}
|
||||
|
||||
// fixSidebarLinks ensures that relative links in the SUMMARY.md (sidebar)
|
||||
|
||||
@@ -289,13 +289,32 @@ func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// Prefer the TCP source address over the body's self-reported IP for
|
||||
// any outbound credential push. The body field is attacker-controllable
|
||||
// (a malicious LAN-resident speaker can set it to any value), while
|
||||
// r.RemoteAddr is the actual peer — and if the service runs behind a
|
||||
// trusted reverse proxy, the TrustedRealIP middleware has already
|
||||
// rewritten it from X-Real-IP / X-Forwarded-For. We log when the two
|
||||
// disagree so the discrepancy is investigable but never trust the body.
|
||||
remoteHost := ""
|
||||
if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
remoteHost = h
|
||||
}
|
||||
|
||||
if deviceIP != "" && remoteHost != "" && deviceIP != remoteHost {
|
||||
log.Printf("[Marge] power_on body IP %q differs from TCP source %q for device %s — using TCP source for credential push",
|
||||
deviceIP, remoteHost, deviceID)
|
||||
}
|
||||
|
||||
target := remoteHost
|
||||
if target == "" {
|
||||
// RemoteAddr was unparseable (shouldn't happen under net/http) —
|
||||
// fall back to the body so we don't silently skip the push.
|
||||
target = deviceIP
|
||||
}
|
||||
|
||||
if target != "" {
|
||||
go s.PrimeDeviceWithSpotify(target)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -144,7 +145,9 @@ func (s *Server) HandleMgmtSpotifyCallback(w http.ResponseWriter, r *http.Reques
|
||||
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>`))
|
||||
// html.EscapeString neutralises any HTML metacharacters in the
|
||||
// caller-supplied error string before it lands in the response.
|
||||
_, _ = w.Write([]byte(`<html><body><h1>Spotify Authorization Failed</h1><p>Error: ` + html.EscapeString(errMsg) + `</p></body></html>`))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -487,7 +490,9 @@ func (s *Server) HandleMgmtAmazonCallback(w http.ResponseWriter, r *http.Request
|
||||
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>Amazon Authorization Failed</h1><p>Error: ` + errMsg + `</p></body></html>`))
|
||||
// html.EscapeString neutralises any HTML metacharacters in the
|
||||
// caller-supplied error string before it lands in the response.
|
||||
_, _ = w.Write([]byte(`<html><body><h1>Amazon Authorization Failed</h1><p>Error: ` + html.EscapeString(errMsg) + `</p></body></html>`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// peerProbeTimeout caps how long the passive observer waits for any
|
||||
// inbound from the device IP after the :8090/swUpdateCheck nudge. 30s
|
||||
// is comfortable for daemon wake-up latency on slow devices while still
|
||||
// keeping the panel responsive; result.ElapsedMs surfaces the actual
|
||||
// observed latency so the budget can be tuned from real data.
|
||||
const peerProbeTimeout = 30 * time.Second
|
||||
|
||||
// peerProbeResponse is the body of POST /setup/peer-probe/{deviceId}.
|
||||
type peerProbeResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// HandlePeerProbe runs the post-migration passive reachability check.
|
||||
// Registers interest in the device's IP, nudges :8090/swUpdateCheck,
|
||||
// and reports whether any inbound from that IP landed within
|
||||
// peerProbeTimeout. Any inbound counts — on a migrated speaker, DNS
|
||||
// interception routes the daemon's outbounds (update fan-out, marge,
|
||||
// BMX) through this service regardless of which URL the daemon
|
||||
// resolved internally, so the question reduces to "did the device
|
||||
// dial us at all."
|
||||
//
|
||||
// Unlike the deprecated round-trip probe, this handler does not mutate
|
||||
// device state. It presupposes the speaker is already migrated; the
|
||||
// pre-flight orchestrator is responsible for only calling it in that
|
||||
// state.
|
||||
func (s *Server) HandlePeerProbe(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
writeJSONError(w, http.StatusBadRequest, "Device ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.sm.RunPeerReachabilityProbe(deviceIP, s.peerObserver, peerProbeTimeout)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
body := peerProbeResponse{
|
||||
OK: err == nil && result != nil && result.Reached,
|
||||
Result: result,
|
||||
}
|
||||
if err != nil {
|
||||
body.Error = err.Error()
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,13 @@ func (s *Server) ServeProxy(target *url.URL) http.HandlerFunc {
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
|
||||
}
|
||||
|
||||
// AllowInsecureUpstreamTLS is opt-in via settings.json — defaults to
|
||||
// false so the upstream certificate chain is verified normally. The
|
||||
// opt-in exists for deployments stuck behind a broken Bose-cloud
|
||||
// chain post end-of-service.
|
||||
settings, _ := s.ds.GetSettings()
|
||||
insecure := settings.AllowInsecureUpstreamTLS
|
||||
|
||||
rp := &httputil.ReverseProxy{
|
||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
pr.SetURL(target)
|
||||
@@ -77,7 +84,7 @@ func (s *Server) ServeProxy(target *url.URL) http.HandlerFunc {
|
||||
lp.LogRequest(pr.Out)
|
||||
},
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -411,13 +411,7 @@ func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Reques
|
||||
targetURL := r.URL.Query().Get("target_url")
|
||||
proxyURL := r.URL.Query().Get("proxy_url")
|
||||
|
||||
options := make(map[string]string)
|
||||
|
||||
for k, v := range r.URL.Query() {
|
||||
if len(v) > 0 && (k == "marge" || k == "stats" || k == "sw_update" || k == "bmx") {
|
||||
options[k] = v[0]
|
||||
}
|
||||
}
|
||||
options := parseMigrationOptions(r.URL.Query())
|
||||
|
||||
summary, err := s.sm.GetMigrationSummary(deviceIP, targetURL, proxyURL, options)
|
||||
if err != nil {
|
||||
@@ -465,13 +459,7 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
proxyURL := r.URL.Query().Get("proxy_url")
|
||||
method := setup.MigrationMethod(r.URL.Query().Get("method"))
|
||||
|
||||
options := make(map[string]string)
|
||||
|
||||
for k, v := range r.URL.Query() {
|
||||
if len(v) > 0 && (k == "marge" || k == "stats" || k == "sw_update" || k == "bmx") {
|
||||
options[k] = v[0]
|
||||
}
|
||||
}
|
||||
options := parseMigrationOptions(r.URL.Query())
|
||||
|
||||
output, err := s.sm.MigrateSpeaker(deviceIP, targetURL, proxyURL, options, method)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
)
|
||||
|
||||
// PeerObserverMiddleware records every incoming request's source IP and
|
||||
// path in the peerObserver registry. It fires on every request before
|
||||
// the handler runs, so passive reachability probes can register a device
|
||||
// IP and learn whether any inbound landed in their wait window.
|
||||
//
|
||||
// Placement: after TrustedRealIPMiddleware (so r.RemoteAddr reflects the
|
||||
// trusted client IP) and after Recoverer (so any panic inside this
|
||||
// middleware is contained). Before any short-circuiting middleware
|
||||
// would be unnecessary — Signal runs before next.ServeHTTP, so the
|
||||
// observation lands regardless of how later middleware handles the
|
||||
// request.
|
||||
func (s *Server) PeerObserverMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err == nil && host != "" {
|
||||
s.peerObserver.Signal(host, setup.PeerHit{Path: r.URL.Path, At: time.Now()})
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// defaultTrustedProxyCIDRs is the safe-by-default list applied when
|
||||
// Settings.TrustedProxyCIDRs is empty. Only loopback addresses are trusted —
|
||||
// i.e. a reverse proxy on the same host. Anyone deploying behind a proxy on a
|
||||
// different host must override this in settings.json.
|
||||
var defaultTrustedProxyCIDRs = []string{
|
||||
"127.0.0.0/8",
|
||||
"::1/128",
|
||||
}
|
||||
|
||||
// TrustedRealIP returns a middleware that delegates to chi's RealIP — which
|
||||
// rewrites r.RemoteAddr from True-Client-IP / X-Real-IP / X-Forwarded-For
|
||||
// headers — but only when the immediate TCP peer is in `trustedPeers`. For
|
||||
// any request whose peer is *not* trusted (i.e. anything other than the
|
||||
// configured reverse proxy), the headers are ignored and r.RemoteAddr stays
|
||||
// as-is.
|
||||
//
|
||||
// This avoids the standard X-Forwarded-* spoofing pitfall: on a flat LAN
|
||||
// where a malicious speaker could send the headers itself, we won't honour
|
||||
// them; behind a reverse proxy we will.
|
||||
//
|
||||
// Returns nil if trustedPeers is empty — caller should not Use a nil mw.
|
||||
func TrustedRealIP(trustedPeers []*net.IPNet) func(http.Handler) http.Handler {
|
||||
if len(trustedPeers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
delegate := middleware.RealIP
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isFromTrustedPeer(r.RemoteAddr, trustedPeers) {
|
||||
delegate(next).ServeHTTP(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// isFromTrustedPeer reports whether remoteAddr (in the host:port shape that
|
||||
// net/http populates) is contained in any of the supplied CIDR blocks.
|
||||
func isFromTrustedPeer(remoteAddr string, trustedPeers []*net.IPNet) bool {
|
||||
host, _, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
host = remoteAddr
|
||||
}
|
||||
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, n := range trustedPeers {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ParseTrustedProxyCIDRs converts string CIDRs into *net.IPNet values, falling
|
||||
// back to defaultTrustedProxyCIDRs when the input is empty. An invalid CIDR
|
||||
// in the input list is reported as an error and stops parsing — better to
|
||||
// fail loud than silently fall back.
|
||||
func ParseTrustedProxyCIDRs(cidrs []string) ([]*net.IPNet, error) {
|
||||
if len(cidrs) == 0 {
|
||||
cidrs = defaultTrustedProxyCIDRs
|
||||
}
|
||||
|
||||
out := make([]*net.IPNet, 0, len(cidrs))
|
||||
|
||||
for _, c := range cidrs {
|
||||
_, n, err := net.ParseCIDR(c)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trusted proxy CIDR %q: %w", c, err)
|
||||
}
|
||||
|
||||
out = append(out, n)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTrustedRealIP(t *testing.T) {
|
||||
cidrs, err := ParseTrustedProxyCIDRs([]string{"127.0.0.0/8", "::1/128"})
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTrustedProxyCIDRs: %v", err)
|
||||
}
|
||||
|
||||
mw := TrustedRealIP(cidrs)
|
||||
if mw == nil {
|
||||
t.Fatal("TrustedRealIP returned nil for non-empty trustedPeers")
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
xRealIP string
|
||||
xForwardedFor string
|
||||
wantRemoteAddr string
|
||||
}{
|
||||
{
|
||||
name: "trusted peer with X-Real-IP is honoured",
|
||||
remoteAddr: "127.0.0.1:54321",
|
||||
xRealIP: "192.168.1.10",
|
||||
wantRemoteAddr: "192.168.1.10",
|
||||
},
|
||||
{
|
||||
name: "trusted peer with X-Forwarded-For is honoured",
|
||||
remoteAddr: "127.0.0.1:54321",
|
||||
xForwardedFor: "192.168.1.20, 10.0.0.1",
|
||||
wantRemoteAddr: "192.168.1.20",
|
||||
},
|
||||
{
|
||||
name: "trusted peer with no headers leaves RemoteAddr alone",
|
||||
remoteAddr: "127.0.0.1:54321",
|
||||
wantRemoteAddr: "127.0.0.1:54321",
|
||||
},
|
||||
{
|
||||
name: "untrusted peer's X-Real-IP is ignored",
|
||||
remoteAddr: "192.168.1.99:54321",
|
||||
xRealIP: "1.2.3.4",
|
||||
wantRemoteAddr: "192.168.1.99:54321",
|
||||
},
|
||||
{
|
||||
name: "untrusted peer's X-Forwarded-For is ignored",
|
||||
remoteAddr: "192.168.1.99:54321",
|
||||
xForwardedFor: "1.2.3.4",
|
||||
wantRemoteAddr: "192.168.1.99:54321",
|
||||
},
|
||||
{
|
||||
name: "trusted peer with garbage X-Real-IP leaves RemoteAddr alone",
|
||||
remoteAddr: "127.0.0.1:54321",
|
||||
xRealIP: "not-an-ip",
|
||||
wantRemoteAddr: "127.0.0.1:54321",
|
||||
},
|
||||
{
|
||||
name: "trusted IPv6 loopback peer is honoured",
|
||||
remoteAddr: "[::1]:54321",
|
||||
xRealIP: "fe80::1",
|
||||
wantRemoteAddr: "fe80::1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var got string
|
||||
|
||||
h := mw(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
got = r.RemoteAddr
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = tc.remoteAddr
|
||||
|
||||
if tc.xRealIP != "" {
|
||||
req.Header.Set("X-Real-IP", tc.xRealIP)
|
||||
}
|
||||
|
||||
if tc.xForwardedFor != "" {
|
||||
req.Header.Set("X-Forwarded-For", tc.xForwardedFor)
|
||||
}
|
||||
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
if got != tc.wantRemoteAddr {
|
||||
t.Errorf("RemoteAddr = %q, want %q", got, tc.wantRemoteAddr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedRealIP_NilForEmptyPeers(t *testing.T) {
|
||||
if mw := TrustedRealIP(nil); mw != nil {
|
||||
t.Error("TrustedRealIP(nil) returned non-nil; expected nil so caller can skip Use()")
|
||||
}
|
||||
|
||||
if mw := TrustedRealIP([]*net.IPNet{}); mw != nil {
|
||||
t.Error("TrustedRealIP([]) returned non-nil; expected nil so caller can skip Use()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrustedProxyCIDRs(t *testing.T) {
|
||||
t.Run("empty input yields loopback default", func(t *testing.T) {
|
||||
got, err := ParseTrustedProxyCIDRs(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTrustedProxyCIDRs: %v", err)
|
||||
}
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("default CIDR count = %d, want 2 (127/8 + ::1/128)", len(got))
|
||||
}
|
||||
|
||||
// Should contain 127.0.0.1 and ::1.
|
||||
if !isFromTrustedPeer("127.0.0.1:1", got) {
|
||||
t.Error("default CIDRs should include 127.0.0.1")
|
||||
}
|
||||
|
||||
if !isFromTrustedPeer("[::1]:1", got) {
|
||||
t.Error("default CIDRs should include ::1")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("custom CIDRs override defaults", func(t *testing.T) {
|
||||
got, err := ParseTrustedProxyCIDRs([]string{"10.0.0.0/8"})
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTrustedProxyCIDRs: %v", err)
|
||||
}
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Errorf("custom CIDR count = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
if !isFromTrustedPeer("10.1.2.3:1", got) {
|
||||
t.Error("10.1.2.3 should be in 10.0.0.0/8")
|
||||
}
|
||||
|
||||
if isFromTrustedPeer("127.0.0.1:1", got) {
|
||||
t.Error("127.0.0.1 should NOT match when default is overridden")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid CIDR returns error", func(t *testing.T) {
|
||||
_, err := ParseTrustedProxyCIDRs([]string{"not-a-cidr"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on invalid CIDR")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package handlers
|
||||
|
||||
import "net/url"
|
||||
|
||||
// migrationOptionKeys is the allow-list of query parameters carried into
|
||||
// the migration manager's options map. Two families coexist:
|
||||
//
|
||||
// - marge / stats / sw_update / bmx — the XML method's per-field
|
||||
// "self | proxied | original" implementation selectors.
|
||||
// - marge_url / stats_url / sw_update_url / bmx_url — the telnet
|
||||
// method's per-field URL overrides (default: derive from target_url).
|
||||
//
|
||||
// Unrecognised keys are dropped so the manager never sees query
|
||||
// parameters it did not opt into.
|
||||
var migrationOptionKeys = map[string]struct{}{
|
||||
"marge": {},
|
||||
"stats": {},
|
||||
"sw_update": {},
|
||||
"bmx": {},
|
||||
"marge_url": {},
|
||||
"stats_url": {},
|
||||
"sw_update_url": {},
|
||||
"bmx_url": {},
|
||||
}
|
||||
|
||||
// parseMigrationOptions copies the recognised keys from query into a
|
||||
// fresh map. Empty values are preserved as empty strings so the caller
|
||||
// can distinguish "explicitly cleared" from "not set" if it ever needs
|
||||
// to; the setup package's telnetURLsFromOptions treats empty as "use
|
||||
// default", which is the desired UI behaviour today.
|
||||
func parseMigrationOptions(query url.Values) map[string]string {
|
||||
out := make(map[string]string, len(migrationOptionKeys))
|
||||
|
||||
for k, v := range query {
|
||||
if _, ok := migrationOptionKeys[k]; !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(v) > 0 {
|
||||
out[k] = v[0]
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseMigrationOptions_AllowsXMLAndTelnetKeys(t *testing.T) {
|
||||
q := url.Values{
|
||||
"marge": []string{"self"},
|
||||
"stats": []string{"proxied"},
|
||||
"sw_update": []string{"original"},
|
||||
"bmx": []string{"self"},
|
||||
"marge_url": []string{"http://example:8000/marge"},
|
||||
"stats_url": []string{"http://example:8000"},
|
||||
"sw_update_url": []string{"http://example:8000/updates/soundtouch"},
|
||||
"bmx_url": []string{"http://example:8000/bmx/registry/v1/services"},
|
||||
}
|
||||
|
||||
got := parseMigrationOptions(q)
|
||||
|
||||
want := map[string]string{
|
||||
"marge": "self",
|
||||
"stats": "proxied",
|
||||
"sw_update": "original",
|
||||
"bmx": "self",
|
||||
"marge_url": "http://example:8000/marge",
|
||||
"stats_url": "http://example:8000",
|
||||
"sw_update_url": "http://example:8000/updates/soundtouch",
|
||||
"bmx_url": "http://example:8000/bmx/registry/v1/services",
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("parseMigrationOptions = %v\nwant %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMigrationOptions_DropsUnknownKeys(t *testing.T) {
|
||||
q := url.Values{
|
||||
"marge": []string{"self"},
|
||||
"target_url": []string{"http://example:8000"}, // not an option
|
||||
"method": []string{"telnet"}, // not an option
|
||||
"random": []string{"value"}, // attacker-controlled noise
|
||||
}
|
||||
|
||||
got := parseMigrationOptions(q)
|
||||
|
||||
if _, ok := got["target_url"]; ok {
|
||||
t.Errorf("target_url leaked into options map: %v", got)
|
||||
}
|
||||
|
||||
if _, ok := got["method"]; ok {
|
||||
t.Errorf("method leaked into options map: %v", got)
|
||||
}
|
||||
|
||||
if _, ok := got["random"]; ok {
|
||||
t.Errorf("random key leaked into options map: %v", got)
|
||||
}
|
||||
|
||||
if got["marge"] != "self" {
|
||||
t.Errorf("marge = %q, want self", got["marge"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMigrationOptions_EmptyQueryReturnsEmptyMap(t *testing.T) {
|
||||
got := parseMigrationOptions(url.Values{})
|
||||
if len(got) != 0 {
|
||||
t.Errorf("got %v, want empty map", got)
|
||||
}
|
||||
}
|
||||
@@ -271,6 +271,12 @@ func (s *Server) performMirror(r *http.Request) *mirrorResponseRecorder {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AllowInsecureUpstreamTLS is opt-in via settings.json — defaults to
|
||||
// false so verification stays on. The opt-in exists for deployments
|
||||
// stuck behind a broken Bose-cloud certificate chain post EOS.
|
||||
settings, _ := s.ds.GetSettings()
|
||||
insecure := settings.AllowInsecureUpstreamTLS
|
||||
|
||||
// Create a proxy that doesn't write to the original ResponseWriter
|
||||
proxy := &httputil.ReverseProxy{
|
||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
@@ -279,7 +285,7 @@ func (s *Server) performMirror(r *http.Request) *mirrorResponseRecorder {
|
||||
pr.Out.Header.Set("X-Mirror-Request", "true")
|
||||
},
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -450,10 +456,22 @@ func (s *Server) saveParityMismatch(req *http.Request, local, upstream *mirrorRe
|
||||
}
|
||||
|
||||
dir := filepath.Join(s.ds.DataDir, "parity_mismatches")
|
||||
_ = os.MkdirAll(dir, 0755)
|
||||
_ = s.ds.MkdirAllUnderBase(dir, 0755)
|
||||
|
||||
filename := fmt.Sprintf("%d_%s.json", time.Now().Unix(), strings.ReplaceAll(req.URL.Path, "/", "_"))
|
||||
_ = os.WriteFile(filepath.Join(dir, filename), data, 0644)
|
||||
// Build a single filename component from req.URL.Path. After replacing
|
||||
// the obvious separators, gate on filepath.IsLocal so a malicious path
|
||||
// containing ".." or platform-specific separators we missed cannot
|
||||
// escape `dir`. The write itself goes through DataStore's *os.Root so
|
||||
// the runtime enforces containment regardless of what's in pathSegment.
|
||||
pathSegment := strings.ReplaceAll(req.URL.Path, "/", "_")
|
||||
pathSegment = strings.ReplaceAll(pathSegment, "\\", "_")
|
||||
|
||||
if !filepath.IsLocal(pathSegment) {
|
||||
pathSegment = "invalid"
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%d_%s.json", time.Now().Unix(), pathSegment)
|
||||
_ = s.ds.WriteFileUnderBase(filepath.Join(dir, filename), data, 0644)
|
||||
}
|
||||
|
||||
type mirrorResponseRecorder struct {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
)
|
||||
|
||||
// peerObserver is the rendezvous between the passive reachability probe
|
||||
// (which registers interest in a device IP and waits for any inbound)
|
||||
// and the chi middleware (which signals on every request whose source
|
||||
// IP matches a registration).
|
||||
//
|
||||
// Unlike probeRegistry, which keys on a unique per-probe token, this
|
||||
// observer keys on the device's IP — the probe doesn't mutate device
|
||||
// state, so there's no token to thread through the request path. Any
|
||||
// inbound from the IP counts as proof of reachability.
|
||||
//
|
||||
// PeerHit and the abstract handle interface live in the setup package
|
||||
// alongside the probe logic; this type implements that interface.
|
||||
type peerObserver struct {
|
||||
mu sync.Mutex
|
||||
pending map[string]chan setup.PeerHit
|
||||
}
|
||||
|
||||
func newPeerObserver() *peerObserver {
|
||||
return &peerObserver{pending: make(map[string]chan setup.PeerHit)}
|
||||
}
|
||||
|
||||
// Register creates a one-shot buffered channel keyed by IP. The buffer
|
||||
// of 1 lets the middleware deliver the first hit and silently drop
|
||||
// subsequent hits during the wait window without blocking. Caller is
|
||||
// responsible for pairing every Register with Forget.
|
||||
func (o *peerObserver) Register(ip string) <-chan setup.PeerHit {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
ch := make(chan setup.PeerHit, 1)
|
||||
o.pending[ip] = ch
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
// Signal delivers a hit to the channel for ip, non-blocking. Returns
|
||||
// true when a matching registration existed AND the hit was delivered
|
||||
// (i.e. the channel had buffer space — first hit during the window).
|
||||
// Subsequent hits during the same window return false without blocking.
|
||||
func (o *peerObserver) Signal(ip string, hit setup.PeerHit) bool {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
ch, ok := o.pending[ip]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
select {
|
||||
case ch <- hit:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Forget removes the entry. Safe to call regardless of whether a hit
|
||||
// landed — does not affect already-returned channels.
|
||||
func (o *peerObserver) Forget(ip string) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
delete(o.pending, ip)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
)
|
||||
|
||||
func TestPeerObserver_RegisterSignalForget(t *testing.T) {
|
||||
o := newPeerObserver()
|
||||
|
||||
ch := o.Register("192.168.1.42")
|
||||
if ch == nil {
|
||||
t.Fatal("Register returned nil channel")
|
||||
}
|
||||
|
||||
first := setup.PeerHit{Path: "/updates/soundtouch", At: time.Now()}
|
||||
if !o.Signal("192.168.1.42", first) {
|
||||
t.Error("Signal returned false for registered IP")
|
||||
}
|
||||
|
||||
// Second signal while the buffer is still full (no reader yet) drops
|
||||
// silently and returns false — only the first hit per window matters.
|
||||
if o.Signal("192.168.1.42", setup.PeerHit{Path: "/streaming/x"}) {
|
||||
t.Error("second Signal returned true; expected false (buffer full, undrained)")
|
||||
}
|
||||
|
||||
select {
|
||||
case got := <-ch:
|
||||
if got.Path != first.Path {
|
||||
t.Errorf("hit.Path = %q, want %q", got.Path, first.Path)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("Signal did not deliver hit to channel")
|
||||
}
|
||||
|
||||
o.Forget("192.168.1.42")
|
||||
|
||||
// After Forget, Signal returns false.
|
||||
if o.Signal("192.168.1.42", first) {
|
||||
t.Error("Signal returned true after Forget")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerObserver_UnknownIP(t *testing.T) {
|
||||
o := newPeerObserver()
|
||||
if o.Signal("10.0.0.1", setup.PeerHit{Path: "/anything"}) {
|
||||
t.Error("Signal returned true for unregistered IP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerObserver_SignalIsNonBlocking(t *testing.T) {
|
||||
o := newPeerObserver()
|
||||
o.Register("192.168.1.42") // never drain
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for i := 0; i < 100; i++ {
|
||||
o.Signal("192.168.1.42", setup.PeerHit{Path: "/x"})
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Signal never blocked even with no reader and a full buffer.
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("Signal blocked when buffer was full — must drop silently")
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ type Server struct {
|
||||
amazonClientSecret string
|
||||
amazonRedirectURI string
|
||||
amazonService *amazon.Service
|
||||
peerObserver *peerObserver
|
||||
}
|
||||
|
||||
// RequestSnapshot represents an immutable snapshot of an HTTP request.
|
||||
@@ -95,11 +96,41 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, pro
|
||||
recordEnabled: recordEnabled,
|
||||
discoveryInterval: 5 * time.Minute,
|
||||
discoveryEnabled: true,
|
||||
peerObserver: newPeerObserver(),
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// TrustedRealIPMiddleware returns a chi middleware that rewrites
|
||||
// r.RemoteAddr from X-Real-IP / X-Forwarded-For / True-Client-IP, but only
|
||||
// when the immediate TCP peer is in the configured trusted-proxy list.
|
||||
// Returns nil when Settings.TrustForwardedHeaders is false (the safe
|
||||
// default), so the caller can skip wiring the middleware entirely.
|
||||
//
|
||||
// The trusted-peer gate prevents the typical X-Forwarded-* spoofing surface:
|
||||
// on a flat LAN where a malicious speaker could send the headers itself, we
|
||||
// won't honour them; behind a documented reverse proxy on loopback we will.
|
||||
func (s *Server) TrustedRealIPMiddleware() func(http.Handler) http.Handler {
|
||||
settings, err := s.ds.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("[RealIP] failed to load settings: %v — skipping forwarded-header trust", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !settings.TrustForwardedHeaders {
|
||||
return nil
|
||||
}
|
||||
|
||||
cidrs, err := ParseTrustedProxyCIDRs(settings.TrustedProxyCIDRs)
|
||||
if err != nil {
|
||||
log.Printf("[RealIP] invalid trusted_proxy_cidrs: %v — skipping forwarded-header trust", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return TrustedRealIP(cidrs)
|
||||
}
|
||||
|
||||
// SetVersionInfo sets the version information for the server.
|
||||
func (s *Server) SetVersionInfo(version, commit, date, repoURL string) {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -53,34 +53,36 @@
|
||||
|
||||
<h3>Migration Process at a Glance</h3>
|
||||
<div class="info-box prerequisite-box">
|
||||
<strong>🔌 Prerequisite: Enable SSH</strong><br/>
|
||||
Migration requires SSH access. To enable it:
|
||||
<ol style="margin-top: 5px; margin-bottom: 5px">
|
||||
<li>
|
||||
Create an empty file named
|
||||
<code>remote_services</code> on a USB stick.
|
||||
</li>
|
||||
<li>
|
||||
Insert it into the speaker's
|
||||
<strong>SERVICE</strong> port and reboot the
|
||||
speaker.
|
||||
</li>
|
||||
</ol>
|
||||
<strong>Verify connection:</strong>
|
||||
<strong>🔌 Speaker shell access</strong><br/>
|
||||
The wizard talks to the speaker over one of two transports.
|
||||
The <strong>Migration</strong> tab probes both automatically
|
||||
and uses whichever your device exposes — you don't have to
|
||||
choose manually.
|
||||
<ul style="margin-top: 5px; margin-bottom: 0; padding-left: 20px;">
|
||||
<li>
|
||||
Use the <strong>Migration</strong> tab to select
|
||||
your device and verify that
|
||||
<em>SSH Connection</em> shows ✅ Success.
|
||||
</li>
|
||||
<li>
|
||||
Or manually:
|
||||
<strong>SSH</strong> (richest option — required for
|
||||
the XML migration, the <code>/etc/resolv.conf</code>
|
||||
DNS hook, and installing the local CA). Enable it by
|
||||
creating an empty <code>remote_services</code> file
|
||||
on a USB stick, inserting it into the speaker's
|
||||
<strong>SERVICE</strong> port, and rebooting. Verify
|
||||
on the Migration tab — <em>SSH</em> in the state
|
||||
card's <em>Transports</em> row should show ✅
|
||||
Reachable. Manual check:
|
||||
<code
|
||||
>ssh -oHostKeyAlgorithms=+ssh-rsa
|
||||
root@<SPEAKER-IP></code
|
||||
>
|
||||
(no password).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Telnet (Port 17000)</strong> — the SSH-less
|
||||
fallback. Most SoundTouch firmware exposes a
|
||||
diagnostic shell on TCP/17000 automatically, no
|
||||
USB-stick setup required. Limited to HTTP migrations
|
||||
(no CA install possible without SSH). The state card
|
||||
surfaces this in the same <em>Transports</em> row.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ol class="guide-steps">
|
||||
@@ -90,7 +92,9 @@
|
||||
Domain" and "Proxy Domain" use an IP address or domain
|
||||
name that is
|
||||
<strong>accessible from your speakers</strong> (usually
|
||||
the IP of this server on your local network).
|
||||
the IP of this server on your local network). You can
|
||||
also edit the Target URL directly from the Migration tab
|
||||
with a <em>Save as default</em> button.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Discovery:</strong> Go to the
|
||||
@@ -107,10 +111,15 @@
|
||||
</li>
|
||||
<li>
|
||||
<strong>Migration:</strong> In the
|
||||
<strong>Migration</strong> tab, redirect your speaker to
|
||||
this local service. We recommend the
|
||||
<strong>XML Configuration</strong> method as it is
|
||||
surgical and easily reversible.
|
||||
<strong>Migration</strong> tab the wizard offers a
|
||||
one-click <strong>Apply Suggested Plan</strong> that
|
||||
picks the right recipe for your speaker (XML over SSH
|
||||
when SSH is available, telnet URL flip otherwise). For
|
||||
mix-and-match across the three independent axes — URL
|
||||
flip transport, DNS interception, CA install — expand
|
||||
<em>Customize this migration</em>. A visible pre-flight
|
||||
check runs before any backend operation touches the
|
||||
speaker.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Verification:</strong> After migration and
|
||||
@@ -468,6 +477,14 @@
|
||||
>
|
||||
<option value="">-- Select a device --</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
id="migration-refresh-btn"
|
||||
onclick="refreshSummary()"
|
||||
title="Reload summary for the selected device"
|
||||
aria-label="Reload summary"
|
||||
style="margin-left: 6px; padding: 2px 8px; font-size: 1em; line-height: 1; cursor: pointer"
|
||||
>↻</button>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
@@ -502,56 +519,109 @@
|
||||
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>
|
||||
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>
|
||||
<a
|
||||
href="/setup/ca.crt"
|
||||
download="soundtouch-ca.crt"
|
||||
style="margin-left: 10px; font-size: 0.85em"
|
||||
title="Download CA cert to import into other clients"
|
||||
>Download CA cert</a>
|
||||
</p>
|
||||
<p>Migration Status: <span id="migration-status"></span></p>
|
||||
|
||||
<div
|
||||
id="migration-state-card"
|
||||
style="margin: 10px 0 16px 0; padding: 12px; border: 1px solid #ddd; background: #fafafa; border-radius: 4px"
|
||||
>
|
||||
<div style="margin-bottom: 12px">
|
||||
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Transports</h4>
|
||||
<div style="display: flex; gap: 24px; flex-wrap: wrap; padding-left: 4px">
|
||||
<div>
|
||||
<strong>SSH:</strong> <span id="state-ssh"></span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Telnet (Port 17000):</strong> <span id="state-telnet"></span>
|
||||
<span id="state-telnet-banner" style="font-size: 0.85em; color: #666; margin-left: 4px"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="state-telnet-error"
|
||||
style="display: none; margin-top: 6px; padding: 4px 8px; background: #fff3e0; border-left: 3px solid #ef6c00; font-size: 0.85em; color: #5d4037"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 12px">
|
||||
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Migration State</h4>
|
||||
<table style="width: 100%; border-collapse: collapse">
|
||||
<tbody>
|
||||
<tr style="border-top: 1px solid #eee">
|
||||
<td style="padding: 6px 8px; width: 170px; vertical-align: top; color: #555">URL Configuration</td>
|
||||
<td id="state-url" style="padding: 6px 8px; vertical-align: top"></td>
|
||||
</tr>
|
||||
<tr style="border-top: 1px solid #eee">
|
||||
<td style="padding: 6px 8px; width: 170px; vertical-align: top; color: #555">DNS Interception</td>
|
||||
<td id="state-dns" style="padding: 6px 8px; vertical-align: top"></td>
|
||||
</tr>
|
||||
<tr style="border-top: 1px solid #eee">
|
||||
<td style="padding: 6px 8px; width: 170px; vertical-align: top; color: #555">CA / TLS</td>
|
||||
<td id="state-ca" style="padding: 6px 8px; vertical-align: top">
|
||||
<span id="state-ca-line"></span>
|
||||
<span style="margin-left: 12px; white-space: nowrap">
|
||||
<button
|
||||
id="trust-ca-btn"
|
||||
type="button"
|
||||
style="display: none; background-color: #607d8b; color: white; border: none; padding: 2px 8px; font-size: 0.85em"
|
||||
>Trust CA Now</button>
|
||||
<a
|
||||
href="/setup/ca.crt"
|
||||
download="soundtouch-ca.crt"
|
||||
style="margin-left: 6px; font-size: 0.85em"
|
||||
title="Download CA cert to import into other clients"
|
||||
>Download CA cert</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Preconditions</h4>
|
||||
<table style="width: 100%; border-collapse: collapse">
|
||||
<tbody>
|
||||
<tr style="border-top: 1px solid #eee">
|
||||
<td style="padding: 4px 8px; width: 170px; color: #555">remote_services</td>
|
||||
<td id="state-remote-services-cell" style="padding: 4px 8px"></td>
|
||||
</tr>
|
||||
<tr style="border-top: 1px solid #eee">
|
||||
<td style="padding: 4px 8px; color: #555">Account paired</td>
|
||||
<td id="state-paired" style="padding: 4px 8px"></td>
|
||||
</tr>
|
||||
<tr style="border-top: 1px solid #eee">
|
||||
<td style="padding: 4px 8px; color: #555">XML config backup</td>
|
||||
<td id="state-backup" style="padding: 4px 8px"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pre-flight panel: appears when the user clicks Apply,
|
||||
runs the configured checks live, then auto-proceeds on
|
||||
success or surfaces failures with override buttons. -->
|
||||
<div
|
||||
id="apply-preflight-panel"
|
||||
style="display: none; margin: 12px 0; padding: 12px; border: 1px solid #2196f3; background: #e3f2fd; border-radius: 4px"
|
||||
>
|
||||
<h4 style="margin: 0 0 8px 0">Pre-flight checks</h4>
|
||||
<ul
|
||||
id="apply-preflight-list"
|
||||
style="list-style: none; padding-left: 0; margin: 0; font-family: monospace; font-size: 0.9em"
|
||||
></ul>
|
||||
<div id="apply-preflight-summary" style="margin-top: 8px; font-weight: bold"></div>
|
||||
<div id="apply-preflight-actions" style="margin-top: 10px"></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="preflight-warnings"
|
||||
style="display: none; margin: 10px 0; padding: 8px 12px; background-color: #fff8e1; border-left: 4px solid #ffb300; font-size: 0.9em"
|
||||
>
|
||||
<strong>Cross-check warnings:</strong>
|
||||
<ul id="preflight-warnings-list" style="margin: 4px 0 0 1em; padding: 0"></ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="connection-test"
|
||||
@@ -612,54 +682,6 @@
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="hosts-redirection-test"
|
||||
style="
|
||||
margin: 15px 0;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
background-color: #fff4e6;
|
||||
display: none;
|
||||
"
|
||||
>
|
||||
<strong>Preliminary /etc/hosts Test:</strong><br/>
|
||||
<span style="font-size: 0.85em; color: #555"
|
||||
>Verify the device's /etc/hosts mechanism before
|
||||
full migration.</span
|
||||
>
|
||||
<div style="margin-top: 10px">
|
||||
Domain: <code>custom-test-api.bose.fake</code>
|
||||
</div>
|
||||
<div style="margin-top: 10px">
|
||||
<button
|
||||
id="test-hosts-btn"
|
||||
style="
|
||||
background-color: #ff9800;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
font-size: 0.9em;
|
||||
"
|
||||
>
|
||||
Test Hosts Redirection
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="hosts-test-result"
|
||||
style="
|
||||
margin-top: 10px;
|
||||
display: none;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.85em;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="dns-redirection-test"
|
||||
style="
|
||||
@@ -709,54 +731,257 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
style="
|
||||
margin: 15px 0;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
background-color: #f9f9f9;
|
||||
"
|
||||
id="migration-plan-card"
|
||||
style="margin: 16px 0; padding: 12px; border: 1px solid #ddd; background: #fafafa; border-radius: 4px"
|
||||
>
|
||||
<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="telnet">
|
||||
Telnet (Port 17000) - no SSH required
|
||||
</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>
|
||||
<h3 style="margin-top: 0">Plan</h3>
|
||||
|
||||
<div style="margin-bottom: 14px">
|
||||
<label for="plan-target-url" style="font-weight: bold">Target service URL:</label>
|
||||
<div style="margin-top: 4px">
|
||||
<input
|
||||
type="text"
|
||||
id="plan-target-url"
|
||||
oninput="onPlanTargetURLChange()"
|
||||
style="width: 320px; font-family: monospace"
|
||||
placeholder="http://192.168.x.x:8000"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
id="plan-save-default-btn"
|
||||
onclick="saveTargetURLAsDefault()"
|
||||
style="margin-left: 6px"
|
||||
>Save as default</button>
|
||||
</div>
|
||||
<div id="plan-target-saved" style="font-size: 0.85em; color: #666; margin-top: 4px"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 14px">
|
||||
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Capabilities</h4>
|
||||
<div style="font-size: 0.9em; line-height: 1.7">
|
||||
<div>This speaker exposes: <span id="plan-detected"></span></div>
|
||||
<div>AfterTouch can offer: <span id="plan-possible"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 14px">
|
||||
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Service URLs</h4>
|
||||
<p style="margin: 0 0 8px 0; font-size: 0.85em; color: #555">
|
||||
Pre-filled from the target URL above. Edit any field for advanced setups (e.g. soundcork users
|
||||
append <code>/marge</code> to <code>margeServerUrl</code>). These overrides apply to both XML
|
||||
and Telnet migrations.
|
||||
</p>
|
||||
<table style="width: 100%; border-collapse: collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align: left; padding: 4px 6px; font-size: 0.85em">Field</th>
|
||||
<th style="text-align: left; padding: 4px 6px; font-size: 0.85em">Current on Device</th>
|
||||
<th style="text-align: left; padding: 4px 6px; font-size: 0.85em">Target URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="padding: 4px 6px; font-family: monospace; font-size: 0.85em">margeServerUrl</td>
|
||||
<td id="plan-current-marge" style="padding: 4px 6px; font-family: monospace; font-size: 0.8em; color: #555">—</td>
|
||||
<td style="padding: 4px 6px">
|
||||
<input
|
||||
type="text"
|
||||
id="plan-marge-url"
|
||||
oninput="validatePlanURLs()"
|
||||
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 6px; font-family: monospace; font-size: 0.85em">statsServerUrl</td>
|
||||
<td id="plan-current-stats" style="padding: 4px 6px; font-family: monospace; font-size: 0.8em; color: #555">—</td>
|
||||
<td style="padding: 4px 6px">
|
||||
<input
|
||||
type="text"
|
||||
id="plan-stats-url"
|
||||
oninput="validatePlanURLs()"
|
||||
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 6px; font-family: monospace; font-size: 0.85em">swUpdateUrl</td>
|
||||
<td id="plan-current-sw_update" style="padding: 4px 6px; font-family: monospace; font-size: 0.8em; color: #555">—</td>
|
||||
<td style="padding: 4px 6px">
|
||||
<input
|
||||
type="text"
|
||||
id="plan-sw_update-url"
|
||||
oninput="validatePlanURLs()"
|
||||
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 6px; font-family: monospace; font-size: 0.85em">bmxRegistryUrl</td>
|
||||
<td id="plan-current-bmx" style="padding: 4px 6px; font-family: monospace; font-size: 0.8em; color: #555">—</td>
|
||||
<td style="padding: 4px 6px">
|
||||
<input
|
||||
type="text"
|
||||
id="plan-bmx-url"
|
||||
oninput="validatePlanURLs()"
|
||||
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="margin-top: 6px; font-size: 0.85em">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="plan-soundcork-mode"
|
||||
onchange="toggleSoundcorkMode()"
|
||||
/>
|
||||
Soundcork mode (append <code>/marge</code> to <code>margeServerUrl</code>)
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onclick="resetPlanURLsToDefaults()"
|
||||
style="margin-left: 16px; font-size: 0.85em"
|
||||
>Reset to defaults</button>
|
||||
</div>
|
||||
<div
|
||||
id="plan-url-validation"
|
||||
style="display: none; margin-top: 8px; padding: 6px 10px; background: #ffebee; border-left: 3px solid #c62828; font-size: 0.85em; color: #c62828"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 14px">
|
||||
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Account pairing</h4>
|
||||
<p id="plan-pair-current" style="margin: 0 0 6px 0; font-size: 0.85em; color: #555">—</p>
|
||||
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap">
|
||||
<label for="plan-pair-id">Account ID:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="plan-pair-id"
|
||||
maxlength="7"
|
||||
pattern="[0-9]{7}"
|
||||
placeholder="1234567"
|
||||
style="font-family: monospace; width: 8em"
|
||||
oninput="onPlanPairIDChange()"
|
||||
/>
|
||||
<button type="button" onclick="generatePlanAccountID()" style="font-size: 0.85em">Generate</button>
|
||||
<select id="plan-pair-existing" onchange="onPlanPairPick()" style="font-size: 0.85em">
|
||||
<option value="">— pick from datastore —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="plan-pair-status" style="margin-top: 4px; font-size: 0.85em; color: #666"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Suggested plan</h4>
|
||||
<div
|
||||
id="plan-suggestion"
|
||||
style="border: 1px solid #c8e6c9; background: #f1f8e9; padding: 10px 12px; border-radius: 3px"
|
||||
>
|
||||
<div id="plan-suggestion-summary" style="font-weight: bold; margin-bottom: 4px"></div>
|
||||
<ul id="plan-suggestion-steps" style="margin: 4px 0 8px 1.2em; padding: 0; font-size: 0.9em"></ul>
|
||||
<button
|
||||
type="button"
|
||||
id="plan-preflight-btn"
|
||||
onclick="preflightSuggestedPlan()"
|
||||
title="Run the same pre-flight checks Apply runs, without proceeding to migrate"
|
||||
style="font-size: 0.95em; margin-right: 6px"
|
||||
>Pre-flight</button>
|
||||
<button
|
||||
type="button"
|
||||
id="plan-apply-btn"
|
||||
onclick="applySuggestedPlan()"
|
||||
style="font-size: 0.95em"
|
||||
>Apply Suggested Plan</button>
|
||||
<span id="plan-apply-status" style="margin-left: 10px; font-size: 0.85em"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details style="margin: 16px 0">
|
||||
<summary style="cursor: pointer; font-weight: bold">Customize this migration</summary>
|
||||
|
||||
<div
|
||||
id="current-resolv-pane"
|
||||
style="display: none; margin-bottom: 20px"
|
||||
id="customize-form"
|
||||
style="margin: 15px 0; padding: 12px; border: 1px solid #ddd; background-color: #f9f9f9; border-radius: 4px"
|
||||
>
|
||||
<span class="config-header"
|
||||
>Current /etc/resolv.conf</span
|
||||
>
|
||||
<pre id="current-resolv-content"></pre>
|
||||
<p style="margin: 0 0 12px 0; font-size: 0.9em; color: #555">
|
||||
Pick any combination of the three axes — the wizard runs
|
||||
the matching backend operations in order. Disabled options
|
||||
require a transport this speaker doesn't expose.
|
||||
</p>
|
||||
|
||||
<fieldset style="margin: 0 0 10px 0; padding: 8px 12px; border: 1px solid #ddd">
|
||||
<legend style="padding: 0 6px; font-weight: bold">URL flip transport</legend>
|
||||
<label style="display: block; margin: 2px 0">
|
||||
<input type="radio" name="customize-url-flip" value="xml" checked
|
||||
onchange="onCustomizeChange()"/>
|
||||
XML over SSH
|
||||
<span class="customize-hint" data-axis="xml" style="margin-left: 6px; font-size: 0.85em; color: #c62828"></span>
|
||||
</label>
|
||||
<label style="display: block; margin: 2px 0">
|
||||
<input type="radio" name="customize-url-flip" value="telnet"
|
||||
onchange="onCustomizeChange()"/>
|
||||
Telnet (Port 17000)
|
||||
<span class="customize-hint" data-axis="telnet" style="margin-left: 6px; font-size: 0.85em; color: #c62828"></span>
|
||||
</label>
|
||||
<label style="display: block; margin: 2px 0">
|
||||
<input type="radio" name="customize-url-flip" value="none"
|
||||
onchange="onCustomizeChange()"/>
|
||||
Skip — leave URLs at the Bose cloud (DNS interception will redirect them instead)
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset style="margin: 0 0 10px 0; padding: 8px 12px; border: 1px solid #ddd">
|
||||
<legend style="padding: 0 6px; font-weight: bold">DNS interception</legend>
|
||||
<label style="display: block; margin: 2px 0">
|
||||
<input type="radio" name="customize-dns" value="none" checked
|
||||
onchange="onCustomizeChange()"/>
|
||||
None
|
||||
</label>
|
||||
<label style="display: block; margin: 2px 0">
|
||||
<input type="radio" name="customize-dns" value="resolv"
|
||||
onchange="onCustomizeChange()"/>
|
||||
<code>/etc/resolv.conf</code> hook (also installs the local CA — needed when URLs stay at <code>https://*.bose.com</code>)
|
||||
<span class="customize-hint" data-axis="resolv" style="margin-left: 6px; font-size: 0.85em; color: #c62828"></span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset style="margin: 0 0 10px 0; padding: 8px 12px; border: 1px solid #ddd">
|
||||
<legend style="padding: 0 6px; font-weight: bold">Local CA install</legend>
|
||||
<label style="display: block; margin: 2px 0">
|
||||
<input type="checkbox" id="customize-ca-install"
|
||||
onchange="onCustomizeChange()"/>
|
||||
Install local root CA on the device via SSH (only needed when targeting <code>https://</code>)
|
||||
<span class="customize-hint" data-axis="ca" style="margin-left: 6px; font-size: 0.85em; color: #c62828"></span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div
|
||||
id="customize-validation"
|
||||
style="display: none; margin: 8px 0; padding: 6px 10px; background: #ffebee; border-left: 3px solid #c62828; font-size: 0.85em; color: #c62828"
|
||||
></div>
|
||||
|
||||
<div style="margin-top: 12px">
|
||||
<button
|
||||
type="button"
|
||||
id="customize-preflight-btn"
|
||||
onclick="preflightCustomPlan()"
|
||||
title="Run the same pre-flight checks Apply runs, without proceeding to migrate"
|
||||
style="padding: 8px 14px; font-size: 0.95em; margin-right: 6px"
|
||||
>Pre-flight</button>
|
||||
<button
|
||||
type="button"
|
||||
id="customize-apply-btn"
|
||||
onclick="applyCustomPlan()"
|
||||
style="background-color: #4caf50; color: white; border: none; padding: 8px 14px; font-size: 0.95em"
|
||||
>Apply Custom Plan</button>
|
||||
<span id="customize-apply-status" style="margin-left: 10px; font-size: 0.9em"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div
|
||||
id="telnet-method-pane"
|
||||
style="display: none; margin-bottom: 20px; padding: 10px; border: 1px solid #ddd; background-color: #f0f7ff;"
|
||||
@@ -772,173 +997,11 @@
|
||||
install a custom CA. If you need end-to-end TLS, use the
|
||||
XML or DNS method instead.
|
||||
</p>
|
||||
<p style="margin: 5px 0; font-size: 0.9em; color: #555">
|
||||
After a successful migration a <em>Pair Account</em>
|
||||
panel will appear below this one — use it to associate
|
||||
the speaker with an account ID before presets and
|
||||
streaming work.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="pair-account-pane"
|
||||
style="display: none; margin-bottom: 20px; padding: 10px; border: 1px solid #ddd; background-color: #fff8e1;"
|
||||
>
|
||||
<h4 style="margin-top: 0">Pair Account</h4>
|
||||
<p
|
||||
id="pair-account-current"
|
||||
style="margin: 5px 0; display: none"
|
||||
></p>
|
||||
<div id="pair-account-fresh" style="display: none">
|
||||
<p style="margin: 5px 0">
|
||||
This speaker has no margeAccountUUID set
|
||||
(factory-reset or never paired). Choose an account
|
||||
ID to attach it to:
|
||||
</p>
|
||||
<div style="margin: 8px 0">
|
||||
<label for="pair-account-existing"
|
||||
>Existing account:</label
|
||||
>
|
||||
<select id="pair-account-existing">
|
||||
<option value="">-- pick from datastore --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="margin: 8px 0">
|
||||
<label for="pair-account-input">7-digit ID:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="pair-account-input"
|
||||
maxlength="7"
|
||||
pattern="[0-9]{7}"
|
||||
placeholder="1234567"
|
||||
style="font-family: monospace; width: 8em"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick="generateAccountID()"
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
id="pair-account-btn"
|
||||
type="button"
|
||||
style="margin-top: 8px"
|
||||
>
|
||||
Pair Account
|
||||
</button>
|
||||
<div
|
||||
id="pair-account-status"
|
||||
style="margin-top: 8px; font-size: 0.9em"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="original-config-pane"
|
||||
style="display: none; margin-bottom: 20px"
|
||||
>
|
||||
<span class="config-header"
|
||||
>Original Config (Backup)</span
|
||||
>
|
||||
<pre id="original-config-content"></pre>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="service-options"
|
||||
style="margin-bottom: 20px; display: none"
|
||||
>
|
||||
<h4>Service Implementations</h4>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Service</th>
|
||||
<th>Original URL</th>
|
||||
<th>Implementation</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Marge (Streaming)</td>
|
||||
<td id="orig-marge">loading...</td>
|
||||
<td>
|
||||
<select
|
||||
id="opt-marge"
|
||||
onchange="refreshSummary()"
|
||||
>
|
||||
<option value="self">
|
||||
AfterTouch (Local Service)
|
||||
</option>
|
||||
<option value="proxied">
|
||||
Proxied (via local service)
|
||||
</option>
|
||||
<option value="original">
|
||||
Original (keep Bose URL)
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stats</td>
|
||||
<td id="orig-stats">loading...</td>
|
||||
<td>
|
||||
<select
|
||||
id="opt-stats"
|
||||
onchange="refreshSummary()"
|
||||
>
|
||||
<option value="self">
|
||||
AfterTouch (Local Service)
|
||||
</option>
|
||||
<option value="proxied">
|
||||
Proxied (via local service)
|
||||
</option>
|
||||
<option value="original">
|
||||
Original (keep Bose URL)
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Software Update</td>
|
||||
<td id="orig-sw_update">loading...</td>
|
||||
<td>
|
||||
<select
|
||||
id="opt-sw_update"
|
||||
onchange="refreshSummary()"
|
||||
>
|
||||
<option value="self">
|
||||
AfterTouch (Local Service)
|
||||
</option>
|
||||
<option value="proxied">
|
||||
Proxied (via local service)
|
||||
</option>
|
||||
<option value="original">
|
||||
Original (keep Bose URL)
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>BMX (Registry)</td>
|
||||
<td id="orig-bmx">loading...</td>
|
||||
<td>
|
||||
<select
|
||||
id="opt-bmx"
|
||||
onchange="refreshSummary()"
|
||||
>
|
||||
<option value="self">
|
||||
AfterTouch (Local Service)
|
||||
</option>
|
||||
<option value="proxied">
|
||||
Proxied (via local service)
|
||||
</option>
|
||||
<option value="original">
|
||||
Original (keep Bose URL)
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="diff-container">
|
||||
<!-- XML diff pair: Current Config | Planned Config. Shown
|
||||
when URL flip = xml in the Customize form. -->
|
||||
<div class="diff-container" id="xml-diff-row" style="display: none">
|
||||
<div id="xml-diff-pane" class="diff-pane">
|
||||
<span class="config-header"
|
||||
>Current Config (on Speaker)</span
|
||||
@@ -964,33 +1027,18 @@
|
||||
<a href="https://github.com/gesellix/bose-soundtouch/blob/main/docs/guides/TROUBLESHOOTING.md#hostname-resolution" target="_blank" style="color: #856404;">Learn more →</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="planned-hosts-pane"
|
||||
class="diff-pane"
|
||||
style="display: none"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Resolv diff pair: Current /etc/resolv.conf | Planned hook.
|
||||
Shown when DNS = resolv in the Customize form. -->
|
||||
<div class="diff-container" id="resolv-diff-row" style="display: none; margin-top: 12px">
|
||||
<div id="current-resolv-pane" class="diff-pane">
|
||||
<span class="config-header"
|
||||
>Planned /etc/hosts Entries</span
|
||||
>Current /etc/resolv.conf</span
|
||||
>
|
||||
<pre id="planned-hosts"></pre>
|
||||
<div
|
||||
style="
|
||||
margin-top: 10px;
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
"
|
||||
>
|
||||
<strong>Note:</strong> This method also injects
|
||||
the AfterTouch Local Root CA into
|
||||
<code>/etc/pki/tls/certs/ca-bundle.crt</code> to
|
||||
enable secure HTTPS communication.
|
||||
</div>
|
||||
<pre id="current-resolv-content"></pre>
|
||||
</div>
|
||||
<div
|
||||
id="planned-resolv-pane"
|
||||
class="diff-pane"
|
||||
style="display: none"
|
||||
>
|
||||
<div id="planned-resolv-pane" class="diff-pane">
|
||||
<span class="config-header"
|
||||
>Planned /etc/resolv.conf Hook</span
|
||||
>
|
||||
@@ -1013,17 +1061,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 15px">
|
||||
<button
|
||||
id="confirm-migrate-btn"
|
||||
style="
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
"
|
||||
>
|
||||
Confirm Migration
|
||||
</button>
|
||||
<button
|
||||
id="revert-migrate-btn"
|
||||
style="
|
||||
@@ -1080,6 +1117,7 @@
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -297,7 +297,7 @@ func mapPresetToParityXML(p models.ServicePreset, sources []models.ConfiguredSou
|
||||
func AccountPresetsToXML(ds *datastore.DataStore, account string) ([]byte, error) {
|
||||
accountDir := ds.AccountDevicesDir(account)
|
||||
|
||||
entries, err := os.ReadDir(accountDir)
|
||||
entries, err := ds.ReadDirUnderBase(accountDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []byte(constants.XMLHeader + "\n<presets/>"), nil
|
||||
@@ -759,9 +759,37 @@ func mapToFullResponseSource(s models.ConfiguredSource) models.FullResponseSourc
|
||||
fullSource.Username = s.SourceKeyAccount
|
||||
}
|
||||
|
||||
// SourceProviderID is a required protobuf field inside recents/preset
|
||||
// source blocks. A persisted source that lost its SourceKey.Type (e.g.
|
||||
// poisoned by an older "INVALID" classification) lands here with an
|
||||
// empty value, so fall back to the canonical default whose ID matches.
|
||||
if fullSource.SourceProviderID == "" && s.ID != "" {
|
||||
if def := canonicalProviderIDByID(s.ID); def != "" {
|
||||
fullSource.SourceProviderID = def
|
||||
}
|
||||
}
|
||||
|
||||
return fullSource
|
||||
}
|
||||
|
||||
// canonicalProviderIDByID returns the canonical SourceProviderID for one of
|
||||
// the well-known built-in source IDs (10001..10005), or "" if the ID isn't
|
||||
// recognised.
|
||||
func canonicalProviderIDByID(id string) string {
|
||||
switch id {
|
||||
case "10002":
|
||||
return strconv.Itoa(constants.InternetRadioProviderID)
|
||||
case "10003":
|
||||
return strconv.Itoa(constants.LocalInternetRadioProviderID)
|
||||
case "10004":
|
||||
return strconv.Itoa(constants.TuneinProviderID)
|
||||
case "10005":
|
||||
return strconv.Itoa(constants.RadioBrowserProviderID)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func mapPresetsToFullResponse(presets []models.ServicePreset, sources []models.ConfiguredSource) []models.FullResponsePreset {
|
||||
var fullPresets []models.FullResponsePreset
|
||||
|
||||
@@ -1039,7 +1067,7 @@ func mergeDefaultSources(stored, defaults []models.ConfiguredSource) []models.Co
|
||||
func AccountSourcesToXML(ds *datastore.DataStore, account string) ([]byte, error) {
|
||||
devicesDir := ds.AccountDevicesDir(account)
|
||||
|
||||
entries, err := os.ReadDir(devicesDir)
|
||||
entries, err := ds.ReadDirUnderBase(devicesDir)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1065,7 +1093,7 @@ func AccountSourcesToXML(ds *datastore.DataStore, account string) ([]byte, error
|
||||
func AccountDevicesToXML(ds *datastore.DataStore, account string) ([]byte, error) {
|
||||
devicesDir := ds.AccountDevicesDir(account)
|
||||
|
||||
entries, err := os.ReadDir(devicesDir)
|
||||
entries, err := ds.ReadDirUnderBase(devicesDir)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1121,7 +1149,7 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
|
||||
fillDefaultProviderSettings(account, &resp)
|
||||
fillAccountInfo(ds, account, &resp)
|
||||
|
||||
entries, err := os.ReadDir(devicesDir)
|
||||
entries, err := ds.ReadDirUnderBase(devicesDir)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1135,10 +1163,13 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parity: use self-closing tags for empty components and sourceSettings
|
||||
// Parity: use self-closing tags for empty components and sourceSettings.
|
||||
// NOTE: do NOT strip empty <sourceproviderid> elements here — the speaker
|
||||
// decodes /full into a protobuf message where recents>recent>source>
|
||||
// sourceproviderid is a *required* field, so removing even an empty element
|
||||
// trips "missing required field" and aborts the whole account sync.
|
||||
data = bytes.ReplaceAll(data, []byte("<components></components>"), []byte("<components/>"))
|
||||
data = bytes.ReplaceAll(data, []byte("<sourceSettings></sourceSettings>"), []byte("<sourceSettings/>"))
|
||||
data = bytes.ReplaceAll(data, []byte("<sourceproviderid></sourceproviderid>"), []byte(""))
|
||||
|
||||
return append([]byte(constants.XMLHeader), data...), nil
|
||||
}
|
||||
@@ -1482,16 +1513,18 @@ func classifyLearnedSource(src *models.ConfiguredSource, sourceID, location, sou
|
||||
switch {
|
||||
case sourceProviderID == strconv.Itoa(constants.TuneinProviderID) || sourceID == constants.ProviderTunein || strings.Contains(location, "/v1/playback/station/"):
|
||||
classifyAsTuneIn(src)
|
||||
case sourceID == constants.ProviderLocalInternetRadio:
|
||||
case sourceProviderID == strconv.Itoa(constants.LocalInternetRadioProviderID) || sourceID == constants.ProviderLocalInternetRadio || strings.Contains(location, "/custom/v1/playback/"):
|
||||
classifyAsLocalInternetRadio(src)
|
||||
case strings.Contains(location, "spotify") || strings.Contains(location, "c3BvdGlme") || sourceID == constants.ProviderSpotify:
|
||||
classifyAsSpotify(src)
|
||||
case strings.Contains(location, "amazon") || sourceID == constants.ProviderAmazon || sourceProviderID == strconv.Itoa(constants.AmazonProviderID):
|
||||
classifyAsAmazon(src)
|
||||
default:
|
||||
src.SourceKey.Type = "INVALID"
|
||||
src.SourceKeyType = "INVALID"
|
||||
}
|
||||
// If we can't classify, leave SourceKey.Type empty so the canonical-by-ID
|
||||
// fallback in mapToFullResponseSource and the read-side applyCanonicalDefaults
|
||||
// still have a chance to repair it. Writing a literal "INVALID" used to lock
|
||||
// the source out of every repair path, producing a <source> block with no
|
||||
// <sourceproviderid> and breaking the speaker's protobuf required-field check.
|
||||
}
|
||||
|
||||
func classifyAsTuneIn(src *models.ConfiguredSource) {
|
||||
@@ -1867,7 +1900,7 @@ func AddSource(ds *datastore.DataStore, account, username, providerID, secret, s
|
||||
|
||||
// List accounts directly from the account directory to be sure we find them.
|
||||
devicesDir := ds.AccountDevicesDir(account)
|
||||
entries, _ := os.ReadDir(devicesDir)
|
||||
entries, _ := ds.ReadDirUnderBase(devicesDir)
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package marge
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// TestAccountFullToXML_RecentWithPoisonedSourceProviderID is a regression test
|
||||
// for the production failure where the speaker's BoseApp rejected the
|
||||
// /streaming/account/.../full response with:
|
||||
//
|
||||
// protobuf::FatalException - CHECK failed: IsInitialized():
|
||||
// Message of type "MargePB.account" is missing required fields:
|
||||
// devices.device[1].recents.recent[0].source.sourceproviderid
|
||||
//
|
||||
// Trigger sequence reproduced here:
|
||||
//
|
||||
// 1. The device POSTs a "laut.fm" recent (location "/custom/v1/playback/...")
|
||||
// against an account that has no Sources.xml yet.
|
||||
// 2. classifyLearnedSource fails to recognise /custom/v1/playback/ and the
|
||||
// numeric source id "10003", and historically wrote sourceKey type="INVALID"
|
||||
// with an empty sourceproviderid.
|
||||
// 3. The persisted Sources.xml then re-appears in /full with an empty
|
||||
// <sourceproviderid> element inside recents>recent>source, which the
|
||||
// post-marshal cleanup stripped entirely — making the speaker's protobuf
|
||||
// decode fail on a required field.
|
||||
//
|
||||
// The fix combines three things, all exercised below:
|
||||
//
|
||||
// - classifyLearnedSource recognises LocalInternetRadio via the
|
||||
// /custom/v1/playback/ URL pattern and via sourceProviderID == "11".
|
||||
// - mapToFullResponseSource falls back to the canonical SourceProviderID
|
||||
// keyed by source ID, so already-poisoned data on disk still renders a
|
||||
// non-empty providerid.
|
||||
// - AccountFullToXML no longer strips empty <sourceproviderid> elements
|
||||
// inside recents/preset source blocks.
|
||||
func TestAccountFullToXML_RecentWithPoisonedSourceProviderID(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "marge-recent-provid-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
account := "1234567"
|
||||
device := "ABCDEF012345"
|
||||
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create device dir: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="ABCDEF012345">
|
||||
<name>Kitchen</name>
|
||||
<type>SoundTouch</type>
|
||||
<moduleType>10 sm2</moduleType>
|
||||
</info>`), 0644); err != nil {
|
||||
t.Fatalf("Failed to write DeviceInfo.xml: %v", err)
|
||||
}
|
||||
|
||||
// Sources.xml reproduces the poisoned entry observed in the user's
|
||||
// backup (May 11): id="10003" with sourceKey type="INVALID" and no
|
||||
// sourceproviderid attribute. Older repair paths (applyCanonicalDefaults,
|
||||
// ensureSourceProviderID) all key off sourceKey.type, so the entry stays
|
||||
// broken at load time.
|
||||
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sources>
|
||||
<source id="10003" secret="" secretType="">
|
||||
<credential type=""></credential>
|
||||
<sourceKey type="INVALID" account=""></sourceKey>
|
||||
</source>
|
||||
</sources>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644); err != nil {
|
||||
t.Fatalf("Failed to write Sources.xml: %v", err)
|
||||
}
|
||||
|
||||
// Recents.xml references the poisoned source via <sourceid>10003</sourceid>.
|
||||
// The location is a laut.fm stream proxied through /custom/v1/playback/ —
|
||||
// exactly the URL pattern the old classifier failed to recognise.
|
||||
recentsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<recents>
|
||||
<recent deviceID="ABCDEF012345" utcTime="1778014606" id="260505002">
|
||||
<contentItem source="INVALID" type="stationurl" location="http://192.168.123.123/custom/v1/playback/aHR0cHM6Ly9zdHJlYW0ubGF1dC5mbS9zbW9vdGgtamF6eg==" sourceAccount="" isPresetable="true">
|
||||
<itemName>Smooth Jazz Instrumental 24/7</itemName>
|
||||
</contentItem>
|
||||
<createdOn>2026-05-05T20:56:49.305+00:00</createdOn>
|
||||
<updatedOn>2026-05-05T20:56:49.305+00:00</updatedOn>
|
||||
<sourceid>10003</sourceid>
|
||||
</recent>
|
||||
</recents>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(recentsXML), 0644); err != nil {
|
||||
t.Fatalf("Failed to write Recents.xml: %v", err)
|
||||
}
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
fullXML, err := AccountFullToXML(ds, account)
|
||||
if err != nil {
|
||||
t.Fatalf("AccountFullToXML failed: %v", err)
|
||||
}
|
||||
|
||||
body := string(fullXML)
|
||||
|
||||
// Locate the recents block and assert every <source> inside it carries a
|
||||
// non-empty <sourceproviderid>. Without the fix, the post-marshal
|
||||
// strip-empty step deletes the empty element and the speaker rejects
|
||||
// the message with "missing required field".
|
||||
recentsRE := regexp.MustCompile(`(?s)<recents>(.*?)</recents>`)
|
||||
matches := recentsRE.FindAllStringSubmatch(body, -1)
|
||||
if len(matches) == 0 {
|
||||
t.Fatalf("Expected at least one <recents> block; body:\n%s", body)
|
||||
}
|
||||
|
||||
sourceInRecentRE := regexp.MustCompile(`(?s)<source(?:\s[^>]*)?>(.*?)</source>`)
|
||||
|
||||
for _, recentsBlock := range matches {
|
||||
for _, src := range sourceInRecentRE.FindAllStringSubmatch(recentsBlock[1], -1) {
|
||||
inner := src[1]
|
||||
if !strings.Contains(inner, "<sourceproviderid>") {
|
||||
t.Errorf("<source> inside <recents> has no <sourceproviderid> element; block:\n%s", src[0])
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(inner, "<sourceproviderid></sourceproviderid>") {
|
||||
t.Errorf("<source> inside <recents> has empty <sourceproviderid>; block:\n%s", src[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And spot-check the canonical fallback fired for the laut.fm recent.
|
||||
if !strings.Contains(body, "<sourceproviderid>11</sourceproviderid>") {
|
||||
t.Errorf("Expected <sourceproviderid>11</sourceproviderid> (LocalInternetRadio) in /full; body:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyLearnedSource_LocalInternetRadioCustomPlayback locks in the
|
||||
// classifier behaviour: a recent POSTed with a /custom/v1/playback/ URL must
|
||||
// classify as LocalInternetRadio. Previously this fell into the "INVALID"
|
||||
// default and poisoned Sources.xml — see the regression test above.
|
||||
func TestClassifyLearnedSource_LocalInternetRadioCustomPlayback(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
sourceID string
|
||||
location string
|
||||
sourceProviderID string
|
||||
}{
|
||||
{
|
||||
name: "laut.fm /custom/v1/playback URL",
|
||||
sourceID: "10003",
|
||||
location: "http://192.168.123.123/custom/v1/playback/aHR0cHM6Ly9zdHJlYW0ubGF1dC5mbS9zbW9vdGgtamF6eg==",
|
||||
},
|
||||
{
|
||||
name: "sourceProviderID==11 alone",
|
||||
sourceID: "999999",
|
||||
location: "http://example.invalid/whatever",
|
||||
sourceProviderID: "11",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
src := createLearnedSource(tc.sourceID, tc.location, "", "", tc.sourceProviderID, "", "")
|
||||
|
||||
if src.SourceKey.Type == "INVALID" || src.SourceKeyType == "INVALID" {
|
||||
t.Errorf("classifier wrote INVALID for %s; src=%+v", tc.name, src)
|
||||
}
|
||||
|
||||
if src.SourceKey.Type != "LOCAL_INTERNET_RADIO" {
|
||||
t.Errorf("expected SourceKey.Type=LOCAL_INTERNET_RADIO, got %q", src.SourceKey.Type)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyLearnedSource_UnknownLeavesKeyEmpty verifies the new default
|
||||
// branch leaves SourceKey.Type empty instead of writing the literal "INVALID"
|
||||
// sentinel that locks the source out of every downstream repair path.
|
||||
func TestClassifyLearnedSource_UnknownLeavesKeyEmpty(t *testing.T) {
|
||||
src := createLearnedSource("SOMETHING_UNKNOWN", "http://example.invalid/nothing", "", "", "", "", "")
|
||||
|
||||
if src.SourceKey.Type == "INVALID" || src.SourceKeyType == "INVALID" {
|
||||
t.Errorf("classifier still writes INVALID sentinel; src=%+v", src)
|
||||
}
|
||||
|
||||
if src.SourceKey.Type != "" {
|
||||
t.Errorf("expected SourceKey.Type empty for an unrecognised source, got %q", src.SourceKey.Type)
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,22 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
var sensitiveHeaders = []string{
|
||||
// alwaysSensitiveHeaders are stripped from log output unconditionally — they
|
||||
// carry credentials whose plaintext value should never appear in a log line
|
||||
// regardless of how the LoggingProxy was constructed.
|
||||
var alwaysSensitiveHeaders = []string{
|
||||
"Authorization",
|
||||
"Proxy-Authorization",
|
||||
"Cookie",
|
||||
"Set-Cookie",
|
||||
"X-Api-Key",
|
||||
"X-Bose-Token",
|
||||
}
|
||||
|
||||
// sensitiveHeaders is kept for backwards compatibility with callers that
|
||||
// reference it by name; it now mirrors alwaysSensitiveHeaders.
|
||||
var sensitiveHeaders = alwaysSensitiveHeaders
|
||||
|
||||
// LoggingProxy wraps a ReverseProxy to provide instrumentation.
|
||||
type LoggingProxy struct {
|
||||
Proxy *httputil.ReverseProxy
|
||||
@@ -26,15 +36,25 @@ type LoggingProxy struct {
|
||||
RecordEnabled bool
|
||||
MaxBodySize int64
|
||||
Recorder *Recorder
|
||||
|
||||
// UnsafeLogCredentialHeaders disables the otherwise-unconditional
|
||||
// redaction of credential-bearing headers (Authorization, Cookie, …) in
|
||||
// LogRequest / LogResponse output. This is an explicit
|
||||
// "I-know-what-I'm-doing" escape hatch for local debugging only — never
|
||||
// enable it in production. Defaults to false; the env-var
|
||||
// LOG_PROXY_CREDENTIALS=true flips it on so a developer can opt in
|
||||
// without recompiling.
|
||||
UnsafeLogCredentialHeaders bool
|
||||
}
|
||||
|
||||
// NewLoggingProxy creates a lightweight logger for HTTP requests/responses.
|
||||
func NewLoggingProxy(_ string, redact bool) *LoggingProxy {
|
||||
// targetURL logic should be handled by the caller or we can parse it here
|
||||
return &LoggingProxy{
|
||||
Redact: redact,
|
||||
LogBody: os.Getenv("LOG_PROXY_BODY") == "true",
|
||||
MaxBodySize: 1024 * 10, // 10KB default limit for logging
|
||||
Redact: redact,
|
||||
LogBody: os.Getenv("LOG_PROXY_BODY") == "true",
|
||||
UnsafeLogCredentialHeaders: os.Getenv("LOG_PROXY_CREDENTIALS") == "true",
|
||||
MaxBodySize: 1024 * 10, // 10KB default limit for logging
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +65,7 @@ func (lp *LoggingProxy) SetRecorder(r *Recorder) {
|
||||
|
||||
// LogRequest prints an abbreviated request with optional header/body redaction.
|
||||
func (lp *LoggingProxy) LogRequest(r *http.Request) {
|
||||
headers := formatHeaders(r.Header, lp.Redact)
|
||||
headers := formatHeaders(r.Header, lp.Redact, lp.UnsafeLogCredentialHeaders)
|
||||
|
||||
bodyStr := "[HIDDEN]"
|
||||
|
||||
@@ -69,7 +89,7 @@ func (lp *LoggingProxy) LogRequest(r *http.Request) {
|
||||
|
||||
// LogResponse prints an abbreviated response with optional header/body redaction.
|
||||
func (lp *LoggingProxy) LogResponse(r *http.Response) {
|
||||
headers := formatHeaders(r.Header, lp.Redact)
|
||||
headers := formatHeaders(r.Header, lp.Redact, lp.UnsafeLogCredentialHeaders)
|
||||
|
||||
bodyStr := "[HIDDEN]"
|
||||
|
||||
@@ -95,14 +115,23 @@ func (lp *LoggingProxy) LogResponse(r *http.Response) {
|
||||
}
|
||||
}
|
||||
|
||||
func formatHeaders(h http.Header, redact bool) string {
|
||||
func formatHeaders(h http.Header, redact, unsafeLogCredentials bool) string {
|
||||
var sb strings.Builder
|
||||
// In Go, http.Header is a map[string][]string.
|
||||
// Iterating over the map directly allows us to see the actual keys
|
||||
// stored in the map, which might not be canonical if set directly.
|
||||
for k, vv := range h {
|
||||
val := strings.Join(vv, ", ")
|
||||
if redact && isSensitive(k) {
|
||||
// Credentials (Authorization, Cookie, …) are redacted by default.
|
||||
// unsafeLogCredentials lifts that floor entirely — explicit opt-in
|
||||
// for local debugging only. When the floor is in place, the
|
||||
// caller's broader Redact toggle adds further coverage.
|
||||
switch {
|
||||
case unsafeLogCredentials:
|
||||
// No redaction.
|
||||
case isAlwaysSensitive(k):
|
||||
val = "[REDACTED]"
|
||||
case redact && isSensitive(k):
|
||||
val = "[REDACTED]"
|
||||
}
|
||||
|
||||
@@ -112,6 +141,18 @@ func formatHeaders(h http.Header, redact bool) string {
|
||||
return strings.TrimSuffix(sb.String(), "\n")
|
||||
}
|
||||
|
||||
// isAlwaysSensitive returns true for credential-bearing headers that must
|
||||
// never appear unredacted in logs regardless of caller configuration.
|
||||
func isAlwaysSensitive(header string) bool {
|
||||
for _, h := range alwaysSensitiveHeaders {
|
||||
if strings.EqualFold(h, header) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isSensitive(header string) bool {
|
||||
for _, h := range sensitiveHeaders {
|
||||
if strings.EqualFold(h, header) {
|
||||
|
||||
@@ -29,6 +29,13 @@ type Recorder struct {
|
||||
variables map[string]string
|
||||
mu sync.Mutex
|
||||
queue chan recordingTask
|
||||
|
||||
// rootMu guards lazy initialisation of root.
|
||||
rootMu sync.Mutex
|
||||
// root is an os.Root anchored at BaseDir; all filesystem operations
|
||||
// that take a caller-derivable path go through it so the Go runtime
|
||||
// guarantees containment regardless of what the path string contains.
|
||||
root *os.Root
|
||||
}
|
||||
|
||||
type recordingTask struct {
|
||||
@@ -90,6 +97,191 @@ func (r *Recorder) Close() {
|
||||
close(r.queue)
|
||||
// We might want to wait here, but for now just closing is a start
|
||||
}
|
||||
|
||||
r.rootMu.Lock()
|
||||
defer r.rootMu.Unlock()
|
||||
|
||||
if r.root != nil {
|
||||
_ = r.root.Close()
|
||||
r.root = nil
|
||||
}
|
||||
}
|
||||
|
||||
// getRoot lazily opens the *os.Root anchored at r.BaseDir. The directory is
|
||||
// MkdirAll-created on first call.
|
||||
func (r *Recorder) getRoot() (*os.Root, error) {
|
||||
r.rootMu.Lock()
|
||||
defer r.rootMu.Unlock()
|
||||
|
||||
if r.root != nil {
|
||||
return r.root, nil
|
||||
}
|
||||
|
||||
if r.BaseDir == "" {
|
||||
return nil, fmt.Errorf("recorder: BaseDir not configured")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(r.BaseDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("recorder: ensure BaseDir %s: %w", r.BaseDir, err)
|
||||
}
|
||||
|
||||
root, err := os.OpenRoot(r.BaseDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recorder: open root at %s: %w", r.BaseDir, err)
|
||||
}
|
||||
|
||||
r.root = root
|
||||
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// rootRel converts an absolute path under r.BaseDir to its root-relative form.
|
||||
func (r *Recorder) rootRel(absPath string) (string, error) {
|
||||
if !filepath.IsAbs(absPath) {
|
||||
a, err := filepath.Abs(absPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
absPath = a
|
||||
}
|
||||
|
||||
if absPath == r.BaseDir {
|
||||
return ".", nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(r.BaseDir, absPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("recorder: %s outside BaseDir: %w", absPath, err)
|
||||
}
|
||||
|
||||
if rel == "." || rel == "" {
|
||||
return ".", nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(rel, "..") {
|
||||
return "", fmt.Errorf("recorder: %s outside BaseDir", absPath)
|
||||
}
|
||||
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
func (r *Recorder) rootMkdirAll(absPath string, perm os.FileMode) error {
|
||||
root, err := r.getRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := r.rootRel(absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
return root.MkdirAll(rel, perm)
|
||||
}
|
||||
|
||||
func (r *Recorder) rootWriteFile(absPath string, data []byte, perm os.FileMode) error {
|
||||
root, err := r.getRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := r.rootRel(absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return root.WriteFile(rel, data, perm)
|
||||
}
|
||||
|
||||
func (r *Recorder) rootReadFile(absPath string) ([]byte, error) {
|
||||
root, err := r.getRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rel, err := r.rootRel(absPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return root.ReadFile(rel)
|
||||
}
|
||||
|
||||
func (r *Recorder) rootStat(absPath string) (os.FileInfo, error) {
|
||||
root, err := r.getRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rel, err := r.rootRel(absPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return root.Stat(rel)
|
||||
}
|
||||
|
||||
func (r *Recorder) rootRemoveAll(absPath string) error {
|
||||
root, err := r.getRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := r.rootRel(absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return root.RemoveAll(rel)
|
||||
}
|
||||
|
||||
func (r *Recorder) rootReadDir(absPath string) ([]os.DirEntry, error) {
|
||||
root, err := r.getRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rel, err := r.rootRel(absPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d, err := root.Open(rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = d.Close() }()
|
||||
|
||||
// *os.File.ReadDir(-1) returns directory order; os.ReadDir sorts by
|
||||
// name. Match the sorted contract so callers don't see a surprise.
|
||||
entries, err := d.ReadDir(-1)
|
||||
if err != nil {
|
||||
return entries, err
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (r *Recorder) rootOpen(absPath string) (*os.File, error) {
|
||||
root, err := r.getRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rel, err := r.rootRel(absPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return root.Open(rel)
|
||||
}
|
||||
|
||||
// Record logs an interaction to the configured category.
|
||||
@@ -99,9 +291,13 @@ func (r *Recorder) Record(category string, req *http.Request, res *http.Response
|
||||
}
|
||||
|
||||
sanitizedSegments, replacements := r.getSanitizedSegments(req.URL.Path)
|
||||
dir := r.getRecordingDir(category, sanitizedSegments)
|
||||
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
dir, err := r.getRecordingDir(category, sanitizedSegments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.rootMkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
@@ -203,7 +399,7 @@ func (r *Recorder) save(task recordingTask) {
|
||||
r.writeResponseWithEnrichment(&buf, task.res, enriched)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(task.path, buf.Bytes(), 0644); err != nil {
|
||||
if err := r.rootWriteFile(task.path, buf.Bytes(), 0644); err != nil {
|
||||
log.Printf("failed to write recording to %s: %v", task.path, err)
|
||||
}
|
||||
|
||||
@@ -237,13 +433,40 @@ func (r *Recorder) getSanitizedSegments(path string) ([]string, map[string]strin
|
||||
return sanitizedSegments, replacements
|
||||
}
|
||||
|
||||
func (r *Recorder) getRecordingDir(category string, sanitizedSegments []string) string {
|
||||
// safeJoin joins r.BaseDir with elem and refuses to construct paths that
|
||||
// would escape BaseDir. Each element must satisfy filepath.IsLocal — i.e.
|
||||
// it must not be absolute, must not contain ".." segments, and (on Windows)
|
||||
// must not name a reserved device. CodeQL recognises filepath.IsLocal as
|
||||
// a path-traversal sanitiser, so taint analysis at call sites that hand the
|
||||
// result to os.* terminates here.
|
||||
func (r *Recorder) safeJoin(elem ...string) (string, error) {
|
||||
if r.BaseDir == "" {
|
||||
return "", fmt.Errorf("recorder: BaseDir not configured")
|
||||
}
|
||||
|
||||
for _, e := range elem {
|
||||
if e == "" {
|
||||
// filepath.Join silently skips empty components, but
|
||||
// filepath.IsLocal returns false for "" — treat empties as
|
||||
// no-ops to preserve the existing call shapes.
|
||||
continue
|
||||
}
|
||||
|
||||
if !filepath.IsLocal(e) {
|
||||
return "", fmt.Errorf("recorder: path component %q escapes BaseDir", e)
|
||||
}
|
||||
}
|
||||
|
||||
return filepath.Join(append([]string{r.BaseDir}, elem...)...), nil
|
||||
}
|
||||
|
||||
func (r *Recorder) getRecordingDir(category string, sanitizedSegments []string) (string, error) {
|
||||
subDir := "root"
|
||||
if len(sanitizedSegments) > 0 {
|
||||
subDir = filepath.Join(sanitizedSegments...)
|
||||
}
|
||||
|
||||
return filepath.Join(r.BaseDir, "interactions", r.SessionID, category, subDir)
|
||||
return r.safeJoin("interactions", r.SessionID, category, subDir)
|
||||
}
|
||||
|
||||
func (r *Recorder) getRecordingPath(dir, method string) string {
|
||||
@@ -395,7 +618,7 @@ func (r *Recorder) updateEnvFile(newVars map[string]string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(envFile, data, 0644)
|
||||
return r.rootWriteFile(envFile, data, 0644)
|
||||
}
|
||||
|
||||
// GetInteractionStats returns statistics about recorded interactions.
|
||||
@@ -406,7 +629,7 @@ func (r *Recorder) GetInteractionStats() (*InteractionStats, error) {
|
||||
}
|
||||
|
||||
interactionsDir := filepath.Join(r.BaseDir, "interactions")
|
||||
if _, err := os.Stat(interactionsDir); os.IsNotExist(err) {
|
||||
if _, err := r.rootStat(interactionsDir); os.IsNotExist(err) {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
@@ -445,7 +668,7 @@ func (r *Recorder) ListInteractions(sessionFilter, categoryFilter, sinceFilter s
|
||||
interactions := make([]Interaction, 0)
|
||||
interactionsDir := filepath.Join(r.BaseDir, "interactions")
|
||||
|
||||
if _, err := os.Stat(interactionsDir); os.IsNotExist(err) {
|
||||
if _, err := r.rootStat(interactionsDir); os.IsNotExist(err) {
|
||||
return interactions, nil
|
||||
}
|
||||
|
||||
@@ -567,7 +790,7 @@ func (r *Recorder) parseInteractionFile(rel, path string, parts []string) (Inter
|
||||
|
||||
// extractSCMUDCFromFile parses SCMUDC enrichment data from a .http file
|
||||
func (r *Recorder) extractSCMUDCFromFile(path string) *EnrichedSCMUDCEvent {
|
||||
content, err := os.ReadFile(path)
|
||||
content, err := r.rootReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -703,7 +926,7 @@ func (r *Recorder) getFullTimestamp(sessionID, filename string) string {
|
||||
}
|
||||
|
||||
func (r *Recorder) peekStatus(path string) int {
|
||||
content, err := os.ReadFile(path)
|
||||
content, err := r.rootReadFile(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
@@ -733,16 +956,19 @@ func (r *Recorder) DeleteSession(sessionID string) error {
|
||||
return fmt.Errorf("session ID is required")
|
||||
}
|
||||
|
||||
sessionDir := filepath.Join(r.BaseDir, "interactions", sessionID)
|
||||
sessionDir, err := r.safeJoin("interactions", sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.RemoveAll(sessionDir)
|
||||
return r.rootRemoveAll(sessionDir)
|
||||
}
|
||||
|
||||
// CleanupSessions deletes all but the most recent keepCount sessions.
|
||||
func (r *Recorder) CleanupSessions(keepCount int) error {
|
||||
interactionsDir := filepath.Join(r.BaseDir, "interactions")
|
||||
|
||||
entries, err := os.ReadDir(interactionsDir)
|
||||
entries, err := r.rootReadDir(interactionsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
@@ -771,7 +997,7 @@ func (r *Recorder) CleanupSessions(keepCount int) error {
|
||||
|
||||
for i := keepCount; i < len(sessions); i++ {
|
||||
sessionDir := filepath.Join(interactionsDir, sessions[i].Name())
|
||||
if err := os.RemoveAll(sessionDir); err != nil {
|
||||
if err := r.rootRemoveAll(sessionDir); err != nil {
|
||||
return fmt.Errorf("failed to delete session %s: %w", sessions[i].Name(), err)
|
||||
}
|
||||
}
|
||||
@@ -781,15 +1007,22 @@ func (r *Recorder) CleanupSessions(keepCount int) error {
|
||||
|
||||
// GetInteractionContent returns the raw content of a recorded interaction.
|
||||
func (r *Recorder) GetInteractionContent(relPath string) ([]byte, error) {
|
||||
fullPath := filepath.Join(r.BaseDir, "interactions", relPath)
|
||||
return os.ReadFile(fullPath)
|
||||
fullPath, err := r.safeJoin("interactions", relPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r.rootReadFile(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)
|
||||
sessionDir, err := r.safeJoin("interactions", sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
info, statErr := os.Stat(sessionDir)
|
||||
info, statErr := r.rootStat(sessionDir)
|
||||
if statErr != nil {
|
||||
return statErr
|
||||
}
|
||||
@@ -839,11 +1072,12 @@ func (r *Recorder) ArchiveSession(sessionID string, w io.Writer) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
f, oErr := os.Open(path)
|
||||
f, oErr := r.rootOpen(path)
|
||||
if oErr != nil {
|
||||
return oErr
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
_, cErr := io.Copy(tw, f)
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsTelnetMigrated_TargetHostnamePresent(t *testing.T) {
|
||||
m := &Manager{ServerURL: "http://example:8000"}
|
||||
|
||||
summary := &MigrationSummary{
|
||||
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\nbmxRegistryUrl=http://example:8000/bmx/registry/v1/services\n",
|
||||
}
|
||||
|
||||
if !m.isTelnetMigrated(summary) {
|
||||
t.Error("isTelnetMigrated = false, want true when getpdo response contains our hostname")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTelnetMigrated_DifferentHostname(t *testing.T) {
|
||||
m := &Manager{ServerURL: "http://example:8000"}
|
||||
|
||||
summary := &MigrationSummary{
|
||||
TelnetVerifiedConfig: "margeServerUrl=https://streaming.bose.com\n",
|
||||
}
|
||||
|
||||
if m.isTelnetMigrated(summary) {
|
||||
t.Error("isTelnetMigrated = true, want false when getpdo response points at the original cloud")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTelnetMigrated_EmptyVerifiedConfig(t *testing.T) {
|
||||
m := &Manager{ServerURL: "http://example:8000"}
|
||||
|
||||
summary := &MigrationSummary{} // TelnetVerifiedConfig empty
|
||||
|
||||
if m.isTelnetMigrated(summary) {
|
||||
t.Error("isTelnetMigrated = true, want false when TelnetVerifiedConfig is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckIsMigrated_TelnetOnlyMigratedDevice covers the gap that motivated
|
||||
// this iteration: SSH is unreachable, but the speaker has been pointed at
|
||||
// our service via telnet (e.g. a firmware that refuses USB unlock). The
|
||||
// migration UI must still report IsMigrated: true.
|
||||
func TestCheckIsMigrated_TelnetOnlyMigratedDevice(t *testing.T) {
|
||||
m := &Manager{ServerURL: "http://example:8000"}
|
||||
|
||||
summary := &MigrationSummary{
|
||||
SSHSuccess: false,
|
||||
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n",
|
||||
}
|
||||
|
||||
m.checkIsMigrated(summary, "192.0.2.1")
|
||||
|
||||
if !summary.IsMigrated {
|
||||
t.Error("IsMigrated = false, want true on a telnet-only migrated device with no SSH")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckIsMigrated_NoTelnetNoSSH ensures we don't false-positive when
|
||||
// neither transport sees the redirect.
|
||||
func TestCheckIsMigrated_NoTelnetNoSSH(t *testing.T) {
|
||||
m := &Manager{ServerURL: "http://example:8000"}
|
||||
|
||||
summary := &MigrationSummary{
|
||||
SSHSuccess: false,
|
||||
TelnetVerifiedConfig: "", // probe failed
|
||||
}
|
||||
|
||||
m.checkIsMigrated(summary, "192.0.2.1")
|
||||
|
||||
if summary.IsMigrated {
|
||||
t.Error("IsMigrated = true, want false when neither SSH nor telnet sees the redirect")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckIsMigrated_PerAxisBooleansArePopulated locks in that each
|
||||
// axis is reported individually so the UI can show partial-state cells.
|
||||
// The mock SSH client claims /etc/hosts has Bose redirects; XML is
|
||||
// unmigrated; resolv has no marker; telnet sees the redirected URL.
|
||||
// All four axis flags must reflect their independent verdicts and
|
||||
// IsMigrated must be the OR.
|
||||
func TestCheckIsMigrated_PerAxisBooleansArePopulated(t *testing.T) {
|
||||
m := &Manager{
|
||||
ServerURL: "http://example:8000",
|
||||
NewSSH: func(string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(cmd string) (string, error) {
|
||||
if cmd == "cat /etc/hosts" {
|
||||
return "192.0.2.1\tstreaming.bose.com\n", nil
|
||||
}
|
||||
return "", errors.New("not implemented in this mock")
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
summary := &MigrationSummary{
|
||||
SSHSuccess: true,
|
||||
CACertTrusted: true, // hosts migration requires CA trust
|
||||
ParsedCurrentConfig: &PrivateCfg{
|
||||
MargeServerUrl: "https://streaming.bose.com",
|
||||
},
|
||||
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n",
|
||||
CurrentResolvConf: "nameserver 8.8.8.8\n",
|
||||
}
|
||||
|
||||
m.checkIsMigrated(summary, "192.0.2.1")
|
||||
|
||||
if !summary.TelnetMigrated {
|
||||
t.Error("TelnetMigrated = false, want true (verified config points at example)")
|
||||
}
|
||||
|
||||
if summary.XMLMigrated {
|
||||
t.Error("XMLMigrated = true, want false (parsed XML still points at streaming.bose.com)")
|
||||
}
|
||||
|
||||
if !summary.HostsMigrated {
|
||||
t.Error("HostsMigrated = false, want true (mock hosts content has Bose redirect + CA trusted)")
|
||||
}
|
||||
|
||||
if summary.ResolvMigrated {
|
||||
t.Error("ResolvMigrated = true, want false (no marker, no example hostname)")
|
||||
}
|
||||
|
||||
if !summary.IsMigrated {
|
||||
t.Error("IsMigrated = false, want true (TelnetMigrated || HostsMigrated)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckIsMigrated_TelnetSeesOriginalSSHSeesOriginal ensures we don't
|
||||
// false-positive when both transports report unmigrated state.
|
||||
func TestCheckIsMigrated_TelnetSeesOriginalSSHSeesOriginal(t *testing.T) {
|
||||
m := &Manager{
|
||||
ServerURL: "http://example:8000",
|
||||
NewSSH: func(string) SSHClient {
|
||||
return &mockSSH{runFunc: func(string) (string, error) { return "", errors.New("file not found") }}
|
||||
},
|
||||
}
|
||||
|
||||
summary := &MigrationSummary{
|
||||
SSHSuccess: true,
|
||||
TelnetVerifiedConfig: "margeServerUrl=https://streaming.bose.com\n",
|
||||
ParsedCurrentConfig: &PrivateCfg{
|
||||
MargeServerUrl: "https://streaming.bose.com",
|
||||
},
|
||||
CurrentResolvConf: "nameserver 8.8.8.8\n",
|
||||
}
|
||||
|
||||
m.checkIsMigrated(summary, "192.0.2.1")
|
||||
|
||||
if summary.IsMigrated {
|
||||
t.Error("IsMigrated = true, want false when both SSH and telnet see the original cloud URLs")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// telnetSummaryEnv builds a Manager whose:
|
||||
// - SSH client is the supplied mockSSH (or a no-op if nil).
|
||||
// - Telnet client is the supplied fakeTelnet.
|
||||
// - Live :8090/info call hits an httptest server returning a minimal XML.
|
||||
//
|
||||
// The deviceIP returned is the httptest server's listener addr ("host:port"),
|
||||
// so the live-info call works; the SSH and telnet clients ignore the addr
|
||||
// and return whatever the fakes are scripted to return.
|
||||
func telnetSummaryEnv(t *testing.T, ssh *mockSSH, ft *fakeTelnet) (*Manager, string, func()) {
|
||||
t.Helper()
|
||||
return telnetSummaryEnvWithInfo(t, ssh, ft, `<info deviceID="123"><name>Test</name></info>`)
|
||||
}
|
||||
|
||||
// telnetSummaryEnvWithInfo is telnetSummaryEnv with a caller-supplied
|
||||
// :8090/info XML body, so individual tests can exercise device-info
|
||||
// fields that affect summary state (e.g. margeAccountUUID for IsPaired).
|
||||
func telnetSummaryEnvWithInfo(t *testing.T, ssh *mockSSH, ft *fakeTelnet, infoXML string) (*Manager, string, func()) {
|
||||
t.Helper()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = fmt.Fprint(w, infoXML)
|
||||
}))
|
||||
|
||||
m := NewManager("http://example:8000", nil, nil)
|
||||
m.NewSSH = func(string) SSHClient {
|
||||
if ssh != nil {
|
||||
return ssh
|
||||
}
|
||||
return &mockSSH{runFunc: func(string) (string, error) { return "", errors.New("ssh disabled in test") }}
|
||||
}
|
||||
m.NewTelnet = func(string) TelnetClient { return ft }
|
||||
|
||||
return m, server.Listener.Addr().String(), server.Close
|
||||
}
|
||||
|
||||
func TestGetMigrationSummary_TelnetSucceedsSSHFails(t *testing.T) {
|
||||
target := "http://example:8000"
|
||||
ft := &fakeTelnet{
|
||||
banner: "BoseShell\n-> ",
|
||||
responses: map[string]string{
|
||||
"getpdo CurrentSystemConfiguration": "margeServerUrl=" + target + "\n",
|
||||
},
|
||||
}
|
||||
|
||||
m, host, cleanup := telnetSummaryEnv(t, nil, ft)
|
||||
defer cleanup()
|
||||
|
||||
summary, err := m.GetMigrationSummary(host, "", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMigrationSummary: %v", err)
|
||||
}
|
||||
|
||||
if summary.SSHSuccess {
|
||||
t.Errorf("SSHSuccess = true, want false")
|
||||
}
|
||||
|
||||
if !summary.TelnetReachable {
|
||||
t.Errorf("TelnetReachable = false, want true")
|
||||
}
|
||||
|
||||
if !strings.Contains(summary.TelnetBanner, "BoseShell") {
|
||||
t.Errorf("TelnetBanner = %q, want it to contain BoseShell", summary.TelnetBanner)
|
||||
}
|
||||
|
||||
if !strings.Contains(summary.TelnetVerifiedConfig, target) {
|
||||
t.Errorf("TelnetVerifiedConfig = %q, want it to contain %q", summary.TelnetVerifiedConfig, target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMigrationSummary_TelnetFailsSSHFails(t *testing.T) {
|
||||
ft := &fakeTelnet{dialErr: errors.New("connection refused")}
|
||||
|
||||
m, host, cleanup := telnetSummaryEnv(t, nil, ft)
|
||||
defer cleanup()
|
||||
|
||||
summary, err := m.GetMigrationSummary(host, "", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMigrationSummary: %v", err)
|
||||
}
|
||||
|
||||
if summary.SSHSuccess {
|
||||
t.Errorf("SSHSuccess = true, want false")
|
||||
}
|
||||
|
||||
if summary.TelnetReachable {
|
||||
t.Errorf("TelnetReachable = true, want false")
|
||||
}
|
||||
|
||||
if !strings.Contains(summary.TelnetProbeError, "connection refused") {
|
||||
t.Errorf("TelnetProbeError = %q, want connection refused", summary.TelnetProbeError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMigrationSummary_IsPairedFromLiveInfo(t *testing.T) {
|
||||
ft := &fakeTelnet{dialErr: errors.New("not the focus of this test")}
|
||||
|
||||
t.Run("with margeAccountUUID", func(t *testing.T) {
|
||||
m, host, cleanup := telnetSummaryEnvWithInfo(t, nil, ft,
|
||||
`<info deviceID="123"><name>Test</name><margeAccountUUID>3230304</margeAccountUUID></info>`,
|
||||
)
|
||||
defer cleanup()
|
||||
|
||||
summary, err := m.GetMigrationSummary(host, "", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMigrationSummary: %v", err)
|
||||
}
|
||||
|
||||
if !summary.IsPaired {
|
||||
t.Errorf("IsPaired = false, want true (margeAccountUUID present in :8090/info)")
|
||||
}
|
||||
|
||||
if summary.AccountID != "3230304" {
|
||||
t.Errorf("AccountID = %q, want 3230304 (live info should populate)", summary.AccountID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without margeAccountUUID", func(t *testing.T) {
|
||||
m, host, cleanup := telnetSummaryEnvWithInfo(t, nil, ft,
|
||||
`<info deviceID="123"><name>Test</name><margeAccountUUID></margeAccountUUID></info>`,
|
||||
)
|
||||
defer cleanup()
|
||||
|
||||
summary, err := m.GetMigrationSummary(host, "", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMigrationSummary: %v", err)
|
||||
}
|
||||
|
||||
if summary.IsPaired {
|
||||
t.Errorf("IsPaired = true, want false (factory-reset device with empty margeAccountUUID)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetMigrationSummary_TelnetSucceedsSSHSucceeds(t *testing.T) {
|
||||
target := "http://example:8000"
|
||||
ft := &fakeTelnet{
|
||||
responses: map[string]string{
|
||||
"getpdo CurrentSystemConfiguration": "margeServerUrl=" + target + "\n",
|
||||
},
|
||||
}
|
||||
|
||||
// SSH mock returns enough for SSHSuccess to be true (cat /opt/Bose/etc/...).
|
||||
ssh := &mockSSH{
|
||||
runFunc: func(cmd string) (string, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(cmd, "cat "+SoundTouchSdkPrivateCfgPath):
|
||||
return `<?xml version="1.0"?><SoundTouchSdkPrivateCfg><margeServerUrl>` + target + `</margeServerUrl></SoundTouchSdkPrivateCfg>`, nil
|
||||
case strings.HasPrefix(cmd, "[ -f"):
|
||||
return "", errors.New("not found")
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
m, host, cleanup := telnetSummaryEnv(t, ssh, ft)
|
||||
defer cleanup()
|
||||
|
||||
summary, err := m.GetMigrationSummary(host, "", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMigrationSummary: %v", err)
|
||||
}
|
||||
|
||||
if !summary.SSHSuccess {
|
||||
t.Errorf("SSHSuccess = false, want true")
|
||||
}
|
||||
|
||||
if !summary.TelnetReachable {
|
||||
t.Errorf("TelnetReachable = false, want true")
|
||||
}
|
||||
|
||||
if !strings.Contains(summary.TelnetVerifiedConfig, target) {
|
||||
t.Errorf("TelnetVerifiedConfig = %q, want %q", summary.TelnetVerifiedConfig, target)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PeerHit is the payload the observer middleware delivers to a probe
|
||||
// waiter when a request from a registered peer IP lands on the service.
|
||||
type PeerHit struct {
|
||||
Path string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// PeerObserverHandle is the abstract view of the peer-observer registry
|
||||
// the probe needs: register interest in an IP, eventually forget it.
|
||||
// The handlers package's peerObserver satisfies this implicitly.
|
||||
type PeerObserverHandle interface {
|
||||
Register(ip string) <-chan PeerHit
|
||||
Forget(ip string)
|
||||
}
|
||||
|
||||
// PeerProbeResult is the JSON-serializable outcome of a passive
|
||||
// reachability probe. Reached is the canonical success bit the UI keys
|
||||
// off; ObservedPath and ElapsedMs are diagnostic.
|
||||
type PeerProbeResult struct {
|
||||
Reached bool `json:"reached"`
|
||||
ObservedPath string `json:"observed_path,omitempty"`
|
||||
ElapsedMs int64 `json:"elapsed_ms"`
|
||||
}
|
||||
|
||||
// RunPeerReachabilityProbe is the post-migration reachability check
|
||||
// that replaces the active swUpdateUrl round-trip. The sequence:
|
||||
//
|
||||
// 1. Register the device IP with the observer.
|
||||
// 2. Nudge :8090/swUpdateCheck on the device to make the swUpdate
|
||||
// daemon fan out *something* sooner than its own ~5min timer.
|
||||
// 3. Wait up to timeout for any inbound from that IP.
|
||||
//
|
||||
// Any inbound counts as proof of reachability — on a migrated speaker,
|
||||
// DNS interception means the daemon's outbounds (update fan-out, marge
|
||||
// polls, BMX registry calls) all funnel through this service regardless
|
||||
// of which URL the daemon resolved internally. We don't need a specific
|
||||
// URL to land; we just need *the device* to dial us.
|
||||
//
|
||||
// The nudge is fire-and-forget. If :8090 is unreachable, the request
|
||||
// returns quickly and we still wait for the daemon's own next fan-out
|
||||
// (or time out). No state on the device is mutated; the probe is safe
|
||||
// to re-run.
|
||||
func (m *Manager) RunPeerReachabilityProbe(deviceIP string, observer PeerObserverHandle, timeout time.Duration) (*PeerProbeResult, error) {
|
||||
if observer == nil {
|
||||
return nil, errors.New("peer probe not configured: observer is nil")
|
||||
}
|
||||
|
||||
if deviceIP == "" {
|
||||
return nil, errors.New("peer probe: deviceIP is required")
|
||||
}
|
||||
|
||||
hitCh := observer.Register(deviceIP)
|
||||
defer observer.Forget(deviceIP)
|
||||
|
||||
// Nudge the device. Fire-and-forget — we don't gate on the response
|
||||
// because the swUpdateCheck endpoint returns immediately after
|
||||
// enqueuing, and the daemon's fan-out is what we actually want to
|
||||
// observe. HTTPGet can be nil in test contexts.
|
||||
if m.HTTPGet != nil {
|
||||
swCheckURL := fmt.Sprintf("http://%s:8090/swUpdateCheck", deviceIP)
|
||||
|
||||
go func() {
|
||||
resp, err := m.HTTPGet(swCheckURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
result := &PeerProbeResult{}
|
||||
|
||||
select {
|
||||
case hit := <-hitCh:
|
||||
result.Reached = true
|
||||
result.ObservedPath = hit.Path
|
||||
case <-time.After(timeout):
|
||||
result.Reached = false
|
||||
}
|
||||
|
||||
result.ElapsedMs = time.Since(start).Milliseconds()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakePeerObserver is a deterministic PeerObserverHandle for unit
|
||||
// tests. It exposes the channel returned from Register so the test can
|
||||
// signal it manually to simulate a device inbound landing.
|
||||
type fakePeerObserver struct {
|
||||
mu sync.Mutex
|
||||
channels map[string]chan PeerHit
|
||||
forgotten []string
|
||||
}
|
||||
|
||||
func newFakePeerObserver() *fakePeerObserver {
|
||||
return &fakePeerObserver{channels: map[string]chan PeerHit{}}
|
||||
}
|
||||
|
||||
func (o *fakePeerObserver) Register(ip string) <-chan PeerHit {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
ch := make(chan PeerHit, 1)
|
||||
o.channels[ip] = ch
|
||||
return ch
|
||||
}
|
||||
|
||||
func (o *fakePeerObserver) Forget(ip string) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
delete(o.channels, ip)
|
||||
o.forgotten = append(o.forgotten, ip)
|
||||
}
|
||||
|
||||
func (o *fakePeerObserver) signal(ip string, hit PeerHit) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
ch, ok := o.channels[ip]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case ch <- hit:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func peerProbeManager(onTrigger func()) *Manager {
|
||||
return &Manager{
|
||||
HTTPGet: func(url string) (*http.Response, error) {
|
||||
if onTrigger != nil {
|
||||
onTrigger()
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
rr.WriteHeader(200)
|
||||
return rr.Result(), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPeerReachabilityProbe_HappyPath(t *testing.T) {
|
||||
obs := newFakePeerObserver()
|
||||
|
||||
// On nudge, simulate the device fanning out to /updates/soundtouch
|
||||
// which the middleware would signal as a hit on this IP.
|
||||
m := peerProbeManager(func() {
|
||||
obs.signal("192.168.1.42", PeerHit{Path: "/updates/soundtouch", At: time.Now()})
|
||||
})
|
||||
|
||||
result, err := m.RunPeerReachabilityProbe("192.168.1.42", obs, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("RunPeerReachabilityProbe error: %v", err)
|
||||
}
|
||||
if !result.Reached {
|
||||
t.Error("Reached = false, want true")
|
||||
}
|
||||
if result.ObservedPath != "/updates/soundtouch" {
|
||||
t.Errorf("ObservedPath = %q, want %q", result.ObservedPath, "/updates/soundtouch")
|
||||
}
|
||||
if len(obs.forgotten) != 1 || obs.forgotten[0] != "192.168.1.42" {
|
||||
t.Errorf("Forget not called for IP: forgotten = %v", obs.forgotten)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPeerReachabilityProbe_Timeout(t *testing.T) {
|
||||
obs := newFakePeerObserver()
|
||||
m := peerProbeManager(nil) // nudge fires but device never responds
|
||||
|
||||
start := time.Now()
|
||||
result, err := m.RunPeerReachabilityProbe("192.168.1.42", obs, 200*time.Millisecond)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("RunPeerReachabilityProbe error: %v", err)
|
||||
}
|
||||
if result.Reached {
|
||||
t.Error("Reached = true, want false (no hit)")
|
||||
}
|
||||
if elapsed < 200*time.Millisecond {
|
||||
t.Errorf("returned early after %v; expected >= 200ms timeout", elapsed)
|
||||
}
|
||||
if len(obs.forgotten) != 1 {
|
||||
t.Errorf("Forget not called after timeout: forgotten = %v", obs.forgotten)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPeerReachabilityProbe_NilObserver(t *testing.T) {
|
||||
m := peerProbeManager(nil)
|
||||
_, err := m.RunPeerReachabilityProbe("192.168.1.42", nil, time.Second)
|
||||
if err == nil {
|
||||
t.Error("expected error for nil observer, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPeerReachabilityProbe_EmptyIP(t *testing.T) {
|
||||
m := peerProbeManager(nil)
|
||||
obs := newFakePeerObserver()
|
||||
_, err := m.RunPeerReachabilityProbe("", obs, time.Second)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty deviceIP, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPeerReachabilityProbe_NilHTTPGetTimesOut(t *testing.T) {
|
||||
// With nil HTTPGet the nudge is skipped entirely; the probe just
|
||||
// waits for the device to dial in on its own. Useful in tests and
|
||||
// in environments where the trigger isn't safe to fire.
|
||||
m := &Manager{} // HTTPGet nil
|
||||
obs := newFakePeerObserver()
|
||||
|
||||
result, err := m.RunPeerReachabilityProbe("192.168.1.42", obs, 100*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
if result.Reached {
|
||||
t.Error("Reached = true with no nudge and no signal")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// crossCheckPreflights compares the URL fields visible via SSH (from the
|
||||
// parsed SoundTouchSdkPrivateCfg.xml) with the same fields visible via
|
||||
// telnet (from `getpdo CurrentSystemConfiguration`). Any field that is
|
||||
// reported by both transports but with different values is recorded as
|
||||
// a non-fatal warning.
|
||||
//
|
||||
// In practice the two sources can diverge briefly: `sys configuration …`
|
||||
// writes the runtime fields, while `envswitch boseurls set …` writes a
|
||||
// parallel persistence layer that wins on next boot — and the XML file
|
||||
// is only re-rendered after a reboot. A warning here is therefore not an
|
||||
// error per se; it usually means "reboot the device to make the two
|
||||
// layers agree."
|
||||
func (m *Manager) crossCheckPreflights(summary *MigrationSummary) {
|
||||
if summary.ParsedCurrentConfig == nil || summary.TelnetVerifiedConfig == "" {
|
||||
return
|
||||
}
|
||||
|
||||
telnet := parseGetpdoConfig(summary.TelnetVerifiedConfig)
|
||||
xml := summary.ParsedCurrentConfig
|
||||
|
||||
pairs := []struct {
|
||||
name string
|
||||
xmlValue string
|
||||
}{
|
||||
{"margeServerUrl", xml.MargeServerUrl},
|
||||
{"statsServerUrl", xml.StatsServerUrl},
|
||||
{"swUpdateUrl", xml.SwUpdateUrl},
|
||||
{"bmxRegistryUrl", xml.BmxRegistryUrl},
|
||||
}
|
||||
|
||||
for _, p := range pairs {
|
||||
telnetValue, hasTelnet := telnet[p.name]
|
||||
if !hasTelnet || p.xmlValue == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if telnetValue == p.xmlValue {
|
||||
continue
|
||||
}
|
||||
|
||||
summary.Warnings = append(summary.Warnings, fmt.Sprintf(
|
||||
"%s differs between transports: SSH-XML=%q telnet-getpdo=%q (a reboot usually re-syncs the runtime layer with the persisted XML)",
|
||||
p.name, p.xmlValue, telnetValue,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// parseGetpdoConfig extracts field values from a `getpdo
|
||||
// CurrentSystemConfiguration` reply. Two formats are accepted:
|
||||
//
|
||||
// 1. Protobuf-text-like nested blocks (the format observed on FW
|
||||
// 27.0.6 ST 10/20/300 in the wild):
|
||||
//
|
||||
// margeServerUrl {
|
||||
// text: "https://streaming.bose.com"
|
||||
// }
|
||||
//
|
||||
// 2. Flat key=value lines (kept as a tolerance path for firmware
|
||||
// variants that report differently or for hand-crafted test
|
||||
// fixtures).
|
||||
//
|
||||
// Any line that doesn't match either shape is silently ignored, so the
|
||||
// parser tolerates banner text, prompt characters (`->`, `->OK`),
|
||||
// blank lines, and unrelated fields.
|
||||
func parseGetpdoConfig(text string) map[string]string {
|
||||
out := map[string]string{}
|
||||
|
||||
var currentKey string
|
||||
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Block open: "<key> {".
|
||||
if strings.HasSuffix(line, "{") {
|
||||
head := strings.TrimSpace(strings.TrimSuffix(line, "{"))
|
||||
if head != "" && isIdentifier(head) {
|
||||
currentKey = head
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Block close.
|
||||
if line == "}" {
|
||||
currentKey = ""
|
||||
continue
|
||||
}
|
||||
|
||||
// "text: ..." inside a block is the field value.
|
||||
if currentKey != "" && strings.HasPrefix(line, "text:") {
|
||||
val := strings.TrimSpace(strings.TrimPrefix(line, "text:"))
|
||||
val = strings.Trim(val, `"`)
|
||||
out[currentKey] = val
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Flat key=value, only if the key is a bare identifier (so we
|
||||
// don't misread protobuf "text: value" as a key=value pair via
|
||||
// some other separator).
|
||||
if i := strings.IndexByte(line, '='); i > 0 {
|
||||
key := strings.TrimSpace(line[:i])
|
||||
if key != "" && isIdentifier(key) {
|
||||
out[key] = strings.TrimSpace(line[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// isIdentifier reports whether s looks like a configuration field name —
|
||||
// alphanumeric or underscore only. Used to keep parseGetpdoConfig from
|
||||
// promoting random "x: y" or "x = y" lines (with spaces, punctuation,
|
||||
// arrows) into the result map.
|
||||
func isIdentifier(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r >= '0' && r <= '9':
|
||||
case r == '_':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseGetpdoConfig_StandardLines(t *testing.T) {
|
||||
in := "margeServerUrl=http://example:8000\nbmxRegistryUrl=http://example:8000/bmx/registry/v1/services\n"
|
||||
|
||||
got := parseGetpdoConfig(in)
|
||||
|
||||
if got["margeServerUrl"] != "http://example:8000" {
|
||||
t.Errorf("margeServerUrl = %q, want http://example:8000", got["margeServerUrl"])
|
||||
}
|
||||
|
||||
if got["bmxRegistryUrl"] != "http://example:8000/bmx/registry/v1/services" {
|
||||
t.Errorf("bmxRegistryUrl = %q", got["bmxRegistryUrl"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGetpdoConfig_TolerantToNoise(t *testing.T) {
|
||||
in := "BoseShell\n-> getpdo CurrentSystemConfiguration\nmargeServerUrl=http://example:8000\nrandom line without equals\n statsServerUrl = http://example:8000 \n-> "
|
||||
|
||||
got := parseGetpdoConfig(in)
|
||||
|
||||
if got["margeServerUrl"] != "http://example:8000" {
|
||||
t.Errorf("margeServerUrl = %q, want http://example:8000", got["margeServerUrl"])
|
||||
}
|
||||
|
||||
if got["statsServerUrl"] != "http://example:8000" {
|
||||
t.Errorf("statsServerUrl = %q, want trimmed http://example:8000", got["statsServerUrl"])
|
||||
}
|
||||
|
||||
if _, exists := got["random line without equals"]; exists {
|
||||
t.Errorf("non-key=value line should not be parsed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseGetpdoConfig_ProtobufTextRealDevice pins the parser to the
|
||||
// live response captured from a SoundTouch 20 (FW 27.0.6.46330.5043500)
|
||||
// against http://mac.fritz.box:8000/setup/summary. This is the format
|
||||
// the parser actually has to handle in production — the prior
|
||||
// key=value-only implementation returned an empty map for this input,
|
||||
// which surfaced as empty "Current on Device" cells in the migration
|
||||
// UI.
|
||||
func TestParseGetpdoConfig_ProtobufTextRealDevice(t *testing.T) {
|
||||
in := `margeServerUrl {
|
||||
text: "https://streaming.bose.com"
|
||||
}
|
||||
statsServerUrl {
|
||||
text: "https://events.api.bosecm.com"
|
||||
}
|
||||
swUpdateUrl {
|
||||
text: "https://worldwide.bose.com/updates/soundtouch"
|
||||
}
|
||||
isZeroconfEnabled {
|
||||
text: true
|
||||
}
|
||||
usePandoraProductionServer {
|
||||
text: true
|
||||
}
|
||||
saveMargeCustomerReport {
|
||||
text: false
|
||||
}
|
||||
bmxRegistryUrl {
|
||||
text: "https://content.api.bose.io/bmx/registry/v1/services"
|
||||
}
|
||||
|
||||
->OK
|
||||
->`
|
||||
|
||||
got := parseGetpdoConfig(in)
|
||||
|
||||
want := map[string]string{
|
||||
"margeServerUrl": "https://streaming.bose.com",
|
||||
"statsServerUrl": "https://events.api.bosecm.com",
|
||||
"swUpdateUrl": "https://worldwide.bose.com/updates/soundtouch",
|
||||
"bmxRegistryUrl": "https://content.api.bose.io/bmx/registry/v1/services",
|
||||
"isZeroconfEnabled": "true",
|
||||
"usePandoraProductionServer": "true",
|
||||
"saveMargeCustomerReport": "false",
|
||||
}
|
||||
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Errorf("%s = %q, want %q", k, got[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossCheckPreflights_AgreementProducesNoWarnings(t *testing.T) {
|
||||
m := &Manager{ServerURL: "http://example:8000"}
|
||||
|
||||
summary := &MigrationSummary{
|
||||
ParsedCurrentConfig: &PrivateCfg{
|
||||
MargeServerUrl: "http://example:8000",
|
||||
StatsServerUrl: "http://example:8000",
|
||||
SwUpdateUrl: "http://example:8000/updates/soundtouch",
|
||||
BmxRegistryUrl: "http://example:8000/bmx/registry/v1/services",
|
||||
},
|
||||
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n" +
|
||||
"statsServerUrl=http://example:8000\n" +
|
||||
"swUpdateUrl=http://example:8000/updates/soundtouch\n" +
|
||||
"bmxRegistryUrl=http://example:8000/bmx/registry/v1/services\n",
|
||||
}
|
||||
|
||||
m.crossCheckPreflights(summary)
|
||||
|
||||
if len(summary.Warnings) != 0 {
|
||||
t.Errorf("Warnings = %v, want none when both transports agree", summary.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossCheckPreflights_MismatchProducesWarning(t *testing.T) {
|
||||
m := &Manager{ServerURL: "http://example:8000"}
|
||||
|
||||
// SSH-XML still shows the original cloud URL (envswitch wrote the
|
||||
// runtime layer but the on-device file hasn't been re-rendered).
|
||||
summary := &MigrationSummary{
|
||||
ParsedCurrentConfig: &PrivateCfg{
|
||||
MargeServerUrl: "https://streaming.bose.com",
|
||||
},
|
||||
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n",
|
||||
}
|
||||
|
||||
m.crossCheckPreflights(summary)
|
||||
|
||||
if len(summary.Warnings) != 1 {
|
||||
t.Fatalf("Warnings = %v, want exactly one warning", summary.Warnings)
|
||||
}
|
||||
|
||||
w := summary.Warnings[0]
|
||||
|
||||
if !strings.Contains(w, "margeServerUrl") {
|
||||
t.Errorf("warning %q should name the field", w)
|
||||
}
|
||||
|
||||
if !strings.Contains(w, "streaming.bose.com") || !strings.Contains(w, "example:8000") {
|
||||
t.Errorf("warning %q should quote both values", w)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossCheckPreflights_NoWarningWhenTelnetMissesField(t *testing.T) {
|
||||
m := &Manager{ServerURL: "http://example:8000"}
|
||||
|
||||
summary := &MigrationSummary{
|
||||
ParsedCurrentConfig: &PrivateCfg{
|
||||
MargeServerUrl: "http://example:8000",
|
||||
StatsServerUrl: "http://example:8000",
|
||||
},
|
||||
// getpdo only echoes margeServerUrl — statsServerUrl is silently
|
||||
// absent on this firmware. Absence is not a disagreement.
|
||||
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n",
|
||||
}
|
||||
|
||||
m.crossCheckPreflights(summary)
|
||||
|
||||
if len(summary.Warnings) != 0 {
|
||||
t.Errorf("Warnings = %v, want none when a field is missing from one transport", summary.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossCheckPreflights_OnlyOneTransportPresent(t *testing.T) {
|
||||
m := &Manager{ServerURL: "http://example:8000"}
|
||||
|
||||
t.Run("telnet only", func(t *testing.T) {
|
||||
summary := &MigrationSummary{
|
||||
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n",
|
||||
}
|
||||
m.crossCheckPreflights(summary)
|
||||
if len(summary.Warnings) != 0 {
|
||||
t.Errorf("Warnings = %v, want none when SSH didn't read the XML", summary.Warnings)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ssh only", func(t *testing.T) {
|
||||
summary := &MigrationSummary{
|
||||
ParsedCurrentConfig: &PrivateCfg{MargeServerUrl: "http://example:8000"},
|
||||
}
|
||||
m.crossCheckPreflights(summary)
|
||||
if len(summary.Warnings) != 0 {
|
||||
t.Errorf("Warnings = %v, want none when telnet didn't respond", summary.Warnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -77,11 +77,24 @@ type MigrationSummary struct {
|
||||
CurrentResolvConf string `json:"current_resolv_conf,omitempty"`
|
||||
PlannedResolv string `json:"planned_resolv,omitempty"`
|
||||
IsMigrated bool `json:"is_migrated"`
|
||||
ResolveIPError string `json:"resolve_ip_error,omitempty"`
|
||||
MirrorEnabled bool `json:"mirror_enabled"`
|
||||
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
|
||||
SkipMirrorEndpoints []string `json:"skip_mirror_endpoints,omitempty"`
|
||||
PreferredSource string `json:"preferred_source,omitempty"`
|
||||
// Per-axis migration signals — IsMigrated is the OR of these. The UI
|
||||
// displays them individually so users can see partial states (e.g.
|
||||
// URLs flipped via telnet but the on-disk XML hasn't caught up, or
|
||||
// DNS interception in place but no CA installed).
|
||||
XMLMigrated bool `json:"xml_migrated"`
|
||||
HostsMigrated bool `json:"hosts_migrated"`
|
||||
ResolvMigrated bool `json:"resolv_migrated"`
|
||||
TelnetMigrated bool `json:"telnet_migrated"`
|
||||
// IsPaired reports whether the device's live :8090/info advertises a
|
||||
// non-empty margeAccountUUID. Surfaced separately so the wizard can
|
||||
// flag pairing as a precondition independently of the URL flip.
|
||||
IsPaired bool `json:"is_paired"`
|
||||
|
||||
ResolveIPError string `json:"resolve_ip_error,omitempty"`
|
||||
MirrorEnabled bool `json:"mirror_enabled"`
|
||||
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
|
||||
SkipMirrorEndpoints []string `json:"skip_mirror_endpoints,omitempty"`
|
||||
PreferredSource string `json:"preferred_source,omitempty"`
|
||||
|
||||
// Telnet (port 17000) preflight state — populated when the user is about to
|
||||
// or has just used MigrationMethodTelnet.
|
||||
@@ -93,6 +106,12 @@ type MigrationSummary struct {
|
||||
// KnownAccountIDs are accountIDs already present in the local datastore;
|
||||
// the UI offers them as choices when pairing a fresh device.
|
||||
KnownAccountIDs []string `json:"known_account_ids,omitempty"`
|
||||
|
||||
// Warnings holds non-fatal advisories emitted during summary
|
||||
// construction — currently the cross-check between SSH-XML and
|
||||
// telnet-getpdo readings of the device's URL configuration. The UI
|
||||
// should display them as informational hints, not errors.
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// SSHClient defines the interface for SSH operations.
|
||||
@@ -244,6 +263,21 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
|
||||
SSHSuccess: false,
|
||||
}
|
||||
|
||||
// Run the telnet preflight in parallel with the SSH-based probes below.
|
||||
// Both transports are queried independently: SSH gives access to
|
||||
// /etc/hosts, /etc/resolv.conf and the on-device XML config; telnet's
|
||||
// `getpdo CurrentSystemConfiguration` reports the live URL set without
|
||||
// needing root. They are complementary, so we wait for both and merge
|
||||
// the results — total wall time = max(ssh, telnet).
|
||||
telnetCh := make(chan MigrationSummary, 1)
|
||||
|
||||
go func() {
|
||||
var local MigrationSummary
|
||||
m.telnetPreflight(&local, deviceIP)
|
||||
|
||||
telnetCh <- local
|
||||
}()
|
||||
|
||||
// Populate device info from datastore and live info
|
||||
m.populateDeviceInfo(summary, deviceIP)
|
||||
|
||||
@@ -279,6 +313,12 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-field literal URL overrides win over both the canonical
|
||||
// derivation and any self/proxied/original mode applied above —
|
||||
// the user picked a URL, so the planned preview reflects exactly
|
||||
// what the XML migration will write.
|
||||
applyURLOverrides(&plannedCfg, options)
|
||||
// Note: CurrentConfig is set by checkCurrentConfig in all cases (success or failure)
|
||||
|
||||
xmlContent, err := xml.MarshalIndent(plannedCfg, "", " ")
|
||||
@@ -322,6 +362,17 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Merge telnet preflight results (started in parallel at the top).
|
||||
telnetResult := <-telnetCh
|
||||
summary.TelnetReachable = telnetResult.TelnetReachable
|
||||
summary.TelnetBanner = telnetResult.TelnetBanner
|
||||
summary.TelnetVerifiedConfig = telnetResult.TelnetVerifiedConfig
|
||||
summary.TelnetProbeError = telnetResult.TelnetProbeError
|
||||
|
||||
// 9. Cross-check SSH-XML and telnet-getpdo readings; surface any
|
||||
// divergence as a non-fatal warning.
|
||||
m.crossCheckPreflights(summary)
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
@@ -386,17 +437,57 @@ func (m *Manager) buildServerHTTPSURL(targetURL string) string {
|
||||
return fmt.Sprintf("https://%s:%s/health", parsedURL.Hostname(), httpsPort)
|
||||
}
|
||||
|
||||
// checkIsMigrated determines if the device is already migrated to AfterTouch.
|
||||
// checkIsMigrated determines if the device is already migrated to
|
||||
// AfterTouch and which mechanism is in place.
|
||||
//
|
||||
// Each axis is recorded as a separate boolean so the UI can show
|
||||
// partial-state cells (e.g. URLs flipped via telnet but the on-disk XML
|
||||
// hasn't been re-rendered, or DNS interception present but no CA
|
||||
// installed). IsMigrated is the OR — if any mechanism reports the
|
||||
// device pointing at our service, the device is "migrated."
|
||||
//
|
||||
// The telnet-based check runs unconditionally because it is the only
|
||||
// migration-state signal available on devices that do not expose SSH
|
||||
// (USB-unlock-refusing firmware on SA-5, ST520, recent ST Portable).
|
||||
// The SSH-based checks need a working shell and cover the /etc/hosts
|
||||
// and /etc/resolv.conf interception variants, neither of which shows
|
||||
// up in `getpdo CurrentSystemConfiguration`.
|
||||
func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) {
|
||||
if !summary.SSHSuccess {
|
||||
return
|
||||
summary.TelnetMigrated = m.isTelnetMigrated(summary)
|
||||
|
||||
if summary.SSHSuccess {
|
||||
client := m.NewSSH(deviceIP)
|
||||
summary.XMLMigrated = m.isXMLMigrated(summary)
|
||||
summary.HostsMigrated = m.isHostsMigrated(client, summary)
|
||||
summary.ResolvMigrated = m.isResolvConfMigrated(client, summary)
|
||||
}
|
||||
|
||||
client := m.NewSSH(deviceIP)
|
||||
summary.IsMigrated = summary.TelnetMigrated ||
|
||||
summary.XMLMigrated ||
|
||||
summary.HostsMigrated ||
|
||||
summary.ResolvMigrated
|
||||
}
|
||||
|
||||
if m.isXMLMigrated(summary) || m.isHostsMigrated(client, summary) || m.isResolvConfMigrated(client, summary) {
|
||||
summary.IsMigrated = true
|
||||
// isTelnetMigrated reports whether the live device config (read via the
|
||||
// telnet preflight's `getpdo CurrentSystemConfiguration`) already points
|
||||
// at our service. Mirrors isXMLMigrated's substring-match semantics — any
|
||||
// occurrence of our hostname in the response is enough.
|
||||
func (m *Manager) isTelnetMigrated(summary *MigrationSummary) bool {
|
||||
if summary.TelnetVerifiedConfig == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
parsedTarget, err := url.Parse(m.ServerURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
targetHost := parsedTarget.Hostname()
|
||||
if targetHost == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.Contains(summary.TelnetVerifiedConfig, targetHost)
|
||||
}
|
||||
|
||||
// isXMLMigrated checks whether current XML config already points to our server.
|
||||
@@ -525,6 +616,12 @@ func (m *Manager) populateDeviceInfo(summary *MigrationSummary, deviceIP string)
|
||||
summary.AccountID = infoXML.MargeAccountUUID
|
||||
}
|
||||
}
|
||||
|
||||
// Pairing state is derived from the live :8090/info value above
|
||||
// (which clobbers the stale datastore copy if both are present).
|
||||
// An empty AccountID at this point means a fresh / factory-reset
|
||||
// device that needs pairing before presets and streaming work.
|
||||
summary.IsPaired = summary.AccountID != ""
|
||||
}
|
||||
|
||||
// checkCurrentConfig reads and validates the current speaker configuration
|
||||
@@ -607,6 +704,38 @@ func (m *Manager) applyProxyOptions(plannedCfg *PrivateCfg, proxyURL string, opt
|
||||
}
|
||||
}
|
||||
|
||||
// applyURLOverrides applies per-field literal URL overrides from the
|
||||
// migration options map (marge_url / stats_url / sw_update_url /
|
||||
// bmx_url) on top of an already-populated PrivateCfg. Empty or missing
|
||||
// entries leave the field unchanged.
|
||||
//
|
||||
// These overrides win over the legacy "self/proxied/original" semantic
|
||||
// applied by applyProxyOptions: if the user picked a literal URL, the
|
||||
// migration honors it verbatim. The XML and Telnet write paths and
|
||||
// the GetMigrationSummary read path all call this so the planned
|
||||
// preview matches what migration actually writes.
|
||||
func applyURLOverrides(cfg *PrivateCfg, options map[string]string) {
|
||||
if cfg == nil || options == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if v := options["marge_url"]; v != "" {
|
||||
cfg.MargeServerUrl = v
|
||||
}
|
||||
|
||||
if v := options["stats_url"]; v != "" {
|
||||
cfg.StatsServerUrl = v
|
||||
}
|
||||
|
||||
if v := options["sw_update_url"]; v != "" {
|
||||
cfg.SwUpdateUrl = v
|
||||
}
|
||||
|
||||
if v := options["bmx_url"]; v != "" {
|
||||
cfg.BmxRegistryUrl = v
|
||||
}
|
||||
}
|
||||
|
||||
// checkRemoteServices checks for remote services files on the device
|
||||
func (m *Manager) checkRemoteServices(summary *MigrationSummary, deviceIP string) {
|
||||
client := m.NewSSH(deviceIP)
|
||||
@@ -687,7 +816,8 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
|
||||
// rw pre-flight, both of which would fail on devices that haven't been
|
||||
// rooted via remote_services.
|
||||
if method == MigrationMethodTelnet {
|
||||
return m.migrateViaTelnet(deviceIP, targetURL)
|
||||
urls := telnetURLsFromOptions(targetURL, options)
|
||||
return m.migrateViaTelnet(deviceIP, targetURL, urls)
|
||||
}
|
||||
|
||||
var logs string
|
||||
@@ -807,6 +937,10 @@ func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options ma
|
||||
}
|
||||
}
|
||||
|
||||
// Per-field literal URL overrides take precedence over the
|
||||
// proxy/original modes applied above — see applyURLOverrides.
|
||||
applyURLOverrides(&cfg, options)
|
||||
|
||||
xmlContent, err := xml.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return logs, fmt.Errorf("failed to marshal XML: %w", err)
|
||||
|
||||
@@ -6,20 +6,77 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// telnetURLConfigCommands returns the canonical sequence of telnet commands
|
||||
// that point a SoundTouch device at the given local-service base URL.
|
||||
// telnetURLs holds the four URLs the migration writes via telnet. Most
|
||||
// users keep all four pointing at the same service base; per-field
|
||||
// overrides exist mainly so soundcork users can append /marge to the
|
||||
// marge URL.
|
||||
type telnetURLs struct {
|
||||
Marge string
|
||||
Stats string
|
||||
SwUpdate string
|
||||
BmxRegistry string
|
||||
}
|
||||
|
||||
// defaultTelnetURLs returns the canonical URL set derived from the
|
||||
// soundtouch-service base targetURL.
|
||||
func defaultTelnetURLs(targetURL string) telnetURLs {
|
||||
return telnetURLs{
|
||||
Marge: targetURL,
|
||||
Stats: targetURL,
|
||||
SwUpdate: targetURL + "/updates/soundtouch",
|
||||
BmxRegistry: targetURL + "/bmx/registry/v1/services",
|
||||
}
|
||||
}
|
||||
|
||||
// telnetURLsFromOptions resolves the four URLs from targetURL plus
|
||||
// per-field overrides supplied via the migration options map. Recognised
|
||||
// keys are marge_url, stats_url, sw_update_url, bmx_url; missing or empty
|
||||
// entries fall back to the canonical default.
|
||||
//
|
||||
// Order matters: `sys configuration …` writes the runtime URL, while
|
||||
// `envswitch boseurls set …` writes a parallel persistence layer that
|
||||
// otherwise wins on the next reboot. See docs/analysis/TELNET-MIGRATION-METHOD.md
|
||||
// §2.1 for the discussion this is derived from.
|
||||
func telnetURLConfigCommands(targetURL string) []string {
|
||||
// We deliberately do not expose a "proxied"/"original" semantic here
|
||||
// (unlike the XML method's applyProxyOptions): per the discussion that
|
||||
// motivated this iteration, the goal is to keep the user model simple —
|
||||
// one base URL plus optional path suffixes — and let the service layer
|
||||
// hold any non-trivial logic.
|
||||
func telnetURLsFromOptions(targetURL string, options map[string]string) telnetURLs {
|
||||
u := defaultTelnetURLs(targetURL)
|
||||
|
||||
if v := options["marge_url"]; v != "" {
|
||||
u.Marge = v
|
||||
}
|
||||
|
||||
if v := options["stats_url"]; v != "" {
|
||||
u.Stats = v
|
||||
}
|
||||
|
||||
if v := options["sw_update_url"]; v != "" {
|
||||
u.SwUpdate = v
|
||||
}
|
||||
|
||||
if v := options["bmx_url"]; v != "" {
|
||||
u.BmxRegistry = v
|
||||
}
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
// Commands returns the canonical sequence of telnet commands. Order
|
||||
// matters: `sys configuration …` writes the runtime layer; the closing
|
||||
// `envswitch boseurls set …` writes the parallel persistence layer that
|
||||
// otherwise wins on the next reboot.
|
||||
//
|
||||
// Envswitch derivation rule: arg1 mirrors u.Marge verbatim, arg2 mirrors
|
||||
// u.SwUpdate verbatim. Soundcork users who set Marge to "<base>/marge"
|
||||
// therefore get "envswitch boseurls set <base>/marge <base>/updates/soundtouch"
|
||||
// without any extra plumbing — the parallel layer stays consistent with
|
||||
// the runtime layer.
|
||||
func (u telnetURLs) Commands() []string {
|
||||
return []string{
|
||||
"sys configuration bmxRegistryUrl " + targetURL + "/bmx/registry/v1/services",
|
||||
"sys configuration statsServerUrl " + targetURL,
|
||||
"sys configuration margeServerUrl " + targetURL,
|
||||
"sys configuration swUpdateUrl " + targetURL + "/updates/soundtouch",
|
||||
"envswitch boseurls set " + targetURL + " " + targetURL + "/updates/soundtouch",
|
||||
"sys configuration bmxRegistryUrl " + u.BmxRegistry,
|
||||
"sys configuration statsServerUrl " + u.Stats,
|
||||
"sys configuration margeServerUrl " + u.Marge,
|
||||
"sys configuration swUpdateUrl " + u.SwUpdate,
|
||||
"envswitch boseurls set " + u.Marge + " " + u.SwUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +88,12 @@ func telnetURLConfigCommands(targetURL string) []string {
|
||||
// The sequence aborts on the first non-OK response so we never half-write the
|
||||
// configuration; the caller can retry safely after fixing the underlying
|
||||
// issue (closed port, hardened firmware, etc.).
|
||||
func (m *Manager) migrateViaTelnet(deviceIP, targetURL string) (string, error) {
|
||||
//
|
||||
// targetURL is kept as a separate verification anchor: most users have
|
||||
// every URL share that base, so substring-matching it against the
|
||||
// device's `getpdo` reply is the simplest "did the writes stick?" check
|
||||
// that still works for the soundcork "/marge on one field" case.
|
||||
func (m *Manager) migrateViaTelnet(deviceIP, targetURL string, urls telnetURLs) (string, error) {
|
||||
if m.NewTelnet == nil {
|
||||
return "", errors.New("telnet migration not configured: Manager.NewTelnet is nil")
|
||||
}
|
||||
@@ -50,7 +112,7 @@ func (m *Manager) migrateViaTelnet(deviceIP, targetURL string) (string, error) {
|
||||
fmt.Fprintf(&logs, "Telnet banner: %q\n", strings.TrimSpace(banner))
|
||||
}
|
||||
|
||||
for _, cmd := range telnetURLConfigCommands(targetURL) {
|
||||
for _, cmd := range urls.Commands() {
|
||||
resp, err := t.SendCommand(cmd)
|
||||
if err != nil {
|
||||
return logs.String(), fmt.Errorf("telnet command %q failed: %w", cmd, err)
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestMigrateViaTelnet_HappyPath(t *testing.T) {
|
||||
}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
logs, err := m.migrateViaTelnet("192.0.2.1", target)
|
||||
logs, err := m.migrateViaTelnet("192.0.2.1", target, defaultTelnetURLs(target))
|
||||
if err != nil {
|
||||
t.Fatalf("migrateViaTelnet: %v", err)
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func TestMigrateViaTelnet_DialFailureReturnsError(t *testing.T) {
|
||||
f := &fakeTelnet{dialErr: errors.New("connection refused")}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
_, err := m.migrateViaTelnet("192.0.2.1", "http://example:8000")
|
||||
_, err := m.migrateViaTelnet("192.0.2.1", "http://example:8000", defaultTelnetURLs("http://example:8000"))
|
||||
if err == nil {
|
||||
t.Fatal("expected dial error, got nil")
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func TestMigrateViaTelnet_CommandNotFoundAborts(t *testing.T) {
|
||||
f := &fakeTelnet{responses: resp}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
_, err := m.migrateViaTelnet("192.0.2.1", target)
|
||||
_, err := m.migrateViaTelnet("192.0.2.1", target, defaultTelnetURLs(target))
|
||||
if err == nil {
|
||||
t.Fatal("expected error when envswitch is rejected, got nil")
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func TestMigrateViaTelnet_VerifyMismatchFails(t *testing.T) {
|
||||
f := &fakeTelnet{responses: resp}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
_, err := m.migrateViaTelnet("192.0.2.1", target)
|
||||
_, err := m.migrateViaTelnet("192.0.2.1", target, defaultTelnetURLs(target))
|
||||
if err == nil {
|
||||
t.Fatal("expected verification mismatch error, got nil")
|
||||
}
|
||||
@@ -173,7 +173,7 @@ func TestMigrateViaTelnet_TransportErrorAborts(t *testing.T) {
|
||||
}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
_, err := m.migrateViaTelnet("192.0.2.1", target)
|
||||
_, err := m.migrateViaTelnet("192.0.2.1", target, defaultTelnetURLs(target))
|
||||
if err == nil {
|
||||
t.Fatal("expected transport error, got nil")
|
||||
}
|
||||
@@ -186,7 +186,7 @@ func TestMigrateViaTelnet_TransportErrorAborts(t *testing.T) {
|
||||
func TestMigrateViaTelnet_MissingNewTelnetIsClearError(t *testing.T) {
|
||||
m := &Manager{ServerURL: "http://example:8000"} // NewTelnet deliberately nil
|
||||
|
||||
_, err := m.migrateViaTelnet("192.0.2.1", "http://example:8000")
|
||||
_, err := m.migrateViaTelnet("192.0.2.1", "http://example:8000", defaultTelnetURLs("http://example:8000"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error when NewTelnet is nil")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// telnetPreflight performs a read-only check of the device's port-17000
|
||||
// diagnostic shell and populates the Telnet* fields on summary.
|
||||
//
|
||||
// It exists so the migration UI can decide whether to offer the telnet
|
||||
// method, and so a telnet-only (SSH-less) device can still tell us whether
|
||||
// it is already pointing at our service. The probe is deliberately scoped
|
||||
// to safe, non-mutating commands:
|
||||
//
|
||||
// 1. TCP dial :17000 (Manager.NewTelnet handles the timeouts).
|
||||
// 2. Read whatever banner the shell emits.
|
||||
// 3. `getpdo CurrentSystemConfiguration` — a read-only command; if the
|
||||
// device answers with "command not found" we record that too so the UI
|
||||
// can disable the telnet option with a reason.
|
||||
//
|
||||
// Errors are recorded on summary.TelnetProbeError rather than returned, so
|
||||
// preflight is best-effort and never breaks the rest of GetMigrationSummary.
|
||||
func (m *Manager) telnetPreflight(summary *MigrationSummary, deviceIP string) {
|
||||
if m.NewTelnet == nil {
|
||||
summary.TelnetProbeError = "telnet client not configured"
|
||||
return
|
||||
}
|
||||
|
||||
t := m.NewTelnet(deviceIP)
|
||||
|
||||
if err := t.Dial(); err != nil {
|
||||
summary.TelnetProbeError = fmt.Sprintf("dial %s:17000: %v", deviceIP, err)
|
||||
return
|
||||
}
|
||||
|
||||
defer func() { _ = t.Close() }()
|
||||
|
||||
summary.TelnetReachable = true
|
||||
|
||||
if banner, _ := t.Probe(); banner != "" {
|
||||
summary.TelnetBanner = strings.TrimSpace(banner)
|
||||
}
|
||||
|
||||
resp, err := t.SendCommand("getpdo CurrentSystemConfiguration")
|
||||
if err != nil {
|
||||
summary.TelnetProbeError = fmt.Sprintf("getpdo CurrentSystemConfiguration: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if isCommandNotFound(resp) {
|
||||
summary.TelnetProbeError = "device rejected getpdo CurrentSystemConfiguration (firmware does not expose it)"
|
||||
return
|
||||
}
|
||||
|
||||
summary.TelnetVerifiedConfig = strings.TrimRight(resp, "\r\n")
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTelnetPreflight_HappyPath(t *testing.T) {
|
||||
target := "http://example:8000"
|
||||
f := &fakeTelnet{
|
||||
banner: "BoseShell\n-> ",
|
||||
responses: map[string]string{
|
||||
"getpdo CurrentSystemConfiguration": "margeServerUrl=" + target + "\nbmxRegistryUrl=" + target + "/bmx/registry/v1/services\n",
|
||||
},
|
||||
}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
summary := &MigrationSummary{}
|
||||
m.telnetPreflight(summary, "192.0.2.1")
|
||||
|
||||
if !summary.TelnetReachable {
|
||||
t.Errorf("TelnetReachable = false, want true")
|
||||
}
|
||||
|
||||
if !strings.Contains(summary.TelnetBanner, "BoseShell") {
|
||||
t.Errorf("TelnetBanner = %q, want it to contain BoseShell", summary.TelnetBanner)
|
||||
}
|
||||
|
||||
if !strings.Contains(summary.TelnetVerifiedConfig, target) {
|
||||
t.Errorf("TelnetVerifiedConfig = %q, want it to contain %q", summary.TelnetVerifiedConfig, target)
|
||||
}
|
||||
|
||||
if summary.TelnetProbeError != "" {
|
||||
t.Errorf("TelnetProbeError = %q, want empty on happy path", summary.TelnetProbeError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelnetPreflight_DialFailureRecorded(t *testing.T) {
|
||||
f := &fakeTelnet{dialErr: errors.New("connection refused")}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
summary := &MigrationSummary{}
|
||||
m.telnetPreflight(summary, "192.0.2.1")
|
||||
|
||||
if summary.TelnetReachable {
|
||||
t.Errorf("TelnetReachable = true, want false on dial failure")
|
||||
}
|
||||
|
||||
if !strings.Contains(summary.TelnetProbeError, "connection refused") {
|
||||
t.Errorf("TelnetProbeError = %q, want it to wrap connection refused", summary.TelnetProbeError)
|
||||
}
|
||||
|
||||
if len(f.commands) != 0 {
|
||||
t.Errorf("commands sent on dial failure: %v, want none", f.commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelnetPreflight_GetpdoCommandNotFoundRecorded(t *testing.T) {
|
||||
f := &fakeTelnet{
|
||||
responses: map[string]string{
|
||||
// Default fakeTelnet behaviour returns "Command not found\n" for
|
||||
// any command not in the map. We rely on that here.
|
||||
},
|
||||
}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
summary := &MigrationSummary{}
|
||||
m.telnetPreflight(summary, "192.0.2.1")
|
||||
|
||||
if !summary.TelnetReachable {
|
||||
t.Errorf("TelnetReachable = false, want true (TCP dial succeeded)")
|
||||
}
|
||||
|
||||
if summary.TelnetVerifiedConfig != "" {
|
||||
t.Errorf("TelnetVerifiedConfig = %q, want empty when getpdo is rejected", summary.TelnetVerifiedConfig)
|
||||
}
|
||||
|
||||
if !strings.Contains(summary.TelnetProbeError, "getpdo") {
|
||||
t.Errorf("TelnetProbeError = %q, want it to mention the rejected command", summary.TelnetProbeError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelnetPreflight_TransportErrorRecorded(t *testing.T) {
|
||||
f := &fakeTelnet{
|
||||
fail: map[string]error{
|
||||
"getpdo CurrentSystemConfiguration": errors.New("read: broken pipe"),
|
||||
},
|
||||
}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
summary := &MigrationSummary{}
|
||||
m.telnetPreflight(summary, "192.0.2.1")
|
||||
|
||||
if !summary.TelnetReachable {
|
||||
t.Errorf("TelnetReachable = false, want true (dial succeeded before send)")
|
||||
}
|
||||
|
||||
if !strings.Contains(summary.TelnetProbeError, "broken pipe") {
|
||||
t.Errorf("TelnetProbeError = %q, want it to wrap broken pipe", summary.TelnetProbeError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelnetPreflight_NoNewTelnetRecordsConfigurationError(t *testing.T) {
|
||||
m := &Manager{ServerURL: "http://example:8000"} // NewTelnet deliberately nil
|
||||
|
||||
summary := &MigrationSummary{}
|
||||
m.telnetPreflight(summary, "192.0.2.1")
|
||||
|
||||
if summary.TelnetReachable {
|
||||
t.Errorf("TelnetReachable = true, want false when NewTelnet is nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(summary.TelnetProbeError, "not configured") {
|
||||
t.Errorf("TelnetProbeError = %q, want it to mention configuration", summary.TelnetProbeError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultTelnetURLs_DerivesAllFourFromBase(t *testing.T) {
|
||||
got := defaultTelnetURLs("http://example:8000")
|
||||
|
||||
want := telnetURLs{
|
||||
Marge: "http://example:8000",
|
||||
Stats: "http://example:8000",
|
||||
SwUpdate: "http://example:8000/updates/soundtouch",
|
||||
BmxRegistry: "http://example:8000/bmx/registry/v1/services",
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("defaultTelnetURLs = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelnetURLsFromOptions_NilOptionsReturnsDefaults(t *testing.T) {
|
||||
got := telnetURLsFromOptions("http://example:8000", nil)
|
||||
want := defaultTelnetURLs("http://example:8000")
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("telnetURLsFromOptions(nil) = %+v, want defaults %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelnetURLsFromOptions_EmptyValueFallsBackToDefault(t *testing.T) {
|
||||
options := map[string]string{
|
||||
"marge_url": "", // empty override should be ignored
|
||||
}
|
||||
|
||||
got := telnetURLsFromOptions("http://example:8000", options)
|
||||
|
||||
if got.Marge != "http://example:8000" {
|
||||
t.Errorf("Marge with empty override = %q, want default", got.Marge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelnetURLsFromOptions_PerFieldOverrides(t *testing.T) {
|
||||
options := map[string]string{
|
||||
"marge_url": "http://example:8000/marge", // soundcork-style
|
||||
"stats_url": "", // ignored
|
||||
"sw_update_url": "http://example:8000/custom/updates",
|
||||
"bmx_url": "http://example:8000/custom/bmx",
|
||||
}
|
||||
|
||||
got := telnetURLsFromOptions("http://example:8000", options)
|
||||
|
||||
if got.Marge != "http://example:8000/marge" {
|
||||
t.Errorf("Marge = %q, want override", got.Marge)
|
||||
}
|
||||
|
||||
if got.Stats != "http://example:8000" {
|
||||
t.Errorf("Stats = %q, want default (empty override)", got.Stats)
|
||||
}
|
||||
|
||||
if got.SwUpdate != "http://example:8000/custom/updates" {
|
||||
t.Errorf("SwUpdate = %q, want override", got.SwUpdate)
|
||||
}
|
||||
|
||||
if got.BmxRegistry != "http://example:8000/custom/bmx" {
|
||||
t.Errorf("BmxRegistry = %q, want override", got.BmxRegistry)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTelnetURLs_Commands_EnvswitchTracksMargeAndSwUpdate is the load-bearing
|
||||
// test for the soundcork case: if the user added /marge to Marge, the
|
||||
// envswitch arg1 must follow the same suffix verbatim, otherwise the
|
||||
// parallel persistence layer will revert margeServerUrl on next reboot
|
||||
// (the very failure mode the user described as "envswitch silently
|
||||
// restores my typo").
|
||||
func TestTelnetURLs_Commands_EnvswitchTracksMargeAndSwUpdate(t *testing.T) {
|
||||
urls := telnetURLs{
|
||||
Marge: "http://example:8000/marge",
|
||||
Stats: "http://example:8000",
|
||||
SwUpdate: "http://example:8000/updates/soundtouch",
|
||||
BmxRegistry: "http://example:8000/bmx/registry/v1/services",
|
||||
}
|
||||
|
||||
cmds := urls.Commands()
|
||||
|
||||
var envswitch string
|
||||
|
||||
for _, c := range cmds {
|
||||
if strings.HasPrefix(c, "envswitch boseurls set ") {
|
||||
envswitch = c
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if envswitch == "" {
|
||||
t.Fatalf("Commands missing envswitch boseurls set:\n%v", cmds)
|
||||
}
|
||||
|
||||
wantEnv := "envswitch boseurls set http://example:8000/marge http://example:8000/updates/soundtouch"
|
||||
if envswitch != wantEnv {
|
||||
t.Errorf("envswitch =\n %q\nwant\n %q", envswitch, wantEnv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateViaTelnet_SoundcorkMargeSuffixPropagatesToEnvswitch(t *testing.T) {
|
||||
target := "http://example:8000"
|
||||
urls := telnetURLs{
|
||||
Marge: "http://example:8000/marge",
|
||||
Stats: "http://example:8000",
|
||||
SwUpdate: "http://example:8000/updates/soundtouch",
|
||||
BmxRegistry: "http://example:8000/bmx/registry/v1/services",
|
||||
}
|
||||
|
||||
// Build a happy-path responder that matches the *new* command set.
|
||||
resp := map[string]string{
|
||||
"sys configuration bmxRegistryUrl " + urls.BmxRegistry: "OK\n",
|
||||
"sys configuration statsServerUrl " + urls.Stats: "OK\n",
|
||||
"sys configuration margeServerUrl " + urls.Marge: "OK\n",
|
||||
"sys configuration swUpdateUrl " + urls.SwUpdate: "OK\n",
|
||||
"envswitch boseurls set " + urls.Marge + " " + urls.SwUpdate: "OK\n",
|
||||
"getpdo CurrentSystemConfiguration": "margeServerUrl=" + urls.Marge + "\n",
|
||||
}
|
||||
|
||||
f := &fakeTelnet{responses: resp}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
if _, err := m.migrateViaTelnet("192.0.2.1", target, urls); err != nil {
|
||||
t.Fatalf("migrateViaTelnet: %v", err)
|
||||
}
|
||||
|
||||
wantEnvCmd := "envswitch boseurls set http://example:8000/marge http://example:8000/updates/soundtouch"
|
||||
|
||||
var saw bool
|
||||
|
||||
for _, c := range f.commands {
|
||||
if c == wantEnvCmd {
|
||||
saw = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !saw {
|
||||
t.Errorf("never sent expected envswitch command %q\nactual commands:\n%v", wantEnvCmd, f.commands)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyURLOverrides_NilSafety(t *testing.T) {
|
||||
// Should not panic on nil cfg or nil options.
|
||||
applyURLOverrides(nil, map[string]string{"marge_url": "x"})
|
||||
|
||||
cfg := &PrivateCfg{}
|
||||
applyURLOverrides(cfg, nil)
|
||||
}
|
||||
|
||||
func TestApplyURLOverrides_EmptyValueIsIgnored(t *testing.T) {
|
||||
cfg := &PrivateCfg{
|
||||
MargeServerUrl: "http://example:8000",
|
||||
}
|
||||
applyURLOverrides(cfg, map[string]string{"marge_url": ""})
|
||||
|
||||
if cfg.MargeServerUrl != "http://example:8000" {
|
||||
t.Errorf("MargeServerUrl was overwritten by empty override: %q", cfg.MargeServerUrl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyURLOverrides_AllFour(t *testing.T) {
|
||||
cfg := &PrivateCfg{
|
||||
MargeServerUrl: "default-marge",
|
||||
StatsServerUrl: "default-stats",
|
||||
SwUpdateUrl: "default-sw",
|
||||
BmxRegistryUrl: "default-bmx",
|
||||
}
|
||||
|
||||
applyURLOverrides(cfg, map[string]string{
|
||||
"marge_url": "http://example:8000/marge",
|
||||
"stats_url": "http://example:8000",
|
||||
"sw_update_url": "http://example:8000/updates/soundtouch",
|
||||
"bmx_url": "http://example:8000/bmx/registry/v1/services",
|
||||
})
|
||||
|
||||
if cfg.MargeServerUrl != "http://example:8000/marge" {
|
||||
t.Errorf("MargeServerUrl = %q", cfg.MargeServerUrl)
|
||||
}
|
||||
|
||||
if cfg.StatsServerUrl != "http://example:8000" {
|
||||
t.Errorf("StatsServerUrl = %q", cfg.StatsServerUrl)
|
||||
}
|
||||
|
||||
if cfg.SwUpdateUrl != "http://example:8000/updates/soundtouch" {
|
||||
t.Errorf("SwUpdateUrl = %q", cfg.SwUpdateUrl)
|
||||
}
|
||||
|
||||
if cfg.BmxRegistryUrl != "http://example:8000/bmx/registry/v1/services" {
|
||||
t.Errorf("BmxRegistryUrl = %q", cfg.BmxRegistryUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyURLOverrides_OverridesProxiedMode locks in the precedence
|
||||
// rule: a literal *_url override wins over a self/proxied/original
|
||||
// mode set on the same field. This is the load-bearing behaviour for
|
||||
// the unified per-field URL editor in the Plan card — the user picked
|
||||
// a URL and the migration honors it verbatim.
|
||||
func TestApplyURLOverrides_OverridesProxiedMode(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
cfg := &PrivateCfg{
|
||||
MargeServerUrl: "http://example:8000", // canonical default
|
||||
}
|
||||
|
||||
currentCfg := &PrivateCfg{
|
||||
MargeServerUrl: "https://streaming.bose.com",
|
||||
}
|
||||
|
||||
options := map[string]string{
|
||||
"marge": "proxied", // legacy mode
|
||||
"marge_url": "http://example:8000/marge",
|
||||
}
|
||||
|
||||
m.applyProxyOptions(cfg, "http://proxy:8000", options, currentCfg)
|
||||
applyURLOverrides(cfg, options)
|
||||
|
||||
if cfg.MargeServerUrl != "http://example:8000/marge" {
|
||||
t.Errorf("MargeServerUrl = %q, want literal override (not the /proxy/… form)", cfg.MargeServerUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetMigrationSummary_HonorsURLOverridesInPlannedConfig drives the
|
||||
// PlannedConfig diff back from a real GetMigrationSummary to confirm
|
||||
// the user's per-field URL overrides reach the planned XML the UI
|
||||
// shows — closing the loop between the Plan card editor and the
|
||||
// preview pane.
|
||||
func TestGetMigrationSummary_HonorsURLOverridesInPlannedConfig(t *testing.T) {
|
||||
m, host, cleanup := telnetSummaryEnv(t, nil, &fakeTelnet{dialErr: errors.New("not the focus of this test")})
|
||||
defer cleanup()
|
||||
|
||||
options := map[string]string{
|
||||
"marge_url": "http://example:8000/marge",
|
||||
}
|
||||
|
||||
summary, err := m.GetMigrationSummary(host, "http://example:8000", "", options)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMigrationSummary: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(summary.PlannedConfig, "<margeServerUrl>http://example:8000/marge</margeServerUrl>") {
|
||||
t.Errorf("PlannedConfig should reflect marge_url override:\n%s", summary.PlannedConfig)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Package fakespeaker runs a minimal HTTP server that impersonates the
|
||||
// SoundTouch device's :8090 API surface with sanitized, embedded fixture
|
||||
// data. It exists so docs/screenshot tooling and integration setups can
|
||||
// register a "speaker" without depending on real hardware or leaking
|
||||
// personal data into committed artifacts.
|
||||
//
|
||||
// The fixture set is deliberately narrow: enough for the soundtouch-service
|
||||
// to accept device registration and render initial UI views. Extend the
|
||||
// route set as additional pre-flight or migration flows need coverage.
|
||||
package fakespeaker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed testdata/info.xml testdata/presets.xml testdata/recents.xml
|
||||
var fixtures embed.FS
|
||||
|
||||
// Config configures a fake speaker. The zero value is valid and binds the
|
||||
// HTTP API to a random port on 127.0.0.1 with no telnet listener.
|
||||
type Config struct {
|
||||
// HTTPListen is the bind address for the device's :8090 HTTP API
|
||||
// (e.g. "127.0.0.1:8090" or ":8090"). Empty means "127.0.0.1:0" —
|
||||
// let the OS pick a port.
|
||||
HTTPListen string
|
||||
|
||||
// TelnetListen is the bind address for the device's :17000
|
||||
// diagnostic shell. Empty disables the telnet listener entirely.
|
||||
// Use "127.0.0.1:17000" to match the real port the wizard probes.
|
||||
TelnetListen string
|
||||
}
|
||||
|
||||
// Server is a running fake speaker. It bundles whichever sub-servers
|
||||
// were enabled in the Config; consult HTTPAddr / TelnetAddr to discover
|
||||
// where they actually bound.
|
||||
type Server struct {
|
||||
srv *http.Server
|
||||
httpAddr string
|
||||
telnet *telnetServer
|
||||
}
|
||||
|
||||
// Start binds the configured listeners and serves them in background
|
||||
// goroutines. It returns once they are ready (so callers can immediately
|
||||
// use the resolved addresses) or with an error if any bind failed.
|
||||
func Start(cfg Config) (*Server, error) {
|
||||
httpListen := cfg.HTTPListen
|
||||
if httpListen == "" {
|
||||
httpListen = "127.0.0.1:0"
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", httpListen)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fakespeaker: listen %s: %w", httpListen, err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
registerRoutes(mux)
|
||||
|
||||
s := &Server{
|
||||
srv: &http.Server{
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
},
|
||||
httpAddr: ln.Addr().String(),
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = s.srv.Serve(ln)
|
||||
}()
|
||||
|
||||
if cfg.TelnetListen != "" {
|
||||
ts, terr := startTelnetServer(cfg.TelnetListen)
|
||||
if terr != nil {
|
||||
_ = s.srv.Close()
|
||||
return nil, terr
|
||||
}
|
||||
|
||||
s.telnet = ts
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// HTTPAddr returns the resolved HTTP listen address as "host:port".
|
||||
func (s *Server) HTTPAddr() string {
|
||||
return s.httpAddr
|
||||
}
|
||||
|
||||
// TelnetAddr returns the resolved telnet listen address as "host:port",
|
||||
// or "" if the telnet listener is disabled.
|
||||
func (s *Server) TelnetAddr() string {
|
||||
if s.telnet == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return s.telnet.Addr()
|
||||
}
|
||||
|
||||
// Stop shuts all sub-servers down, blocking until in-flight requests
|
||||
// finish or ctx is cancelled.
|
||||
func (s *Server) Stop(ctx context.Context) error {
|
||||
if s.telnet != nil {
|
||||
s.telnet.Stop()
|
||||
}
|
||||
|
||||
if err := s.srv.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func registerRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/info", serveFixture("testdata/info.xml"))
|
||||
mux.HandleFunc("/presets", serveFixture("testdata/presets.xml"))
|
||||
mux.HandleFunc("/recents", serveFixture("testdata/recents.xml"))
|
||||
}
|
||||
|
||||
func serveFixture(path string) http.HandlerFunc {
|
||||
body, err := fixtures.ReadFile(path)
|
||||
if err != nil {
|
||||
// Embed failure is a build-time programmer error; surface it
|
||||
// loudly the first time the route is hit.
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "fakespeaker: missing fixture "+path+": "+err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package fakespeaker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFakeSpeakerServesFixtures(t *testing.T) {
|
||||
s, err := Start(Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_ = s.Stop(ctx)
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
root string
|
||||
}{
|
||||
{"/info", "info"},
|
||||
{"/presets", "presets"},
|
||||
{"/recents", "recents"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
resp, err := http.Get("http://" + s.HTTPAddr() + tc.path) //nolint:noctx
|
||||
if err != nil {
|
||||
t.Fatalf("get %s: %v", tc.path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
|
||||
var root struct {
|
||||
XMLName xml.Name
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(body, &root); err != nil {
|
||||
t.Fatalf("parse XML: %v\n%s", err, body)
|
||||
}
|
||||
|
||||
if root.XMLName.Local != tc.root {
|
||||
t.Fatalf("root element = %q, want %q", root.XMLName.Local, tc.root)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package fakespeaker
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// telnetBanner mimics what a real SoundTouch device emits on connect to
|
||||
// :17000. The exact wording is not load-bearing for the migration UI —
|
||||
// only TelnetReachable is — but a non-empty banner matches the production
|
||||
// shape and gets surfaced in the wizard for diagnostic value.
|
||||
const telnetBanner = "Welcome to the Bose SoundTouch diagnostic shell\r\n"
|
||||
|
||||
// telnetGetpdoResponse simulates the protobuf-text-like reply to
|
||||
// `getpdo CurrentSystemConfiguration` for an *unmigrated* speaker — every
|
||||
// URL still points at the Bose cloud. This is the happy path for a
|
||||
// documentation screenshot: the wizard renders as "Not Migrated", lists
|
||||
// the original URLs, and offers the migration plan.
|
||||
//
|
||||
// The shape matches what preflight_crosscheck.parseGetpdoConfig expects:
|
||||
// "<key> {\n text: \"<value>\"\n}".
|
||||
const telnetGetpdoResponse = `margeServerUrl {
|
||||
text: "https://streaming.bose.com"
|
||||
}
|
||||
statsServerUrl {
|
||||
text: "https://stats.bose.com"
|
||||
}
|
||||
swUpdateUrl {
|
||||
text: "https://worldwide.bose.com/updates/soundtouch"
|
||||
}
|
||||
bmxRegistryUrl {
|
||||
text: "https://bmxservice.bose.com/bmx/registry/v1/services"
|
||||
}
|
||||
->OK
|
||||
`
|
||||
|
||||
// telnetServer is a minimal TCP server that satisfies the read-only pre-flight
|
||||
// probe in pkg/service/setup/telnet_preflight.go. It handles only the commands
|
||||
// the wizard actually issues and answers every other line with a stub.
|
||||
type telnetServer struct {
|
||||
ln net.Listener
|
||||
addr string
|
||||
wg sync.WaitGroup
|
||||
once sync.Once
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func startTelnetServer(listen string) (*telnetServer, error) {
|
||||
ln, err := net.Listen("tcp", listen)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fakespeaker telnet: listen %s: %w", listen, err)
|
||||
}
|
||||
|
||||
s := &telnetServer{
|
||||
ln: ln,
|
||||
addr: ln.Addr().String(),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
s.wg.Add(1)
|
||||
|
||||
go s.accept()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *telnetServer) Addr() string {
|
||||
return s.addr
|
||||
}
|
||||
|
||||
func (s *telnetServer) Stop() {
|
||||
s.once.Do(func() {
|
||||
close(s.done)
|
||||
_ = s.ln.Close()
|
||||
})
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
func (s *telnetServer) accept() {
|
||||
defer s.wg.Done()
|
||||
|
||||
for {
|
||||
conn, err := s.ln.Accept()
|
||||
if err != nil {
|
||||
// Listener closed → graceful shutdown; any other error means
|
||||
// the OS gave up on us and we should also stop.
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-s.done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
s.wg.Add(1)
|
||||
|
||||
go s.handle(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *telnetServer) handle(conn net.Conn) {
|
||||
defer s.wg.Done()
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
// Banner on connect — clients read it via Probe() before any command.
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
|
||||
|
||||
if _, err := conn.Write([]byte(telnetBanner)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
|
||||
for {
|
||||
// No idle deadline — let the client drive the cadence. The client
|
||||
// closes the socket after it has its answer (~600 ms idle window),
|
||||
// which surfaces here as io.EOF and ends the loop.
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
resp := respondTo(strings.TrimRight(line, "\r\n"))
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
|
||||
|
||||
if _, werr := conn.Write([]byte(resp)); werr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func respondTo(cmd string) string {
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
|
||||
switch cmd {
|
||||
case "getpdo CurrentSystemConfiguration":
|
||||
return telnetGetpdoResponse
|
||||
case "":
|
||||
return "->OK\r\n"
|
||||
default:
|
||||
// Unrecognized commands get a benign acknowledgement so the
|
||||
// probe loop never hangs waiting for a response.
|
||||
return "->OK\r\n"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package fakespeaker
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTelnetServerBannerAndGetpdo(t *testing.T) {
|
||||
s, err := Start(Config{TelnetListen: "127.0.0.1:0"})
|
||||
if err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_ = s.Stop(ctx)
|
||||
})
|
||||
|
||||
if s.TelnetAddr() == "" {
|
||||
t.Fatalf("telnet listener not started")
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", s.TelnetAddr(), 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
|
||||
banner, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("read banner: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(banner, "Bose SoundTouch") {
|
||||
t.Errorf("banner = %q, want substring %q", banner, "Bose SoundTouch")
|
||||
}
|
||||
|
||||
if _, err := conn.Write([]byte("getpdo CurrentSystemConfiguration\r\n")); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
|
||||
buf := make([]byte, 1024)
|
||||
|
||||
var got strings.Builder
|
||||
|
||||
for {
|
||||
n, err := conn.Read(buf)
|
||||
if n > 0 {
|
||||
got.Write(buf[:n])
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if strings.Contains(got.String(), "->OK") {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
out := got.String()
|
||||
|
||||
for _, want := range []string{"margeServerUrl", "streaming.bose.com", "swUpdateUrl"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("response missing %q\nfull response:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<info deviceID="DEADBEEFCAFE">
|
||||
<name>Demo SoundTouch</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<margeAccountUUID>0000000</margeAccountUUID>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.demo.2026-01-01T00:00:00</softwareVersion>
|
||||
<serialNumber>SN0000000000000000DEMO</serialNumber>
|
||||
</component>
|
||||
<component>
|
||||
<componentCategory>PackagedProduct</componentCategory>
|
||||
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.demo.2026-01-01T00:00:00</softwareVersion>
|
||||
<serialNumber>000000P00000000DEMO</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<margeURL>https://streaming.bose.com</margeURL>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>02:00:00:00:00:01</macAddress>
|
||||
<ipAddress>127.0.0.1</ipAddress>
|
||||
</networkInfo>
|
||||
<networkInfo type="SMSC">
|
||||
<macAddress>02:00:00:00:00:01</macAddress>
|
||||
<ipAddress>127.0.0.1</ipAddress>
|
||||
</networkInfo>
|
||||
<moduleType>sm2</moduleType>
|
||||
<variant>rhino</variant>
|
||||
<variantMode>normal</variantMode>
|
||||
<countryCode>GB</countryCode>
|
||||
<regionCode>GB</regionCode>
|
||||
</info>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<presets>
|
||||
<preset id="1" createdOn="1700000000" updatedOn="1700000000">
|
||||
<ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s24939" sourceAccount="" isPresetable="true">
|
||||
<itemName>Demo Radio</itemName>
|
||||
<containerArt>https://example.invalid/preset1.jpg</containerArt>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset id="2" createdOn="1700000000" updatedOn="1700000000">
|
||||
<ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s00000" sourceAccount="" isPresetable="true">
|
||||
<itemName>Demo News</itemName>
|
||||
<containerArt>https://example.invalid/preset2.jpg</containerArt>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
</presets>
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<recents/>
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
@@ -168,11 +169,92 @@ func DecryptBlob(encKey, macKey, blob []byte) ([]byte, error) {
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// validateZcBaseURL parses zcBaseURL and ensures the URL points at a
|
||||
// non-routable host on the LAN. Speakers live on the local network; rejecting
|
||||
// non-local hosts prevents the upstream caller from being tricked into
|
||||
// making outbound requests to arbitrary hosts (server-side request forgery).
|
||||
//
|
||||
// The validator is strict on purpose:
|
||||
// - the scheme must be http or https,
|
||||
// - the host must be a *literal IP* (no DNS / mDNS hostnames — see note
|
||||
// below) that is loopback, RFC1918 private, or IPv4/IPv6 link-local,
|
||||
// - the returned URL is rebuilt from validated components so the
|
||||
// subsequent String() call no longer carries the original tainted host
|
||||
// value, which CodeQL recognises as taint sanitisation.
|
||||
//
|
||||
// Note on hostnames: SoundTouch speakers announce themselves with
|
||||
// IP-based zeroconf URLs in the captures we have. If a future deployment
|
||||
// needs mDNS support, the right place to add it is in the caller — resolve
|
||||
// the hostname to an IP and pass the IP-form URL in here. Doing the lookup
|
||||
// inside the validator would re-introduce the very SSRF surface CodeQL is
|
||||
// flagging, because malicious DNS could point a *.local name at a
|
||||
// public host between the lookup and the request.
|
||||
func validateZcBaseURL(zcBaseURL string) (*url.URL, error) {
|
||||
u, err := url.Parse(zcBaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zeroconf URL %q: parse: %w", zcBaseURL, err)
|
||||
}
|
||||
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return nil, fmt.Errorf("zeroconf URL %q: scheme %q not allowed — must be http or https", zcBaseURL, u.Scheme)
|
||||
}
|
||||
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return nil, fmt.Errorf("zeroconf URL %q: missing host", zcBaseURL)
|
||||
}
|
||||
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return nil, fmt.Errorf(
|
||||
"zeroconf URL %q: host %q must be a literal IP — resolve the hostname to a private-network IP first "+
|
||||
"(e.g. `getent hosts %s` or `dig +short %s`) and retry with the resolved address",
|
||||
zcBaseURL, host, host, host)
|
||||
}
|
||||
|
||||
if !ip.IsLoopback() && !ip.IsPrivate() && !ip.IsLinkLocalUnicast() {
|
||||
return nil, fmt.Errorf(
|
||||
"zeroconf URL %q: host %q is not on a local network — only loopback (127.0.0.0/8, ::1), "+
|
||||
"RFC1918 private (10/8, 172.16/12, 192.168/16) and link-local (169.254/16, fe80::/10) "+
|
||||
"addresses are accepted",
|
||||
zcBaseURL, host)
|
||||
}
|
||||
|
||||
// Build a fresh URL from validated components only — the IP literal,
|
||||
// the original port, the original path. Pre-existing ?query and
|
||||
// #fragment are stripped so callers can attach their own cleanly.
|
||||
hostPort := ip.String()
|
||||
if port := u.Port(); port != "" {
|
||||
hostPort = net.JoinHostPort(ip.String(), port)
|
||||
}
|
||||
|
||||
return &url.URL{
|
||||
Scheme: u.Scheme,
|
||||
Host: hostPort,
|
||||
Path: u.Path,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// withAction returns the validated base URL with ?action=<action> appended.
|
||||
func withAction(base *url.URL, action string) string {
|
||||
u := *base
|
||||
q := u.Query()
|
||||
q.Set("action", action)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// GetInfo fetches the speaker's DH public key via GET ?action=getInfo.
|
||||
func GetInfo(zcBaseURL string) ([]byte, error) {
|
||||
base, err := validateZcBaseURL(zcBaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getInfo: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
resp, err := client.Get(zcBaseURL + "?action=getInfo")
|
||||
resp, err := client.Get(withAction(base, "getInfo"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getInfo: %w", err)
|
||||
}
|
||||
@@ -209,6 +291,11 @@ func GetInfo(zcBaseURL string) ([]byte, error) {
|
||||
// it falls back to the simplified tokenType=accesstoken approach.
|
||||
// zcBaseURL is the base URL of the ZeroConf endpoint, e.g. "http://192.168.1.10:8200/zc".
|
||||
func PushCredentials(zcBaseURL, username, accessToken string) error {
|
||||
base, err := validateZcBaseURL(zcBaseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pushCredentials: %w", err)
|
||||
}
|
||||
|
||||
speakerPublicKey, err := GetInfo(zcBaseURL)
|
||||
if err != nil {
|
||||
log.Printf("[ZeroConf] getInfo failed (%v), falling back to simplified token push", err)
|
||||
@@ -237,7 +324,7 @@ func PushCredentials(zcBaseURL, username, accessToken string) error {
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
resp, err := client.PostForm(zcBaseURL+"?action=addUser", data)
|
||||
resp, err := client.PostForm(withAction(base, "addUser"), data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pushCredentials: addUser: %w", err)
|
||||
}
|
||||
@@ -255,6 +342,11 @@ func PushCredentials(zcBaseURL, username, accessToken string) error {
|
||||
// pushSimplifiedToken is the fallback for firmware that does not support DH
|
||||
// key exchange. It sends the raw OAuth access token directly as the blob.
|
||||
func pushSimplifiedToken(zcBaseURL, username, accessToken string) error {
|
||||
base, err := validateZcBaseURL(zcBaseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pushSimplifiedToken: %w", err)
|
||||
}
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("userName", username)
|
||||
data.Set("blob", accessToken)
|
||||
@@ -263,7 +355,7 @@ func pushSimplifiedToken(zcBaseURL, username, accessToken string) error {
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
resp, err := client.PostForm(zcBaseURL+"?action=addUser", data)
|
||||
resp, err := client.PostForm(withAction(base, "addUser"), data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pushSimplifiedToken: %w", err)
|
||||
}
|
||||
|
||||
@@ -312,3 +312,54 @@ func readProtoVarint(data []byte) (uint64, int) {
|
||||
}
|
||||
return 0, len(data)
|
||||
}
|
||||
|
||||
func TestValidateZcBaseURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantOK bool
|
||||
wantHost string // expected u.Host on success
|
||||
wantPath string
|
||||
}{
|
||||
{"loopback", "http://127.0.0.1:8200/zc", true, "127.0.0.1:8200", "/zc"},
|
||||
{"loopback no port", "http://127.0.0.1/zc", true, "127.0.0.1", "/zc"},
|
||||
{"private 192", "http://192.168.1.10:8200/zc", true, "192.168.1.10:8200", "/zc"},
|
||||
{"private 10", "http://10.0.0.5/zc", true, "10.0.0.5", "/zc"},
|
||||
{"private 172", "http://172.16.0.1/zc", true, "172.16.0.1", "/zc"},
|
||||
{"link-local v4", "http://169.254.10.20/zc", true, "169.254.10.20", "/zc"},
|
||||
{"ipv6 loopback", "http://[::1]:8200/zc", true, "[::1]:8200", "/zc"},
|
||||
{"ipv6 link-local", "http://[fe80::1]:8200/zc", true, "[fe80::1]:8200", "/zc"},
|
||||
{"strips query", "http://192.168.1.10:8200/zc?foo=bar", true, "192.168.1.10:8200", "/zc"},
|
||||
|
||||
{"public IP rejected", "http://1.1.1.1/zc", false, "", ""},
|
||||
{"public ipv6 rejected", "http://[2001:db8::1]/zc", false, "", ""},
|
||||
{"hostname rejected", "http://myspeaker.local/zc", false, "", ""},
|
||||
{"plain hostname rejected", "http://speaker/zc", false, "", ""},
|
||||
{"ftp scheme rejected", "ftp://192.168.1.10/zc", false, "", ""},
|
||||
{"file scheme rejected", "file:///etc/passwd", false, "", ""},
|
||||
{"empty host rejected", "http:///zc", false, "", ""},
|
||||
{"unparseable rejected", "::not a url::", false, "", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := validateZcBaseURL(tc.input)
|
||||
if tc.wantOK {
|
||||
if err != nil {
|
||||
t.Fatalf("validateZcBaseURL(%q) returned error %v, want success", tc.input, err)
|
||||
}
|
||||
if got.Host != tc.wantHost {
|
||||
t.Errorf("Host = %q, want %q", got.Host, tc.wantHost)
|
||||
}
|
||||
if got.Path != tc.wantPath {
|
||||
t.Errorf("Path = %q, want %q", got.Path, tc.wantPath)
|
||||
}
|
||||
if got.RawQuery != "" {
|
||||
t.Errorf("RawQuery = %q, want empty (validator should strip query)", got.RawQuery)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Errorf("validateZcBaseURL(%q) succeeded, want error", tc.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Package speaker holds protocol-level constants for the Bose SoundTouch
|
||||
// speaker's local API surface: the well-known HTTP port, the request paths
|
||||
// exposed by every device, and the on-device file locations the migration
|
||||
// flow needs to know about.
|
||||
//
|
||||
// This package is intentionally a leaf with no internal dependencies, so
|
||||
// any layer can import it (client library, service, CLI, tests) without
|
||||
// introducing a cycle or a cross-topic edge. Anything speaker-shaped that
|
||||
// would otherwise be duplicated between packages belongs here.
|
||||
package speaker
|
||||
|
||||
// HTTPPort is the well-known port the SoundTouch device exposes its local
|
||||
// API on (e.g. /info, /presets, /group).
|
||||
const HTTPPort = 8090
|
||||
|
||||
// Well-known HTTP paths the SoundTouch device serves on HTTPPort.
|
||||
const (
|
||||
DeviceInfoPath = "/info"
|
||||
PresetsPath = "/presets"
|
||||
RecentsPath = "/recents"
|
||||
)
|
||||
|
||||
// On-device filesystem paths that the migration/sync flow needs to read or
|
||||
// write over SSH. These live in the device's persistence area and are not
|
||||
// part of the HTTP surface.
|
||||
const (
|
||||
SourcesFileLocation = "/mnt/nv/BoseApp-Persistence/1/Sources.xml"
|
||||
GroupServiceFileLocation = "/mnt/nv/BoseApp-Persistence/1/GroupService.xml"
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
package speaker
|
||||
|
||||
import "testing"
|
||||
|
||||
// Sanity-check the well-known values — a wrong number here would silently
|
||||
// break every transport and is cheap to guard against.
|
||||
func TestSpeakerConstants(t *testing.T) {
|
||||
if HTTPPort != 8090 {
|
||||
t.Errorf("HTTPPort = %d, want 8090", HTTPPort)
|
||||
}
|
||||
|
||||
cases := map[string]string{
|
||||
"DeviceInfoPath": DeviceInfoPath,
|
||||
"PresetsPath": PresetsPath,
|
||||
"RecentsPath": RecentsPath,
|
||||
"SourcesFileLocation": SourcesFileLocation,
|
||||
"GroupServiceFileLocation": GroupServiceFileLocation,
|
||||
}
|
||||
|
||||
for name, val := range cases {
|
||||
if val == "" {
|
||||
t.Errorf("%s is empty", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,14 +18,22 @@ import (
|
||||
)
|
||||
|
||||
// Default values for a fresh Client.
|
||||
//
|
||||
// The dial and read budgets were originally tighter (2s / 5s); both were
|
||||
// relaxed after observing transient i/o-timeout failures on healthy
|
||||
// speakers that reliably resolved on a second attempt. The diagnostic
|
||||
// shell on FW 27.0.6 occasionally takes >2s to accept a fresh TCP
|
||||
// connection — likely while the device is servicing other work — so a
|
||||
// short dial budget produces flaky preflight results without indicating
|
||||
// a real reachability problem.
|
||||
const (
|
||||
DefaultPort = 17000
|
||||
DefaultDialTimeout = 2 * time.Second
|
||||
DefaultReadTimeout = 5 * time.Second
|
||||
DefaultWriteTimeout = 2 * time.Second
|
||||
DefaultDialTimeout = 4 * time.Second
|
||||
DefaultReadTimeout = 7 * time.Second
|
||||
DefaultWriteTimeout = 3 * time.Second
|
||||
// idleWindow is how long we wait for further bytes after the first
|
||||
// byte of a response before treating the response as complete.
|
||||
idleWindow = 400 * time.Millisecond
|
||||
idleWindow = 600 * time.Millisecond
|
||||
)
|
||||
|
||||
// Client is a connected (or about-to-be-connected) session to a SoundTouch
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
VERSION=${VERSION:-0.73.0}
|
||||
VERSION=${VERSION:-0.74.0}
|
||||
GH_REPO=${GH_REPO:-gesellix/Bose-SoundTouch}
|
||||
BINARY_URL=${BINARY_URL:-https://github.com/$GH_REPO/releases/download/v$VERSION/soundtouch-service-v$VERSION-linux-armv7}
|
||||
INIT_SCRIPT_URL=${INIT_SCRIPT_URL:-https://raw.githubusercontent.com/$GH_REPO/v$VERSION/scripts/on-device-install/aftertouch}
|
||||
|
||||
@@ -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.24.0}}"
|
||||
VERSION="${1:-${VERSION:-v0.74.0}}"
|
||||
# Normalize version prefix
|
||||
if [[ ! "$VERSION" =~ ^v ]]; then
|
||||
VERSION="v${VERSION}"
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
// Command screenshots drives a headless Chrome via chromedp to capture
|
||||
// PNG screenshots of the soundtouch-service web UI for documentation.
|
||||
//
|
||||
// It is deliberately decoupled from any speaker/service setup: callers
|
||||
// are responsible for having the service reachable at --base and any
|
||||
// required devices already registered. See cmd/dummy-speaker for a
|
||||
// matching no-hardware backend.
|
||||
//
|
||||
// Manifest format (JSON):
|
||||
//
|
||||
// {
|
||||
// "shots": [
|
||||
// {
|
||||
// "name": "ui-settings",
|
||||
// "path": "/web/",
|
||||
// "click_selector": "button[onclick*=\"tab-settings\"]",
|
||||
// "wait_selector": "#tab-settings.active",
|
||||
// "viewport": {"width": 1280, "height": 900},
|
||||
// "settle_ms": 250
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
)
|
||||
|
||||
type viewport struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Scale float64 `json:"scale"`
|
||||
}
|
||||
|
||||
type shot struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
ClickSelector string `json:"click_selector,omitempty"`
|
||||
WaitSelector string `json:"wait_selector,omitempty"`
|
||||
Evaluate string `json:"evaluate,omitempty"` // JS to run after the click (e.g. to programmatically select a device + trigger summary)
|
||||
WaitAfterEval string `json:"wait_after_eval,omitempty"` // selector to wait for once the JS evaluation has completed
|
||||
Viewport viewport `json:"viewport,omitempty"`
|
||||
SettleMs int `json:"settle_ms,omitempty"`
|
||||
}
|
||||
|
||||
type manifest struct {
|
||||
Shots []shot `json:"shots"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
base := flag.String("base", "http://localhost:8000", "service base URL")
|
||||
manifestPath := flag.String("manifest", "scripts/screenshots/manifest.json", "path to shot manifest JSON")
|
||||
outDir := flag.String("out", "docs/images", "output directory for PNGs")
|
||||
timeoutSec := flag.Int("timeout", 30, "per-shot timeout (seconds)")
|
||||
flag.Parse()
|
||||
|
||||
m, err := readManifest(*manifestPath)
|
||||
if err != nil {
|
||||
log.Fatalf("read manifest: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(*outDir, 0o755); err != nil {
|
||||
log.Fatalf("mkdir %s: %v", *outDir, err)
|
||||
}
|
||||
|
||||
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(),
|
||||
append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", true),
|
||||
chromedp.Flag("disable-gpu", true),
|
||||
chromedp.Flag("hide-scrollbars", true),
|
||||
)...)
|
||||
defer cancelAlloc()
|
||||
|
||||
browserCtx, cancelBrowser := chromedp.NewContext(allocCtx)
|
||||
defer cancelBrowser()
|
||||
|
||||
if err := chromedp.Run(browserCtx); err != nil {
|
||||
log.Fatalf("launch browser: %v", err)
|
||||
}
|
||||
|
||||
failed := 0
|
||||
|
||||
for _, sh := range m.Shots {
|
||||
log.Printf("capturing %s", sh.Name)
|
||||
|
||||
if err := capture(browserCtx, *base, *outDir, sh, time.Duration(*timeoutSec)*time.Second); err != nil {
|
||||
log.Printf(" failed: %v", err)
|
||||
|
||||
failed++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf(" ok")
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
log.Fatalf("%d shot(s) failed", failed)
|
||||
}
|
||||
}
|
||||
|
||||
func readManifest(path string) (*manifest, error) {
|
||||
raw, err := os.ReadFile(path) //nolint:gosec
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func capture(parent context.Context, baseURL, outDir string, sh shot, timeout time.Duration) error {
|
||||
ctx, cancel := context.WithTimeout(parent, timeout)
|
||||
defer cancel()
|
||||
|
||||
w, h := sh.Viewport.Width, sh.Viewport.Height
|
||||
if w == 0 {
|
||||
w = 1280
|
||||
}
|
||||
|
||||
if h == 0 {
|
||||
h = 900
|
||||
}
|
||||
|
||||
scale := sh.Viewport.Scale
|
||||
if scale == 0 {
|
||||
scale = 2 // retina-equivalent DPR; sharper text in captured PNGs
|
||||
}
|
||||
|
||||
settle := time.Duration(sh.SettleMs) * time.Millisecond
|
||||
if settle == 0 {
|
||||
settle = 200 * time.Millisecond
|
||||
}
|
||||
|
||||
tabCtx, tabCancel := chromedp.NewContext(ctx)
|
||||
defer tabCancel()
|
||||
|
||||
url := baseURL + sh.Path
|
||||
|
||||
actions := []chromedp.Action{
|
||||
chromedp.EmulateViewport(int64(w), int64(h), chromedp.EmulateScale(scale)),
|
||||
chromedp.Navigate(url),
|
||||
chromedp.WaitReady("body", chromedp.ByQuery),
|
||||
}
|
||||
|
||||
if sh.ClickSelector != "" {
|
||||
actions = append(actions,
|
||||
chromedp.WaitVisible(sh.ClickSelector, chromedp.ByQuery),
|
||||
chromedp.Click(sh.ClickSelector, chromedp.ByQuery),
|
||||
)
|
||||
}
|
||||
|
||||
if sh.WaitSelector != "" {
|
||||
actions = append(actions, chromedp.WaitVisible(sh.WaitSelector, chromedp.ByQuery))
|
||||
}
|
||||
|
||||
if sh.Evaluate != "" {
|
||||
actions = append(actions, chromedp.Evaluate(sh.Evaluate, nil))
|
||||
}
|
||||
|
||||
if sh.WaitAfterEval != "" {
|
||||
actions = append(actions, chromedp.WaitVisible(sh.WaitAfterEval, chromedp.ByQuery))
|
||||
}
|
||||
|
||||
actions = append(actions, chromedp.Sleep(settle))
|
||||
|
||||
var buf []byte
|
||||
|
||||
actions = append(actions, chromedp.FullScreenshot(&buf, 100))
|
||||
|
||||
if err := chromedp.Run(tabCtx, actions...); err != nil {
|
||||
return fmt.Errorf("chromedp: %w", err)
|
||||
}
|
||||
|
||||
outPath := filepath.Join(outDir, sh.Name+".png")
|
||||
if err := os.WriteFile(outPath, buf, 0o644); err != nil { //nolint:gosec
|
||||
return fmt.Errorf("write %s: %w", outPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"shots": [
|
||||
{
|
||||
"name": "ui-settings",
|
||||
"path": "/",
|
||||
"click_selector": "button[onclick*=\"tab-settings\"]",
|
||||
"wait_selector": "#tab-settings.active",
|
||||
"settle_ms": 300
|
||||
},
|
||||
{
|
||||
"name": "ui-devices",
|
||||
"path": "/",
|
||||
"click_selector": "button[onclick*=\"tab-devices\"]",
|
||||
"wait_selector": "#tab-devices.active",
|
||||
"settle_ms": 500
|
||||
},
|
||||
{
|
||||
"name": "ui-sync",
|
||||
"path": "/",
|
||||
"click_selector": "button[onclick*=\"tab-sync\"]",
|
||||
"wait_selector": "#tab-sync.active",
|
||||
"settle_ms": 300
|
||||
},
|
||||
{
|
||||
"name": "ui-migration",
|
||||
"path": "/",
|
||||
"click_selector": "button[onclick*=\"tab-migration\"]",
|
||||
"wait_selector": "#tab-migration.active",
|
||||
"evaluate": "prepareMigration('DEADBEEFCAFE')",
|
||||
"wait_after_eval": "#migration-summary",
|
||||
"settle_ms": 4000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrates an end-to-end screenshot capture: spins up a clean
|
||||
# soundtouch-service + dummy-speaker, drives the web UI in headless
|
||||
# Chrome via the chromedp runner, then tears everything down.
|
||||
#
|
||||
# Outputs to docs/images/ by default. Override with OUT_DIR=/some/path.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
OUT_DIR="${OUT_DIR:-docs/images}"
|
||||
SERVICE_PORT="${SERVICE_PORT:-8000}"
|
||||
SPEAKER_PORT="${SPEAKER_PORT:-8090}"
|
||||
DATA_DIR="$(mktemp -d -t soundtouch-screenshots-XXXXXX)"
|
||||
LOG_DIR="$(mktemp -d -t soundtouch-screenshot-logs-XXXXXX)"
|
||||
|
||||
SERVICE_PID=""
|
||||
SPEAKER_PID=""
|
||||
|
||||
cleanup() {
|
||||
set +e
|
||||
if [ -n "$SPEAKER_PID" ] && kill -0 "$SPEAKER_PID" 2>/dev/null; then
|
||||
kill "$SPEAKER_PID"
|
||||
wait "$SPEAKER_PID" 2>/dev/null
|
||||
fi
|
||||
if [ -n "$SERVICE_PID" ] && kill -0 "$SERVICE_PID" 2>/dev/null; then
|
||||
kill "$SERVICE_PID"
|
||||
wait "$SERVICE_PID" 2>/dev/null
|
||||
fi
|
||||
rm -rf "$DATA_DIR"
|
||||
echo "logs retained at $LOG_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "==> building binaries"
|
||||
go build -o "$LOG_DIR/soundtouch-service" ./cmd/soundtouch-service
|
||||
go build -o "$LOG_DIR/dummy-speaker" ./cmd/dummy-speaker
|
||||
go build -o "$LOG_DIR/screenshots" ./scripts/screenshots
|
||||
|
||||
echo "==> seeding settings.json (generic hostname + discovery off to avoid leaking real network info)"
|
||||
cat > "$DATA_DIR/settings.json" <<'EOF'
|
||||
{
|
||||
"server_url": "http://aftertouch.local:8000",
|
||||
"https_server_url": "https://aftertouch.local:8443",
|
||||
"discovery_enabled": false,
|
||||
"discovery_interval": "1h"
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "==> starting soundtouch-service on :$SERVICE_PORT (data: $DATA_DIR)"
|
||||
"$LOG_DIR/soundtouch-service" --port "$SERVICE_PORT" --data-dir "$DATA_DIR" \
|
||||
> "$LOG_DIR/service.log" 2>&1 &
|
||||
SERVICE_PID=$!
|
||||
|
||||
echo "==> waiting for service to be ready"
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsS "http://127.0.0.1:$SERVICE_PORT/setup/devices" > /dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$SERVICE_PID" 2>/dev/null; then
|
||||
echo "service died early; log tail:"
|
||||
tail -40 "$LOG_DIR/service.log"
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
echo "==> starting dummy-speaker on :$SPEAKER_PORT (registering with service)"
|
||||
# Register as bare IP (no port) so the service appends :8090 for HTTP and
|
||||
# :17000 for telnet exactly the way it does with real hardware. This is
|
||||
# also why the listeners below bind to the canonical Bose ports.
|
||||
"$LOG_DIR/dummy-speaker" \
|
||||
--listen "127.0.0.1:$SPEAKER_PORT" \
|
||||
--telnet-listen "127.0.0.1:17000" \
|
||||
--register "http://127.0.0.1:$SERVICE_PORT" \
|
||||
--register-as "127.0.0.1" \
|
||||
> "$LOG_DIR/speaker.log" 2>&1 &
|
||||
SPEAKER_PID=$!
|
||||
sleep 1
|
||||
|
||||
if ! kill -0 "$SPEAKER_PID" 2>/dev/null; then
|
||||
echo "dummy-speaker died early; log tail:"
|
||||
tail -40 "$LOG_DIR/speaker.log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> capturing screenshots into $OUT_DIR"
|
||||
"$LOG_DIR/screenshots" \
|
||||
--base "http://127.0.0.1:$SERVICE_PORT" \
|
||||
--manifest scripts/screenshots/manifest.json \
|
||||
--out "$OUT_DIR"
|
||||
|
||||
echo "==> done"
|
||||