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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package telnet
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// scriptedServer is a minimal mock of the device's port-17000 shell. It
|
||||
// returns the supplied banner on connect, then for each line read it emits
|
||||
// the corresponding entry from responses (or "Command not found" if the line
|
||||
// is not in the map).
|
||||
type scriptedServer struct {
|
||||
t *testing.T
|
||||
listener net.Listener
|
||||
banner string
|
||||
responses map[string]string
|
||||
// hangAfter, if non-empty, names a command after which the server stops
|
||||
// responding (to exercise the read-timeout path).
|
||||
hangAfter string
|
||||
// closeAfter, if non-empty, names a command after which the server closes
|
||||
// the connection mid-stream.
|
||||
closeAfter string
|
||||
|
||||
stop chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func newScriptedServer(t *testing.T, banner string, responses map[string]string) *scriptedServer {
|
||||
t.Helper()
|
||||
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
s := &scriptedServer{
|
||||
t: t,
|
||||
listener: l,
|
||||
banner: banner,
|
||||
responses: responses,
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
|
||||
s.wg.Add(1)
|
||||
|
||||
go s.serve()
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *scriptedServer) addr() string {
|
||||
return s.listener.Addr().String()
|
||||
}
|
||||
|
||||
func (s *scriptedServer) hostPort() (string, int) {
|
||||
host, portStr, err := net.SplitHostPort(s.addr())
|
||||
if err != nil {
|
||||
s.t.Fatalf("split host/port: %v", err)
|
||||
}
|
||||
|
||||
port := 0
|
||||
|
||||
if _, err := parseInt(portStr, &port); err != nil {
|
||||
s.t.Fatalf("parse port %q: %v", portStr, err)
|
||||
}
|
||||
|
||||
return host, port
|
||||
}
|
||||
|
||||
func (s *scriptedServer) close() {
|
||||
close(s.stop)
|
||||
_ = s.listener.Close()
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
func (s *scriptedServer) serve() {
|
||||
defer s.wg.Done()
|
||||
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
if s.banner != "" {
|
||||
_, _ = conn.Write([]byte(s.banner))
|
||||
}
|
||||
|
||||
r := bufio.NewReader(conn)
|
||||
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cmd := strings.TrimRight(line, "\r\n")
|
||||
if cmd == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if cmd == s.closeAfter {
|
||||
return
|
||||
}
|
||||
|
||||
resp, ok := s.responses[cmd]
|
||||
if !ok {
|
||||
resp = "Command not found\n"
|
||||
}
|
||||
|
||||
_, _ = conn.Write([]byte(resp))
|
||||
|
||||
if cmd == s.hangAfter {
|
||||
// Block until the server is closed; the client's read deadline
|
||||
// must fire before then.
|
||||
<-s.stop
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseInt is a tiny strconv.Atoi wrapper so we don't drag strconv into this file.
|
||||
func parseInt(s string, out *int) (int, error) {
|
||||
n := 0
|
||||
|
||||
for _, ch := range s {
|
||||
if ch < '0' || ch > '9' {
|
||||
return 0, errors.New("not a number")
|
||||
}
|
||||
|
||||
n = n*10 + int(ch-'0')
|
||||
}
|
||||
|
||||
*out = n
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func newClientFor(t *testing.T, s *scriptedServer) *Client {
|
||||
t.Helper()
|
||||
|
||||
host, port := s.hostPort()
|
||||
|
||||
c := NewClient(host)
|
||||
c.Port = port
|
||||
// Tighten the timeouts so tests fail fast if the implementation regresses.
|
||||
c.DialTimeout = 500 * time.Millisecond
|
||||
c.ReadTimeout = 1500 * time.Millisecond
|
||||
c.WriteTimeout = 500 * time.Millisecond
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func TestNewClient_Defaults(t *testing.T) {
|
||||
c := NewClient("192.168.1.10")
|
||||
if c.Host != "192.168.1.10" {
|
||||
t.Errorf("Host = %q, want 192.168.1.10", c.Host)
|
||||
}
|
||||
|
||||
if c.Port != DefaultPort {
|
||||
t.Errorf("Port = %d, want %d", c.Port, DefaultPort)
|
||||
}
|
||||
|
||||
if c.DialTimeout != DefaultDialTimeout {
|
||||
t.Errorf("DialTimeout = %v, want %v", c.DialTimeout, DefaultDialTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDial_Failure(t *testing.T) {
|
||||
// A reserved-for-test address that nothing should be listening on.
|
||||
c := NewClient("127.0.0.1")
|
||||
c.Port = 1 // privileged port, will not connect from a test
|
||||
c.DialTimeout = 200 * time.Millisecond
|
||||
|
||||
if err := c.Dial(); err == nil {
|
||||
t.Error("expected dial failure, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbe_ReturnsBanner(t *testing.T) {
|
||||
s := newScriptedServer(t, "BoseShell v1\n-> ", nil)
|
||||
defer s.close()
|
||||
|
||||
c := newClientFor(t, s)
|
||||
|
||||
if err := c.Dial(); err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
got, err := c.Probe()
|
||||
if err != nil {
|
||||
t.Fatalf("Probe: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(got, "BoseShell v1") {
|
||||
t.Errorf("Probe = %q, want to contain banner", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbe_NoBannerIsOK(t *testing.T) {
|
||||
s := newScriptedServer(t, "", nil)
|
||||
defer s.close()
|
||||
|
||||
c := newClientFor(t, s)
|
||||
|
||||
if err := c.Dial(); err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
got, err := c.Probe()
|
||||
if err != nil {
|
||||
t.Fatalf("Probe: %v", err)
|
||||
}
|
||||
|
||||
if got != "" {
|
||||
t.Errorf("Probe = %q, want empty when no banner is sent", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendCommand_HappyPath(t *testing.T) {
|
||||
s := newScriptedServer(t, "", map[string]string{
|
||||
"sys configuration bmxRegistryUrl http://example:8000/bmx/registry/v1/services": "OK\n",
|
||||
"sys configuration margeServerUrl http://example:8000": "OK\n",
|
||||
"getpdo CurrentSystemConfiguration": "margeServerUrl=http://example:8000\nbmxRegistryUrl=http://example:8000/bmx/registry/v1/services\n",
|
||||
})
|
||||
defer s.close()
|
||||
|
||||
c := newClientFor(t, s)
|
||||
|
||||
if err := c.Dial(); err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
resp, err := c.SendCommand("sys configuration margeServerUrl http://example:8000")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCommand: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(resp, "OK") {
|
||||
t.Errorf("response = %q, want to contain OK", resp)
|
||||
}
|
||||
|
||||
resp, err = c.SendCommand("getpdo CurrentSystemConfiguration")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCommand getpdo: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(resp, "margeServerUrl=http://example:8000") {
|
||||
t.Errorf("getpdo response = %q, want to echo configured url", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendCommand_CommandNotFound(t *testing.T) {
|
||||
s := newScriptedServer(t, "", map[string]string{})
|
||||
defer s.close()
|
||||
|
||||
c := newClientFor(t, s)
|
||||
|
||||
if err := c.Dial(); err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
resp, err := c.SendCommand("definitely not a real command")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCommand: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(resp, "Command not found") {
|
||||
t.Errorf("response = %q, want to contain 'Command not found'", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendCommand_DeadlineFiresWhenDeviceHangs(t *testing.T) {
|
||||
s := newScriptedServer(t, "", map[string]string{
|
||||
"first": "OK\n",
|
||||
"second": "",
|
||||
})
|
||||
s.hangAfter = "second"
|
||||
|
||||
defer s.close()
|
||||
|
||||
c := newClientFor(t, s)
|
||||
c.ReadTimeout = 600 * time.Millisecond
|
||||
|
||||
if err := c.Dial(); err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
if _, err := c.SendCommand("first"); err != nil {
|
||||
t.Fatalf("first SendCommand: %v", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
_, err := c.SendCommand("second")
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "timed out") {
|
||||
t.Errorf("err = %v, want timed-out wording", err)
|
||||
}
|
||||
|
||||
// The error must arrive within roughly the ReadTimeout, not after several
|
||||
// times that — guards against an accidental infinite read loop.
|
||||
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||
t.Errorf("SendCommand returned after %v, want under 2s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendCommand_ConnectionClosedMidStream(t *testing.T) {
|
||||
s := newScriptedServer(t, "", map[string]string{})
|
||||
s.closeAfter = "trigger close"
|
||||
|
||||
defer s.close()
|
||||
|
||||
c := newClientFor(t, s)
|
||||
|
||||
if err := c.Dial(); err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
_, err := c.SendCommand("trigger close")
|
||||
if err == nil {
|
||||
t.Fatal("expected error after server closes mid-stream, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendCommand_FailsWithoutDial(t *testing.T) {
|
||||
c := NewClient("127.0.0.1")
|
||||
if _, err := c.SendCommand("anything"); err == nil {
|
||||
t.Error("SendCommand without Dial should fail, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClose_IsIdempotent(t *testing.T) {
|
||||
s := newScriptedServer(t, "", nil)
|
||||
defer s.close()
|
||||
|
||||
c := newClientFor(t, s)
|
||||
|
||||
if err := c.Dial(); err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
|
||||
if err := c.Close(); err != nil {
|
||||
t.Errorf("first Close: %v", err)
|
||||
}
|
||||
|
||||
if err := c.Close(); err != nil {
|
||||
t.Errorf("second Close: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# On-Device Installer
|
||||
|
||||
Allows to run AfterTouch on SoundTouch devices directly, eliminating the need to run and maintain a separate server on the local network.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
### Invasiveness
|
||||
|
||||
AfterTouch usually normally migrates the SoundTouch devices very noninvasive, by changing the configuration of the device. Running AfterTouch on the device itself is slightly more invasive, because it needs to create a script that starts AfterTouch on boot.
|
||||
|
||||
### AfterTouch Availability
|
||||
|
||||
Some devices will expose the AfterTouch port, some won't. We currently (May 2026) suspect that the newer generation devices (those with Bluetooth) will expose the port, while the older ones won't. We're still investigating how to expose AfterTouch on all devices.
|
||||
|
||||
If your device doesn't expose the port, you can still use the on-device installer, but you'll need to run AfterTouch on each one of your speakers individually and may only access AfterTouch via ssh port forwarding. This will also make OAuth authentication a little more tricky, but should also work via SSH port forwarding.
|
||||
|
||||
### Space Limitation
|
||||
|
||||
The storage space on the SoundTouch devices is very limited. At the moment only one AfterTouch installation barely fits on them with enough room for the data it needs to maintain. When installing, make sure that you have removed any binaries and folders of previous installation attempts.
|
||||
|
||||
The space limitation also means we are currently unsure on how to update the system, because two binaries are already too large. We are currently working on this - both by checking how we can make the binaries smaller, but also on how we can extend the storage space (e.g. by running AfterTouch from a USB drive).
|
||||
|
||||
## Installation
|
||||
|
||||
Enable SSH on your SoundTouch device using the usual "Stick with remote_services" method. Connect with the following command.
|
||||
|
||||
```bash
|
||||
ssh -oHostKeyAlgorithms=+ssh-rsa root@<IP_ADDRESS_OF_SPEAKER>
|
||||
```
|
||||
|
||||
Then, run the following command to install AfterTouch on the device.
|
||||
|
||||
```bash
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
```
|
||||
|
||||
After the installation check if you can access AfterTouch from your local device by navigating to `http://<IP_ADDRESS_OF_SPEAKER>:8000`. If you can access the AfterTouch UI, you're good to go! If not, you may need to run AfterTouch on the speaker via SSH port forwarding.
|
||||
|
||||
```bash
|
||||
ssh -L 8000:localhost:8000 root@<IP_ADDRESS_OF_SPEAKER>
|
||||
```
|
||||
|
||||
## Updating AfterTouch
|
||||
|
||||
To update AfterTouch, simply run the installation command again. The installer will check if there's a new version available and update it if necessary.
|
||||
|
||||
## Uninstallation
|
||||
|
||||
Before uninstall, you might want to revert the migration, especially the changes to the server URLs (even though having configured an unresponsive local server probably is about as bad as having configured unresponsive Bose servers). To uninstall AfterTouch, run the following command on the speaker.
|
||||
|
||||
```bash
|
||||
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/uninstall.sh | sh
|
||||
```
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/bin/sh
|
||||
### BEGIN INIT INFO
|
||||
# Provides: aftertouch-service
|
||||
# Required-Start: $network $local_fs
|
||||
# Required-Stop: $network $local_fs
|
||||
# Default-Start: 2 3 4 5
|
||||
# Default-Stop: 0 1 6
|
||||
# Short-Description: Run AfterTouch on this device
|
||||
# Description: Start/stop AfterTouch soundtouch-service
|
||||
### END INIT INFO
|
||||
|
||||
|
||||
NAME="aftertouch-service"
|
||||
DESC="Bose AfterTouch service"
|
||||
DAEMON="/opt/aftertouch/aftertouch-service"
|
||||
PIDFILE="/var/run/$NAME.pid"
|
||||
DATADIR="/opt/aftertouch/data"
|
||||
SCRIPTNAME="/etc/init.d/$NAME"
|
||||
USER="root"
|
||||
|
||||
|
||||
# Export PATH
|
||||
export PATH="/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin"
|
||||
|
||||
|
||||
# Sanity check executable
|
||||
test -x "$DAEMON" || {
|
||||
echo "ERROR: Cannot execute $DAEMON (check path and permissions)." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
echo "Starting $DESC..."
|
||||
|
||||
mount -o remount,rw / >/dev/null 2>&1 || {
|
||||
echo "ERROR: remount failed." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
mkdir -p "$DATADIR"
|
||||
|
||||
start-stop-daemon --start \
|
||||
--quiet \
|
||||
--pidfile "$PIDFILE" \
|
||||
--background \
|
||||
--make-pidfile \
|
||||
--chuid "$USER" \
|
||||
--startas "/bin/sh" \
|
||||
-- -c "\"$DAEMON\" --data-dir '$DATADIR' --record-interactions=false --discovery-interval=60m"
|
||||
|
||||
tries=0
|
||||
max_tries=60
|
||||
while [ $tries -lt $max_tries ]; do
|
||||
if curl -fsS http://localhost:8000 >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
tries=$((tries + 1))
|
||||
done
|
||||
|
||||
exit 1
|
||||
;;
|
||||
|
||||
stop)
|
||||
echo "Stopping $DESC..."
|
||||
if [ -f "$PIDFILE" ]; then
|
||||
start-stop-daemon --stop \
|
||||
--quiet \
|
||||
--oknodo \
|
||||
--pidfile "$PIDFILE"
|
||||
rm -f "$PIDFILE"
|
||||
else
|
||||
echo "No $NAME running (no PID file)." >&2
|
||||
fi
|
||||
;;
|
||||
|
||||
restart|force-reload)
|
||||
"$0" stop
|
||||
sleep 2
|
||||
"$0" start
|
||||
;;
|
||||
|
||||
status)
|
||||
if [ -f "$PIDFILE" ]; then
|
||||
PID=$(cat "$PIDFILE")
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
echo "$NAME is running."
|
||||
else
|
||||
echo "$NAME is not running (PID file exists but process is dead)."
|
||||
fi
|
||||
else
|
||||
echo "$NAME is not running."
|
||||
fi
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Usage: $SCRIPTNAME {start|stop|restart|force-reload|status}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
VERSION=${VERSION:-0.74.0}
|
||||
GH_REPO=${GH_REPO:-gesellix/Bose-SoundTouch}
|
||||
BINARY_URL=${BINARY_URL:-https://github.com/$GH_REPO/releases/download/v$VERSION/soundtouch-service-v$VERSION-linux-armv7}
|
||||
INIT_SCRIPT_URL=${INIT_SCRIPT_URL:-https://raw.githubusercontent.com/$GH_REPO/v$VERSION/scripts/on-device-install/aftertouch}
|
||||
UPDATE_TMP_DIR=${UPDATE_TMP_DIR:-/media/aftertouch}
|
||||
|
||||
|
||||
rm -rf "$UPDATE_TMP_DIR" || true
|
||||
mkdir -p "$UPDATE_TMP_DIR"
|
||||
|
||||
echo "Installing Aftertouch $VERSION ..."
|
||||
mkdir -p /opt/aftertouch
|
||||
curl \
|
||||
-sSL \
|
||||
-o "$UPDATE_TMP_DIR/binary" \
|
||||
--fail \
|
||||
"$BINARY_URL"
|
||||
|
||||
mv "$UPDATE_TMP_DIR/binary" /opt/aftertouch/aftertouch-service
|
||||
chmod +x /opt/aftertouch/aftertouch-service
|
||||
|
||||
echo "Creating init script..."
|
||||
curl \
|
||||
-sSL \
|
||||
-o "$UPDATE_TMP_DIR/init-script" \
|
||||
--fail \
|
||||
"$INIT_SCRIPT_URL"
|
||||
|
||||
mv "$UPDATE_TMP_DIR/init-script" /etc/init.d/aftertouch
|
||||
chmod +x /etc/init.d/aftertouch
|
||||
update-rc.d aftertouch defaults
|
||||
|
||||
echo "Installation complete. Running initial startup to accelerate future startups..."
|
||||
/etc/init.d/aftertouch start
|
||||
|
||||
/etc/init.d/aftertouch status
|
||||
|
||||
echo "Installation complete. Aftertouch $VERSION is now running on your device."
|
||||
echo "You can try to connect to at http://<your-device-ip>:8000 ."
|
||||
echo "If the connection fails, reconnect ssh with port forwarding like:"
|
||||
echo "ssh -L 8000:localhost:8000 root@<IP_ADDRESS_OF_SPEAKER>"
|
||||
@@ -0,0 +1,4 @@
|
||||
/etc/init.d/aftertouch stop
|
||||
rm -rf /etc/init.d/aftertouch
|
||||
update-rc.d -f aftertouch remove
|
||||
rm -rf /opt/aftertouch
|
||||
@@ -28,7 +28,7 @@ set -euo pipefail
|
||||
# - Safe to re-run; it will update binary/config/unit and restart the service.
|
||||
# ==============================================================================
|
||||
|
||||
VERSION="${1:-${VERSION:-v0.24.0}}"
|
||||
VERSION="${1:-${VERSION:-v0.74.0}}"
|
||||
# Normalize version prefix
|
||||
if [[ ! "$VERSION" =~ ^v ]]; then
|
||||
VERSION="v${VERSION}"
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
// Command screenshots drives a headless Chrome via chromedp to capture
|
||||
// PNG screenshots of the soundtouch-service web UI for documentation.
|
||||
//
|
||||
// It is deliberately decoupled from any speaker/service setup: callers
|
||||
// are responsible for having the service reachable at --base and any
|
||||
// required devices already registered. See cmd/dummy-speaker for a
|
||||
// matching no-hardware backend.
|
||||
//
|
||||
// Manifest format (JSON):
|
||||
//
|
||||
// {
|
||||
// "shots": [
|
||||
// {
|
||||
// "name": "ui-settings",
|
||||
// "path": "/web/",
|
||||
// "click_selector": "button[onclick*=\"tab-settings\"]",
|
||||
// "wait_selector": "#tab-settings.active",
|
||||
// "viewport": {"width": 1280, "height": 900},
|
||||
// "settle_ms": 250
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
)
|
||||
|
||||
type viewport struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Scale float64 `json:"scale"`
|
||||
}
|
||||
|
||||
type shot struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
ClickSelector string `json:"click_selector,omitempty"`
|
||||
WaitSelector string `json:"wait_selector,omitempty"`
|
||||
Evaluate string `json:"evaluate,omitempty"` // JS to run after the click (e.g. to programmatically select a device + trigger summary)
|
||||
WaitAfterEval string `json:"wait_after_eval,omitempty"` // selector to wait for once the JS evaluation has completed
|
||||
Viewport viewport `json:"viewport,omitempty"`
|
||||
SettleMs int `json:"settle_ms,omitempty"`
|
||||
}
|
||||
|
||||
type manifest struct {
|
||||
Shots []shot `json:"shots"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
base := flag.String("base", "http://localhost:8000", "service base URL")
|
||||
manifestPath := flag.String("manifest", "scripts/screenshots/manifest.json", "path to shot manifest JSON")
|
||||
outDir := flag.String("out", "docs/images", "output directory for PNGs")
|
||||
timeoutSec := flag.Int("timeout", 30, "per-shot timeout (seconds)")
|
||||
flag.Parse()
|
||||
|
||||
m, err := readManifest(*manifestPath)
|
||||
if err != nil {
|
||||
log.Fatalf("read manifest: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(*outDir, 0o755); err != nil {
|
||||
log.Fatalf("mkdir %s: %v", *outDir, err)
|
||||
}
|
||||
|
||||
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(),
|
||||
append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", true),
|
||||
chromedp.Flag("disable-gpu", true),
|
||||
chromedp.Flag("hide-scrollbars", true),
|
||||
)...)
|
||||
defer cancelAlloc()
|
||||
|
||||
browserCtx, cancelBrowser := chromedp.NewContext(allocCtx)
|
||||
defer cancelBrowser()
|
||||
|
||||
if err := chromedp.Run(browserCtx); err != nil {
|
||||
log.Fatalf("launch browser: %v", err)
|
||||
}
|
||||
|
||||
failed := 0
|
||||
|
||||
for _, sh := range m.Shots {
|
||||
log.Printf("capturing %s", sh.Name)
|
||||
|
||||
if err := capture(browserCtx, *base, *outDir, sh, time.Duration(*timeoutSec)*time.Second); err != nil {
|
||||
log.Printf(" failed: %v", err)
|
||||
|
||||
failed++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf(" ok")
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
log.Fatalf("%d shot(s) failed", failed)
|
||||
}
|
||||
}
|
||||
|
||||
func readManifest(path string) (*manifest, error) {
|
||||
raw, err := os.ReadFile(path) //nolint:gosec
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func capture(parent context.Context, baseURL, outDir string, sh shot, timeout time.Duration) error {
|
||||
ctx, cancel := context.WithTimeout(parent, timeout)
|
||||
defer cancel()
|
||||
|
||||
w, h := sh.Viewport.Width, sh.Viewport.Height
|
||||
if w == 0 {
|
||||
w = 1280
|
||||
}
|
||||
|
||||
if h == 0 {
|
||||
h = 900
|
||||
}
|
||||
|
||||
scale := sh.Viewport.Scale
|
||||
if scale == 0 {
|
||||
scale = 2 // retina-equivalent DPR; sharper text in captured PNGs
|
||||
}
|
||||
|
||||
settle := time.Duration(sh.SettleMs) * time.Millisecond
|
||||
if settle == 0 {
|
||||
settle = 200 * time.Millisecond
|
||||
}
|
||||
|
||||
tabCtx, tabCancel := chromedp.NewContext(ctx)
|
||||
defer tabCancel()
|
||||
|
||||
url := baseURL + sh.Path
|
||||
|
||||
actions := []chromedp.Action{
|
||||
chromedp.EmulateViewport(int64(w), int64(h), chromedp.EmulateScale(scale)),
|
||||
chromedp.Navigate(url),
|
||||
chromedp.WaitReady("body", chromedp.ByQuery),
|
||||
}
|
||||
|
||||
if sh.ClickSelector != "" {
|
||||
actions = append(actions,
|
||||
chromedp.WaitVisible(sh.ClickSelector, chromedp.ByQuery),
|
||||
chromedp.Click(sh.ClickSelector, chromedp.ByQuery),
|
||||
)
|
||||
}
|
||||
|
||||
if sh.WaitSelector != "" {
|
||||
actions = append(actions, chromedp.WaitVisible(sh.WaitSelector, chromedp.ByQuery))
|
||||
}
|
||||
|
||||
if sh.Evaluate != "" {
|
||||
actions = append(actions, chromedp.Evaluate(sh.Evaluate, nil))
|
||||
}
|
||||
|
||||
if sh.WaitAfterEval != "" {
|
||||
actions = append(actions, chromedp.WaitVisible(sh.WaitAfterEval, chromedp.ByQuery))
|
||||
}
|
||||
|
||||
actions = append(actions, chromedp.Sleep(settle))
|
||||
|
||||
var buf []byte
|
||||
|
||||
actions = append(actions, chromedp.FullScreenshot(&buf, 100))
|
||||
|
||||
if err := chromedp.Run(tabCtx, actions...); err != nil {
|
||||
return fmt.Errorf("chromedp: %w", err)
|
||||
}
|
||||
|
||||
outPath := filepath.Join(outDir, sh.Name+".png")
|
||||
if err := os.WriteFile(outPath, buf, 0o644); err != nil { //nolint:gosec
|
||||
return fmt.Errorf("write %s: %w", outPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"shots": [
|
||||
{
|
||||
"name": "ui-settings",
|
||||
"path": "/",
|
||||
"click_selector": "button[onclick*=\"tab-settings\"]",
|
||||
"wait_selector": "#tab-settings.active",
|
||||
"settle_ms": 300
|
||||
},
|
||||
{
|
||||
"name": "ui-devices",
|
||||
"path": "/",
|
||||
"click_selector": "button[onclick*=\"tab-devices\"]",
|
||||
"wait_selector": "#tab-devices.active",
|
||||
"settle_ms": 500
|
||||
},
|
||||
{
|
||||
"name": "ui-sync",
|
||||
"path": "/",
|
||||
"click_selector": "button[onclick*=\"tab-sync\"]",
|
||||
"wait_selector": "#tab-sync.active",
|
||||
"settle_ms": 300
|
||||
},
|
||||
{
|
||||
"name": "ui-migration",
|
||||
"path": "/",
|
||||
"click_selector": "button[onclick*=\"tab-migration\"]",
|
||||
"wait_selector": "#tab-migration.active",
|
||||
"evaluate": "prepareMigration('DEADBEEFCAFE')",
|
||||
"wait_after_eval": "#migration-summary",
|
||||
"settle_ms": 4000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrates an end-to-end screenshot capture: spins up a clean
|
||||
# soundtouch-service + dummy-speaker, drives the web UI in headless
|
||||
# Chrome via the chromedp runner, then tears everything down.
|
||||
#
|
||||
# Outputs to docs/images/ by default. Override with OUT_DIR=/some/path.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
OUT_DIR="${OUT_DIR:-docs/images}"
|
||||
SERVICE_PORT="${SERVICE_PORT:-8000}"
|
||||
SPEAKER_PORT="${SPEAKER_PORT:-8090}"
|
||||
DATA_DIR="$(mktemp -d -t soundtouch-screenshots-XXXXXX)"
|
||||
LOG_DIR="$(mktemp -d -t soundtouch-screenshot-logs-XXXXXX)"
|
||||
|
||||
SERVICE_PID=""
|
||||
SPEAKER_PID=""
|
||||
|
||||
cleanup() {
|
||||
set +e
|
||||
if [ -n "$SPEAKER_PID" ] && kill -0 "$SPEAKER_PID" 2>/dev/null; then
|
||||
kill "$SPEAKER_PID"
|
||||
wait "$SPEAKER_PID" 2>/dev/null
|
||||
fi
|
||||
if [ -n "$SERVICE_PID" ] && kill -0 "$SERVICE_PID" 2>/dev/null; then
|
||||
kill "$SERVICE_PID"
|
||||
wait "$SERVICE_PID" 2>/dev/null
|
||||
fi
|
||||
rm -rf "$DATA_DIR"
|
||||
echo "logs retained at $LOG_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "==> building binaries"
|
||||
go build -o "$LOG_DIR/soundtouch-service" ./cmd/soundtouch-service
|
||||
go build -o "$LOG_DIR/dummy-speaker" ./cmd/dummy-speaker
|
||||
go build -o "$LOG_DIR/screenshots" ./scripts/screenshots
|
||||
|
||||
echo "==> seeding settings.json (generic hostname + discovery off to avoid leaking real network info)"
|
||||
cat > "$DATA_DIR/settings.json" <<'EOF'
|
||||
{
|
||||
"server_url": "http://aftertouch.local:8000",
|
||||
"https_server_url": "https://aftertouch.local:8443",
|
||||
"discovery_enabled": false,
|
||||
"discovery_interval": "1h"
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "==> starting soundtouch-service on :$SERVICE_PORT (data: $DATA_DIR)"
|
||||
"$LOG_DIR/soundtouch-service" --port "$SERVICE_PORT" --data-dir "$DATA_DIR" \
|
||||
> "$LOG_DIR/service.log" 2>&1 &
|
||||
SERVICE_PID=$!
|
||||
|
||||
echo "==> waiting for service to be ready"
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsS "http://127.0.0.1:$SERVICE_PORT/setup/devices" > /dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$SERVICE_PID" 2>/dev/null; then
|
||||
echo "service died early; log tail:"
|
||||
tail -40 "$LOG_DIR/service.log"
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
echo "==> starting dummy-speaker on :$SPEAKER_PORT (registering with service)"
|
||||
# Register as bare IP (no port) so the service appends :8090 for HTTP and
|
||||
# :17000 for telnet exactly the way it does with real hardware. This is
|
||||
# also why the listeners below bind to the canonical Bose ports.
|
||||
"$LOG_DIR/dummy-speaker" \
|
||||
--listen "127.0.0.1:$SPEAKER_PORT" \
|
||||
--telnet-listen "127.0.0.1:17000" \
|
||||
--register "http://127.0.0.1:$SERVICE_PORT" \
|
||||
--register-as "127.0.0.1" \
|
||||
> "$LOG_DIR/speaker.log" 2>&1 &
|
||||
SPEAKER_PID=$!
|
||||
sleep 1
|
||||
|
||||
if ! kill -0 "$SPEAKER_PID" 2>/dev/null; then
|
||||
echo "dummy-speaker died early; log tail:"
|
||||
tail -40 "$LOG_DIR/speaker.log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> capturing screenshots into $OUT_DIR"
|
||||
"$LOG_DIR/screenshots" \
|
||||
--base "http://127.0.0.1:$SERVICE_PORT" \
|
||||
--manifest scripts/screenshots/manifest.json \
|
||||
--out "$OUT_DIR"
|
||||
|
||||
echo "==> done"
|
||||