Compare commits
@@ -86,13 +86,24 @@ jobs:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
goos: [linux, darwin, windows]
|
||||
goarch: [amd64, arm64]
|
||||
exclude:
|
||||
# Windows ARM64 builds are experimental
|
||||
- goos: windows
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
- goos: linux
|
||||
goarch: arm
|
||||
goarm: 7
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
- goos: freebsd
|
||||
goarch: amd64
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -103,22 +114,48 @@ jobs:
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Build CLI
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.mod') }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Build binaries
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOARM: ${{ matrix.goarm }}
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
output_name="soundtouch-cli-${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
output_name="${output_name}.exe"
|
||||
ARCH_SUFFIX="${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
if [[ -n "${{ matrix.goarm }}" ]]; then
|
||||
ARCH_SUFFIX="${ARCH_SUFFIX}v${{ matrix.goarm }}"
|
||||
fi
|
||||
go build -trimpath -ldflags="-s -w" -o "$output_name" ./cmd/soundtouch-cli
|
||||
|
||||
EXT=""
|
||||
if [[ "${{ matrix.goos }}" == "windows" ]]; then
|
||||
EXT=".exe"
|
||||
fi
|
||||
|
||||
mkdir -p build
|
||||
|
||||
for binary in soundtouch-cli soundtouch-service soundtouch-web soundtouch-backup; do
|
||||
OUTPUT="build/${binary}-${ARCH_SUFFIX}${EXT}"
|
||||
echo "Building $OUTPUT"
|
||||
go build -trimpath -ldflags="-s -w" -o "$OUTPUT" "./cmd/$binary"
|
||||
done
|
||||
|
||||
ls -la build/
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: soundtouch-cli-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: soundtouch-cli-*
|
||||
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
|
||||
path: build/
|
||||
|
||||
security:
|
||||
name: Basic Security Check
|
||||
@@ -271,8 +308,22 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Determine push eligibility
|
||||
id: push-check
|
||||
run: |
|
||||
# Push on main, and on same-repo PRs (forks can't push to GHCR via GITHUB_TOKEN).
|
||||
SHOULD_PUSH="false"
|
||||
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
|
||||
SHOULD_PUSH="true"
|
||||
elif [[ "${{ github.event_name }}" == "pull_request" && \
|
||||
"${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then
|
||||
SHOULD_PUSH="true"
|
||||
fi
|
||||
echo "should-push=$SHOULD_PUSH" >> "$GITHUB_OUTPUT"
|
||||
echo "Will push: $SHOULD_PUSH"
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
if: steps.push-check.outputs.should-push == 'true'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -286,7 +337,9 @@ jobs:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=ref,event=pr
|
||||
type=ref,event=pr,prefix=preview-pr-
|
||||
type=sha,prefix=preview-sha-,format=short,enable=${{ github.event_name == 'pull_request' }}
|
||||
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push soundtouch-service Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
@@ -294,7 +347,7 @@ jobs:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
push: ${{ steps.push-check.outputs.should-push == 'true' }}
|
||||
tags: ${{ steps.meta-service.outputs.tags }}
|
||||
labels: ${{ steps.meta-service.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
@@ -307,7 +360,9 @@ jobs:
|
||||
images: ghcr.io/${{ github.repository }}-web
|
||||
tags: |
|
||||
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=ref,event=pr
|
||||
type=ref,event=pr,prefix=preview-pr-
|
||||
type=sha,prefix=preview-sha-,format=short,enable=${{ github.event_name == 'pull_request' }}
|
||||
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push soundtouch-web Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
@@ -315,12 +370,49 @@ jobs:
|
||||
context: .
|
||||
target: soundtouch-web
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
push: ${{ steps.push-check.outputs.should-push == 'true' }}
|
||||
tags: ${{ steps.meta-web.outputs.tags }}
|
||||
labels: ${{ steps.meta-web.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Summarize published images
|
||||
if: steps.push-check.outputs.should-push == 'true'
|
||||
env:
|
||||
SERVICE_TAGS: ${{ steps.meta-service.outputs.tags }}
|
||||
WEB_TAGS: ${{ steps.meta-web.outputs.tags }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
{
|
||||
echo "## 🐳 Published Docker Images"
|
||||
echo ""
|
||||
if [[ "$EVENT_NAME" == "pull_request" ]]; then
|
||||
echo "**Preview** images for PR #${PR_NUMBER}. These are not release builds."
|
||||
elif [[ "$REF_NAME" == "main" ]]; then
|
||||
echo "**Edge** images from \`main\`."
|
||||
else
|
||||
echo "**Preview** images from branch \`${REF_NAME}\`. These are not release builds."
|
||||
fi
|
||||
echo ""
|
||||
echo "### soundtouch-service"
|
||||
echo ""
|
||||
echo '```bash'
|
||||
while IFS= read -r tag; do
|
||||
[[ -n "$tag" ]] && echo "docker pull $tag"
|
||||
done <<< "$SERVICE_TAGS"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### soundtouch-web"
|
||||
echo ""
|
||||
echo '```bash'
|
||||
while IFS= read -r tag; do
|
||||
[[ -n "$tag" ]] && echo "docker pull $tag"
|
||||
done <<< "$WEB_TAGS"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
notify:
|
||||
name: Notify Status
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -20,6 +20,8 @@ See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVI
|
||||
|
||||
A local server that replaces the Bose cloud ("AfterTouch"). Once your speaker is redirected to it, you have full control without any Bose cloud dependency. The built-in web UI at `http://localhost:8000` handles all setup — no config files needed to get started.
|
||||
|
||||
If you want to run a server for this - no problem. The service is small enough to run on the SoundTouch itself. See the [On-Device Installer](./scripts/on-device-install/README.md) for instructions.
|
||||
|
||||
**Two scenarios:**
|
||||
|
||||
**Before shutdown — migrate your existing setup**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -1070,6 +1087,8 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
|
||||
r.Post("/revert/{deviceId}", server.HandleRevertMigration)
|
||||
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
|
||||
r.Get("/account-id-suggestions/{deviceId}", server.HandleAccountIDSuggestions)
|
||||
r.Post("/pair-account/{deviceId}", server.HandlePairAccount)
|
||||
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services/{deviceId}", server.HandleRemoveRemoteServices)
|
||||
|
||||
@@ -48,6 +48,7 @@ GET /mgmt/spotify/callback handlers.(
|
||||
GET /mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
|
||||
GET /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
GET /proxy/* handlers.(*Server).HandleProxyRequest-fm
|
||||
GET /setup/account-id-suggestions/{deviceId} handlers.(*Server).HandleAccountIDSuggestions-fm
|
||||
GET /setup/ca.crt handlers.(*Server).HandleGetCACert-fm
|
||||
GET /setup/devices handlers.(*Server).HandleListDiscoveredDevices-fm
|
||||
GET /setup/devices/{deviceId}/events handlers.(*Server).HandleGetDeviceEvents-fm
|
||||
@@ -121,6 +122,8 @@ POST /setup/devices handlers.(
|
||||
POST /setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
|
||||
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
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
// Package handlers contains HTTP handlers for the SoundTouch web UI.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
bmxpkg "github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// WebApp holds the application state and dependencies
|
||||
type WebApp struct {
|
||||
Devices map[string]*webtypes.DeviceConnection
|
||||
Upgrader websocket.Upgrader
|
||||
WSClients map[*websocket.Conn]bool
|
||||
WSMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewWebApp creates a new WebApp instance for SPA mode
|
||||
func NewWebApp() *WebApp {
|
||||
return &WebApp{
|
||||
Devices: make(map[string]*webtypes.DeviceConnection),
|
||||
WSClients: make(map[*websocket.Conn]bool),
|
||||
Upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(_ *http.Request) bool { return true },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDevices returns all devices as JSON
|
||||
func (app *WebApp) HandleAPIDevices(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Return all devices as JSON
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"lastSeen": device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDevice returns a specific device as JSON
|
||||
func (app *WebApp) HandleAPIDevice(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
if deviceID == "" {
|
||||
app.sendError(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Update device status to get fresh power state
|
||||
app.UpdateDeviceStatus(deviceID, device)
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIControl handles device control commands
|
||||
func (app *WebApp) HandleAPIControl(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
action := chi.URLParam(r, "action")
|
||||
|
||||
if deviceID == "" {
|
||||
app.sendError(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
app.handleControlAction(w, r, action, device)
|
||||
}
|
||||
|
||||
// handleControlAction processes different control actions
|
||||
func (app *WebApp) handleControlAction(w http.ResponseWriter, r *http.Request, action string, device *webtypes.DeviceConnection) {
|
||||
switch action {
|
||||
case "play":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.Play()
|
||||
app.sendControlResponse(w, err, "Started playback")
|
||||
case "pause":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.Pause()
|
||||
app.sendControlResponse(w, err, "Paused playback")
|
||||
case "stop":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.Stop()
|
||||
app.sendControlResponse(w, err, "Stopped playback")
|
||||
case "next":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.NextTrack()
|
||||
app.sendControlResponse(w, err, "Next track")
|
||||
case "previous":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.PrevTrack()
|
||||
app.sendControlResponse(w, err, "Previous track")
|
||||
case "volume":
|
||||
app.handleVolumeControl(w, r, device)
|
||||
case "mute":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SendKey(models.KeyMute)
|
||||
app.sendControlResponse(w, err, "Toggled mute")
|
||||
case "preset":
|
||||
app.handlePresetControl(w, r, device)
|
||||
case "bass":
|
||||
app.handleBassControl(w, r, device)
|
||||
case "source":
|
||||
app.handleSourceControl(w, r, device)
|
||||
default:
|
||||
app.sendError(w, "Unknown action", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// handleVolumeControl processes volume control requests
|
||||
func (app *WebApp) handleVolumeControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "POST required for volume control", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var volumeReq webtypes.VolumeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&volumeReq); err != nil {
|
||||
app.sendError(w, "Invalid volume data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if volumeReq.Level < 0 || volumeReq.Level > 100 {
|
||||
app.sendError(w, "Volume must be between 0 and 100", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SetVolume(volumeReq.Level)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Volume set to %d", volumeReq.Level))
|
||||
}
|
||||
|
||||
// handlePresetControl processes preset control requests
|
||||
func (app *WebApp) handlePresetControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
presetParam := r.URL.Query().Get("id")
|
||||
if presetParam == "" {
|
||||
app.sendError(w, "Preset ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
presetID, err := strconv.Atoi(presetParam)
|
||||
if err != nil {
|
||||
app.sendError(w, "Invalid preset ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = device.Client.SelectPreset(presetID)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Selected preset %d", presetID))
|
||||
}
|
||||
|
||||
// handleBassControl processes bass control requests
|
||||
func (app *WebApp) handleBassControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "POST required for bass control", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var bassReq webtypes.BassRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&bassReq); err != nil {
|
||||
app.sendError(w, "Invalid bass data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if bassReq.Level < -9 || bassReq.Level > 9 {
|
||||
app.sendError(w, "Bass must be between -9 and 9", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SetBass(bassReq.Level)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Bass set to %d", bassReq.Level))
|
||||
}
|
||||
|
||||
// handleSourceControl processes source control requests
|
||||
func (app *WebApp) handleSourceControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
sourceParam := r.URL.Query().Get("name")
|
||||
if sourceParam == "" {
|
||||
app.sendError(w, "Source name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SelectSource(sourceParam, "")
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Selected source %s", sourceParam))
|
||||
}
|
||||
|
||||
// sendControlResponse sends a control command response
|
||||
func (app *WebApp) sendControlResponse(w http.ResponseWriter, err error, successMessage string) {
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"message": successMessage},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// sendError sends an error response
|
||||
func (app *WebApp) sendError(w http.ResponseWriter, message string, statusCode int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: false,
|
||||
Error: message,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode error response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeviceKey handles sending key commands to devices
|
||||
func (app *WebApp) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
key := chi.URLParam(r, "key")
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
err := device.Client.SendKey(key)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Sent key command: %s", key))
|
||||
}
|
||||
|
||||
// HandleDirectVolumeControl handles direct volume setting via URL parameter
|
||||
func (app *WebApp) HandleDirectVolumeControl(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
volumeLevel, err := strconv.Atoi(chi.URLParam(r, "volume"))
|
||||
if err != nil || volumeLevel < 0 || volumeLevel > 100 {
|
||||
app.sendError(w, "Invalid volume level (0-100)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
err = device.Client.SetVolume(volumeLevel)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Volume set to %d", volumeLevel))
|
||||
}
|
||||
|
||||
// HandleDevicePower handles power toggle commands for devices
|
||||
func (app *WebApp) HandleDevicePower(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Send POWER key command to toggle device power
|
||||
err := device.Client.SendKey("POWER")
|
||||
app.sendControlResponse(w, err, "Power toggle command sent")
|
||||
}
|
||||
|
||||
// HandleDevicePowerStatus handles lightweight power status check
|
||||
func (app *WebApp) HandleDevicePowerStatus(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Quick power status check by getting now playing
|
||||
nowPlaying, err := device.Client.GetNowPlaying()
|
||||
if err != nil {
|
||||
app.sendControlResponse(w, err, "Failed to get power status")
|
||||
return
|
||||
}
|
||||
|
||||
isPoweredOn := nowPlaying != nil && nowPlaying.Source != "STANDBY"
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"deviceId": deviceID,
|
||||
"isPoweredOn": isPoweredOn,
|
||||
"source": nowPlaying.Source,
|
||||
},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastDeviceList sends updated device list to all connected WebSocket clients
|
||||
func (app *WebApp) BroadcastDeviceList() {
|
||||
app.WSMutex.RLock()
|
||||
defer app.WSMutex.RUnlock()
|
||||
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"lastSeen": device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
message := webtypes.WebSocketMessage{
|
||||
Type: "devices",
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
// Send to all connected clients
|
||||
var failedClients []*websocket.Conn
|
||||
|
||||
for client := range app.WSClients {
|
||||
if err := client.WriteJSON(message); err != nil {
|
||||
log.Printf("Failed to send device update to WebSocket client: %v", err)
|
||||
// Mark for removal to avoid modifying map during iteration
|
||||
failedClients = append(failedClients, client)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove failed clients
|
||||
for _, client := range failedClients {
|
||||
delete(app.WSClients, client)
|
||||
client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastDiscoveryStatus sends discovery progress updates to all connected WebSocket clients
|
||||
func (app *WebApp) BroadcastDiscoveryStatus(status string, deviceCount int) {
|
||||
app.WSMutex.RLock()
|
||||
defer app.WSMutex.RUnlock()
|
||||
|
||||
message := webtypes.WebSocketMessage{
|
||||
Type: "discovery_status",
|
||||
Data: map[string]interface{}{
|
||||
"status": status,
|
||||
"deviceCount": deviceCount,
|
||||
},
|
||||
}
|
||||
|
||||
// Send to all connected clients
|
||||
var failedClients []*websocket.Conn
|
||||
|
||||
for client := range app.WSClients {
|
||||
if err := client.WriteJSON(message); err != nil {
|
||||
log.Printf("Failed to send discovery status to WebSocket client: %v", err)
|
||||
// Mark for removal to avoid modifying map during iteration
|
||||
failedClients = append(failedClients, client)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove failed clients
|
||||
for _, client := range failedClients {
|
||||
delete(app.WSClients, client)
|
||||
client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInSearch handles TuneIn search requests, proxying directly to the bmx package.
|
||||
func (app *WebApp) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
app.sendError(w, "query parameter 'q' is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := bmxpkg.TuneInSearch(query)
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInNavigate handles TuneIn browse/navigate requests, proxying directly to the bmx package.
|
||||
// Supported path suffixes (relative to /api/tunein/navigate):
|
||||
// - (empty) → top-level browse
|
||||
// - /{encodedURI} → browse the given TuneIn URI
|
||||
// - /sub/{n}/{encodedURI} → single subsection
|
||||
// - /profiles/{type}/{id}/{encodedURI} → artist/program profile
|
||||
func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
|
||||
wildcard := chi.URLParam(r, "*")
|
||||
|
||||
var (
|
||||
resp interface{}
|
||||
err error
|
||||
)
|
||||
|
||||
if wildcard == "" {
|
||||
resp, err = bmxpkg.TuneInNavigate("", nil)
|
||||
} else {
|
||||
firstSlash := strings.Index(wildcard, "/")
|
||||
if firstSlash == -1 {
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
} else {
|
||||
pfx := wildcard[:firstSlash]
|
||||
rest := wildcard[firstSlash+1:]
|
||||
|
||||
switch pfx {
|
||||
case "sub":
|
||||
secondSlash := strings.Index(rest, "/")
|
||||
if secondSlash == -1 {
|
||||
resp, err = bmxpkg.TuneInNavigate(rest, nil)
|
||||
} else {
|
||||
n, parseErr := strconv.Atoi(rest[:secondSlash])
|
||||
if parseErr != nil {
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
} else {
|
||||
resp, err = bmxpkg.TuneInNavigate(rest[secondSlash+1:], &n)
|
||||
}
|
||||
}
|
||||
case "profiles":
|
||||
parts := strings.SplitN(rest, "/", 3)
|
||||
if len(parts) < 3 {
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
} else {
|
||||
resp, err = bmxpkg.TuneInNavigateProfile(parts[2])
|
||||
}
|
||||
default:
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandlePlayTuneIn plays a TuneIn content item on a specific device via POST /select.
|
||||
func (app *WebApp) HandlePlayTuneIn(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
if deviceID == "" {
|
||||
app.sendError(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, fmt.Sprintf("Device '%s' not found", deviceID), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Location string `json:"location"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
ContainerArt string `json:"containerArt"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
app.sendError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Location == "" {
|
||||
app.sendError(w, "location is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
itemType := req.Type
|
||||
if itemType == "" {
|
||||
itemType = "stationurl"
|
||||
}
|
||||
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: itemType,
|
||||
Location: req.Location,
|
||||
ItemName: req.Name,
|
||||
IsPresetable: true,
|
||||
ContainerArt: req.ContainerArt,
|
||||
}
|
||||
|
||||
if err := device.Client.SelectContentItem(contentItem); err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: map[string]string{"message": "Playing " + req.Name}}); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
package soundtouchweb
|
||||
// Package handlers contains tests for HTTP handlers.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -9,9 +10,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
package soundtouchweb
|
||||
// Package handlers contains WebSocket handlers for real-time communication.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -6,13 +7,13 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// HandleWebSocket handles browser WebSocket connections for real-time updates.
|
||||
// HandleWebSocket handles WebSocket connections for real-time updates
|
||||
func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := app.Upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
@@ -21,16 +22,19 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Unregister client
|
||||
app.WSMutex.Lock()
|
||||
delete(app.WSClients, conn)
|
||||
app.WSMutex.Unlock()
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
// Register client
|
||||
app.WSMutex.Lock()
|
||||
app.WSClients[conn] = true
|
||||
app.WSMutex.Unlock()
|
||||
|
||||
// Send initial device list
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
@@ -40,20 +44,30 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{Type: "devices", Data: devices}); err != nil {
|
||||
initialMessage := webtypes.WebSocketMessage{
|
||||
Type: "devices",
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(initialMessage); err != nil {
|
||||
log.Printf("Failed to send initial data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Keep connection alive and send updates
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Set up ping handler to detect client disconnects
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
// Set initial read deadline
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
|
||||
// Handle incoming messages in a separate goroutine
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
|
||||
@@ -65,19 +79,24 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Main loop for sending periodic updates
|
||||
for range ticker.C {
|
||||
// Send ping to check if client is still connected
|
||||
if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
|
||||
log.Printf("Failed to send ping: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send periodic status updates
|
||||
for id, device := range app.Devices {
|
||||
if device.Status.IsConnected {
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
statusMessage := webtypes.WebSocketMessage{
|
||||
Type: "status_update",
|
||||
DeviceID: id,
|
||||
Data: device.Status,
|
||||
}); err != nil {
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(statusMessage); err != nil {
|
||||
log.Printf("Failed to send status update: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -86,31 +105,36 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDiscover acknowledges a discovery request (actual discovery is triggered by Mount).
|
||||
// HandleAPIDiscover triggers device discovery
|
||||
func (app *WebApp) HandleAPIDiscover(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Discovery will be triggered by the main app
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(webtypes.APIResponse{
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"message": "Discovery started"},
|
||||
}); err != nil {
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// ConnectDeviceWebSocket establishes a WebSocket connection to a SoundTouch device.
|
||||
// ConnectDeviceWebSocket establishes a WebSocket connection to a device
|
||||
func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.DeviceConnection) {
|
||||
// Skip WebSocket connection if client is not available (e.g., in tests)
|
||||
if conn.Client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
wsClient := conn.Client.NewWebSocketClient(nil)
|
||||
|
||||
// Setup event handlers
|
||||
wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) {
|
||||
conn.Status.NowPlaying = &event.NowPlaying
|
||||
conn.Status.LastActivity = time.Now()
|
||||
@@ -131,6 +155,7 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
|
||||
conn.Status.LastActivity = time.Now()
|
||||
})
|
||||
|
||||
// Connect WebSocket
|
||||
if err := wsClient.Connect(); err != nil {
|
||||
log.Printf("Failed to connect WebSocket for device %s: %v", deviceID, err)
|
||||
return
|
||||
@@ -141,6 +166,7 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
|
||||
|
||||
log.Printf("WebSocket connected for device %s", deviceID)
|
||||
|
||||
// Wait for disconnection
|
||||
wsClient.Wait()
|
||||
|
||||
conn.Status.IsConnected = false
|
||||
@@ -148,46 +174,55 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
|
||||
log.Printf("WebSocket disconnected for device %s", deviceID)
|
||||
}
|
||||
|
||||
// UpdateDeviceStatus fetches current status from a device.
|
||||
// UpdateDeviceStatus fetches current status from device
|
||||
func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection) {
|
||||
// Skip status update if client is not available (e.g., in tests)
|
||||
if conn.Client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
statusUpdated := false
|
||||
|
||||
// Get current now playing
|
||||
if nowPlaying, err := conn.Client.GetNowPlaying(); err == nil {
|
||||
conn.Status.NowPlaying = nowPlaying
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Get current volume
|
||||
if volume, err := conn.Client.GetVolume(); err == nil {
|
||||
conn.Status.Volume = volume
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Get presets
|
||||
if presets, err := conn.Client.GetPresets(); err == nil {
|
||||
conn.Status.Presets = presets
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Update last activity if any status was updated
|
||||
if statusUpdated {
|
||||
conn.Status.LastActivity = time.Now()
|
||||
}
|
||||
|
||||
// Get sources
|
||||
if sources, err := conn.Client.GetSources(); err == nil {
|
||||
conn.Status.Sources = sources
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Get bass (if available)
|
||||
if bass, err := conn.Client.GetBass(); err == nil {
|
||||
conn.Status.Bass = bass
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Mark as connected if we successfully got at least one status
|
||||
conn.Status.IsConnected = statusUpdated
|
||||
conn.Status.LastActivity = time.Now()
|
||||
}
|
||||
|
||||
// HandleDeviceWebSocket handles per-device WebSocket connections for real-time device-specific updates.
|
||||
// HandleDeviceWebSocket handles individual device WebSocket connections for real-time device-specific updates
|
||||
func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
if deviceID == "" {
|
||||
@@ -210,21 +245,31 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
log.Printf("Device WebSocket connected for %s", deviceID)
|
||||
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
// Send initial device status
|
||||
initialMessage := webtypes.WebSocketMessage{
|
||||
Type: "device_status",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{"info": device.DeviceInfo, "status": device.Status},
|
||||
}); err != nil {
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(initialMessage); err != nil {
|
||||
log.Printf("Failed to send initial device status: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Set up ping handler to detect client disconnects
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
// Set initial read deadline
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
|
||||
// Handle incoming messages in a separate goroutine
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
|
||||
@@ -236,26 +281,36 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}()
|
||||
|
||||
// Send periodic device status updates
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
// Send ping to check if client is still connected
|
||||
if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
|
||||
log.Printf("Failed to send ping to device WebSocket %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
// Send device status update
|
||||
statusMessage := webtypes.WebSocketMessage{
|
||||
Type: "device_status",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{"info": device.DeviceInfo, "status": device.Status},
|
||||
}); err != nil {
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(statusMessage); err != nil {
|
||||
log.Printf("Failed to send device status update for %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// If device has active WebSocket connection to SoundTouch device,
|
||||
// also send any real-time updates from that connection
|
||||
if device.WebSocket != nil && device.Status.IsConnected {
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
realtimeMessage := webtypes.WebSocketMessage{
|
||||
Type: "device_realtime",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
@@ -263,57 +318,12 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
"volume": device.Status.Volume,
|
||||
"timestamp": time.Now(),
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(realtimeMessage); err != nil {
|
||||
log.Printf("Failed to send realtime update for %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastDeviceList sends the updated device list to all connected browser WebSocket clients.
|
||||
func (app *WebApp) BroadcastDeviceList() {
|
||||
app.WSMutex.RLock()
|
||||
defer app.WSMutex.RUnlock()
|
||||
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"lastSeen": device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
app.broadcast(webtypes.WebSocketMessage{Type: "devices", Data: devices})
|
||||
}
|
||||
|
||||
// BroadcastDiscoveryStatus sends discovery progress to all connected browser WebSocket clients.
|
||||
func (app *WebApp) BroadcastDiscoveryStatus(status string, deviceCount int) {
|
||||
app.WSMutex.RLock()
|
||||
defer app.WSMutex.RUnlock()
|
||||
|
||||
app.broadcast(webtypes.WebSocketMessage{
|
||||
Type: "discovery_status",
|
||||
Data: map[string]interface{}{"status": status, "deviceCount": deviceCount},
|
||||
})
|
||||
}
|
||||
|
||||
// broadcast sends a message to all registered WS clients, removing failed ones.
|
||||
// Caller must hold at least a read lock on WSMutex.
|
||||
func (app *WebApp) broadcast(msg webtypes.WebSocketMessage) {
|
||||
var failed []*websocket.Conn
|
||||
|
||||
for client := range app.WSClients {
|
||||
if err := client.WriteJSON(msg); err != nil {
|
||||
log.Printf("Failed to broadcast to WebSocket client: %v", err)
|
||||
|
||||
failed = append(failed, client)
|
||||
}
|
||||
}
|
||||
|
||||
for _, client := range failed {
|
||||
delete(app.WSClients, client)
|
||||
client.Close()
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/config"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var staticFS embed.FS
|
||||
|
||||
func main() {
|
||||
app := &cli.App{
|
||||
Name: "soundtouch-web",
|
||||
@@ -38,10 +49,36 @@ func main() {
|
||||
addr = bindAddr + ":" + port
|
||||
}
|
||||
|
||||
webApp := soundtouchweb.New()
|
||||
// Create web app without templates (SPA mode)
|
||||
webApp := handlers.NewWebApp()
|
||||
|
||||
r := chi.NewRouter()
|
||||
webApp.Mount(r)
|
||||
// Initialize discovery service
|
||||
cfg, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
log.Printf("Failed to load config: %v, using defaults", err)
|
||||
|
||||
cfg = config.DefaultConfig()
|
||||
}
|
||||
|
||||
cfg.DiscoveryTimeout = 10 * time.Second
|
||||
cfg.CacheEnabled = true
|
||||
|
||||
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
|
||||
|
||||
// Discover devices on startup
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
webApp.BroadcastDiscoveryStatus("starting", len(webApp.Devices))
|
||||
|
||||
discoverDevices(ctx, webApp, discoveryService)
|
||||
|
||||
webApp.BroadcastDiscoveryStatus("completed", len(webApp.Devices))
|
||||
webApp.BroadcastDeviceList()
|
||||
}()
|
||||
|
||||
r := setupRoutes(webApp, discoveryService)
|
||||
|
||||
log.Printf("SoundTouch Web UI starting on http://%s", addr)
|
||||
|
||||
@@ -53,3 +90,126 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Static assets (embedded in binary)
|
||||
subFS, _ := fs.Sub(staticFS, "static")
|
||||
r.Get("/static/*", http.StripPrefix("/static", http.FileServer(http.FS(subFS))).ServeHTTP)
|
||||
|
||||
// Serve index.html for SPA routes
|
||||
serveIndex := func(w http.ResponseWriter, _ *http.Request) {
|
||||
data, _ := staticFS.ReadFile("static/index.html")
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// WebSocket endpoint
|
||||
r.Get("/ws", app.HandleWebSocket)
|
||||
|
||||
// API endpoints
|
||||
r.Get("/api/devices", app.HandleAPIDevices)
|
||||
r.Get("/api/device/{id}", app.HandleAPIDevice)
|
||||
r.Post("/api/discover", func(w http.ResponseWriter, r *http.Request) {
|
||||
app.HandleAPIDiscover(w, r)
|
||||
// Trigger discovery
|
||||
//nolint:contextcheck // Context is created within goroutine
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Broadcast discovery start
|
||||
app.BroadcastDiscoveryStatus("starting", len(app.Devices))
|
||||
|
||||
discoverDevices(ctx, app, discoveryService)
|
||||
|
||||
// Broadcast discovery completion and updated device list
|
||||
app.BroadcastDiscoveryStatus("completed", len(app.Devices))
|
||||
app.BroadcastDeviceList()
|
||||
}()
|
||||
})
|
||||
|
||||
// Device control endpoints (GET for most actions, POST for volume/bass)
|
||||
r.Get("/api/control/{id}/{action}", app.HandleAPIControl)
|
||||
r.Post("/api/control/{id}/{action}", app.HandleAPIControl)
|
||||
|
||||
// TuneIn browse, search, and playback
|
||||
r.Get("/api/tunein/search", app.HandleTuneInSearch)
|
||||
r.Get("/api/tunein/navigate", app.HandleTuneInNavigate)
|
||||
r.Get("/api/tunein/navigate/*", app.HandleTuneInNavigate)
|
||||
r.Post("/api/tunein/play/{id}", app.HandlePlayTuneIn)
|
||||
|
||||
// Enhanced device control endpoints
|
||||
r.Post("/api/device-key/{id}/{key}", app.HandleDeviceKey)
|
||||
r.Post("/api/device-volume/{id}/{volume}", app.HandleDirectVolumeControl)
|
||||
r.Post("/api/device-power/{id}", app.HandleDevicePower)
|
||||
r.Get("/api/device-power-status/{id}", app.HandleDevicePowerStatus)
|
||||
r.Get("/api/device-ws/{id}", app.HandleDeviceWebSocket)
|
||||
|
||||
// SPA routes - serve index.html for client-side routing
|
||||
r.Get("/", serveIndex)
|
||||
r.Get("/devices", serveIndex)
|
||||
r.Get("/device/*", serveIndex)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func discoverDevices(ctx context.Context, app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) {
|
||||
log.Println("Starting device discovery...")
|
||||
|
||||
devices, err := discoveryService.DiscoverDevices(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Discovery failed: %v", err)
|
||||
app.BroadcastDiscoveryStatus("failed", len(app.Devices))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Found %d devices", len(devices))
|
||||
|
||||
for _, device := range devices {
|
||||
deviceID := device.Host // Use host as unique ID for now
|
||||
|
||||
// Skip if we already have this device
|
||||
if _, exists := app.Devices[deviceID]; exists {
|
||||
app.Devices[deviceID].LastSeen = time.Now()
|
||||
continue
|
||||
}
|
||||
|
||||
// Create new device connection
|
||||
clientConfig := &client.Config{
|
||||
Host: device.Host,
|
||||
Port: device.Port,
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
soundTouchClient := client.NewClient(clientConfig)
|
||||
|
||||
// Get device info
|
||||
deviceInfo, err := soundTouchClient.GetDeviceInfo()
|
||||
if err != nil {
|
||||
log.Printf("Failed to get device info for %s: %v", device.Host, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create device connection
|
||||
conn := &webtypes.DeviceConnection{
|
||||
Client: soundTouchClient,
|
||||
DeviceInfo: deviceInfo,
|
||||
LastSeen: time.Now(),
|
||||
Status: webtypes.DeviceStatus{
|
||||
IsConnected: false,
|
||||
LastActivity: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
// Initial status fetch asynchronously to avoid blocking discovery
|
||||
go app.UpdateDeviceStatus(deviceID, conn)
|
||||
|
||||
app.Devices[deviceID] = conn
|
||||
|
||||
log.Printf("Added device: %s (%s) at %s", deviceInfo.Name, deviceInfo.Type, device.Host)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -55,19 +55,22 @@ func TestSPARouting(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Simulate SPA routing handler
|
||||
spaHandler := func(w http.ResponseWriter, r *http.Request) {
|
||||
// If it's an API route, let it pass through
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/ws") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the SPA index.html content (simulated)
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>SoundTouch Web</title>
|
||||
<title>SoundTouch Control Center</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">SPA Content</div>
|
||||
@@ -97,7 +100,7 @@ func TestSPARouting(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAPIEndpoints(t *testing.T) {
|
||||
app := soundtouchweb.NewWebApp()
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -124,7 +127,7 @@ func TestAPIEndpoints(t *testing.T) {
|
||||
name: "device API with ID",
|
||||
path: "/api/device/test-device",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
expectedStatus: http.StatusNotFound, // Device won't exist in test
|
||||
expectedJSON: true,
|
||||
},
|
||||
}
|
||||
@@ -157,6 +160,7 @@ func TestAPIEndpoints(t *testing.T) {
|
||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
||||
}
|
||||
|
||||
// Validate JSON response structure
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Invalid JSON response: %v", err)
|
||||
@@ -167,7 +171,7 @@ func TestAPIEndpoints(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAPIResponseFormat(t *testing.T) {
|
||||
app := soundtouchweb.NewWebApp()
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/devices", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -179,6 +183,7 @@ func TestAPIResponseFormat(t *testing.T) {
|
||||
t.Fatalf("Failed to decode JSON response: %v", err)
|
||||
}
|
||||
|
||||
// Check API response structure
|
||||
if !response.Success {
|
||||
t.Errorf("Expected success=true, got success=%v", response.Success)
|
||||
}
|
||||
@@ -187,6 +192,7 @@ func TestAPIResponseFormat(t *testing.T) {
|
||||
t.Errorf("Expected data field to be present")
|
||||
}
|
||||
|
||||
// Data should be an empty map for no devices
|
||||
dataMap, ok := response.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("Expected data to be a map, got %T", response.Data)
|
||||
@@ -198,7 +204,7 @@ func TestAPIResponseFormat(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestControlAPIValidation(t *testing.T) {
|
||||
app := soundtouchweb.NewWebApp()
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -243,6 +249,7 @@ func TestControlAPIValidation(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
// Add a mock device for testing unknown action validation
|
||||
mockDevice := &webtypes.DeviceConnection{
|
||||
Client: nil,
|
||||
DeviceInfo: &models.DeviceInfo{Name: "Test Device"},
|
||||
@@ -271,6 +278,7 @@ func TestControlAPIValidation(t *testing.T) {
|
||||
t.Errorf("Test %s: Expected status %d, got %d. Response: %s", tt.name, tt.expectedStatus, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Validate error response format
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
||||
@@ -293,8 +301,9 @@ func TestControlAPIValidation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWebSocketUpgrade(t *testing.T) {
|
||||
app := soundtouchweb.NewWebApp()
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
// Test WebSocket upgrade request
|
||||
req := httptest.NewRequest("GET", "/ws", nil)
|
||||
req.Header.Set("Connection", "upgrade")
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
@@ -303,11 +312,16 @@ func TestWebSocketUpgrade(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// The actual WebSocket upgrade will fail in test environment,
|
||||
// but we can check that the handler exists and accepts the request
|
||||
app.HandleWebSocket(w, req)
|
||||
|
||||
// In a real test environment, this would fail with a websocket upgrade error
|
||||
// We're just checking the handler doesn't panic and processes the request
|
||||
}
|
||||
|
||||
func TestJSONAPIConsistency(t *testing.T) {
|
||||
app := soundtouchweb.NewWebApp()
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
endpoints := []string{
|
||||
"/api/devices",
|
||||
@@ -330,16 +344,19 @@ func TestJSONAPIConsistency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// All API endpoints should return JSON
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Endpoint %s should return JSON, got %s", endpoint, contentType)
|
||||
}
|
||||
|
||||
// All responses should follow APIResponse structure
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Endpoint %s returned invalid JSON: %v", endpoint, err)
|
||||
}
|
||||
|
||||
// Response should have either data or error
|
||||
if response.Success && response.Data == nil {
|
||||
t.Errorf("Endpoint %s: success response should have data", endpoint)
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,200 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SoundTouch Control Center</title>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link href="/static/css/app.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="#" onclick="showPage('devices')">
|
||||
<i class="bi bi-speaker"></i>
|
||||
SoundTouch Control
|
||||
</a>
|
||||
<div class="navbar-nav ms-auto">
|
||||
<a
|
||||
class="nav-link"
|
||||
href="#"
|
||||
onclick="showPage('devices')"
|
||||
title="Home"
|
||||
>
|
||||
<i class="bi bi-house"></i>
|
||||
</a>
|
||||
<a
|
||||
class="nav-link tunein-nav-link"
|
||||
href="#"
|
||||
onclick="showPage('tunein')"
|
||||
title="TuneIn Browse"
|
||||
>
|
||||
<img
|
||||
src="/static/img/tunein-mono.svg"
|
||||
alt="TuneIn"
|
||||
class="tunein-nav-icon"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
class="nav-link"
|
||||
href="#"
|
||||
onclick="discoverDevices()"
|
||||
title="Discover Devices"
|
||||
>
|
||||
<i class="bi bi-search"></i>
|
||||
</a>
|
||||
<button
|
||||
class="theme-toggle nav-link"
|
||||
onclick="toggleTheme()"
|
||||
title="Toggle Dark Mode"
|
||||
>
|
||||
<i id="theme-icon" class="bi bi-moon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
<!-- Device List Page -->
|
||||
<div id="devices-page" class="page active">
|
||||
<div
|
||||
class="d-flex justify-content-between align-items-center mb-4"
|
||||
>
|
||||
<h2>Your SoundTouch Devices</h2>
|
||||
<button class="btn btn-primary" onclick="discoverDevices()">
|
||||
<i class="bi bi-search"></i>
|
||||
Discover Devices
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="devices-loading" class="loading-spinner"></div>
|
||||
|
||||
<div id="devices-list" class="row">
|
||||
<!-- Device cards will be inserted here by JavaScript -->
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="no-devices"
|
||||
style="display: none"
|
||||
class="text-center py-5"
|
||||
>
|
||||
<i class="bi bi-speaker display-1 text-muted"></i>
|
||||
<h4 class="mt-3">No Devices Found</h4>
|
||||
<p class="text-muted">
|
||||
Click "Discover Devices" to search for SoundTouch
|
||||
speakers on your network.
|
||||
</p>
|
||||
<button class="btn btn-primary" onclick="discoverDevices()">
|
||||
<i class="bi bi-search"></i>
|
||||
Start Discovery
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TuneIn Browse Page -->
|
||||
<div id="tunein-page" class="page">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2><img src="/static/img/tunein-dark.svg" alt="TuneIn" class="tunein-heading-icon me-2" />TuneIn Browse</h2>
|
||||
</div>
|
||||
|
||||
<div class="tunein-search-bar mb-3">
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
id="tunein-search-input"
|
||||
class="form-control"
|
||||
placeholder="Search stations, podcasts..."
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="tuneInSearch(document.getElementById('tunein-search-input').value)"
|
||||
>
|
||||
<i class="bi bi-search"></i>
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
onclick="tuneInBrowse()"
|
||||
title="Browse top level"
|
||||
>
|
||||
<i class="bi bi-house"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav id="tunein-breadcrumb" class="mb-3" style="display: none">
|
||||
<!-- filled by JavaScript -->
|
||||
</nav>
|
||||
|
||||
<div id="tunein-results">
|
||||
<!-- filled by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Control Page -->
|
||||
<div id="device-page" class="page">
|
||||
<div class="back-button">
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
onclick="showPage('devices')"
|
||||
>
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
Back to Devices
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="device-content">
|
||||
<!-- Device control content will be inserted here by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container text-center">
|
||||
<small>
|
||||
SoundTouch Web Control Interface -
|
||||
<a
|
||||
href="https://github.com/gesellix/Bose-SoundTouch"
|
||||
target="_blank"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
Open Source Project
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Toast container for notifications -->
|
||||
<div class="toast-container"></div>
|
||||
|
||||
<!-- Device picker for TuneIn playback -->
|
||||
<div class="modal fade" id="devicePickerModal" tabindex="-1" aria-labelledby="devicePickerLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header py-2">
|
||||
<h6 class="modal-title" id="devicePickerLabel">
|
||||
<i class="bi bi-speaker me-2"></i>Play on device
|
||||
</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-2" id="devicePickerList">
|
||||
<!-- device buttons filled by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- Application JavaScript -->
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +1,4 @@
|
||||
accounts/
|
||||
backend/
|
||||
certs/
|
||||
default/
|
||||
dns/
|
||||
|
||||
@@ -61,6 +61,8 @@
|
||||
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
|
||||
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
|
||||
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
|
||||
* [Telnet (Port 17000) Migration Method](analysis/TELNET-MIGRATION-METHOD.md)
|
||||
* [Telnet Command Reference](analysis/TELNET-COMMAND-REFERENCE.md)
|
||||
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
|
||||
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
|
||||
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
To enable offline operation or use custom services like **SoundCork** or **ÜberBöse API**, SoundTouch devices must be redirected from Bose's official cloud endpoints to a local or custom server. This document outlines the three known methods to achieve this, gathered from community reverse-engineering efforts in the **SoundCork** and **ÜberBöse API** projects.
|
||||
|
||||
> A fourth, **SSH-free** path — driving the device's diagnostic shell on TCP port 17000 — is being added as a peer to the XML and DNS methods. See **[TELNET-MIGRATION-METHOD.md](TELNET-MIGRATION-METHOD.md)** for the use cases, community findings, and feasibility analysis. The `/etc/hosts` method documented below is now deprecated and will not be exposed in the web UI.
|
||||
|
||||
## Overview of Redirection Targets
|
||||
|
||||
SoundTouch devices primarily communicate with the following domains:
|
||||
@@ -32,19 +34,25 @@ The most robust and granular method involves modifying the device's private conf
|
||||
Requires SSH access to the device.
|
||||
```xml
|
||||
<SoundTouchSdkPrivateCfg>
|
||||
<margeServerUrl>http://192.168.1.10:8000/marge</margeServerUrl>
|
||||
<margeServerUrl>http://192.168.1.10:8000</margeServerUrl>
|
||||
<statsServerUrl>http://192.168.1.10:8000</statsServerUrl>
|
||||
<swUpdateUrl>http://192.168.1.10:8000/updates/soundtouch</swUpdateUrl>
|
||||
<bmxRegistryUrl>http://192.168.1.10:8000/bmx/registry/v1/services</bmxRegistryUrl>
|
||||
</SoundTouchSdkPrivateCfg>
|
||||
```
|
||||
|
||||
> **Note on `margeServerUrl`** — `soundtouch-service` mounts the marge endpoints
|
||||
> at the **root** of port 8000, so the URL has no `/marge` suffix.
|
||||
> [`deborahgu/soundcork`](https://github.com/deborahgu/soundcork) routes marge
|
||||
> under a `/marge` sub-path, so users redirecting to soundcork must append it
|
||||
> (`http://192.168.1.10:8000/marge`).
|
||||
|
||||
### Pros & Cons
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Granular Control**: Redirect specific services while leaving others (e.g., updates) intact. | **Requires SSH**: Must have root/SSH access to the device. |
|
||||
| **Persistent**: Survives software updates (usually). | **Syntax Sensitive**: Errors in XML can cause boot issues or service failures. |
|
||||
| **Native**: Uses the device's built-in configuration mechanism. | |
|
||||
| Pros | Cons |
|
||||
|:----------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------|
|
||||
| **Granular Control**: Redirect specific services while leaving others (e.g., updates) intact. | **Requires SSH**: Must have root/SSH access to the device. |
|
||||
| **Persistent**: Survives software updates (usually). | **Syntax Sensitive**: Errors in XML can cause boot issues or service failures. |
|
||||
| **Native**: Uses the device's built-in configuration mechanism. | |
|
||||
|
||||
---
|
||||
|
||||
@@ -66,11 +74,11 @@ Requires SSH access. Add entries for the target domains:
|
||||
```
|
||||
|
||||
### Pros & Cons
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Simple**: Easy to understand and implement. | **Requires SSH**: Must have root access. |
|
||||
| Pros | Cons |
|
||||
|:--------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Simple**: Easy to understand and implement. | **Requires SSH**: Must have root access. |
|
||||
| **Universal**: Affects all processes on the device attempting to reach those domains. | **HTTPS Issues**: Redirecting HTTPS domains to a local IP will cause SSL certificate errors unless the device is patched to skip verification or trust a custom CA. |
|
||||
| | **Brittle**: Some firmware versions may overwrite `/etc/hosts` on reboot. |
|
||||
| | **Brittle**: Some firmware versions may overwrite `/etc/hosts` on reboot. |
|
||||
|
||||
---
|
||||
|
||||
@@ -104,12 +112,12 @@ sed "s#\^https:....bose.\+apigee..net..#http[aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
4. Restore execution permissions and reboot.
|
||||
|
||||
### Pros & Cons
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Bypass Config**: Works even if the firmware ignores XML settings. | **High Risk**: Modifying binaries can lead to permanent bricks or boot loops. |
|
||||
| Pros | Cons |
|
||||
|:------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------|
|
||||
| **Bypass Config**: Works even if the firmware ignores XML settings. | **High Risk**: Modifying binaries can lead to permanent bricks or boot loops. |
|
||||
| **Hardcoded Redirects**: Can catch URLs that aren't exposed in configuration files. | **Length Constraint**: Custom URLs must fit within the space of the original strings. |
|
||||
| | **Firmware Specific**: Patches must be reapplied after every software update. |
|
||||
| | **Complexity**: Requires understanding of binary structures and potential checksums. |
|
||||
| | **Firmware Specific**: Patches must be reapplied after every software update. |
|
||||
| | **Complexity**: Requires understanding of binary structures and potential checksums. |
|
||||
|
||||
---
|
||||
|
||||
@@ -117,11 +125,11 @@ sed "s#\^https:....bose.\+apigee..net..#http[aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
|
||||
### Summary Table
|
||||
|
||||
| Method | Primary Use Case | Ease | Safety | Persistence | Granularity |
|
||||
| :--- | :--- | :---: | :---: | :---: | :---: |
|
||||
| **XML Config** | Logical service redirection | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **`/etc/hosts`** | Quick global DNS override | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
|
||||
| **Binary Patch** | Bypassing hardcoded checks | ⭐ | ⭐ | ⭐ | ⭐⭐⭐ |
|
||||
| Method | Primary Use Case | Ease | Safety | Persistence | Granularity |
|
||||
|:-----------------|:----------------------------|:-----:|:------:|:-----------:|:-----------:|
|
||||
| **XML Config** | Logical service redirection | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **`/etc/hosts`** | Quick global DNS override | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
|
||||
| **Binary Patch** | Bypassing hardcoded checks | ⭐ | ⭐ | ⭐ | ⭐⭐⭐ |
|
||||
|
||||
---
|
||||
|
||||
@@ -176,9 +184,10 @@ As suggested by community members, you can configure the device to trust your ow
|
||||
- **Method B (Symlinks)**: Add the certificate to `/etc/ssl/certs/` and create a hash symlink using `c_rehash` (if available) or manual mapping.
|
||||
|
||||
**Pros & Cons**:
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Secure**: Maintains end-to-end encryption. | **Requires SSH**: Must have root access to modify the trust store. |
|
||||
|
||||
| Pros | Cons |
|
||||
|:-------------------------------------------------------|:-----------------------------------------------------------------------|
|
||||
| **Secure**: Maintains end-to-end encryption. | **Requires SSH**: Must have root access to modify the trust store. |
|
||||
| **Clean**: No binary patching required for SSL bypass. | **Update Risk**: Firmware updates might overwrite the `ca-bundle.crt`. |
|
||||
|
||||
### Option 2: SSL Verification Bypass
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
# Bose SoundTouch Telnet (Port 17000) Command Reference
|
||||
|
||||
A consolidated reference for the diagnostic shell that listens on TCP port
|
||||
17000 across the SoundTouch line. Compiled from multiple community sources
|
||||
to give a single map of what's been observed in the wild — useful both for
|
||||
implementing automation against it (see
|
||||
[TELNET-MIGRATION-METHOD.md](TELNET-MIGRATION-METHOD.md)) and for manual
|
||||
recovery / WiFi setup.
|
||||
|
||||
> **Important caveat.** The command set is firmware-dependent. Anything that
|
||||
> existed in firmware 1.x–7.x (`flarn2006`'s era) was progressively trimmed;
|
||||
> some commands listed here have been removed on firmware 27.x. Where a
|
||||
> command's availability is known to vary, the **Availability** column says so.
|
||||
|
||||
## Sources
|
||||
|
||||
| # | Source | Era / focus |
|
||||
|----|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| S1 | [flarn2006: "Hacking the Bose SoundTouch and its Linux insides"](https://flarn2006.blogspot.com/2014/09/hacking-bose-soundtouch-and-its-linux.html) (2014) | Firmware 1.x–7.x; root shell discovery, codenames |
|
||||
| S2 | [Sam Hobbs: "Connect Bose SoundTouch 10 to WiFi using Linux Telnet"](https://samhobbs.co.uk/2016/01/connect-bose-soundtouch-10-wifi-using-linux-telnet) (2016) | ST 10 setup mode; `network`/`sys` families |
|
||||
| S3 | [izndgroup: "Connect Bose SoundTouch 10 to WiFi"](https://technical.izndgroup.com/2021/02/connect-bose-soundtouch-10-to-wifi.html) (2021) | Reissue of S2 with later-firmware notes |
|
||||
| S4 | [sijeffrey/SoundTouch — `bose` script](https://github.com/sijeffrey/SoundTouch/blob/master/bose) (2017) | `nc`-based remote-control script using `sys`/`ws` |
|
||||
| S5 | [r/bose "SoundTouch telnet probing"](https://www.reddit.com/r/bose/comments/1o5zkym/soundtouch_telnet_probing/) | Recent (post-EOS) probing on ST 10 firmware `27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29`; comments mirrored in [#221](https://github.com/gesellix/Bose-SoundTouch/issues/221) |
|
||||
| S6 | Issue [#221](https://github.com/gesellix/Bose-SoundTouch/issues/221), [#236](https://github.com/gesellix/Bose-SoundTouch/issues/236), [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141) | The migration commands we already implement |
|
||||
|
||||
---
|
||||
|
||||
## Connecting to the shell
|
||||
|
||||
### From an already-on-network device
|
||||
|
||||
The shell binds to TCP port 17000 on every device family observed (ST 10/20/300, Wave III/IV, ST 520, SA-5 — see §"Firmware era notes" for caveats). No authentication.
|
||||
|
||||
```bash
|
||||
# A no-op probe just to verify reach.
|
||||
echo '' | nc -w 2 <device-ip> 17000
|
||||
|
||||
# Or interactively — works the same.
|
||||
telnet <device-ip> 17000
|
||||
```
|
||||
|
||||
The `bose` script (S4) goes one level lower and writes commands directly to a `/dev/tcp/<ip>/17000` redirection target instead of using `nc`. That's the same wire protocol with no library between.
|
||||
|
||||
### From a factory-fresh / WiFi-less device
|
||||
|
||||
Per S2/S3 — newer firmware may have closed this on some models:
|
||||
|
||||
1. **Enter setup mode.** Press and hold key **2** + **volume down** for 5 seconds until the WiFi LED turns amber.
|
||||
2. **Connect your laptop to the speaker's open access point.** The speaker becomes its own AP.
|
||||
3. **Telnet to `192.0.2.1` on port 17000.**
|
||||
|
||||
Once you've added a WiFi profile (see `network wifi profiles add` below) the speaker reboots into station mode and the AP goes away.
|
||||
|
||||
### Hardware key combinations on the device itself
|
||||
|
||||
| Combo | Effect | Source |
|
||||
|-------------------|------------------------------------------|--------|
|
||||
| `1` + volume-down | Factory reset | S2, S3 |
|
||||
| `2` + volume-down | Setup mode (open WiFi AP at `192.0.2.1`) | S2, S3 |
|
||||
| `3` + volume-down | Toggle WiFi / Bluetooth | S2, S3 |
|
||||
| `4` + volume-down | Check for software updates | S2, S3 |
|
||||
|
||||
---
|
||||
|
||||
## The `network` family — WiFi & interfaces
|
||||
|
||||
| Command | Purpose | Availability | Source |
|
||||
|------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|----------------------------|--------|
|
||||
| `network wifi status` | Current SSID, state (e.g. `WIFI_STATION_CONNECTED`), signal strength. Returns XML-like `<WiFiStatus SSID="…" state="…">`. | Wide | S2, S3 |
|
||||
| `network wifi scan [<maxresults>]` | Site survey. | Wide | S2 |
|
||||
| `network wifi profiles info` | Lists stored WiFi profiles (passphrases shown encrypted). | Wide | S2, S3 |
|
||||
| `network wifi profiles add <ssid> <security> [<password>]` | Adds a WiFi network. `<security>` ∈ `none` \| `wep` \| `wpa_or_wpa2`. | Wide; setup-mode workhorse | S2, S3 |
|
||||
| `network wifi profiles clear` | Wipes all stored profiles. | Wide | S2 |
|
||||
| `network status` | All interfaces and IP addresses. | Wide | S2, S3 |
|
||||
| `network dhcp` | Current DHCP interface info. | Wide | S2 |
|
||||
| `network mode auto\|wifioff\|wifisetup` | Switch radio / setup-AP state. | Wide | S2 |
|
||||
|
||||
**Example session — adding a network from setup mode (S3):**
|
||||
|
||||
```
|
||||
network wifi profiles add foobarHub wpa_or_wpa2 topsecret
|
||||
```
|
||||
|
||||
The speaker stores the profile, drops the setup AP, and reboots into station mode.
|
||||
|
||||
---
|
||||
|
||||
## The `key` family — front-panel button emulation
|
||||
|
||||
Each `key …` command emulates a press of a physical button on the speaker
|
||||
or remote. Confirmed working on ST 10 / FW `27.0.6.46330.5043500` (S5);
|
||||
also visible on the ST 20/300/Wave captures in #221. Different from the
|
||||
`sys presetkey N p` form (S4) — the `key prefix_N` shape on FW 27 is what
|
||||
the device's own remote sends.
|
||||
|
||||
| Command | Effect | Source |
|
||||
|---------------------------------|---------------------------------------------------------------------------------------|--------|
|
||||
| `key prefix_1` … `key prefix_6` | Triggers preset 1–6 (same as a remote preset press). | S5 |
|
||||
| `key play` | Begin / resume playback. | S5 |
|
||||
| `key pause` | Pause playback. | S5 |
|
||||
| `key stop` | Stop playback (does **not** terminate the underlying stream). | S5 |
|
||||
| `key prev` | Restart current song / previous track. | S5 |
|
||||
| `key next` | Next track. | S5 |
|
||||
| `key aux` | Toggle Bluetooth / AUX input. | S5 |
|
||||
| `key power` | Echoes "OK" but no observable effect on FW 27.x — possibly handled at a higher layer. | S5 |
|
||||
|
||||
The S4 `bose` script's `sys presetkey N p` form still works, but `key prefix_N` is shorter and matches what the remote already does on FW 27.x.
|
||||
|
||||
---
|
||||
|
||||
## The `sys` family — system control & service URLs
|
||||
|
||||
The `sys` family is the one our migration uses (see §"What we use during migration"). Two distinct sub-syntaxes coexist:
|
||||
|
||||
- **Single-token verbs:** `sys reboot`, `sys volume`, `sys power`, etc.
|
||||
- **`sys configuration <key> <value>` setters** that modify persisted runtime configuration. Used for the four service URLs (margeServerUrl, statsServerUrl, swUpdateUrl, bmxRegistryUrl).
|
||||
|
||||
| Command | Purpose | Availability | Source |
|
||||
|---------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------|------------|
|
||||
| `sys reboot` | Restart the device. | Wide | S2, S6 |
|
||||
| `sys factorydefault` | Reset to factory defaults. | Wide | S1, S2 |
|
||||
| `sys ver` | Firmware version string, e.g. `BoseApp version: 27.0.6.46330.5043500 …`. | Wide; confirmed on FW 27.x | S1, S5 |
|
||||
| `sys power` | Toggle power. Confirmed working on older firmware via S2/S4; on FW 27.x ST 10 the response is `OK` but with **no observable effect** — power state may be controlled elsewhere on that build. | Varies | S2, S4, S5 |
|
||||
| `sys playpause` | Toggle playback. | Wide | S2 |
|
||||
| `sys stop`, `sys pause` | Accepted (return `OK`) but **no observable effect** on FW 27.x ST 10 — the working stop/pause path on that firmware is `key stop` / `key pause`. | Wide / no-op | S5 |
|
||||
| `sys volume` | Print current volume. The S4 script parses the 5th token of the first line. | Wide | S2, S4, S5 |
|
||||
| `sys volume <int>` | Set absolute volume to `<int>`. | Wide | S5 |
|
||||
| `sys volume up <n>` / `sys volume down <n>` | Adjust volume by `<n>` (steps, not dB). | Wide | S4 |
|
||||
| `sys volume <value> updateDisplay` | Set absolute volume and update the front-panel display. | Wide | S2 |
|
||||
| `sys presetkey <1-6> p` | Trigger a preset (`p` = press). Older shape of `key prefix_<N>`. | Wide | S4 |
|
||||
| `sys timeout inactivity disable` (or `off`) | Stop the auto-shutoff timer. May need to be sent twice. | Wide | S1, S2 |
|
||||
| `sys configuration` (no args) | Returns the usage hint `sys configuration <XMLTag> <XMLValue>` — confirms the underlying setter is XML-tag-keyed. | FW 27.x | S5 |
|
||||
| `sys configuration bmxRegistryUrl <url>` | Set the Bose Media eXchange registry URL. | Wide; **migration** | S6 |
|
||||
| `sys configuration statsServerUrl <url>` | Set the telemetry/stats endpoint. | Wide; **migration** | S6 |
|
||||
| `sys configuration margeServerUrl <url>` | Set the marge / streaming endpoint. | Wide; **migration** | S6 |
|
||||
| `sys configuration swUpdateUrl <url>` | Set the software-update endpoint. | Wide; **migration** | S6 |
|
||||
|
||||
Each `sys configuration` setter is reported by users to return `OK` on success. Wait for that token between commands (S6, `foob61451`).
|
||||
|
||||
---
|
||||
|
||||
## The `envswitch` family — parallel persistence layer
|
||||
|
||||
`envswitch` writes to a separate, lower-level persistence store that **wins on next reboot** if the corresponding `sys configuration` value differs. So our migration writes both — see TELNET-MIGRATION-METHOD.md §2.1.
|
||||
|
||||
| Command | Purpose | Source |
|
||||
|---------------------------------------------------|-----------------------------------------------------------------------------------------------|---------|
|
||||
| `envswitch boseurls set <margeUrl> <swUpdateUrl>` | Persist the marge and update URLs. **Two arguments**, in that order. | S6 |
|
||||
| `envswitch accountid set <numeric-id>` | Equivalent to the HTTP `/setMargeAccount` POST. Used as fallback in our `PairAccount` helper. | S6 |
|
||||
| `envswitch accountid get` | Plausible by symmetry but **not yet confirmed** across firmwares; we probe it best-effort. | (probe) |
|
||||
|
||||
---
|
||||
|
||||
## The `getpdo` family — read persisted configuration
|
||||
|
||||
`getpdo <selector>` prints the contents of a persisted-data-object. We use it as the verification step after writing URLs.
|
||||
|
||||
| Selector | Purpose | Source |
|
||||
|-------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|
|
||||
| `getpdo CurrentSystemConfiguration` | Echoes the resolved URL set, including margeServerUrl/bmxRegistryUrl/statsServerUrl/swUpdateUrl. We grep our targetURL out of this to confirm a successful migration. | S6 |
|
||||
|
||||
---
|
||||
|
||||
## The `scm` family — service control
|
||||
|
||||
`scm` (System Control / Module manager) lets you inspect and restart internal services.
|
||||
|
||||
| Command | Purpose | Availability | Source |
|
||||
|-------------------------|------------------------------------------------------------------------------------------|----------------|------------------------------------------------------------------------------|
|
||||
| `scm list` | List running services. | Older firmware | S1 |
|
||||
| `scm restart <service>` | Restart a service by name. | Older firmware | S1 |
|
||||
| `scm uboot_ver` | Print bootloader version (`U-Boot 2013.01.01-…`). Confirmed working on SA-5 with FW 9.x. | Older firmware | [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141) |
|
||||
|
||||
---
|
||||
|
||||
## Shell-unlock commands
|
||||
|
||||
These are the commands that gated SSH access on older firmware. Both have been progressively removed; on FW 27.x they generally do nothing useful.
|
||||
|
||||
| Command | Purpose | Availability | Source |
|
||||
|-----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------|----------------------------------------------------------------------------------|
|
||||
| `remote_services on` | Enable SSH on port 22. Volatile (re-enter after reboot). Response: `remote services on`. **Removed in FW 7.x+**. | Old | S1 |
|
||||
| `local_services on` | Alternative enablement; works on some firmware where `remote_services` was removed. SA-5 FW 9.x reports `local services on`, but this alone does not appear to grant SSH on most models. | Old, hit-or-miss | S1, [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141) |
|
||||
| `demo enter` / `mode enter` | Unlocks demo / button-test mode (used historically to recover bricked units). | Old | S1 |
|
||||
|
||||
---
|
||||
|
||||
## The `ws` and `swupdate` families
|
||||
|
||||
| Command | Purpose | Availability | Source |
|
||||
|------------------|---------------------------------------------------------------------------------------------------------|--------------|--------|
|
||||
| `ws getpresets` | Returns an XML list of presets — the S4 script parses the `<itemName>…<text>…` blocks to extract names. | Wide | S4 |
|
||||
| `swupdate abort` | Cancel a software update in progress. | Wide | S1 |
|
||||
|
||||
---
|
||||
|
||||
## `help`
|
||||
|
||||
Lists the commands available on the running firmware. **Frequently removed** on later firmware — returns `Command not found` on FW 27.x in many of the captures we have. Still worth probing once during preflight: a successful response is a quick way to enumerate what this specific build supports without trial-and-error.
|
||||
|
||||
---
|
||||
|
||||
## Device codenames (S1)
|
||||
|
||||
These show up in `getpdo`, `network status`, and SSH-side hostnames. Useful for matching captures to hardware.
|
||||
|
||||
| Codename | Hardware |
|
||||
|----------|------------------------------------------------|
|
||||
| `lisa` | Adapter (older speakers running Bose firmware) |
|
||||
| `spotty` | SoundTouch 20 |
|
||||
| `rhino` | SoundTouch 10 |
|
||||
| `mojo` | SoundTouch 30 |
|
||||
| `taigan` | SoundTouch Portable |
|
||||
|
||||
---
|
||||
|
||||
## Firmware era notes
|
||||
|
||||
- **Firmware 1.x–7.x** (S1 era): everything — `help`, `remote_services on`, full `scm`, and an in-shell login prompt. `flarn2006` documents the original Linux insides.
|
||||
- **Firmware 8.x–14.x** (S2 era): `remote_services on` removed; `network`, `sys`, `envswitch`, `getpdo` still present. `local_services on` works on some Wave/SA-5 models.
|
||||
- **Firmware 27.x** (S5/S6 era — the long-lived "frozen" build that survived through EOS): `help`, `remote_services on`, and `sys ver` removed in some builds; `sys configuration …` and `envswitch …` confirmed working on ST 10, ST 20, ST 300, Wave III, Wave IV. **This is the firmware our migration targets**. The Portable on more recent firmware drops further commands and is the hardest target.
|
||||
|
||||
S5 enumerated the **top-level command roots** that don't return "Command not found" on a vanilla ST 10 (`rhino`) running `27.0.6.46330.5043500`:
|
||||
|
||||
```
|
||||
key
|
||||
net
|
||||
sys
|
||||
getpdo
|
||||
```
|
||||
|
||||
Notably absent from that probe: `network`, `envswitch`, `scm`, `ws`, `swupdate`, `remote_services`, `local_services`, `demo`, `mode`, `help`. **However**, other captures on the same firmware family (S6, ST 20 / Wave III / Wave IV) accept `envswitch …`, suggesting either per-model variation in the shipped command table or an SSH/role gate the S5 author didn't trip. Implementations that use `envswitch` should treat its absence as a recoverable preflight outcome (we already do).
|
||||
|
||||
`net` is observed as a valid root by S5 but its sub-commands aren't enumerated; it may be a shorthand alias for `network` on FW 27.x ST 10.
|
||||
|
||||
---
|
||||
|
||||
## What we use during migration
|
||||
|
||||
For quick reference, the exact sequence our `pkg/service/setup.migrateViaTelnet` issues, all on the same connection, in this order:
|
||||
|
||||
```
|
||||
sys configuration bmxRegistryUrl <serverURL>/bmx/registry/v1/services
|
||||
sys configuration statsServerUrl <serverURL>
|
||||
sys configuration margeServerUrl <serverURL>
|
||||
sys configuration swUpdateUrl <serverURL>/updates/soundtouch
|
||||
envswitch boseurls set <serverURL> <serverURL>/updates/soundtouch
|
||||
getpdo CurrentSystemConfiguration
|
||||
```
|
||||
|
||||
Plus, when pairing a fresh device whose `:8090/setMargeAccount` is missing or wedged, the helper falls back to:
|
||||
|
||||
```
|
||||
envswitch accountid set <7-digit-id>
|
||||
```
|
||||
|
||||
Reboot is **not** part of these sequences — it stays a user-initiated action via the existing reboot button, which now accepts `?method=telnet|ssh` and sends `sys reboot` when telnet is picked.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope here, but worth recording
|
||||
|
||||
- **Setup-mode WiFi onboarding via 192.0.2.1.** The community uses this to add a fresh device to a network without the Bose app. Our `soundtouch-service` does not currently automate this, but `network wifi profiles add` is the entry point if we ever do.
|
||||
- **Direct preset / playback control via `sys`.** The S4 `bose` script demonstrates a viable headless remote-control path that does not need our marge emulation at all. Useful as a fallback for tooling on devices that refuse to talk to any cloud.
|
||||
- **`scm restart <service>`.** Not used today, but a possible recovery primitive on older firmware where a stuck service blocks streaming.
|
||||
@@ -0,0 +1,730 @@
|
||||
# Telnet (Port 17000) Migration Method — Analysis
|
||||
|
||||
This document captures the use cases, community findings, and feasibility analysis
|
||||
for adding a **Telnet/port 17000** migration path to `soundtouch-service` as a
|
||||
peer of the existing XML and DNS-based methods. The `/etc/hosts` method stays
|
||||
deprecated and is intentionally kept off the visible UI options.
|
||||
|
||||
> **Sources** — community discussion synthesised from
|
||||
> [gesellix/Bose-SoundTouch#221](https://github.com/gesellix/Bose-SoundTouch/issues/221),
|
||||
> [gesellix/Bose-SoundTouch#236](https://github.com/gesellix/Bose-SoundTouch/issues/236),
|
||||
> [scheilch/opencloudtouch#167](https://github.com/scheilch/opencloudtouch/issues/167),
|
||||
> [deborahgu/soundcork#228](https://github.com/deborahgu/soundcork/issues/228),
|
||||
> [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141),
|
||||
> the post-EOS walkthrough PDF in `docs/`,
|
||||
> [Bose SoundTouch Telnet Probing thread](https://www.reddit.com/r/bose/comments/1o5zkym/soundtouch_telnet_probing/),
|
||||
> and [flarn2006's blog post on hacking SoundTouch](https://flarn2006.blogspot.com/2014/09/hacking-bose-soundtouch-and-its-linux.html).
|
||||
|
||||
---
|
||||
|
||||
## 1. Why a third method is needed
|
||||
|
||||
The two currently shipped methods both have hard preconditions that block real
|
||||
users:
|
||||
|
||||
| Method | Preconditions | Failure modes seen in the wild |
|
||||
|-----------------------------------------|--------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **XML** (`SoundTouchSdkPrivateCfg.xml`) | SSH/root access — needs `remote_services` USB unlock first | Some firmware revisions (e.g. SA-5, ST520, latest ST Portable) refuse the USB unlock entirely; `remote_services on` was removed from the telnet command set in firmware 7.x and later. |
|
||||
| **DNS** (`resolv.conf` priority hook) | SSH/root access; service must own port 53 on the LAN gateway | Won't fit users behind ISP routers they can't reconfigure; still requires the device to be SSH-reachable to write the hook. |
|
||||
|
||||
The community has demonstrated a **third path that needs no SSH at all**:
|
||||
the device's built-in **diagnostic Telnet shell on TCP port 17000** accepts
|
||||
configuration commands that change exactly the same fields the XML method would.
|
||||
|
||||
### 1.1 Confirmed user reports (firmware 27.0.6.46330.5043500 unless noted)
|
||||
|
||||
| Reporter | Hardware | Outcome |
|
||||
|--------------------|---------------------------|-------------------------------------------------------------------------------------------------------------|
|
||||
| `foob61451` (#221) | ST 10, ST 20 (non-rooted) | All four URLs persisted via `sys configuration …`; `envswitch boseurls set …` survived `sys reboot`. |
|
||||
| `bveenker` (#221) | Wave III | URLs accepted; presets work after pairing via `/setMargeAccount` (see §3). |
|
||||
| `stephan48` (#221) | Wave IV | Telnet:1700 + USB stick `remote_services` did **not** work; **port 17000 telnet** worked for all four URLs. |
|
||||
| `mcdona1d` (#141) | ST 20, ST 300 | Confirmed working with `sys configuration …` + `envswitch …` + `sys reboot`. |
|
||||
| `TJGigs` (#228) | ST 20 ×2, ST 10 | Wraps telnet:17000 into an admin "Smart Inject" tool; uses `sys reboot` over telnet to nudge devices. |
|
||||
|
||||
So the method is plausible across **at least ST 10/20/300 and Wave III/IV** on
|
||||
the most common firmware that survived the EOS cut, **without the USB unlock
|
||||
dance** that newer firmware refuses.
|
||||
|
||||
---
|
||||
|
||||
## 2. The Telnet:17000 command set we rely on
|
||||
|
||||
> For a broader catalogue of every telnet command the community has documented
|
||||
> across firmware eras (the `key`, `network`, `sys`, `envswitch`, `getpdo`,
|
||||
> `scm`, `ws`, `swupdate`, and shell-unlock families), see
|
||||
> **[TELNET-COMMAND-REFERENCE.md](TELNET-COMMAND-REFERENCE.md)**. This
|
||||
> section only lists the subset our migration actually drives.
|
||||
|
||||
|
||||
### 2.1 URL configuration (the migration payload)
|
||||
|
||||
The sequence we send for `soundtouch-service` (community-validated in #221, #141):
|
||||
|
||||
```
|
||||
sys configuration bmxRegistryUrl http://<service-host>:8000/bmx/registry/v1/services
|
||||
sys configuration statsServerUrl http://<service-host>:8000
|
||||
sys configuration margeServerUrl http://<service-host>:8000
|
||||
sys configuration swUpdateUrl http://<service-host>:8000/updates/soundtouch
|
||||
envswitch boseurls set http://<service-host>:8000 http://<service-host>:8000/updates/soundtouch
|
||||
getpdo CurrentSystemConfiguration
|
||||
```
|
||||
|
||||
`sys reboot` is **not** part of this sequence. The migration flow only writes
|
||||
configuration — the reboot is user-initiated via the existing reboot button in
|
||||
the web UI, mirroring what XML/DNS migration already does. See §6.2 for how
|
||||
that button gains a `?method=ssh|telnet` selector.
|
||||
|
||||
Three important details from the discussion:
|
||||
|
||||
1. **`sys configuration` alone is not enough.** `stephan48` reported that
|
||||
without the `envswitch boseurls set …` line his typo in `bmxRegistryUrl` was
|
||||
silently restored on reboot — i.e. there is a parallel "envswitch" persistence
|
||||
layer that wins on next boot if you don't also write to it. **We must always
|
||||
issue both.**
|
||||
2. **margeServerUrl path is bare for `soundtouch-service`.** We mount the marge
|
||||
endpoints at the **root** of port 8000, matching what the existing XML
|
||||
migration writes (`Manager.migrateViaXML` in `pkg/service/setup/setup.go`
|
||||
sets `MargeServerUrl: targetURL` without any suffix). Some community
|
||||
recipes appended `/marge` because they were targeting
|
||||
[`deborahgu/soundcork`](https://github.com/deborahgu/soundcork), which
|
||||
routes marge under that sub-path. **For our service: bare URL. For users
|
||||
redirecting to soundcork: append `/marge`** to both `margeServerUrl` and
|
||||
the first argument of `envswitch boseurls set`.
|
||||
3. **Each command must be sent one at a time, waiting for the device's `OK`
|
||||
response** before sending the next one (`foob61451`'s explicit warning).
|
||||
|
||||
### 2.2 Account pairing fallback
|
||||
|
||||
`envswitch accountid set <numeric-id>` was reported by `bveenker` (#221) as an
|
||||
in-band equivalent to the HTTP `/setMargeAccount` call, useful when the
|
||||
`/setMargeAccount` endpoint is missing on the firmware (see §3).
|
||||
|
||||
### 2.3 Probing / preflight
|
||||
|
||||
- A bare TCP connect to `<deviceIP>:17000` answers (no auth) on devices we care
|
||||
about.
|
||||
- Useful read-only verification command: `getpdo CurrentSystemConfiguration` —
|
||||
prints the URLs after the changes have been applied so we can verify before
|
||||
rebooting.
|
||||
- `sys reboot` is the trigger that re-reads both layers.
|
||||
|
||||
### 2.4 What Telnet:17000 cannot do
|
||||
|
||||
- It does **not** install a custom CA. So if a user wants HTTPS rather than HTTP
|
||||
redirection to our service (the DNS-method scenario, where `resolv.conf`
|
||||
redirection collides with the device's TLS validation unless our root CA is
|
||||
trusted on the device), telnet alone won't cover it. This is fine for our
|
||||
default flow, which uses plain `http://` URLs to the service's port 8000.
|
||||
- It does not give us a way to read or write `Sources.xml` (third-party
|
||||
account credentials) — that still requires SSH, but for a migration we don't
|
||||
actually need it.
|
||||
|
||||
---
|
||||
|
||||
## 3. The `/setMargeAccount` problem (issue #236, #228)
|
||||
|
||||
### 3.1 What it is
|
||||
|
||||
A factory-reset speaker has an empty `<margeAccountUUID/>` in `:8090/info`. The
|
||||
marge endpoints fail with 502 / unhandled until that field is populated, which
|
||||
is why several users (#221, #236) saw **everything except AUX** broken after
|
||||
migration:
|
||||
|
||||
```
|
||||
POST http://<deviceIP>:8090/setMargeAccount
|
||||
Content-Type: application/xml
|
||||
|
||||
<PairDeviceWithAccount>
|
||||
<accountId>1234567</accountId>
|
||||
<userAuthToken>soundcorkdoesntcare</userAuthToken>
|
||||
</PairDeviceWithAccount>
|
||||
```
|
||||
|
||||
The values are not validated by the local service, so any numeric `accountId`
|
||||
will work — soundcork's runbook (#228) literally calls the token
|
||||
`soundcorkdoesntcare` to make the point.
|
||||
|
||||
### 3.2 Why it's broken in practice
|
||||
|
||||
There are **three independent failure modes** observed:
|
||||
|
||||
| Symptom | Cause | Detection |
|
||||
|-----------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------|
|
||||
| Endpoint returns 404 / "not implemented" | Newer firmware (e.g. some BST20 Portable, latest ST Portable) drops the endpoint entirely. | `GET /supportedURLs` does **not** list `/setMargeAccount` in `<URL location="…"/>`. |
|
||||
| Endpoint hangs (no response / socket stays open) | "Broken state" the user explicitly called out — endpoint advertised, but handler is wedged. | Caller has to time out; we currently have no timeout, so the request appears to hang the migration UI indefinitely. |
|
||||
| `POST /marge/streaming/support/power_on` → 502 unhandled (#236) | Device keeps polling marge after migration but no `margeAccountUUID` was ever assigned, so all subsequent calls fail. | `:8090/info` shows `<margeAccountUUID/>` empty after reboot. |
|
||||
|
||||
### 3.3 Required handling
|
||||
|
||||
Per the user's brief, the migration logic must:
|
||||
|
||||
1. **Probe** `GET http://<deviceIP>:8090/supportedURLs` and check whether
|
||||
`/setMargeAccount` is in the list **before** trying to POST it.
|
||||
2. **Time-bound** the POST aggressively (e.g. ≤5s connect + ≤10s read) and treat
|
||||
anything over the budget as a failure rather than waiting indefinitely.
|
||||
3. On either failure mode, **fall back** to the telnet equivalent
|
||||
`envswitch accountid set <id>` over the same `pkg/telnet` connection used
|
||||
for the URL flip. Reboot stays a user-initiated action (§6.2).
|
||||
4. If telnet:17000 is **also** unreachable, surface a clear "your firmware does
|
||||
not support unattended pairing — please pair manually via the official Bose
|
||||
app *before* it goes EOS, or open SSH and use the XML method" error rather
|
||||
than leaving the device in a half-migrated state.
|
||||
|
||||
### 3.4 Where the `<id>` comes from
|
||||
|
||||
The device's current account ID is already discoverable through endpoints we
|
||||
control:
|
||||
|
||||
- **`GET :8090/info`** returns `<margeAccountUUID>…</margeAccountUUID>`. If it
|
||||
is non-empty the device is already paired — **reuse that ID**, do not
|
||||
reassign. Our local marge accepts any ID, so the existing one is fine.
|
||||
- If it is empty (factory reset), the user picks one in the UI:
|
||||
1. **Pick from existing accounts.** The setup UI lists IDs returned by
|
||||
`DataStore.ListAccounts()` so a user can re-attach a fresh device to an
|
||||
account that already has presets/recents/sources.
|
||||
2. **Enter manually.** Free-form text input, validated as **exactly 7
|
||||
numeric digits** (the format every Bose-cloud-issued ID has had in the
|
||||
captures we've seen, and the format the wider community uses in their
|
||||
recipes).
|
||||
3. **Randomize.** A "Generate" button that picks a 7-digit number and
|
||||
re-rolls if it collides with an existing account in the local datastore.
|
||||
- **Telnet read-back (best-effort).** `envswitch accountid get` is plausible by
|
||||
symmetry with `envswitch accountid set` (#221) but is not yet confirmed
|
||||
across firmwares. We will probe it during preflight; if it returns a value
|
||||
we cross-check it against `:8090/info` and warn on mismatch.
|
||||
|
||||
This means the user is never *forced* to invent a number — the common path is
|
||||
"the device already has an ID, reuse it" — and the manual/randomize controls
|
||||
only show up when the device is genuinely fresh.
|
||||
|
||||
---
|
||||
|
||||
## 4. Port 17000 availability
|
||||
|
||||
The diagnostic shell is gated by firmware build and product family. Anecdotally:
|
||||
|
||||
- ST 10 / ST 20 / ST 300 / Wave III / Wave IV on FW 27.0.6 → **open**.
|
||||
- SA-5 with FW 9.x → some commands present (`local_services on`) but
|
||||
**no `remote_services on`** and no SSH on FW 9.0.43.23466 (#141).
|
||||
- Modern firmware on some Portables → endpoint set has shrunk further.
|
||||
|
||||
Because of this, we cannot assume port 17000 is reachable. The migration flow
|
||||
must:
|
||||
|
||||
1. **Probe** with a TCP connect to `<deviceIP>:17000`, with a tight timeout
|
||||
(≤2s). A successful TCP handshake is necessary but not sufficient — some
|
||||
hardened firmware closes the port immediately.
|
||||
2. **Banner check.** After connecting, read whatever the device sends within
|
||||
~1s. The diagnostic shell prints a small banner (firmware-dependent); a
|
||||
blank read or an immediate close means we should treat it as "telnet not
|
||||
usable" and disable the option.
|
||||
3. **Capability check.** Issue a no-op like `getpdo CurrentSystemConfiguration`
|
||||
and look for any non-empty response. If the device replies "Command not
|
||||
found" we abort and suggest XML or DNS instead.
|
||||
4. **Surface state to the UI.** The migration form should grey out the Telnet
|
||||
option when the probe fails and show *why* (closed, banner missing,
|
||||
command rejected) instead of letting the user click into a dead end.
|
||||
|
||||
---
|
||||
|
||||
## 5. Implementation feasibility — Telnet client in Go
|
||||
|
||||
This is a feasibility check only; no code is written yet.
|
||||
|
||||
### 5.1 Protocol
|
||||
|
||||
"Telnet" on port 17000 is effectively a line-oriented plain-TCP shell. The
|
||||
device prints a small prompt (`->` in the SA-5 captures from #141) and reads
|
||||
newline-terminated commands. There is **no** real Telnet option negotiation
|
||||
(no `IAC`/`DO`/`WILL` exchanges visible in the wild captures), so we don't
|
||||
need `golang.org/x/crypto/ssh`-class machinery.
|
||||
|
||||
### 5.2 Standard-library only
|
||||
|
||||
A minimal client is just `net.DialTimeout("tcp", host+":17000", 2*time.Second)` +
|
||||
`bufio.Scanner` + `time.Time`-based deadlines on `Conn`. No third-party Telnet
|
||||
library is needed; `github.com/reiver/go-telnet` would be overkill and adds
|
||||
maintenance surface for no benefit. This matches the project's KISS principle
|
||||
in `docs/CLAUDE.md` §3.
|
||||
|
||||
### 5.3 Cross-platform compatibility
|
||||
|
||||
`net.Dial` over TCP works identically on Windows, macOS, Linux and (with
|
||||
limitations on listening) WASM. WASM-side: `soundtouch-service` runs server-side
|
||||
anyway, so this only matters for `soundtouch-cli`, where TCP dial works in any
|
||||
target other than browser-WASM — an acceptable carve-out documented separately.
|
||||
|
||||
### 5.4 Concurrency / safety
|
||||
|
||||
Each migration is a single goroutine driving one device. The client must:
|
||||
|
||||
- enforce per-command response deadlines so a wedged device cannot stall the
|
||||
migration UI (mirrors the `/setMargeAccount` requirement);
|
||||
- abort the rest of the sequence on the first non-`OK` response so we don't
|
||||
half-write configuration;
|
||||
- always close the socket on error.
|
||||
|
||||
### 5.5 Testing strategy
|
||||
|
||||
We can test without a real speaker by spinning up a `net.Listen("tcp", "127.0.0.1:0")`
|
||||
in the test, scripting it to consume our commands and emit canned `OK`/error
|
||||
responses. That gives us deterministic coverage for:
|
||||
|
||||
- happy path (all four URLs accepted),
|
||||
- single-command failure → sequence aborts, no further commands sent,
|
||||
- "command not found" on `envswitch …` → fallback path exercised,
|
||||
- TCP closed mid-stream → migration aborts cleanly,
|
||||
- read deadline triggers when the device hangs (the broken-state simulation).
|
||||
|
||||
The repo already follows the "real device responses preferred, mock servers
|
||||
otherwise" rule (see `docs/CLAUDE.md` §1, §8). The tests above are the mock-server
|
||||
half of that pattern.
|
||||
|
||||
### 5.6 Where it lives
|
||||
|
||||
The protocol client is **a standalone package**, not buried inside
|
||||
`pkg/service/setup`, so it can be reused from CLI tools, future setup wizards,
|
||||
and tests without dragging the migration manager in:
|
||||
|
||||
```
|
||||
pkg/telnet/ # NEW reusable package
|
||||
client.go # Dial / SendCommand / Probe / Close
|
||||
client_test.go # mock-server tests against a net.Listen
|
||||
|
||||
pkg/service/setup/
|
||||
telnet_migration.go # NEW thin wrapper that imports pkg/telnet
|
||||
# and runs the URL config sequence
|
||||
marge_pairing.go # NEW /setMargeAccount probe + post + telnet
|
||||
# `envswitch accountid set` fallback
|
||||
setup.go # add MigrationMethodTelnet const + case
|
||||
```
|
||||
|
||||
UI plumbing is `pkg/service/handlers/web/index.html` (option list) and
|
||||
`pkg/service/handlers/web/js/script.js` (`toggleMigrationMethod()`). The
|
||||
deprecated `hosts` option is already hidden from the dropdown when we ship
|
||||
this; we just add a `telnet` option next to `xml`/`resolv`.
|
||||
|
||||
### 5.7 Verdict
|
||||
|
||||
**Feasible and small.** Estimated scope: ~200 lines of client code in
|
||||
`pkg/telnet`, ~300 lines of tests, plus a `MigrationMethodTelnet` branch in
|
||||
`Manager.MigrateSpeaker`, plus the preflight probe described in §4 and the
|
||||
`/setMargeAccount` guarding described in §3.
|
||||
|
||||
---
|
||||
|
||||
## 6. Decisions made (was: open questions)
|
||||
|
||||
1. **Account-ID generation.** Resolved — see §3.4. The migration form reads
|
||||
`:8090/info` first; if `margeAccountUUID` is non-empty it is reused.
|
||||
Otherwise the UI offers (a) pick from `DataStore.ListAccounts()`,
|
||||
(b) manual entry validated as 7 numeric digits, (c) a "Generate" button
|
||||
that randomizes a 7-digit number and re-rolls on collision.
|
||||
2. **Reboot policy.** Migration writes configuration only — it does **not**
|
||||
issue `sys reboot` itself. Reboot stays user-initiated via the existing
|
||||
reboot button in the web UI, the same way XML/DNS migration already works.
|
||||
That button's endpoint (`POST /setup/reboot/{deviceId}`,
|
||||
`Manager.Reboot(deviceIP)`) gains an optional `?method=ssh|telnet` query
|
||||
parameter; default stays `ssh` so existing behavior is preserved. The
|
||||
button itself uses a plain `confirm()` dialog before firing.
|
||||
3. **CA / HTTPS story.** Telnet has no way to install a custom CA. Documented
|
||||
as an explicit limitation: telnet method = HTTP-only redirect to our
|
||||
service. Users who need end-to-end TLS must use the XML or DNS method.
|
||||
*Possible future enhancement* — a hybrid "install CA via SSH/XML, then drive
|
||||
the URL flip via Telnet" path. Feasibility unknown; not in this iteration.
|
||||
|
||||
---
|
||||
|
||||
> **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
|
||||
TCP client with `Dial`, `SendCommand`, `Probe`, `Close`, all deadline-driven.
|
||||
No external dependencies, usable from CLI, service, and tests.
|
||||
- **New `MigrationMethodTelnet = "telnet"`** constant in `pkg/service/setup/setup.go`
|
||||
plus a `migrateViaTelnet` branch in `Manager.MigrateSpeaker`.
|
||||
- **New `pkg/service/setup/telnet_migration.go`** orchestrating the URL
|
||||
configuration sequence (§2.1) on top of `pkg/telnet`. Configuration only —
|
||||
no `sys reboot` here.
|
||||
- **New `pkg/service/setup/marge_pairing.go`** with `PairAccount(deviceIP, id)`:
|
||||
probes `/supportedURLs`, time-bounded `POST /setMargeAccount`, falls back to
|
||||
telnet `envswitch accountid set <id>` on missing/wedged endpoint.
|
||||
- **`Manager.Reboot` and `HandleRebootDevice` gain a method selector** —
|
||||
signature changes to `Reboot(deviceIP string, method RebootMethod) (string, error)`
|
||||
with `RebootMethodSSH` (default, today's behavior) and `RebootMethodTelnet`
|
||||
(sends `sys reboot` over a fresh `pkg/telnet` connection). Handler reads
|
||||
`?method=ssh|telnet` from the query string.
|
||||
- **`MigrationSummary` gains** `TelnetReachable`, `TelnetBanner`,
|
||||
`TelnetCommandsAccepted`, `SetMargeAccountSupported`, `CurrentAccountID`,
|
||||
`KnownAccountIDs` so the UI can show preflight outcomes and offer reuse.
|
||||
- **UI** — `web/index.html` dropdown gets a `telnet` option (greyed out when
|
||||
preflight fails) and a new pane for picking/entering/randomizing a 7-digit
|
||||
account ID when `:8090/info` reports an empty `margeAccountUUID`. The
|
||||
existing reboot button gets a method selector (radio or dropdown) wired to
|
||||
the new query param, with `confirm()` before firing. The legacy `hosts`
|
||||
option stays out of the dropdown (deprecated).
|
||||
|
||||
---
|
||||
|
||||
## 8. Device compatibility today
|
||||
|
||||
What follows is the current best read on which devices our `migrateViaTelnet`
|
||||
flow handles end-to-end, derived from the same six sources catalogued in
|
||||
[TELNET-COMMAND-REFERENCE.md](TELNET-COMMAND-REFERENCE.md) plus the issue
|
||||
threads cited above. This is migration-outcome perspective; for per-command
|
||||
availability see the reference doc.
|
||||
|
||||
### 8.1 Proven to work end-to-end
|
||||
|
||||
All on the firmware-27.0.6 family, which is what survived through Bose's
|
||||
end-of-service cut. Multi-reporter agreement on every row.
|
||||
|
||||
| Device | Reporter(s) | Source | Confirmed |
|
||||
|----------|-----------------------------|------------------|----------------------------------------------------------------------------|
|
||||
| ST 10 | foob61451, TJGigs | #221, #228 | All four URLs persist; `envswitch boseurls set` survives `sys reboot` |
|
||||
| ST 20 | foob61451, mcdona1d, TJGigs | #221, #141, #228 | Same; multiple independent reports |
|
||||
| ST 300 | mcdona1d | #141 | `sys configuration` + `envswitch` + `sys reboot` round-trip |
|
||||
| Wave III | bveenker | #221 | URLs accepted; presets work after pairing fallback (§3) |
|
||||
| Wave IV | stephan48 | #221 | Port-17000 path **was the only one that worked** — USB-stick unlock failed |
|
||||
|
||||
The exact sequence each reporter ran by hand is the sequence our migration
|
||||
sends (§2.1). So the migration's happy path is exercised against five
|
||||
hardware variants in independent captures.
|
||||
|
||||
### 8.2 Proven to need the pairing fallback
|
||||
|
||||
Migration of the URLs themselves works on these models, but
|
||||
`POST /setMargeAccount` is missing or wedged on the firmware build, so
|
||||
pairing has to go through the telnet `envswitch accountid set <id>` path
|
||||
that `setup.PairAccount` already implements.
|
||||
|
||||
| Device | Reporter | Source | Why fallback is needed |
|
||||
|--------------------------------|----------|-----------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| ST Portable / FW 27.0.6 | jmosen | #236 | After migration: `POST /marge/streaming/support/power_on` → 502; `<margeAccountUUID/>` empty. Time-bounded HTTP path fails; envswitch fallback succeeds. |
|
||||
| BST20 Portable (factory reset) | ubittner | scheilch/opencloudtouch#167 | `<margeAccountUUID/>` empty; `/setMargeAccount` not in `/supportedURLs`. HTTP path skipped entirely; only the telnet fallback works. |
|
||||
|
||||
### 8.3 Likely to fail (but the failure is clean)
|
||||
|
||||
Our preflight + abort-on-first-rejection design (`TestMigrateViaTelnet_CommandNotFoundAborts`)
|
||||
means none of these scenarios leave a device half-configured. The user is
|
||||
told what failed and pointed to the XML or DNS method.
|
||||
|
||||
| Device | Source | Likely cause |
|
||||
|--------------------------------------------|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **SA-5** (sound amplifier) on FW 9.0.43.x | soundcork#141 | FW 9.x has a different shell generation: `->` prompt, `local_services on`, `scm uboot_ver`. **`sys configuration` and `envswitch` are not documented as working there.** Migration fails on command #1. |
|
||||
| **Recent ST Portable** (post-27.0.6.46330) | #236 (indirect) | `/setMargeAccount` removal points to broader command-set shrinkage. If `envswitch accountid set` is also gone, both migration and pairing fallback fail; user is told to pair via the official Bose app before EOS, or use XML over SSH. |
|
||||
|
||||
### 8.4 Unknown — would benefit from real-device verification
|
||||
|
||||
| Device | Why unknown | What we'd want to confirm |
|
||||
|----------------------------|---------------------------------------------------------------------|--------------------------------------------------------------------------|
|
||||
| **ST 30** (`mojo`) | No concrete capture in any of the six sources | Almost certainly works — same FW family as ST 10/20/300 — but unverified |
|
||||
| **ST 520 / Home Cinema** | USB-unlock reports failing (#141), no port-17000 capture either way | Whether `sys configuration` and `envswitch` are exposed at all |
|
||||
| **Wave Music System I/II** | `flarn2006`-era hardware, not seen in 27.x reports | Whether port 17000 is even open on those models |
|
||||
|
||||
### 8.5 The S5 "valid roots" tension
|
||||
|
||||
S5 (the r/bose telnet-probing thread) lists only `key`, `net`, `sys`,
|
||||
`getpdo` as command roots that don't return "Command not found" on its
|
||||
ST 10 / FW 27.0.6 — which would seem to rule out `envswitch`. But foob61451
|
||||
on the same hardware/firmware ran `envswitch boseurls set` successfully
|
||||
(#221).
|
||||
|
||||
The most plausible reading is that **S5 is a non-exhaustive probe**, not a
|
||||
negative claim: the author writes "I've made some educated guesses and come
|
||||
up with the following valid commands" and never says they tested
|
||||
`envswitch`. We do not down-weight `envswitch` availability on the strength
|
||||
of S5 alone — but if a real-device run ever shows `envswitch` rejected on
|
||||
an ST 10, our preflight catches it, the migration aborts on the first
|
||||
non-OK response, and the user gets a clear error rather than partial state.
|
||||
|
||||
### 8.6 Failure-mode matrix
|
||||
|
||||
What `migrateViaTelnet` does in each failure mode (verified by
|
||||
`pkg/telnet` and `pkg/service/setup` unit tests):
|
||||
|
||||
| Failure | Outcome | Test |
|
||||
|------------------------------------------------|---------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------|
|
||||
| Port 17000 closed / TCP unreachable | `Dial` errors before any command is sent; UI shows the error; nothing persisted | `TestMigrateViaTelnet_DialFailureReturnsError` |
|
||||
| `sys configuration` rejected (cmd #1) | Sequence aborts; verification not sent; rest of commands not attempted | `TestMigrateViaTelnet_CommandNotFoundAborts` (envswitch variant — generalises) |
|
||||
| `envswitch boseurls set` rejected | Sequence aborts; runtime-only `sys configuration` state reverts on reboot — no permanent damage | `TestMigrateViaTelnet_CommandNotFoundAborts` |
|
||||
| Verification mismatch (URLs not echoed back) | Loud "verification failed" error; live state may persist until reboot but UI never claims success | `TestMigrateViaTelnet_VerifyMismatchFails` |
|
||||
| `/setMargeAccount` 502 / hang | 5s connect + 12s total budget enforced; falls through to telnet `envswitch accountid set` | `TestPairAccount_FallsBackWhenHTTPReturnsServerError` |
|
||||
| `/setMargeAccount` missing in `/supportedURLs` | HTTP path skipped; goes straight to telnet `envswitch accountid set` | `TestPairAccount_FallsBackWhenSetMargeAccountMissing` |
|
||||
| Both pairing paths unavailable | Structured error: "use the official Bose app before EOS, or open SSH and use the XML method" | `TestPairAccount_NoTelnetAndHTTPMissingReturnsClearError`, `TestPairAccount_TelnetCommandNotFoundReportsBothPaths` |
|
||||
|
||||
### 8.7 TL;DR
|
||||
|
||||
- **Green light** — ST 10, ST 20, ST 300, Wave III, Wave IV on FW 27.0.6 (multi-reporter agreement).
|
||||
- **Yellow** — ST Portable and BST20 Portable: migration works, pairing needs our fallback (already implemented).
|
||||
- **Red, but fails cleanly** — SA-5 on FW 9.x, possibly newer ST Portable builds.
|
||||
- **Unverified but expected to work** — ST 30, ST 520, Wave Music System I/II.
|
||||
|
||||
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
|
||||
@@ -13,18 +14,24 @@ require (
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/term v0.42.0
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/term v0.43.0
|
||||
)
|
||||
|
||||
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.39.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/image v0.40.0 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.37.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=
|
||||
@@ -44,10 +62,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
@@ -56,8 +74,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
@@ -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,13 +106,14 @@ 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=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -105,8 +124,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
@@ -117,8 +136,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
@@ -1254,6 +1556,17 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
|
||||
}
|
||||
|
||||
sources := make([]models.ConfiguredSource, len(sourcesWrap.Sources))
|
||||
defaults := ds.getDefaultSources()
|
||||
|
||||
// Pre-claim IDs already explicitly set in the file so the canonical fill
|
||||
// below doesn't reuse them when multiple entries share a SourceKey.Type.
|
||||
claimedIDs := make(map[string]bool, len(sourcesWrap.Sources))
|
||||
for i := range sourcesWrap.Sources {
|
||||
if id := sourcesWrap.Sources[i].ID; id != "" {
|
||||
claimedIDs[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
for i := range sourcesWrap.Sources {
|
||||
ps := &sourcesWrap.Sources[i]
|
||||
s := &sources[i]
|
||||
@@ -1293,11 +1606,9 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
|
||||
s.SourceKeyAccount = s.SourceKey.Account
|
||||
}
|
||||
|
||||
// Ensure Type is populated from SourceKey if missing
|
||||
if s.Type == "" && s.SourceKey.Type != "" {
|
||||
s.Type = s.SourceKey.Type
|
||||
}
|
||||
applyCanonicalDefaults(s, defaults, claimedIDs)
|
||||
|
||||
// Last-resort ID for unknown providers.
|
||||
if s.ID == "" {
|
||||
s.ID = strconv.Itoa(2000001 + i)
|
||||
}
|
||||
@@ -1306,13 +1617,58 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
|
||||
return sources, nil
|
||||
}
|
||||
|
||||
// applyCanonicalDefaults fills missing canonical ID/Type/SourceProviderID for
|
||||
// known providers and repairs Type that was previously synthesized from
|
||||
// SourceKey.Type (e.g. "AUX") rather than the canonical value (e.g. "Audio").
|
||||
// Without this, the on-device Sources.xml — which carries only displayName +
|
||||
// sourceKey — would round-trip as id="2000001+i" type="<sourceKey.Type>" and
|
||||
// be rejected by the speaker as INVALID_SOURCE after migration.
|
||||
//
|
||||
// claimedIDs tracks which canonical IDs are already in use so that multiple
|
||||
// entries with the same SourceKey.Type don't collide on the same ID.
|
||||
func applyCanonicalDefaults(s *models.ConfiguredSource, defaults []models.ConfiguredSource, claimedIDs map[string]bool) {
|
||||
def := findCanonicalSource(defaults, s.SourceKey.Type)
|
||||
if def == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if s.ID == "" && !claimedIDs[def.ID] {
|
||||
s.ID = def.ID
|
||||
claimedIDs[def.ID] = true
|
||||
}
|
||||
|
||||
if s.Type == "" || s.Type == s.SourceKey.Type {
|
||||
s.Type = def.Type
|
||||
}
|
||||
|
||||
if s.SourceProviderID == "" {
|
||||
s.SourceProviderID = def.SourceProviderID
|
||||
}
|
||||
}
|
||||
|
||||
// findCanonicalSource returns the default source matching the given
|
||||
// SourceKey.Type, or nil if it's not one of our known providers.
|
||||
func findCanonicalSource(defaults []models.ConfiguredSource, sourceKeyType string) *models.ConfiguredSource {
|
||||
if sourceKeyType == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := range defaults {
|
||||
if defaults[i].SourceKey.Type == sourceKeyType {
|
||||
return &defaults[i]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveConfiguredSources saves the configured sources list for the specified account and device.
|
||||
func (ds *DataStore) SaveConfiguredSources(account, device string, sources []models.ConfiguredSource) error {
|
||||
ds.fileMutex.Lock()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1607,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
|
||||
}
|
||||
@@ -1618,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
|
||||
}
|
||||
@@ -1627,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
|
||||
}
|
||||
@@ -1639,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
|
||||
}
|
||||
@@ -1663,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
|
||||
}
|
||||
@@ -1680,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
|
||||
}
|
||||
@@ -1724,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.
|
||||
@@ -1733,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
|
||||
}
|
||||
@@ -1756,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)
|
||||
}
|
||||
|
||||
@@ -1773,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
|
||||
}
|
||||
|
||||
@@ -1791,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
|
||||
}
|
||||
|
||||
@@ -1857,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)
|
||||
}
|
||||
|
||||
@@ -1883,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
|
||||
}
|
||||
@@ -1907,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.
|
||||
@@ -1923,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
|
||||
}
|
||||
}
|
||||
@@ -1936,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
|
||||
@@ -1950,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
|
||||
}
|
||||
@@ -1976,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
|
||||
}
|
||||
|
||||
@@ -1998,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)
|
||||
@@ -2031,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)
|
||||
}
|
||||
@@ -2047,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.
|
||||
@@ -2061,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
|
||||
}
|
||||
|
||||
@@ -98,3 +98,155 @@ func TestSaveSources_Format(t *testing.T) {
|
||||
t.Errorf("Sources.xml should not contain <sourceSettings> tag")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetConfiguredSources_MinimalAuxEntryNormalized covers the migration case from
|
||||
// issue #195: the device's on-disk Sources.xml carries only displayName + sourceKey
|
||||
// for AUX (no id, no type). When read back, the AUX entry must surface as the
|
||||
// canonical id="10001" type="Audio" sourceproviderid="9", not synthesized values.
|
||||
func TestGetConfiguredSources_MinimalAuxEntryNormalized(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-sources-min-aux-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
account := "1234567"
|
||||
device := "001122334455"
|
||||
|
||||
deviceDir := ds.AccountDeviceDir(account, device)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
minimalSourcesXML := `<sources>
|
||||
<source displayName="AUX IN" secret="">
|
||||
<sourceKey type="AUX" account="AUX" />
|
||||
</source>
|
||||
</sources>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(minimalSourcesXML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sources, err := ds.GetConfiguredSources(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfiguredSources failed: %v", err)
|
||||
}
|
||||
|
||||
if len(sources) != 1 {
|
||||
t.Fatalf("expected 1 source, got %d", len(sources))
|
||||
}
|
||||
|
||||
s := sources[0]
|
||||
if s.ID != "10001" {
|
||||
t.Errorf("expected canonical AUX id 10001, got %q", s.ID)
|
||||
}
|
||||
if s.Type != "Audio" {
|
||||
t.Errorf("expected canonical AUX type 'Audio', got %q", s.Type)
|
||||
}
|
||||
if s.SourceKey.Type != "AUX" || s.SourceKey.Account != "AUX" {
|
||||
t.Errorf("expected sourceKey type/account AUX/AUX, got %q/%q", s.SourceKey.Type, s.SourceKey.Account)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetConfiguredSources_DuplicateProviderUniqueIDs ensures that when a file
|
||||
// contains multiple entries for the same SourceKey.Type (e.g. two AUX entries),
|
||||
// only one gets the canonical ID; the rest fall back to synthesized IDs so they
|
||||
// don't collide.
|
||||
func TestGetConfiguredSources_DuplicateProviderUniqueIDs(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-sources-dup-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
account := "1234567"
|
||||
device := "001122334455"
|
||||
|
||||
deviceDir := ds.AccountDeviceDir(account, device)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dupXML := `<sources>
|
||||
<source displayName="AUX IN" secret="">
|
||||
<sourceKey type="AUX" account="AUX" />
|
||||
</source>
|
||||
<source displayName="AUX 2" secret="">
|
||||
<sourceKey type="AUX" account="AUX" />
|
||||
</source>
|
||||
</sources>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(dupXML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sources, err := ds.GetConfiguredSources(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfiguredSources failed: %v", err)
|
||||
}
|
||||
|
||||
if len(sources) != 2 {
|
||||
t.Fatalf("expected 2 sources, got %d", len(sources))
|
||||
}
|
||||
|
||||
if sources[0].ID == sources[1].ID {
|
||||
t.Errorf("duplicate AUX entries must not share an ID, got %q for both", sources[0].ID)
|
||||
}
|
||||
|
||||
// Both should still have Type repaired to the canonical "Audio".
|
||||
for i, s := range sources {
|
||||
if s.Type != "Audio" {
|
||||
t.Errorf("source %d: expected Type 'Audio', got %q", i, s.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetConfiguredSources_PoisonedAuxEntryRepaired covers the case where a previous
|
||||
// version of the datastore already persisted bad synthesized values (type="AUX",
|
||||
// id="2000001"). On read, those values must be repaired to the canonical defaults.
|
||||
func TestGetConfiguredSources_PoisonedAuxEntryRepaired(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-sources-poisoned-aux-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
account := "1234567"
|
||||
device := "001122334455"
|
||||
|
||||
deviceDir := ds.AccountDeviceDir(account, device)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
poisonedXML := `<sources>
|
||||
<source displayName="AUX IN" id="2000001" secret="" secretType="" type="AUX">
|
||||
<credential type=""></credential>
|
||||
<sourceKey type="AUX" account="AUX"></sourceKey>
|
||||
</source>
|
||||
</sources>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(poisonedXML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sources, err := ds.GetConfiguredSources(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfiguredSources failed: %v", err)
|
||||
}
|
||||
|
||||
if len(sources) != 1 {
|
||||
t.Fatalf("expected 1 source, got %d", len(sources))
|
||||
}
|
||||
|
||||
s := sources[0]
|
||||
if s.Type != "Audio" {
|
||||
t.Errorf("expected Type to be repaired to 'Audio', got %q", s.Type)
|
||||
}
|
||||
// ID repair is intentionally not aggressive — only empty IDs are filled
|
||||
// from canonical defaults to avoid breaking references in recents/presets.
|
||||
if s.ID != "2000001" {
|
||||
t.Errorf("expected ID preserved as 2000001, got %q", s.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,138 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// accountIDSuggestionsResponse is the body of GET /setup/account-id-suggestions/{deviceId}.
|
||||
// `current` is the device's existing margeAccountUUID (empty when the device is fresh / factory-reset).
|
||||
// `known` is the list of accountIDs already present in the local datastore, so the UI can offer
|
||||
// the user a way to re-attach a fresh device to an existing account.
|
||||
type accountIDSuggestionsResponse struct {
|
||||
Current string `json:"current"`
|
||||
Known []string `json:"known"`
|
||||
}
|
||||
|
||||
// HandleAccountIDSuggestions returns the device's current account ID (from
|
||||
// :8090/info, empty if unset) plus the list of account IDs already present
|
||||
// in the local datastore.
|
||||
func (s *Server) HandleAccountIDSuggestions(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
|
||||
}
|
||||
|
||||
resp := accountIDSuggestionsResponse{}
|
||||
|
||||
if info, err := s.sm.GetLiveDeviceInfo(deviceIP); err == nil {
|
||||
resp.Current = info.MargeAccountUUID
|
||||
}
|
||||
|
||||
if known, err := s.ds.ListAccounts(); err == nil {
|
||||
resp.Known = known
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// pairAccountResponse is the body of POST /setup/pair-account/{deviceId}.
|
||||
type pairAccountResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Result setup.PairAccountResult `json:"result"`
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// HandlePairAccount associates the device with the supplied 7-digit account ID,
|
||||
// trying HTTP /setMargeAccount first and falling back to telnet
|
||||
// `envswitch accountid set`.
|
||||
//
|
||||
// Query params:
|
||||
// - account_id (required) — must pass setup.IsValidAccountID
|
||||
func (s *Server) HandlePairAccount(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
writeJSONError(w, http.StatusBadRequest, "Device ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
accountID := r.URL.Query().Get("account_id")
|
||||
if !setup.IsValidAccountID(accountID) {
|
||||
writeJSONError(w, http.StatusBadRequest, "account_id must be exactly 7 digits")
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var t setup.TelnetClient
|
||||
|
||||
if s.sm.NewTelnet != nil {
|
||||
t = s.sm.NewTelnet(deviceIP)
|
||||
if dialErr := t.Dial(); dialErr != nil {
|
||||
// Telnet not reachable — fall through with t=nil so PairAccount
|
||||
// can decide based on HTTP availability alone.
|
||||
t = nil
|
||||
} else {
|
||||
defer func() { _ = t.Close() }()
|
||||
}
|
||||
}
|
||||
|
||||
result, output, err := s.sm.PairAccount(deviceIP, accountID, t)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
body := pairAccountResponse{
|
||||
OK: err == nil,
|
||||
Result: result,
|
||||
Output: output,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
body.Error = err.Error()
|
||||
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(body); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// jsonErrorBody is the static shape of error responses from this file.
|
||||
// Avoiding map[string]interface{} keeps errchkjson satisfied: the typed
|
||||
// struct guarantees encoding can't fail with a runtime type error.
|
||||
type jsonErrorBody struct {
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// writeJSONError is a small helper for the handlers in this file to keep
|
||||
// error wiring out of the happy path. It mirrors what the rest of the
|
||||
// package does inline.
|
||||
func writeJSONError(w http.ResponseWriter, status int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(jsonErrorBody{OK: false, Message: message}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -1066,7 +1054,9 @@ func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
output, err := s.sm.Reboot(deviceIP)
|
||||
method := setup.RebootMethod(r.URL.Query().Get("method"))
|
||||
|
||||
output, err := s.sm.Reboot(deviceIP, method)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
@@ -127,6 +127,8 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
|
||||
r.Post("/revert/{deviceId}", server.HandleRevertMigration)
|
||||
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
|
||||
r.Get("/account-id-suggestions/{deviceId}", server.HandleAccountIDSuggestions)
|
||||
r.Post("/pair-account/{deviceId}", server.HandlePairAccount)
|
||||
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
|
||||
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
|
||||
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
|
||||
|
||||
@@ -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,160 +731,277 @@
|
||||
</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="hosts">
|
||||
/etc/hosts + Root CA (Advanced - global
|
||||
redirection)
|
||||
</option>
|
||||
<option value="resolv">
|
||||
/etc/resolv.conf (DHCP-Aware - Redirect via DNS
|
||||
Hook)
|
||||
</option>
|
||||
</select>
|
||||
<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="customize-form"
|
||||
style="margin: 15px 0; padding: 12px; border: 1px solid #ddd; background-color: #f9f9f9; border-radius: 4px"
|
||||
>
|
||||
<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="dns-port-warning"
|
||||
style="
|
||||
margin-top: 5px;
|
||||
color: #d32f2f;
|
||||
font-weight: bold;
|
||||
font-size: 0.9em;
|
||||
display: none;
|
||||
"
|
||||
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="current-resolv-pane"
|
||||
style="display: none; margin-bottom: 20px"
|
||||
id="telnet-method-pane"
|
||||
style="display: none; margin-bottom: 20px; padding: 10px; border: 1px solid #ddd; background-color: #f0f7ff;"
|
||||
>
|
||||
<span class="config-header"
|
||||
>Current /etc/resolv.conf</span
|
||||
>
|
||||
<pre id="current-resolv-content"></pre>
|
||||
<h4 style="margin-top: 0">Telnet Migration (Port 17000)</h4>
|
||||
<p style="margin: 5px 0">
|
||||
Drives the speaker's diagnostic shell over TCP/17000.
|
||||
Requires no SSH access. Works on most ST 10/20/300 and
|
||||
Wave III/IV firmware (27.0.6.x).
|
||||
</p>
|
||||
<p style="margin: 5px 0; font-size: 0.9em; color: #555">
|
||||
Limitation: HTTP-only redirection — telnet has no way to
|
||||
install a custom CA. If you need end-to-end TLS, use the
|
||||
XML or DNS method instead.
|
||||
</p>
|
||||
</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
|
||||
@@ -888,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
|
||||
>
|
||||
@@ -937,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="
|
||||
@@ -1004,6 +1117,7 @@
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -105,7 +105,10 @@ func ensureTimestamps(s *models.ConfiguredSource) {
|
||||
}
|
||||
|
||||
func ensureSourceType(s *models.ConfiguredSource) {
|
||||
if s.Type == "" || (s.SourceKey.Type != "" && s.SourceKey.Type != constants.ProviderAux && s.SourceKey.Type != constants.ProviderBluetooth) {
|
||||
// AUX must be normalized to Type="Audio" — the speaker rejects type="AUX"
|
||||
// (which the datastore previously synthesized from SourceKey.Type).
|
||||
// Bluetooth is left alone since its canonical Type isn't "Audio".
|
||||
if s.Type == "" || (s.SourceKey.Type != "" && s.SourceKey.Type != constants.ProviderBluetooth) {
|
||||
if s.SourceKey.Type == constants.ProviderAmazon {
|
||||
s.Type = constants.ProviderAmazon
|
||||
} else {
|
||||
@@ -294,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
|
||||
@@ -756,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
|
||||
|
||||
@@ -1036,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
|
||||
}
|
||||
@@ -1062,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
|
||||
}
|
||||
@@ -1118,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
|
||||
}
|
||||
@@ -1132,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
|
||||
}
|
||||
@@ -1479,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) {
|
||||
@@ -1864,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,242 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PairAccountTimeouts bounds every step of the pairing call so a wedged
|
||||
// device cannot stall the migration UI indefinitely.
|
||||
const (
|
||||
supportedURLsTimeout = 3 * time.Second
|
||||
setMargeAccountConn = 5 * time.Second
|
||||
setMargeAccountTotal = 12 * time.Second
|
||||
)
|
||||
|
||||
// PairAccountResult records what was attempted, so the UI can show a
|
||||
// breadcrumb of which path actually succeeded (or that both failed).
|
||||
type PairAccountResult struct {
|
||||
SetMargeAccountSupported bool `json:"set_marge_account_supported"`
|
||||
HTTPAttempted bool `json:"http_attempted"`
|
||||
HTTPError string `json:"http_error,omitempty"`
|
||||
TelnetAttempted bool `json:"telnet_attempted"`
|
||||
TelnetError string `json:"telnet_error,omitempty"`
|
||||
Method string `json:"method"` // "http" | "telnet" | ""
|
||||
}
|
||||
|
||||
// PairAccount associates the speaker at deviceIP with accountID. It tries
|
||||
// the device's HTTP /setMargeAccount endpoint first; on missing endpoint or
|
||||
// any time-bounded failure it falls back to a telnet
|
||||
// `envswitch accountid set <id>` over the supplied client. If telnet is nil
|
||||
// or also fails, PairAccount returns a structured error explaining the next
|
||||
// step a user can take.
|
||||
func (m *Manager) PairAccount(deviceIP, accountID string, t TelnetClient) (PairAccountResult, string, error) {
|
||||
var (
|
||||
result PairAccountResult
|
||||
logs strings.Builder
|
||||
)
|
||||
|
||||
if !IsValidAccountID(accountID) {
|
||||
return result, "", fmt.Errorf("invalid account ID %q: must be exactly 7 digits", accountID)
|
||||
}
|
||||
|
||||
supported, supportedErr := m.probeSetMargeAccount(deviceIP)
|
||||
result.SetMargeAccountSupported = supported
|
||||
|
||||
switch {
|
||||
case supportedErr != nil:
|
||||
fmt.Fprintf(&logs, "supportedURLs probe failed: %v\n", supportedErr)
|
||||
case supported:
|
||||
logs.WriteString("supportedURLs lists /setMargeAccount — trying HTTP\n")
|
||||
default:
|
||||
logs.WriteString("supportedURLs does NOT list /setMargeAccount — skipping HTTP, going straight to telnet\n")
|
||||
}
|
||||
|
||||
if supported {
|
||||
result.HTTPAttempted = true
|
||||
|
||||
if err := m.postSetMargeAccount(deviceIP, accountID); err != nil {
|
||||
result.HTTPError = err.Error()
|
||||
|
||||
fmt.Fprintf(&logs, "HTTP /setMargeAccount failed: %v\n", err)
|
||||
} else {
|
||||
result.Method = "http"
|
||||
|
||||
logs.WriteString("HTTP /setMargeAccount succeeded\n")
|
||||
|
||||
return result, logs.String(), nil
|
||||
}
|
||||
}
|
||||
|
||||
if t == nil {
|
||||
return result, logs.String(), errors.New(
|
||||
"pairing failed: HTTP /setMargeAccount unavailable and no telnet client supplied — " +
|
||||
"open the official Bose app and pair manually before EOS, or use the SSH-based XML method")
|
||||
}
|
||||
|
||||
result.TelnetAttempted = true
|
||||
|
||||
cmd := "envswitch accountid set " + accountID
|
||||
|
||||
resp, err := t.SendCommand(cmd)
|
||||
if err != nil {
|
||||
result.TelnetError = err.Error()
|
||||
|
||||
return result, logs.String(), fmt.Errorf("HTTP unavailable and telnet fallback failed: %w", err)
|
||||
}
|
||||
|
||||
if isCommandNotFound(resp) {
|
||||
result.TelnetError = "envswitch accountid: command not found on this firmware"
|
||||
|
||||
return result, logs.String(), errors.New(
|
||||
"pairing failed: HTTP /setMargeAccount missing AND telnet `envswitch accountid` rejected — " +
|
||||
"firmware does not expose either pairing path")
|
||||
}
|
||||
|
||||
fmt.Fprintf(&logs, "Telnet %q → %s\n", cmd, strings.TrimRight(resp, "\r\n"))
|
||||
|
||||
result.Method = "telnet"
|
||||
|
||||
return result, logs.String(), nil
|
||||
}
|
||||
|
||||
// probeSetMargeAccount fetches /supportedURLs and reports whether
|
||||
// /setMargeAccount is in the listing.
|
||||
func (m *Manager) probeSetMargeAccount(deviceIP string) (bool, error) {
|
||||
url := buildDeviceURL(deviceIP, "/supportedURLs")
|
||||
|
||||
client := &http.Client{Timeout: supportedURLsTimeout}
|
||||
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("GET %s: %w", url, err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Errorf("GET %s returned %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read %s: %w", url, err)
|
||||
}
|
||||
|
||||
var doc struct {
|
||||
URLs []struct {
|
||||
Location string `xml:"location,attr"`
|
||||
} `xml:"URL"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(body, &doc); err != nil {
|
||||
// Fallback to substring match — some firmwares return a slightly
|
||||
// different XML root that Go's strict parser refuses.
|
||||
return strings.Contains(string(body), "/setMargeAccount"), nil
|
||||
}
|
||||
|
||||
for _, u := range doc.URLs {
|
||||
if u.Location == "/setMargeAccount" {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// postSetMargeAccount sends the pairing XML body to the device's
|
||||
// /setMargeAccount endpoint with bounded timeouts.
|
||||
func (m *Manager) postSetMargeAccount(deviceIP, accountID string) error {
|
||||
url := buildDeviceURL(deviceIP, "/setMargeAccount")
|
||||
|
||||
body := fmt.Sprintf(
|
||||
`<PairDeviceWithAccount><accountId>%s</accountId><userAuthToken>aftertouch</userAuthToken></PairDeviceWithAccount>`,
|
||||
accountID,
|
||||
)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: setMargeAccountTotal,
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: setMargeAccountConn}).DialContext,
|
||||
ResponseHeaderTimeout: setMargeAccountTotal - setMargeAccountConn,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Post(url, "application/xml", strings.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("POST %s: %w", url, err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
return fmt.Errorf("POST %s returned %d: %s", url, resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildDeviceURL builds a URL for a SoundTouch device's HTTP API. If
|
||||
// deviceIP already includes a port (test scenarios using httptest) it is
|
||||
// reused as-is; otherwise the canonical port 8090 is appended.
|
||||
func buildDeviceURL(deviceIP, path string) string {
|
||||
if _, _, err := net.SplitHostPort(deviceIP); err == nil {
|
||||
return "http://" + deviceIP + path
|
||||
}
|
||||
|
||||
return "http://" + deviceIP + ":8090" + path
|
||||
}
|
||||
|
||||
// IsValidAccountID reports whether s is a syntactically valid SoundTouch
|
||||
// account ID — exactly 7 numeric digits, the format used by every
|
||||
// Bose-cloud-issued ID we have observed in captures.
|
||||
func IsValidAccountID(s string) bool {
|
||||
if len(s) != 7 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, ch := range s {
|
||||
if ch < '0' || ch > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// GenerateAccountID returns a fresh 7-digit account ID that does not collide
|
||||
// with any value in known. It uses crypto/rand and re-rolls on collision.
|
||||
func GenerateAccountID(known []string) (string, error) {
|
||||
taken := make(map[string]bool, len(known))
|
||||
for _, k := range known {
|
||||
taken[k] = true
|
||||
}
|
||||
|
||||
const maxAttempts = 32
|
||||
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
// 7-digit space starts at 1_000_000 to avoid leading zeros, ending at
|
||||
// 9_999_999. Range size is 9_000_000.
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(9_000_000))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("crypto/rand: %w", err)
|
||||
}
|
||||
|
||||
candidate := fmt.Sprintf("%07d", n.Int64()+1_000_000)
|
||||
if !taken[candidate] {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("could not generate a non-colliding account ID after 32 attempts")
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeDevice spins up an httptest.Server that pretends to be the SoundTouch
|
||||
// device's :8090 HTTP API. It records POSTs to /setMargeAccount so tests
|
||||
// can assert on the body.
|
||||
type fakeDevice struct {
|
||||
srv *httptest.Server
|
||||
addr string // "host:port" usable as deviceIP
|
||||
supportsSetMarge bool
|
||||
postStatus int // status code returned for POST /setMargeAccount
|
||||
postDelay time.Duration
|
||||
gotPostBody string
|
||||
}
|
||||
|
||||
func newFakeDevice(t *testing.T) *fakeDevice {
|
||||
t.Helper()
|
||||
|
||||
d := &fakeDevice{
|
||||
supportsSetMarge: true,
|
||||
postStatus: http.StatusOK,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/supportedURLs", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
if d.supportsSetMarge {
|
||||
_, _ = w.Write([]byte(`<supportedURLs><URL location="/setMargeAccount"/><URL location="/info"/></supportedURLs>`))
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte(`<supportedURLs><URL location="/info"/></supportedURLs>`))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/setMargeAccount", func(w http.ResponseWriter, r *http.Request) {
|
||||
if d.postDelay > 0 {
|
||||
time.Sleep(d.postDelay)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
d.gotPostBody = string(body)
|
||||
|
||||
w.WriteHeader(d.postStatus)
|
||||
})
|
||||
|
||||
d.srv = httptest.NewServer(mux)
|
||||
|
||||
u := d.srv.URL[len("http://"):]
|
||||
|
||||
host, port, err := net.SplitHostPort(u)
|
||||
if err != nil {
|
||||
t.Fatalf("split httptest URL: %v", err)
|
||||
}
|
||||
|
||||
d.addr = host + ":" + port
|
||||
|
||||
t.Cleanup(d.srv.Close)
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
func TestPairAccount_HappyPathHTTP(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
res, _, err := m.PairAccount(d.addr, "1234567", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PairAccount: %v", err)
|
||||
}
|
||||
|
||||
if res.Method != "http" {
|
||||
t.Errorf("Method = %q, want http", res.Method)
|
||||
}
|
||||
|
||||
if !res.SetMargeAccountSupported {
|
||||
t.Error("SetMargeAccountSupported should be true")
|
||||
}
|
||||
|
||||
if !res.HTTPAttempted {
|
||||
t.Error("HTTPAttempted should be true")
|
||||
}
|
||||
|
||||
if res.TelnetAttempted {
|
||||
t.Error("TelnetAttempted should be false on the happy HTTP path")
|
||||
}
|
||||
|
||||
if !strings.Contains(d.gotPostBody, "<accountId>1234567</accountId>") {
|
||||
t.Errorf("device received %q, want <accountId>1234567</accountId>", d.gotPostBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairAccount_FallsBackWhenSetMargeAccountMissing(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.supportsSetMarge = false
|
||||
|
||||
f := &fakeTelnet{
|
||||
responses: map[string]string{"envswitch accountid set 1234567": "OK\n"},
|
||||
}
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
res, _, err := m.PairAccount(d.addr, "1234567", f)
|
||||
if err != nil {
|
||||
t.Fatalf("PairAccount: %v", err)
|
||||
}
|
||||
|
||||
if res.Method != "telnet" {
|
||||
t.Errorf("Method = %q, want telnet", res.Method)
|
||||
}
|
||||
|
||||
if res.SetMargeAccountSupported {
|
||||
t.Error("SetMargeAccountSupported should be false")
|
||||
}
|
||||
|
||||
if res.HTTPAttempted {
|
||||
t.Error("HTTPAttempted should be false when supportedURLs reports the endpoint missing")
|
||||
}
|
||||
|
||||
if !res.TelnetAttempted {
|
||||
t.Error("TelnetAttempted should be true")
|
||||
}
|
||||
|
||||
if len(f.commands) != 1 || f.commands[0] != "envswitch accountid set 1234567" {
|
||||
t.Errorf("telnet commands = %v, want one envswitch accountid", f.commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairAccount_FallsBackWhenHTTPReturnsServerError(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.postStatus = http.StatusBadGateway
|
||||
|
||||
f := &fakeTelnet{
|
||||
responses: map[string]string{"envswitch accountid set 7654321": "OK\n"},
|
||||
}
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
res, _, err := m.PairAccount(d.addr, "7654321", f)
|
||||
if err != nil {
|
||||
t.Fatalf("PairAccount: %v", err)
|
||||
}
|
||||
|
||||
if res.Method != "telnet" {
|
||||
t.Errorf("Method = %q, want telnet", res.Method)
|
||||
}
|
||||
|
||||
if res.HTTPError == "" {
|
||||
t.Error("HTTPError should be populated when POST returned 502")
|
||||
}
|
||||
|
||||
if !res.TelnetAttempted {
|
||||
t.Error("TelnetAttempted should be true after HTTP failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairAccount_HTTPSuccessSkipsTelnet(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
|
||||
f := &fakeTelnet{}
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
res, _, err := m.PairAccount(d.addr, "1234567", f)
|
||||
if err != nil {
|
||||
t.Fatalf("PairAccount: %v", err)
|
||||
}
|
||||
|
||||
if res.Method != "http" {
|
||||
t.Errorf("Method = %q, want http", res.Method)
|
||||
}
|
||||
|
||||
if len(f.commands) != 0 {
|
||||
t.Errorf("telnet should not have been used; commands = %v", f.commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairAccount_NoTelnetAndHTTPMissingReturnsClearError(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.supportsSetMarge = false
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
_, _, err := m.PairAccount(d.addr, "1234567", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when both paths are unavailable")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "no telnet client") {
|
||||
t.Errorf("err = %v, want to mention missing telnet client", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairAccount_TelnetCommandNotFoundReportsBothPaths(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.supportsSetMarge = false
|
||||
|
||||
f := &fakeTelnet{
|
||||
responses: map[string]string{"envswitch accountid set 1234567": "Command not found\n"},
|
||||
}
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
_, _, err := m.PairAccount(d.addr, "1234567", f)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when telnet rejects the fallback")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "envswitch") {
|
||||
t.Errorf("err = %v, want to mention envswitch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairAccount_RejectsInvalidAccountID(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
for _, badID := range []string{"", "12345", "12345678", "abcdefg", "12345 6"} {
|
||||
_, _, err := m.PairAccount("127.0.0.1:9999", badID, nil)
|
||||
if err == nil {
|
||||
t.Errorf("PairAccount accepted invalid ID %q", badID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairAccount_TelnetTransportErrorReturned(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.supportsSetMarge = false
|
||||
|
||||
f := &fakeTelnet{
|
||||
fail: map[string]error{"envswitch accountid set 1234567": errors.New("connection reset")},
|
||||
}
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
_, _, err := m.PairAccount(d.addr, "1234567", f)
|
||||
if err == nil {
|
||||
t.Fatal("expected telnet transport error to be surfaced")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "connection reset") {
|
||||
t.Errorf("err = %v, want to wrap connection reset", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidAccountID(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"1234567", true},
|
||||
{"0000000", true},
|
||||
{"9999999", true},
|
||||
{"", false},
|
||||
{"123456", false},
|
||||
{"12345678", false},
|
||||
{"123456a", false},
|
||||
{"-123456", false},
|
||||
{" 123456", false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := IsValidAccountID(tc.in); got != tc.want {
|
||||
t.Errorf("IsValidAccountID(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAccountID_AvoidsCollisions(t *testing.T) {
|
||||
id, err := GenerateAccountID(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccountID(nil): %v", err)
|
||||
}
|
||||
|
||||
if !IsValidAccountID(id) {
|
||||
t.Errorf("generated ID %q is not valid", id)
|
||||
}
|
||||
|
||||
// Block out a fairly small space and check we still get a fresh ID.
|
||||
known := []string{"1000000", "1000001", "1000002"}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
got, err := GenerateAccountID(known)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccountID: %v", err)
|
||||
}
|
||||
|
||||
for _, k := range known {
|
||||
if got == k {
|
||||
t.Errorf("generated %q collides with known list %v", got, known)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReboot_DefaultIsSSH(t *testing.T) {
|
||||
var ranCmds []string
|
||||
|
||||
m := &Manager{
|
||||
NewSSH: func(host string) SSHClient {
|
||||
return &mockSSH{runFunc: func(cmd string) (string, error) {
|
||||
ranCmds = append(ranCmds, cmd)
|
||||
return "ok\n", nil
|
||||
}}
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := m.Reboot("192.0.2.1", ""); err != nil {
|
||||
t.Fatalf("Reboot: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, c := range ranCmds {
|
||||
if strings.Contains(c, "reboot") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("expected SSH `reboot` command, got %v", ranCmds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReboot_TelnetSendsSysReboot(t *testing.T) {
|
||||
f := &fakeTelnet{
|
||||
responses: map[string]string{"sys reboot": "OK\n"},
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
NewTelnet: func(host string) TelnetClient { return f },
|
||||
}
|
||||
|
||||
if _, err := m.Reboot("192.0.2.1", RebootMethodTelnet); err != nil {
|
||||
t.Fatalf("Reboot: %v", err)
|
||||
}
|
||||
|
||||
if len(f.commands) != 1 || f.commands[0] != "sys reboot" {
|
||||
t.Errorf("commands = %v, want [sys reboot]", f.commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReboot_TelnetTreatsCloseAsSuccess(t *testing.T) {
|
||||
// The device closes the socket as part of rebooting. SendCommand surfaces
|
||||
// that as an EOF/closed error; the reboot path must absorb it.
|
||||
f := &fakeTelnet{
|
||||
fail: map[string]error{"sys reboot": errors.New("EOF")},
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
NewTelnet: func(host string) TelnetClient { return f },
|
||||
}
|
||||
|
||||
out, err := m.Reboot("192.0.2.1", RebootMethodTelnet)
|
||||
if err != nil {
|
||||
t.Fatalf("Reboot should swallow socket-close after sys reboot, got %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(out, "connection closed by reboot") {
|
||||
t.Errorf("output should annotate the close, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReboot_TelnetSurfacesDialError(t *testing.T) {
|
||||
f := &fakeTelnet{dialErr: errors.New("connection refused")}
|
||||
m := &Manager{
|
||||
NewTelnet: func(host string) TelnetClient { return f },
|
||||
}
|
||||
|
||||
if _, err := m.Reboot("192.0.2.1", RebootMethodTelnet); err == nil {
|
||||
t.Fatal("expected dial error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReboot_UnknownMethodErrors(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
if _, err := m.Reboot("192.0.2.1", RebootMethod("ftp")); err == nil {
|
||||
t.Fatal("expected error for unsupported reboot method")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package setup
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/ssh"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/telnet"
|
||||
)
|
||||
|
||||
// MigrationMethod represents the method used to migrate a speaker.
|
||||
@@ -32,6 +34,9 @@ const (
|
||||
MigrationMethodHosts MigrationMethod = "hosts"
|
||||
// MigrationMethodResolvConf redirects services by injecting a priority DNS hook into the DHCP logic and updating the CA trust store.
|
||||
MigrationMethodResolvConf MigrationMethod = "resolv"
|
||||
// MigrationMethodTelnet redirects services by driving the device's diagnostic
|
||||
// shell on TCP port 17000. Requires no SSH access on the device.
|
||||
MigrationMethodTelnet MigrationMethod = "telnet"
|
||||
)
|
||||
|
||||
// SoundTouchSdkPrivateCfgPath is the path to the speaker's private configuration file on device.
|
||||
@@ -72,11 +77,41 @@ 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.
|
||||
TelnetReachable bool `json:"telnet_reachable"`
|
||||
TelnetBanner string `json:"telnet_banner,omitempty"`
|
||||
TelnetVerifiedConfig string `json:"telnet_verified_config,omitempty"`
|
||||
TelnetProbeError string `json:"telnet_probe_error,omitempty"`
|
||||
|
||||
// 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.
|
||||
@@ -85,12 +120,23 @@ type SSHClient interface {
|
||||
UploadContent(content []byte, remotePath string) error
|
||||
}
|
||||
|
||||
// TelnetClient defines the interface for the device's port-17000 diagnostic
|
||||
// shell. The concrete implementation lives in github.com/gesellix/bose-soundtouch/pkg/telnet;
|
||||
// the interface exists so tests can substitute a mock.
|
||||
type TelnetClient interface {
|
||||
Dial() error
|
||||
Probe() (string, error)
|
||||
SendCommand(cmd string) (string, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Manager handles the migration of speakers to the service.
|
||||
type Manager struct {
|
||||
ServerURL string
|
||||
DataStore *datastore.DataStore
|
||||
Crypto *certmanager.CertificateManager
|
||||
NewSSH func(host string) SSHClient
|
||||
NewTelnet func(host string) TelnetClient
|
||||
|
||||
// GetDNSRunning is an optional callback to check the actual state of the DNS server.
|
||||
GetDNSRunning func() (bool, string)
|
||||
@@ -112,6 +158,9 @@ func NewManager(serverURL string, ds *datastore.DataStore, cm *certmanager.Certi
|
||||
NewSSH: func(host string) SSHClient {
|
||||
return ssh.NewClient(host)
|
||||
},
|
||||
NewTelnet: func(host string) TelnetClient {
|
||||
return telnet.NewClient(host)
|
||||
},
|
||||
HTTPGet: http.Get,
|
||||
MgmtUsername: "admin",
|
||||
MgmtPassword: "change_me!",
|
||||
@@ -214,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)
|
||||
|
||||
@@ -249,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, "", " ")
|
||||
@@ -292,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
|
||||
}
|
||||
|
||||
@@ -356,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.
|
||||
@@ -495,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
|
||||
@@ -577,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)
|
||||
@@ -653,6 +812,14 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
|
||||
method = MigrationMethodXML
|
||||
}
|
||||
|
||||
// Telnet is SSH-free by design — skip the SSH-based off-device backup and
|
||||
// rw pre-flight, both of which would fail on devices that haven't been
|
||||
// rooted via remote_services.
|
||||
if method == MigrationMethodTelnet {
|
||||
urls := telnetURLsFromOptions(targetURL, options)
|
||||
return m.migrateViaTelnet(deviceIP, targetURL, urls)
|
||||
}
|
||||
|
||||
var logs string
|
||||
|
||||
// 0. Off-device backup for safety
|
||||
@@ -770,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)
|
||||
@@ -1778,12 +1949,40 @@ func (m *Manager) RemoveRemoteServices(deviceIP string) (string, error) {
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
// Reboot reboots the speaker at the given IP.
|
||||
func (m *Manager) Reboot(deviceIP string) (string, error) {
|
||||
// RebootMethod selects the transport used to reboot a speaker.
|
||||
type RebootMethod string
|
||||
|
||||
const (
|
||||
// RebootMethodSSH reboots via SSH `reboot` (the original behavior). Requires
|
||||
// a rooted device (remote_services unlocked).
|
||||
RebootMethodSSH RebootMethod = "ssh"
|
||||
// RebootMethodTelnet reboots via the device's port-17000 diagnostic shell
|
||||
// using `sys reboot`. Requires no SSH access.
|
||||
RebootMethodTelnet RebootMethod = "telnet"
|
||||
)
|
||||
|
||||
// Reboot reboots the speaker at the given IP using the requested transport.
|
||||
// An empty method defaults to RebootMethodSSH, preserving prior behavior.
|
||||
func (m *Manager) Reboot(deviceIP string, method RebootMethod) (string, error) {
|
||||
if method == "" {
|
||||
method = RebootMethodSSH
|
||||
}
|
||||
|
||||
switch method {
|
||||
case RebootMethodSSH:
|
||||
return m.rebootViaSSH(deviceIP)
|
||||
case RebootMethodTelnet:
|
||||
return m.rebootViaTelnet(deviceIP)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported reboot method: %s", method)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) rebootViaSSH(deviceIP string) (string, error) {
|
||||
client := m.NewSSH(deviceIP)
|
||||
rwCmd := "(rw || mount -o remount,rw /)"
|
||||
|
||||
fmt.Printf("Rebooting speaker at %s\n", deviceIP)
|
||||
fmt.Printf("Rebooting speaker at %s via SSH\n", deviceIP)
|
||||
|
||||
out, err := client.Run(fmt.Sprintf("%s && reboot", rwCmd))
|
||||
if err != nil {
|
||||
@@ -1793,6 +1992,54 @@ func (m *Manager) Reboot(deviceIP string) (string, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Manager) rebootViaTelnet(deviceIP string) (string, error) {
|
||||
if m.NewTelnet == nil {
|
||||
return "", errors.New("telnet reboot not configured: Manager.NewTelnet is nil")
|
||||
}
|
||||
|
||||
fmt.Printf("Rebooting speaker at %s via telnet\n", deviceIP)
|
||||
|
||||
t := m.NewTelnet(deviceIP)
|
||||
if err := t.Dial(); err != nil {
|
||||
return "", fmt.Errorf("telnet dial %s:17000 failed: %w", deviceIP, err)
|
||||
}
|
||||
|
||||
defer func() { _ = t.Close() }()
|
||||
|
||||
// We deliberately don't wait for a response — the device closes the socket
|
||||
// as part of rebooting, and SendCommand would surface that as an error
|
||||
// even though the reboot itself succeeded. Treat any short read or close
|
||||
// as "command was accepted".
|
||||
resp, err := t.SendCommand("sys reboot")
|
||||
if err != nil {
|
||||
// A read error after the write is the expected case (socket dies on
|
||||
// reboot). Only surface real transport failures; treat the rest as
|
||||
// success and let the caller verify by polling :8090/info.
|
||||
if isLikelyRebootCloseError(err) {
|
||||
return resp + "\n[connection closed by reboot]", nil
|
||||
}
|
||||
|
||||
return resp, fmt.Errorf("failed to send sys reboot: %w", err)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// isLikelyRebootCloseError returns true if err looks like the socket closed
|
||||
// because the device started rebooting, rather than a real connectivity
|
||||
// problem. We are intentionally generous here: the user already opted into
|
||||
// rebooting, so a closed socket is expected.
|
||||
func isLikelyRebootCloseError(err error) bool {
|
||||
msg := err.Error()
|
||||
for _, marker := range []string{"EOF", "closed", "connection reset", "broken pipe", "timed out"} {
|
||||
if strings.Contains(msg, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// TestDomain is the fake domain used for preliminary redirection tests.
|
||||
const TestDomain = "custom-test-api.bose.fake"
|
||||
|
||||
|
||||
@@ -946,7 +946,7 @@ func TestReboot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
_, err := m.Reboot("192.168.1.10")
|
||||
_, err := m.Reboot("192.168.1.10", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Reboot failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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 " + u.BmxRegistry,
|
||||
"sys configuration statsServerUrl " + u.Stats,
|
||||
"sys configuration margeServerUrl " + u.Marge,
|
||||
"sys configuration swUpdateUrl " + u.SwUpdate,
|
||||
"envswitch boseurls set " + u.Marge + " " + u.SwUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
// migrateViaTelnet runs the URL-configuration sequence over the device's
|
||||
// port-17000 diagnostic shell. It writes configuration only — reboot is left
|
||||
// to the user, who triggers it via the existing reboot button (which now
|
||||
// accepts a method=telnet|ssh selector).
|
||||
//
|
||||
// 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.).
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
|
||||
var logs strings.Builder
|
||||
|
||||
t := m.NewTelnet(deviceIP)
|
||||
if err := t.Dial(); err != nil {
|
||||
return logs.String(), fmt.Errorf("telnet dial %s:17000 failed: %w", deviceIP, err)
|
||||
}
|
||||
|
||||
defer func() { _ = t.Close() }()
|
||||
|
||||
banner, _ := t.Probe()
|
||||
if banner != "" {
|
||||
fmt.Fprintf(&logs, "Telnet banner: %q\n", strings.TrimSpace(banner))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&logs, "→ %s\n%s\n", cmd, strings.TrimRight(resp, "\r\n"))
|
||||
|
||||
if isCommandNotFound(resp) {
|
||||
return logs.String(), fmt.Errorf("device rejected %q (firmware does not expose this command)", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
verify, err := t.SendCommand("getpdo CurrentSystemConfiguration")
|
||||
if err != nil {
|
||||
return logs.String(), fmt.Errorf("verification command failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&logs, "→ getpdo CurrentSystemConfiguration\n%s\n", strings.TrimRight(verify, "\r\n"))
|
||||
|
||||
if !strings.Contains(verify, targetURL) {
|
||||
return logs.String(), fmt.Errorf("verification failed: getpdo response does not contain %q (device may have rejected the new URLs)", targetURL)
|
||||
}
|
||||
|
||||
logs.WriteString("Telnet migration succeeded. Reboot the device to apply.\n")
|
||||
|
||||
return logs.String(), nil
|
||||
}
|
||||
|
||||
// isCommandNotFound returns true if the device's response to a command
|
||||
// indicates the command is not available on this firmware. Different firmware
|
||||
// builds use slightly different wording; we accept any of the observed
|
||||
// variants.
|
||||
func isCommandNotFound(resp string) bool {
|
||||
low := strings.ToLower(resp)
|
||||
|
||||
return strings.Contains(low, "command not found") ||
|
||||
strings.Contains(low, "unknown command") ||
|
||||
strings.Contains(low, "not implemented")
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeTelnet is a deterministic TelnetClient for unit tests. The responses
|
||||
// map keys on the exact command string; the value is what SendCommand
|
||||
// returns. Commands not in the map return "Command not found\n".
|
||||
type fakeTelnet struct {
|
||||
dialErr error
|
||||
banner string
|
||||
responses map[string]string
|
||||
// fail returns this error from SendCommand for the named command.
|
||||
fail map[string]error
|
||||
// commands records every command actually sent, in order, so tests can
|
||||
// assert on sequencing.
|
||||
commands []string
|
||||
}
|
||||
|
||||
func (f *fakeTelnet) Dial() error { return f.dialErr }
|
||||
func (f *fakeTelnet) Probe() (string, error) { return f.banner, nil }
|
||||
func (f *fakeTelnet) Close() error { return nil }
|
||||
|
||||
func (f *fakeTelnet) SendCommand(cmd string) (string, error) {
|
||||
f.commands = append(f.commands, cmd)
|
||||
|
||||
if err, ok := f.fail[cmd]; ok {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if resp, ok := f.responses[cmd]; ok {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
return "Command not found\n", nil
|
||||
}
|
||||
|
||||
func newFakeTelnetManager(f *fakeTelnet) *Manager {
|
||||
m := &Manager{
|
||||
ServerURL: "http://example:8000",
|
||||
NewTelnet: func(host string) TelnetClient { return f },
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func happyResponses(targetURL string) map[string]string {
|
||||
return map[string]string{
|
||||
"sys configuration bmxRegistryUrl " + targetURL + "/bmx/registry/v1/services": "OK\n",
|
||||
"sys configuration statsServerUrl " + targetURL: "OK\n",
|
||||
"sys configuration margeServerUrl " + targetURL: "OK\n",
|
||||
"sys configuration swUpdateUrl " + targetURL + "/updates/soundtouch": "OK\n",
|
||||
"envswitch boseurls set " + targetURL + " " + targetURL + "/updates/soundtouch": "OK\n",
|
||||
"getpdo CurrentSystemConfiguration": "margeServerUrl=" + targetURL + "\nbmxRegistryUrl=" + targetURL + "/bmx/registry/v1/services\n",
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateViaTelnet_HappyPath(t *testing.T) {
|
||||
target := "http://example:8000"
|
||||
f := &fakeTelnet{
|
||||
banner: "BoseShell\n-> ",
|
||||
responses: happyResponses(target),
|
||||
}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
logs, err := m.migrateViaTelnet("192.0.2.1", target, defaultTelnetURLs(target))
|
||||
if err != nil {
|
||||
t.Fatalf("migrateViaTelnet: %v", err)
|
||||
}
|
||||
|
||||
wantOrder := []string{
|
||||
"sys configuration bmxRegistryUrl " + target + "/bmx/registry/v1/services",
|
||||
"sys configuration statsServerUrl " + target,
|
||||
"sys configuration margeServerUrl " + target,
|
||||
"sys configuration swUpdateUrl " + target + "/updates/soundtouch",
|
||||
"envswitch boseurls set " + target + " " + target + "/updates/soundtouch",
|
||||
"getpdo CurrentSystemConfiguration",
|
||||
}
|
||||
|
||||
if len(f.commands) != len(wantOrder) {
|
||||
t.Fatalf("sent %d commands, want %d:\n%v", len(f.commands), len(wantOrder), f.commands)
|
||||
}
|
||||
|
||||
for i, want := range wantOrder {
|
||||
if f.commands[i] != want {
|
||||
t.Errorf("command[%d] = %q, want %q", i, f.commands[i], want)
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.Contains(logs, "succeeded") {
|
||||
t.Errorf("logs missing success marker:\n%s", logs)
|
||||
}
|
||||
|
||||
if !strings.Contains(logs, "BoseShell") {
|
||||
t.Errorf("logs missing banner echo:\n%s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
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", defaultTelnetURLs("http://example:8000"))
|
||||
if err == nil {
|
||||
t.Fatal("expected dial error, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "connection refused") {
|
||||
t.Errorf("err = %v, want to wrap connection refused", err)
|
||||
}
|
||||
|
||||
if len(f.commands) != 0 {
|
||||
t.Errorf("expected no commands sent on dial failure, got %v", f.commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateViaTelnet_CommandNotFoundAborts(t *testing.T) {
|
||||
target := "http://example:8000"
|
||||
resp := happyResponses(target)
|
||||
// The ST20-Portable case: `envswitch` is not implemented.
|
||||
delete(resp, "envswitch boseurls set "+target+" "+target+"/updates/soundtouch")
|
||||
|
||||
f := &fakeTelnet{responses: resp}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
_, err := m.migrateViaTelnet("192.0.2.1", target, defaultTelnetURLs(target))
|
||||
if err == nil {
|
||||
t.Fatal("expected error when envswitch is rejected, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "envswitch") {
|
||||
t.Errorf("err = %v, want to mention the rejected command", err)
|
||||
}
|
||||
|
||||
// The verification command must NOT have been sent — the run aborts on
|
||||
// the first rejection.
|
||||
for _, c := range f.commands {
|
||||
if c == "getpdo CurrentSystemConfiguration" {
|
||||
t.Errorf("verification was sent after a rejected command: %v", f.commands)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateViaTelnet_VerifyMismatchFails(t *testing.T) {
|
||||
target := "http://example:8000"
|
||||
resp := happyResponses(target)
|
||||
// Device echoes the OLD URLs (envswitch/sys configuration silently dropped).
|
||||
resp["getpdo CurrentSystemConfiguration"] = "margeServerUrl=https://streaming.bose.com\n"
|
||||
|
||||
f := &fakeTelnet{responses: resp}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
_, err := m.migrateViaTelnet("192.0.2.1", target, defaultTelnetURLs(target))
|
||||
if err == nil {
|
||||
t.Fatal("expected verification mismatch error, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "verification failed") {
|
||||
t.Errorf("err = %v, want to mention verification failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateViaTelnet_TransportErrorAborts(t *testing.T) {
|
||||
target := "http://example:8000"
|
||||
f := &fakeTelnet{
|
||||
responses: happyResponses(target),
|
||||
fail: map[string]error{
|
||||
"sys configuration margeServerUrl " + target: errors.New("write: broken pipe"),
|
||||
},
|
||||
}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
_, err := m.migrateViaTelnet("192.0.2.1", target, defaultTelnetURLs(target))
|
||||
if err == nil {
|
||||
t.Fatal("expected transport error, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "broken pipe") {
|
||||
t.Errorf("err = %v, want to wrap broken pipe", err)
|
||||
}
|
||||
}
|
||||
|
||||
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", defaultTelnetURLs("http://example:8000"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error when NewTelnet is nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "NewTelnet") {
|
||||
t.Errorf("err = %v, want a configuration error mentioning NewTelnet", err)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,465 +0,0 @@
|
||||
/* ── Reset & Base ─────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #f5f5f5;
|
||||
--surface: #ffffff;
|
||||
--border: #e0e0e0;
|
||||
--text: #1a1a1a;
|
||||
--text-dim: #666;
|
||||
--accent: #000000;
|
||||
--accent-fg: #ffffff;
|
||||
--online: #22c55e;
|
||||
--offline: #9ca3af;
|
||||
--radius: 8px;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,.10), 0 1px 2px rgba(0,0,0,.06);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #111;
|
||||
--surface: #1e1e1e;
|
||||
--border: #333;
|
||||
--text: #f0f0f0;
|
||||
--text-dim: #aaa;
|
||||
--accent: #e0e0e0;
|
||||
--accent-fg:#111;
|
||||
}
|
||||
}
|
||||
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; cursor: pointer; }
|
||||
button { cursor: pointer; font: inherit; border: none; background: none; }
|
||||
ul { list-style: none; }
|
||||
img { display: block; max-width: 100%; }
|
||||
|
||||
/* ── Layout ──────────────────────────────────────────────────────────────── */
|
||||
.app { display: flex; flex-direction: column; min-height: 100vh; }
|
||||
|
||||
/* ── Navbar ──────────────────────────────────────────────────────────────── */
|
||||
.navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.25rem;
|
||||
height: 52px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
|
||||
.brand { font-size: 1.1rem; font-weight: 600; letter-spacing: .02em; }
|
||||
|
||||
.nav-links { display: flex; align-items: center; gap: .75rem; }
|
||||
|
||||
.nav-links a, .nav-links .btn-icon {
|
||||
color: var(--accent-fg);
|
||||
opacity: .75;
|
||||
font-size: .9rem;
|
||||
padding: .25rem .5rem;
|
||||
border-radius: 4px;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
|
||||
.nav-links a:hover, .nav-links .btn-icon:hover, .nav-links a.active { opacity: 1; }
|
||||
|
||||
.nav-tunein-icon { height: 18px; display: inline-block; filter: brightness(0) invert(1); opacity: .75; }
|
||||
.nav-links a:hover .nav-tunein-icon, .nav-links a.active .nav-tunein-icon { opacity: 1; }
|
||||
|
||||
/* ── Main content ─────────────────────────────────────────────────────────── */
|
||||
.main-content { flex: 1; padding: 1.5rem 1.25rem; max-width: 960px; width: 100%; margin: 0 auto; }
|
||||
|
||||
/* ── Page header ──────────────────────────────────────────────────────────── */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.page-header h2 { font-size: 1.4rem; font-weight: 600; flex: 1; }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────────────── */
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
padding: .45rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: .875rem;
|
||||
font-weight: 500;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.btn-primary:hover { opacity: .85; }
|
||||
|
||||
.btn-secondary {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
padding: .45rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: .875rem;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--bg); }
|
||||
|
||||
.btn-icon {
|
||||
color: inherit;
|
||||
font-size: 1.1rem;
|
||||
padding: .25rem .4rem;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.btn-icon:hover { opacity: .7; }
|
||||
|
||||
.back-btn {
|
||||
color: var(--text-dim);
|
||||
font-size: .875rem;
|
||||
padding: .3rem .6rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.back-btn:hover { background: var(--bg); }
|
||||
|
||||
/* ── Device grid ─────────────────────────────────────────────────────────── */
|
||||
.device-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: box-shadow .15s, transform .1s;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.device-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.12); transform: translateY(-1px); }
|
||||
|
||||
.device-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: .25rem; }
|
||||
.device-name { font-weight: 600; font-size: .95rem; }
|
||||
.device-type { font-size: .8rem; color: var(--text-dim); margin-bottom: .5rem; }
|
||||
|
||||
.device-indicator {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.device-indicator.online { background: var(--online); }
|
||||
.device-indicator.offline { background: var(--offline); }
|
||||
|
||||
.now-playing-mini { font-size: .8rem; color: var(--text-dim); margin-top: .25rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.play-status { margin-right: .3rem; }
|
||||
.standby-label { font-size: .8rem; color: var(--text-dim); margin-top: .25rem; }
|
||||
|
||||
/* ── Device detail ───────────────────────────────────────────────────────── */
|
||||
.device-detail { max-width: 560px; }
|
||||
|
||||
/* ── Now playing ─────────────────────────────────────────────────────────── */
|
||||
.now-playing {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
box-shadow: var(--shadow);
|
||||
min-height: 80px;
|
||||
align-items: center;
|
||||
}
|
||||
.now-playing.standby { color: var(--text-dim); font-size: .9rem; }
|
||||
|
||||
.album-art { width: 64px; height: 64px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
|
||||
|
||||
.track-info { flex: 1; overflow: hidden; }
|
||||
.track-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.track-artist { font-size: .875rem; color: var(--text-dim); margin-top: .15rem; }
|
||||
.track-album { font-size: .8rem; color: var(--text-dim); }
|
||||
.track-meta { display: flex; align-items: center; gap: .5rem; margin-top: .25rem; }
|
||||
.track-source { font-size: .75rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; }
|
||||
.buffering-badge { font-size: .7rem; color: var(--text-dim); background: var(--bg); border-radius: 4px; padding: .1rem .35rem; }
|
||||
|
||||
/* ── Transport controls ──────────────────────────────────────────────────── */
|
||||
.controls {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.transport { display: flex; align-items: center; gap: .5rem; margin-bottom: .75rem; }
|
||||
|
||||
.ctrl-btn {
|
||||
font-size: 1.25rem;
|
||||
padding: .4rem .7rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
transition: background .12s;
|
||||
}
|
||||
.ctrl-btn:hover { background: var(--bg); }
|
||||
.ctrl-btn.play-btn { font-size: 1.5rem; padding: .4rem .9rem; }
|
||||
.ctrl-btn.active { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
||||
|
||||
.volume-row { display: flex; align-items: center; gap: .75rem; }
|
||||
.volume-icon { font-size: 1rem; }
|
||||
.volume-slider { flex: 1; accent-color: var(--accent); }
|
||||
.volume-value { font-size: .8rem; color: var(--text-dim); min-width: 2.5ch; text-align: right; }
|
||||
|
||||
.bass-row { display: flex; align-items: center; gap: .75rem; margin-top: .5rem; }
|
||||
.bass-label { font-size: .8rem; color: var(--text-dim); width: 2.5ch; }
|
||||
|
||||
/* ── Progress bar ────────────────────────────────────────────────────────── */
|
||||
.progress-row { margin-top: .35rem; display: flex; align-items: center; gap: .5rem; }
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
transition: width .9s linear;
|
||||
}
|
||||
.progress-time { font-size: .7rem; color: var(--text-dim); white-space: nowrap; flex-shrink: 0; }
|
||||
|
||||
/* ── Presets ─────────────────────────────────────────────────────────────── */
|
||||
.presets-section, .sources-section { margin-top: 1.25rem; }
|
||||
.section-title { font-size: .8rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-dim); margin-bottom: .6rem; }
|
||||
|
||||
.preset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: .4rem;
|
||||
}
|
||||
|
||||
.preset-slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: .25rem;
|
||||
padding: .4rem .2rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background .1s, box-shadow .1s;
|
||||
min-height: 72px;
|
||||
}
|
||||
.preset-slot:hover:not(:disabled) { background: var(--bg); box-shadow: var(--shadow); }
|
||||
.preset-slot:disabled { opacity: .4; cursor: default; }
|
||||
.preset-slot.active { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
|
||||
.preset-slot.active .preset-name { color: var(--accent-fg); }
|
||||
|
||||
.preset-art { width: 36px; height: 36px; border-radius: 4px; object-fit: cover; }
|
||||
.preset-source-label { font-size: .6rem; font-weight: 600; text-transform: uppercase; opacity: .6; }
|
||||
.preset-name { font-size: .65rem; text-align: center; line-height: 1.2; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; word-break: break-word; color: var(--text-dim); }
|
||||
.preset-num {
|
||||
position: absolute; top: 2px; right: 4px;
|
||||
font-size: .6rem; font-weight: 700; color: var(--text-dim); opacity: .5;
|
||||
}
|
||||
|
||||
/* ── Sources ─────────────────────────────────────────────────────────────── */
|
||||
.source-list { display: flex; flex-wrap: wrap; gap: .4rem; }
|
||||
|
||||
.source-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .35rem;
|
||||
padding: .35rem .7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
font-size: .8rem;
|
||||
transition: background .1s, border-color .1s;
|
||||
}
|
||||
.source-btn:hover { background: var(--bg); }
|
||||
.source-btn.active { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
|
||||
.source-btn.local { border-style: dashed; }
|
||||
.source-icon { font-size: .9rem; line-height: 1; }
|
||||
.source-name { font-weight: 500; }
|
||||
|
||||
/* ── Zone ────────────────────────────────────────────────────────────────── */
|
||||
.zone-section { margin-top: 1.25rem; }
|
||||
|
||||
.zone-row { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
|
||||
.zone-status-label { font-size: .875rem; color: var(--text-dim); }
|
||||
|
||||
.zone-members { display: flex; flex-direction: column; gap: .3rem; }
|
||||
.zone-member {
|
||||
display: flex; align-items: center; gap: .6rem;
|
||||
padding: .4rem .6rem;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.zone-master-row { background: var(--bg); }
|
||||
|
||||
.zone-badge {
|
||||
font-size: .65rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .05em; padding: .15rem .4rem; border-radius: 3px; flex-shrink: 0;
|
||||
}
|
||||
.zone-badge.master { background: var(--accent); color: var(--accent-fg); }
|
||||
.zone-badge.slave { background: var(--border); color: var(--text-dim); }
|
||||
|
||||
.zone-member-name { flex: 1; font-size: .875rem; }
|
||||
.zone-remove { font-size: .75rem; color: var(--text-dim); padding: .15rem .35rem; }
|
||||
.zone-remove:hover { color: var(--text); }
|
||||
|
||||
.zone-actions { display: flex; gap: .5rem; margin-top: .25rem; flex-wrap: wrap; }
|
||||
.zone-btn { font-size: .8rem; padding: .3rem .7rem; }
|
||||
|
||||
/* ── Recents ─────────────────────────────────────────────────────────────── */
|
||||
.recents-section { margin-top: 1.25rem; }
|
||||
|
||||
.recents-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
|
||||
.recent-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
width: 100%;
|
||||
padding: .5rem .6rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
transition: background .1s;
|
||||
}
|
||||
.recent-item:hover { background: var(--bg); }
|
||||
|
||||
.recent-art {
|
||||
width: 40px; height: 40px; border-radius: 4px;
|
||||
object-fit: cover; flex-shrink: 0;
|
||||
}
|
||||
.recent-art-empty {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg); font-size: 1.1rem;
|
||||
}
|
||||
.recent-info { flex: 1; overflow: hidden; }
|
||||
.recent-name { display: block; font-size: .875rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.recent-source { display: block; font-size: .75rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; margin-top: .1rem; }
|
||||
.recent-play { color: var(--text-dim); font-size: .75rem; flex-shrink: 0; opacity: .5; }
|
||||
.recent-item:hover .recent-play { opacity: 1; }
|
||||
|
||||
/* ── TuneIn ──────────────────────────────────────────────────────────────── */
|
||||
.tunein-toolbar { display: flex; gap: .5rem; margin-bottom: 1rem; }
|
||||
.tunein-search-input {
|
||||
flex: 1;
|
||||
padding: .45rem .75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: .875rem;
|
||||
}
|
||||
.tunein-search-input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
|
||||
.breadcrumb { display: flex; align-items: center; gap: .4rem; margin-bottom: .75rem; font-size: .85rem; flex-wrap: wrap; }
|
||||
.breadcrumb-sep { color: var(--text-dim); }
|
||||
.breadcrumb-link { color: var(--text-dim); cursor: pointer; }
|
||||
.breadcrumb-link:hover { text-decoration: underline; }
|
||||
.breadcrumb-current { font-weight: 500; }
|
||||
|
||||
.loading-bar {
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, var(--accent) 0%, transparent 100%);
|
||||
border-radius: 1px;
|
||||
margin-bottom: 1rem;
|
||||
animation: loading 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes loading { 0%,100% { opacity: .4; } 50% { opacity: 1; } }
|
||||
|
||||
.tunein-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
|
||||
.tunein-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
padding: .6rem .75rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: background .1s;
|
||||
}
|
||||
.tunein-item:hover { background: var(--bg); }
|
||||
|
||||
.tunein-thumb { width: 40px; height: 40px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
|
||||
.tunein-item-info { flex: 1; overflow: hidden; }
|
||||
.tunein-item-name { display: block; font-size: .9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tunein-item-desc { display: block; font-size: .75rem; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tunein-item-arrow { color: var(--text-dim); font-size: .9rem; flex-shrink: 0; }
|
||||
|
||||
/* ── Device picker overlay ───────────────────────────────────────────────── */
|
||||
.overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,.45);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.device-picker {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,.2);
|
||||
}
|
||||
.picker-title { font-weight: 600; margin-bottom: .25rem; }
|
||||
.picker-item-name { font-size: .875rem; color: var(--text-dim); margin-bottom: 1rem; }
|
||||
.picker-devices { display: flex; flex-direction: column; gap: .5rem; margin-bottom: 1rem; }
|
||||
.picker-device-btn {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: .6rem 1rem;
|
||||
text-align: left;
|
||||
font-size: .9rem;
|
||||
transition: background .1s;
|
||||
}
|
||||
.picker-device-btn:hover { background: var(--border); }
|
||||
.picker-cancel { width: 100%; }
|
||||
.picker-no-devices { font-size: .875rem; color: var(--text-dim); text-align: center; padding: .5rem 0; }
|
||||
|
||||
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||
.empty-state { text-align: center; padding: 4rem 1rem; color: var(--text-dim); }
|
||||
.empty-icon { font-size: 3rem; margin-bottom: 1rem; opacity: .4; }
|
||||
.empty-state p { margin-bottom: 1.5rem; }
|
||||
|
||||
/* ── Toast ───────────────────────────────────────────────────────────────── */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
padding: .6rem 1.25rem;
|
||||
border-radius: 999px;
|
||||
font-size: .875rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.2);
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
animation: fade-in .2s ease;
|
||||
}
|
||||
@keyframes fade-in { from { opacity: 0; transform: translateX(-50%) translateY(8px); } }
|
||||
|
Before Width: | Height: | Size: 859 B |
@@ -1,9 +0,0 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Morse 'S' (drei Punkte) -->
|
||||
<circle cx="8" cy="10" r="3" fill="#0055aa"/>
|
||||
<circle cx="16" cy="10" r="3" fill="#0055aa"/>
|
||||
<circle cx="24" cy="10" r="3" fill="#0055aa"/>
|
||||
|
||||
<!-- Morse 'T' (ein langer Strich) -->
|
||||
<rect x="5" y="18" width="22" height="6" rx="2" fill="#ffcc00"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 381 B |
@@ -1,24 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SoundTouch Web</title>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"preact": "/static/vendor/preact.module.js",
|
||||
"preact/hooks": "/static/vendor/preact-hooks.module.js",
|
||||
"htm": "/static/vendor/htm.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg" />
|
||||
<link rel="alternate icon" href="/static/img/favicon.ico" />
|
||||
<link rel="stylesheet" href="/static/css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,40 +0,0 @@
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||
|
||||
async function req(url, opts = {}) {
|
||||
const r = await fetch(url, opts);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
devices: () => req('/api/devices'),
|
||||
device: (id) => req(`/api/device/${id}`),
|
||||
discover: () => req('/api/discover', { method: 'POST' }),
|
||||
key: (id, key) => req(`/api/device-key/${id}/${key}`, { method: 'POST' }),
|
||||
volume: (id, level) => req(`/api/device-volume/${id}/${level}`, { method: 'POST' }),
|
||||
bass: (id, level) => req(`/api/control/${id}/bass`, {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ level }),
|
||||
}),
|
||||
power: (id) => req(`/api/device-power/${id}`, { method: 'POST' }),
|
||||
recents: (id) => req(`/api/device-recents/${id}`),
|
||||
zone: (id) => req(`/api/zone/${id}`),
|
||||
zoneAdd: (masterId, slaveId) => req(`/api/zone/${masterId}/add/${slaveId}`, { method: 'POST' }),
|
||||
zoneRemove: (masterId, slaveId) => req(`/api/zone/${masterId}/remove/${slaveId}`, { method: 'POST' }),
|
||||
zoneDissolve: (id) => req(`/api/zone/${id}/dissolve`, { method: 'POST' }),
|
||||
zoneLeave: (id) => req(`/api/zone/${id}/leave`, { method: 'POST' }),
|
||||
play: (id, item) => req(`/api/device-play/${id}`, {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(item),
|
||||
}),
|
||||
tuneInBrowse: (path) => req(path ? `/api/tunein/navigate/${path}` : '/api/tunein/navigate'),
|
||||
tuneInSearch: (q) => req(`/api/tunein/search?q=${encodeURIComponent(q)}`),
|
||||
control: (id, action, presetId) => req(`/api/control/${id}/${action}?id=${presetId}`),
|
||||
selectSource: (id, source, account) => req(`/api/control/${id}/source?name=${encodeURIComponent(source)}&account=${encodeURIComponent(account || '')}`),
|
||||
tuneInPlay: (deviceId, item) => req(`/api/tunein/play/${deviceId}`, {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(item),
|
||||
}),
|
||||
};
|
||||
@@ -1,141 +0,0 @@
|
||||
import { h, render } from 'preact';
|
||||
import { useState, useEffect, useCallback } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
import { DeviceList } from './components/DeviceList.js';
|
||||
import { NowPlaying } from './components/NowPlaying.js';
|
||||
import { Controls } from './components/Controls.js';
|
||||
import { Presets } from './components/Presets.js';
|
||||
import { Sources } from './components/Sources.js';
|
||||
import { Zone } from './components/Zone.js';
|
||||
import { Recents } from './components/Recents.js';
|
||||
import { TuneInBrowser } from './components/TuneInBrowser.js';
|
||||
import { api } from './api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
function DeviceDetail({ deviceId, devices, onBack }) {
|
||||
const device = devices[deviceId];
|
||||
|
||||
if (!device) {
|
||||
return html`
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onClick=${onBack}>← Back</button>
|
||||
</div>
|
||||
<p>Device not found.</p>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="device-detail">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onClick=${onBack}>← Back</button>
|
||||
<h2>${device.info?.Name || deviceId}</h2>
|
||||
<button class="btn-icon" onClick=${() => api.power(deviceId)} title="Power">⏻</button>
|
||||
</div>
|
||||
<${NowPlaying} nowPlaying=${device.status?.nowPlaying} />
|
||||
<${Controls} deviceId=${deviceId} status=${device.status} />
|
||||
<${Presets} deviceId=${deviceId} status=${device.status} />
|
||||
<${Sources} deviceId=${deviceId} status=${device.status} />
|
||||
<${Zone} deviceId=${deviceId} devices=${devices} />
|
||||
<${Recents} deviceId=${deviceId} />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [devices, setDevices] = useState({});
|
||||
const [page, setPage] = useState('devices');
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${protocol}//${location.host}/ws`);
|
||||
let reconnectTimer;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'devices') {
|
||||
setDevices(msg.data || {});
|
||||
} else if (msg.type === 'discovery_status') {
|
||||
if (msg.data?.status === 'completed') {
|
||||
showToast(`Found ${msg.data.deviceCount} device(s)`);
|
||||
}
|
||||
} else if (msg.type === 'status_update' && msg.deviceId) {
|
||||
setDevices(prev => ({
|
||||
...prev,
|
||||
[msg.deviceId]: { ...prev[msg.deviceId], status: msg.data },
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
reconnectTimer = setTimeout(() => location.reload(), 5000);
|
||||
};
|
||||
|
||||
return () => {
|
||||
clearTimeout(reconnectTimer);
|
||||
ws.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function showToast(msg) {
|
||||
setToast(msg);
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
}
|
||||
|
||||
const navigate = useCallback((p, id = null) => {
|
||||
setPage(p);
|
||||
setSelectedId(id);
|
||||
}, []);
|
||||
|
||||
async function discover() {
|
||||
showToast('Discovering devices…');
|
||||
await api.discover();
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="app">
|
||||
<nav class="navbar">
|
||||
<a class="brand" href="#" onClick=${(e) => { e.preventDefault(); navigate('devices'); }}>
|
||||
SoundTouch
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="#" class="${page === 'devices' || page === 'device' ? 'active' : ''}"
|
||||
onClick=${(e) => { e.preventDefault(); navigate('devices'); }}>
|
||||
Devices
|
||||
</a>
|
||||
<a href="#" class="${page === 'tunein' ? 'active' : ''}"
|
||||
onClick=${(e) => { e.preventDefault(); navigate('tunein'); }}>
|
||||
<img src="/static/img/tunein-mono.svg" alt="TuneIn" class="nav-tunein-icon" />
|
||||
</a>
|
||||
<button class="btn-icon" onClick=${discover} title="Discover">⟳</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
${page === 'devices' && html`
|
||||
<${DeviceList}
|
||||
devices=${devices}
|
||||
onSelect=${(id) => navigate('device', id)}
|
||||
onDiscover=${discover}
|
||||
/>
|
||||
`}
|
||||
${page === 'device' && html`
|
||||
<${DeviceDetail}
|
||||
deviceId=${selectedId}
|
||||
devices=${devices}
|
||||
onBack=${() => navigate('devices')}
|
||||
/>
|
||||
`}
|
||||
${page === 'tunein' && html`
|
||||
<${TuneInBrowser} devices=${devices} />
|
||||
`}
|
||||
</main>
|
||||
|
||||
${toast && html`<div class="toast">${toast}</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render(html`<${App} />`, document.getElementById('app'));
|
||||
@@ -1,80 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
export function Controls({ deviceId, status }) {
|
||||
const np = status?.nowPlaying;
|
||||
const isPlaying = np?.PlayStatus === 'PLAY_STATE';
|
||||
const actualVolume = status?.volume?.ActualVolume ?? 0;
|
||||
const isMuted = status?.volume?.MuteEnabled ?? false;
|
||||
const shuffle = np?.ShuffleSetting ?? 'SHUFFLE_OFF';
|
||||
const repeat = np?.RepeatSetting ?? 'REPEAT_OFF';
|
||||
const actualBass = status?.bass?.TargetBass ?? 0;
|
||||
const hasBass = status?.bass != null;
|
||||
|
||||
const [localVolume, setLocalVolume] = useState(actualVolume);
|
||||
const [localBass, setLocalBass] = useState(actualBass);
|
||||
|
||||
useEffect(() => { setLocalVolume(actualVolume); }, [actualVolume]);
|
||||
useEffect(() => { setLocalBass(actualBass); }, [actualBass]);
|
||||
|
||||
const send = (key) => api.key(deviceId, key);
|
||||
|
||||
function onVolumeChange(e) {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
setLocalVolume(val);
|
||||
api.volume(deviceId, val);
|
||||
}
|
||||
|
||||
function onBassChange(e) {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
setLocalBass(val);
|
||||
api.bass(deviceId, val);
|
||||
}
|
||||
|
||||
function toggleShuffle() {
|
||||
send(shuffle === 'SHUFFLE_ON' ? 'SHUFFLE_OFF' : 'SHUFFLE_ON');
|
||||
}
|
||||
|
||||
function cycleRepeat() {
|
||||
if (repeat === 'REPEAT_OFF') send('REPEAT_ALL');
|
||||
else if (repeat === 'REPEAT_ALL') send('REPEAT_ONE');
|
||||
else send('REPEAT_OFF');
|
||||
}
|
||||
|
||||
const repeatIcon = repeat === 'REPEAT_ONE' ? '🔂' : '🔁';
|
||||
|
||||
return html`
|
||||
<div class="controls">
|
||||
<div class="transport">
|
||||
<button class="ctrl-btn" onClick=${() => send('PREV_TRACK')} title="Previous">⏮</button>
|
||||
<button class="ctrl-btn play-btn" onClick=${() => send(isPlaying ? 'PAUSE' : 'PLAY')}>
|
||||
${isPlaying ? '⏸' : '▶'}
|
||||
</button>
|
||||
<button class="ctrl-btn" onClick=${() => send('NEXT_TRACK')} title="Next">⏭</button>
|
||||
<button class="ctrl-btn ${isMuted ? 'active' : ''}" onClick=${() => send('MUTE')} title="Mute">
|
||||
${isMuted ? '🔇' : '🔊'}
|
||||
</button>
|
||||
<button class="ctrl-btn ${shuffle === 'SHUFFLE_ON' ? 'active' : ''}" onClick=${toggleShuffle} title="Shuffle">🔀</button>
|
||||
<button class="ctrl-btn ${repeat !== 'REPEAT_OFF' ? 'active' : ''}" onClick=${cycleRepeat} title="Repeat">${repeatIcon}</button>
|
||||
</div>
|
||||
<div class="volume-row">
|
||||
<span class="volume-icon">🔈</span>
|
||||
<input type="range" class="volume-slider" min="0" max="100"
|
||||
value=${localVolume} onInput=${onVolumeChange} />
|
||||
<span class="volume-value">${localVolume}</span>
|
||||
</div>
|
||||
${hasBass && html`
|
||||
<div class="bass-row">
|
||||
<span class="bass-label">Bass</span>
|
||||
<input type="range" class="volume-slider" min="-9" max="9"
|
||||
value=${localBass} onInput=${onBassChange} />
|
||||
<span class="volume-value">${localBass > 0 ? '+' : ''}${localBass}</span>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import htm from 'htm';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
function DeviceCard({ id, device, onSelect }) {
|
||||
const { info, status } = device;
|
||||
const np = status?.nowPlaying;
|
||||
const isPlaying = np?.PlayStatus === 'PLAY_STATE';
|
||||
const isStandby = !np || np.Source === 'STANDBY';
|
||||
|
||||
return html`
|
||||
<div class="device-card" onClick=${() => onSelect(id)}>
|
||||
<div class="device-header">
|
||||
<span class="device-name">${info?.Name || id}</span>
|
||||
<span class="device-indicator ${status?.isConnected ? 'online' : 'offline'}"></span>
|
||||
</div>
|
||||
<div class="device-type">${info?.Type || ''}</div>
|
||||
${!isStandby && html`
|
||||
<div class="now-playing-mini">
|
||||
<span class="play-status">${isPlaying ? '▶' : '⏸'}</span>
|
||||
<span class="track-mini">${np.Track || np.StationName || np.Source}</span>
|
||||
${np.Artist && html`<span class="artist-mini"> — ${np.Artist}</span>`}
|
||||
</div>
|
||||
`}
|
||||
${isStandby && html`<div class="standby-label">Standby</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function DeviceList({ devices, onSelect, onDiscover }) {
|
||||
const entries = Object.entries(devices);
|
||||
|
||||
return html`
|
||||
<div class="page-header">
|
||||
<h2>Devices</h2>
|
||||
<button class="btn-secondary" onClick=${onDiscover}>Discover</button>
|
||||
</div>
|
||||
${entries.length === 0
|
||||
? html`
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">◉</div>
|
||||
<p>No devices found on your network.</p>
|
||||
<button class="btn-primary" onClick=${onDiscover}>Start Discovery</button>
|
||||
</div>`
|
||||
: html`
|
||||
<div class="device-grid">
|
||||
${entries.map(([id, device]) => html`
|
||||
<${DeviceCard} key=${id} id=${id} device=${device} onSelect=${onSelect} />
|
||||
`)}
|
||||
</div>`
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
function fmt(secs) {
|
||||
if (!secs || secs <= 0) return '0:00';
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function NowPlaying({ nowPlaying }) {
|
||||
const [position, setPosition] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const pos = nowPlaying?.Time?.Position ?? 0;
|
||||
setPosition(pos);
|
||||
if (nowPlaying?.PlayStatus !== 'PLAY_STATE') return;
|
||||
const id = setInterval(() => setPosition(p => p + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [nowPlaying?.Time?.Position, nowPlaying?.PlayStatus]);
|
||||
|
||||
if (!nowPlaying || nowPlaying.Source === 'STANDBY') {
|
||||
return html`<div class="now-playing standby">Standby</div>`;
|
||||
}
|
||||
|
||||
const title = nowPlaying.Track || nowPlaying.StationName || nowPlaying.Source;
|
||||
const artURL = nowPlaying.Art?.URL;
|
||||
const isBuffering = nowPlaying.PlayStatus === 'BUFFERING_STATE';
|
||||
const total = nowPlaying.Time?.Total ?? 0;
|
||||
const pct = total > 0 ? Math.min(100, (position / total) * 100) : 0;
|
||||
|
||||
return html`
|
||||
<div class="now-playing">
|
||||
${artURL && html`<img class="album-art" src=${artURL} alt="" />`}
|
||||
<div class="track-info">
|
||||
<div class="track-title">${title}</div>
|
||||
${nowPlaying.Artist && html`<div class="track-artist">${nowPlaying.Artist}</div>`}
|
||||
${nowPlaying.Album && html`<div class="track-album">${nowPlaying.Album}</div>`}
|
||||
<div class="track-meta">
|
||||
<span class="track-source">${nowPlaying.Source}</span>
|
||||
${isBuffering && html`<span class="buffering-badge">Buffering…</span>`}
|
||||
</div>
|
||||
${total > 0 && html`
|
||||
<div class="progress-row">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
<span class="progress-time">${fmt(position)} / ${fmt(total)}</span>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import htm from 'htm';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
const SOURCE_LABELS = {
|
||||
TUNEIN: 'TuneIn', SPOTIFY: 'Spotify', AMAZON: 'Amazon',
|
||||
PANDORA: 'Pandora', IHEARTRADIO: 'iHeart', DEEZER: 'Deezer',
|
||||
LOCAL_INTERNET_RADIO: 'Internet Radio',
|
||||
};
|
||||
|
||||
function sourceLabel(source) {
|
||||
return SOURCE_LABELS[source] || source;
|
||||
}
|
||||
|
||||
function PresetSlot({ preset, deviceId, active }) {
|
||||
const item = preset?.ContentItem;
|
||||
const isEmpty = !item;
|
||||
const art = item?.ContainerArt;
|
||||
const name = item?.ItemName || `Preset ${preset?.ID ?? ''}`;
|
||||
|
||||
function select() {
|
||||
if (!isEmpty) api.control(deviceId, 'preset', preset.ID);
|
||||
}
|
||||
|
||||
return html`
|
||||
<button
|
||||
class="preset-slot ${isEmpty ? 'empty' : ''} ${active ? 'active' : ''}"
|
||||
onClick=${select}
|
||||
disabled=${isEmpty}
|
||||
title=${isEmpty ? 'Empty' : name}
|
||||
>
|
||||
${art
|
||||
? html`<img class="preset-art" src=${art} alt="" />`
|
||||
: html`<span class="preset-source-label">${isEmpty ? '—' : sourceLabel(item.Source)}</span>`
|
||||
}
|
||||
<span class="preset-name">${isEmpty ? 'Empty' : name}</span>
|
||||
<span class="preset-num">${preset?.ID ?? ''}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
export function Presets({ deviceId, status }) {
|
||||
const presets = status?.presets?.Preset ?? [];
|
||||
const currentSource = status?.nowPlaying?.Source;
|
||||
const currentLocation = status?.nowPlaying?.ContentItem?.Location;
|
||||
|
||||
// Build a map for quick lookup, then render slots 1-6
|
||||
const byId = Object.fromEntries(presets.map(p => [p.ID, p]));
|
||||
const slots = [1, 2, 3, 4, 5, 6].map(id => byId[id] ?? { ID: id, ContentItem: null });
|
||||
|
||||
function isActive(preset) {
|
||||
const item = preset.ContentItem;
|
||||
return item && item.Source === currentSource && item.Location === currentLocation;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="presets-section">
|
||||
<h3 class="section-title">Presets</h3>
|
||||
<div class="preset-grid">
|
||||
${slots.map(preset => html`
|
||||
<${PresetSlot}
|
||||
key=${preset.ID}
|
||||
preset=${preset}
|
||||
deviceId=${deviceId}
|
||||
active=${isActive(preset)}
|
||||
/>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
const SOURCE_ICONS = {
|
||||
TUNEIN: '📻', SPOTIFY: '🎵', AMAZON: '🎶', PANDORA: '🎸',
|
||||
DEEZER: '🎵', IHEART: '📻', BLUETOOTH: '📶', AUX: '🔌',
|
||||
LOCAL_MUSIC: '💽', STORED_MUSIC: '💽',
|
||||
};
|
||||
|
||||
export function Recents({ deviceId }) {
|
||||
const [items, setItems] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deviceId) return;
|
||||
api.recents(deviceId).then(resp => {
|
||||
setItems(resp.data?.Items ?? []);
|
||||
}).catch(() => {
|
||||
setItems([]);
|
||||
}).finally(() => setLoading(false));
|
||||
}, [deviceId]);
|
||||
|
||||
if (loading) return html`
|
||||
<div class="recents-section">
|
||||
<div class="section-title">Recents</div>
|
||||
<div class="loading-bar"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
function play(item) {
|
||||
const ci = item.ContentItem;
|
||||
if (!ci?.Location) return;
|
||||
api.play(deviceId, {
|
||||
source: ci.Source,
|
||||
type: ci.Type,
|
||||
location: ci.Location,
|
||||
sourceAccount: ci.SourceAccount,
|
||||
itemName: ci.ItemName,
|
||||
containerArt: ci.ContainerArt,
|
||||
isPresetable: ci.IsPresetable,
|
||||
});
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="recents-section">
|
||||
<div class="section-title">Recents</div>
|
||||
<div class="recents-list">
|
||||
${items.map(item => {
|
||||
const ci = item.ContentItem;
|
||||
if (!ci) return null;
|
||||
const icon = SOURCE_ICONS[ci.Source] ?? '♪';
|
||||
return html`
|
||||
<button class="recent-item" key=${item.ID || item.UTCTime} onClick=${() => play(item)}>
|
||||
${ci.ContainerArt
|
||||
? html`<img class="recent-art" src=${ci.ContainerArt} alt="" />`
|
||||
: html`<div class="recent-art recent-art-empty">${icon}</div>`
|
||||
}
|
||||
<div class="recent-info">
|
||||
<span class="recent-name">${ci.ItemName || ci.Source}</span>
|
||||
<span class="recent-source">${ci.Source}</span>
|
||||
</div>
|
||||
<span class="recent-play">▶</span>
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import htm from 'htm';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
const SOURCE_ICONS = {
|
||||
TUNEIN: '📻', SPOTIFY: '🎵', AMAZON: '🛒', PANDORA: '🎶',
|
||||
BLUETOOTH: '📶', AUX: '🔌', OPTICAL: '💡', HDMI: '📺',
|
||||
IHEARTRADIO: '❤️', DEEZER: '🎼', LOCAL_INTERNET_RADIO: '📡',
|
||||
AIRPLAY: '📡', PRODUCT: '🔊',
|
||||
};
|
||||
|
||||
export function Sources({ deviceId, status }) {
|
||||
const items = status?.sources?.SourceItem ?? [];
|
||||
const currentSource = status?.nowPlaying?.Source;
|
||||
const currentAccount = status?.nowPlaying?.SourceAccount;
|
||||
|
||||
const ready = items.filter(s => s.Status === 'READY');
|
||||
if (ready.length === 0) return null;
|
||||
|
||||
function select(src) {
|
||||
api.selectSource(deviceId, src.Source, src.SourceAccount ?? '');
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="sources-section">
|
||||
<h3 class="section-title">Sources</h3>
|
||||
<div class="source-list">
|
||||
${ready.map(src => {
|
||||
const isActive = src.Source === currentSource &&
|
||||
(!src.SourceAccount || src.SourceAccount === currentAccount);
|
||||
return html`
|
||||
<button
|
||||
key=${src.Source + (src.SourceAccount || '')}
|
||||
class="source-btn ${isActive ? 'active' : ''} ${src.IsLocal ? 'local' : ''}"
|
||||
onClick=${() => select(src)}
|
||||
title=${src.Source}
|
||||
>
|
||||
<span class="source-icon">${SOURCE_ICONS[src.Source] || '🔊'}</span>
|
||||
<span class="source-name">${src.DisplayName || src.Source}</span>
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
// BmxNavResponse has shape { bmx_sections: [{ name, items: [{ name, imageUrl, subtitle, _links }] }] }
|
||||
// _links.bmx_navigate.href = "/v1/navigate/{encodedPath}" — strip prefix for API call
|
||||
// _links.bmx_playback.href = station/track URL, type = "stationurl"|"tracklisturl"
|
||||
|
||||
function navPath(item) {
|
||||
const href = item._links?.bmx_navigate?.href;
|
||||
return href ? href.replace(/^\/v1\/navigate\//, '') : null;
|
||||
}
|
||||
|
||||
function playbackInfo(item) {
|
||||
const link = item._links?.bmx_playback;
|
||||
return link ? { location: link.href, type: link.type || 'stationurl' } : null;
|
||||
}
|
||||
|
||||
function flattenSections(data) {
|
||||
if (!data?.bmx_sections) return [];
|
||||
return data.bmx_sections.flatMap(section =>
|
||||
(section.items || []).map(item => ({ ...item, _sectionName: section.name }))
|
||||
);
|
||||
}
|
||||
|
||||
export function TuneInBrowser({ devices }) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [navStack, setNavStack] = useState([{ label: 'TuneIn', path: null }]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pendingPlay, setPendingPlay] = useState(null);
|
||||
|
||||
useEffect(() => { browse(null); }, []);
|
||||
|
||||
async function browse(path) {
|
||||
setLoading(true);
|
||||
const resp = await api.tuneInBrowse(path);
|
||||
setLoading(false);
|
||||
if (resp.success) setItems(flattenSections(resp.data));
|
||||
}
|
||||
|
||||
async function search(q) {
|
||||
if (!q.trim()) return;
|
||||
setLoading(true);
|
||||
const resp = await api.tuneInSearch(q);
|
||||
setLoading(false);
|
||||
if (resp.success) {
|
||||
setNavStack([{ label: 'TuneIn', path: null }, { label: `"${q}"`, path: null }]);
|
||||
setItems(flattenSections(resp.data));
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(item) {
|
||||
const path = navPath(item);
|
||||
const play = playbackInfo(item);
|
||||
|
||||
if (path) {
|
||||
setNavStack(s => [...s, { label: item.name, path }]);
|
||||
browse(path);
|
||||
} else if (play) {
|
||||
setPendingPlay({ ...play, name: item.name, image: item.imageUrl });
|
||||
}
|
||||
}
|
||||
|
||||
function navTo(index) {
|
||||
const stack = navStack.slice(0, index + 1);
|
||||
setNavStack(stack);
|
||||
browse(stack[stack.length - 1].path);
|
||||
}
|
||||
|
||||
async function playOn(deviceId) {
|
||||
await api.tuneInPlay(deviceId, { location: pendingPlay.location, type: pendingPlay.type, name: pendingPlay.name });
|
||||
setPendingPlay(null);
|
||||
}
|
||||
|
||||
const deviceEntries = Object.entries(devices);
|
||||
|
||||
return html`
|
||||
<div class="tunein-browser">
|
||||
<div class="tunein-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
class="tunein-search-input"
|
||||
placeholder="Search stations, podcasts…"
|
||||
value=${searchQuery}
|
||||
onInput=${(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown=${(e) => e.key === 'Enter' && search(searchQuery)}
|
||||
/>
|
||||
<button class="btn-primary" onClick=${() => search(searchQuery)}>Search</button>
|
||||
<button class="btn-secondary" onClick=${() => {
|
||||
setNavStack([{ label: 'TuneIn', path: null }]);
|
||||
setSearchQuery('');
|
||||
browse(null);
|
||||
}}>Browse</button>
|
||||
</div>
|
||||
|
||||
${navStack.length > 1 && html`
|
||||
<nav class="breadcrumb">
|
||||
${navStack.map((entry, i) => html`
|
||||
${i > 0 && html`<span class="breadcrumb-sep">›</span>`}
|
||||
${i < navStack.length - 1
|
||||
? html`<a class="breadcrumb-link" onClick=${() => navTo(i)}>${entry.label}</a>`
|
||||
: html`<span class="breadcrumb-current">${entry.label}</span>`
|
||||
}
|
||||
`)}
|
||||
</nav>
|
||||
`}
|
||||
|
||||
${loading && html`<div class="loading-bar"></div>`}
|
||||
|
||||
<ul class="tunein-list">
|
||||
${items.map((item, i) => {
|
||||
const isNav = !!navPath(item);
|
||||
return html`
|
||||
<li key=${item._links?.self?.href || i} class="tunein-item" onClick=${() => navigate(item)}>
|
||||
${item.imageUrl && html`<img class="tunein-thumb" src=${item.imageUrl} alt="" />`}
|
||||
<div class="tunein-item-info">
|
||||
<span class="tunein-item-name">${item.name}</span>
|
||||
${item.subtitle && html`<span class="tunein-item-desc">${item.subtitle}</span>`}
|
||||
</div>
|
||||
<span class="tunein-item-arrow">${isNav ? '›' : '▶'}</span>
|
||||
</li>
|
||||
`;
|
||||
})}
|
||||
</ul>
|
||||
|
||||
${pendingPlay && html`
|
||||
<div class="overlay" onClick=${() => setPendingPlay(null)}>
|
||||
<div class="device-picker" onClick=${(e) => e.stopPropagation()}>
|
||||
<h3 class="picker-title">Play on device</h3>
|
||||
<p class="picker-item-name">${pendingPlay.name}</p>
|
||||
<div class="picker-devices">
|
||||
${deviceEntries.length === 0 && html`<p class="picker-no-devices">No devices found. Try discovering first.</p>`}
|
||||
${deviceEntries.map(([id, d]) => html`
|
||||
<button class="picker-device-btn" onClick=${() => playOn(id)}>
|
||||
${d.info?.name || id}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<button class="btn-secondary picker-cancel" onClick=${() => setPendingPlay(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
export function Zone({ deviceId, devices }) {
|
||||
const [zone, setZone] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
function refresh() {
|
||||
api.zone(deviceId).then(resp => {
|
||||
if (resp.success) setZone(resp.data);
|
||||
}).finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { refresh(); }, [deviceId]);
|
||||
|
||||
async function addDevice(slaveId) {
|
||||
setShowPicker(false);
|
||||
await api.zoneAdd(deviceId, slaveId);
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function removeDevice(slaveId) {
|
||||
await api.zoneRemove(deviceId, slaveId);
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function dissolve() {
|
||||
await api.zoneDissolve(deviceId);
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function leave() {
|
||||
await api.zoneLeave(deviceId);
|
||||
refresh();
|
||||
}
|
||||
|
||||
if (loading) return html`
|
||||
<div class="zone-section">
|
||||
<div class="section-title">Zone</div>
|
||||
<div class="loading-bar"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (!zone) return null;
|
||||
|
||||
// Devices not already in the zone are available to add
|
||||
const zoneIps = new Set([zone.masterIp, ...(zone.members || []).map(m => m.ip)].filter(Boolean));
|
||||
const available = Object.entries(devices || {}).filter(([ip]) => !zoneIps.has(ip));
|
||||
|
||||
const deviceName = (ip) => devices[ip]?.info?.Name ?? ip;
|
||||
|
||||
return html`
|
||||
<div class="zone-section">
|
||||
<div class="section-title">Zone</div>
|
||||
|
||||
${zone.isStandalone && html`
|
||||
<div class="zone-row">
|
||||
<span class="zone-status-label">Standalone</span>
|
||||
${available.length > 0 && html`
|
||||
<button class="btn-secondary zone-btn" onClick=${() => setShowPicker(true)}>+ Group with…</button>
|
||||
`}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${zone.isMaster && html`
|
||||
<div class="zone-members">
|
||||
<div class="zone-member zone-master-row">
|
||||
<span class="zone-badge master">Master</span>
|
||||
<span class="zone-member-name">${deviceName(deviceId)}</span>
|
||||
</div>
|
||||
${(zone.members || []).map(m => html`
|
||||
<div class="zone-member" key=${m.ip}>
|
||||
<span class="zone-badge slave">Member</span>
|
||||
<span class="zone-member-name">${m.name || m.ip}</span>
|
||||
<button class="btn-icon zone-remove" title="Remove from zone"
|
||||
onClick=${() => removeDevice(m.ip)}>✕</button>
|
||||
</div>
|
||||
`)}
|
||||
<div class="zone-actions">
|
||||
${available.length > 0 && html`
|
||||
<button class="btn-secondary zone-btn" onClick=${() => setShowPicker(true)}>+ Add speaker</button>
|
||||
`}
|
||||
<button class="btn-secondary zone-btn" onClick=${dissolve}>Dissolve zone</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${zone.isSlave && html`
|
||||
<div class="zone-row">
|
||||
<span class="zone-badge slave">Member</span>
|
||||
<span class="zone-member-name">Zone: ${zone.masterName || zone.masterIp}</span>
|
||||
<button class="btn-secondary zone-btn" onClick=${leave}>Leave zone</button>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${showPicker && html`
|
||||
<div class="overlay" onClick=${() => setShowPicker(false)}>
|
||||
<div class="device-picker" onClick=${e => e.stopPropagation()}>
|
||||
<div class="picker-title">Add to zone</div>
|
||||
<div class="picker-devices">
|
||||
${available.map(([ip, d]) => html`
|
||||
<button class="picker-device-btn" key=${ip} onClick=${() => addDevice(ip)}>
|
||||
${d.info?.Name ?? ip}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<button class="btn-secondary picker-cancel" onClick=${() => setShowPicker(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -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,180 @@
|
||||
// Package telnet provides a minimal line-oriented client for the SoundTouch
|
||||
// device's diagnostic shell on TCP port 17000.
|
||||
//
|
||||
// The protocol observed in the wild is a plain TCP stream with no Telnet
|
||||
// option negotiation (no IAC sequences), so the client uses the standard
|
||||
// library's net package directly. All I/O is deadline-driven so a wedged
|
||||
// device can never stall the caller indefinitely.
|
||||
package telnet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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 = 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 = 600 * time.Millisecond
|
||||
)
|
||||
|
||||
// Client is a connected (or about-to-be-connected) session to a SoundTouch
|
||||
// diagnostic shell. A Client is not safe for concurrent use; create one per
|
||||
// device interaction.
|
||||
type Client struct {
|
||||
Host string
|
||||
Port int
|
||||
DialTimeout time.Duration
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
|
||||
conn net.Conn
|
||||
}
|
||||
|
||||
// NewClient returns a Client targeting host:17000 with the default timeouts.
|
||||
func NewClient(host string) *Client {
|
||||
return &Client{
|
||||
Host: host,
|
||||
Port: DefaultPort,
|
||||
DialTimeout: DefaultDialTimeout,
|
||||
ReadTimeout: DefaultReadTimeout,
|
||||
WriteTimeout: DefaultWriteTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// Dial establishes the TCP connection. Subsequent calls are a no-op as long
|
||||
// as the existing connection is still open.
|
||||
func (c *Client) Dial() error {
|
||||
if c.conn != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
|
||||
|
||||
conn, err := net.DialTimeout("tcp", addr, c.DialTimeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %s: %w", addr, err)
|
||||
}
|
||||
|
||||
c.conn = conn
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close terminates the TCP connection. Calling Close on a closed Client is a
|
||||
// no-op.
|
||||
func (c *Client) Close() error {
|
||||
if c.conn == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := c.conn.Close()
|
||||
c.conn = nil
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Probe reads any banner the device emits immediately after connect. It
|
||||
// returns whatever bytes arrive within a short window; an empty banner is
|
||||
// not treated as an error because some firmware revisions stay silent until
|
||||
// the first command.
|
||||
func (c *Client) Probe() (string, error) {
|
||||
if c.conn == nil {
|
||||
return "", errors.New("telnet: not connected")
|
||||
}
|
||||
|
||||
if err := c.conn.SetReadDeadline(time.Now().Add(idleWindow * 2)); err != nil {
|
||||
return "", fmt.Errorf("set read deadline: %w", err)
|
||||
}
|
||||
|
||||
buf := make([]byte, 1024)
|
||||
|
||||
n, err := c.conn.Read(buf)
|
||||
if err != nil && !errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
return "", fmt.Errorf("read banner: %w", err)
|
||||
}
|
||||
|
||||
return string(buf[:n]), nil
|
||||
}
|
||||
|
||||
// SendCommand writes cmd followed by CRLF and reads the device's response.
|
||||
// The read terminates when the connection has been idle for idleWindow after
|
||||
// the first byte arrived, or when the overall ReadTimeout is reached.
|
||||
//
|
||||
// Returns the raw response text (callers decide what counts as success — the
|
||||
// device's textual conventions vary by firmware: some commands return "OK",
|
||||
// others echo state, others return nothing).
|
||||
func (c *Client) SendCommand(cmd string) (string, error) {
|
||||
if c.conn == nil {
|
||||
return "", errors.New("telnet: not connected")
|
||||
}
|
||||
|
||||
if err := c.conn.SetWriteDeadline(time.Now().Add(c.WriteTimeout)); err != nil {
|
||||
return "", fmt.Errorf("set write deadline: %w", err)
|
||||
}
|
||||
|
||||
if _, err := c.conn.Write([]byte(cmd + "\r\n")); err != nil {
|
||||
return "", fmt.Errorf("write %q: %w", cmd, err)
|
||||
}
|
||||
|
||||
overall := time.Now().Add(c.ReadTimeout)
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
chunk := make([]byte, 1024)
|
||||
haveBytes := false
|
||||
|
||||
for {
|
||||
deadline := overall
|
||||
|
||||
if haveBytes {
|
||||
d := time.Now().Add(idleWindow)
|
||||
if d.Before(overall) {
|
||||
deadline = d
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.conn.SetReadDeadline(deadline); err != nil {
|
||||
return buf.String(), fmt.Errorf("set read deadline: %w", err)
|
||||
}
|
||||
|
||||
n, err := c.conn.Read(chunk)
|
||||
if n > 0 {
|
||||
buf.Write(chunk[:n])
|
||||
|
||||
haveBytes = true
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
if haveBytes {
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
return buf.String(), fmt.Errorf("timed out waiting for response to %q", cmd)
|
||||
}
|
||||
|
||||
return buf.String(), fmt.Errorf("read after %q: %w", cmd, err)
|
||||
}
|
||||
}
|
||||