mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
Compare commits
@@ -86,7 +86,7 @@ body:
|
||||
attributes:
|
||||
label: AfterTouch version
|
||||
description: Shown in the admin UI footer, or via the binary's `--version`.
|
||||
placeholder: "v0.111.2"
|
||||
placeholder: "v0.123.0"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ jobs:
|
||||
- name: Check documentation links
|
||||
run: |
|
||||
npm install -g markdown-link-check
|
||||
find . -name "*.md" -not -path "./tests/*" -not -path "./node_modules/*" -print0 | xargs -0 -n1 markdown-link-check -q -v -c .github/markdown-link-check.json
|
||||
./scripts/check-doc-links.sh
|
||||
|
||||
- name: Warn on pending images
|
||||
run: |
|
||||
@@ -308,7 +308,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Set build date
|
||||
id: build_date
|
||||
@@ -330,7 +330,7 @@ jobs:
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: steps.push-check.outputs.should-push == 'true'
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -51,6 +51,6 @@ jobs:
|
||||
run: go build ./...
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -346,6 +346,14 @@ jobs:
|
||||
TAG_NAME="${{ needs.validate.outputs.tag }}"
|
||||
VERSION="${TAG_NAME#v}"
|
||||
|
||||
# Real per-platform links for the two most-used tools, generated
|
||||
# from the deterministic `<binary>-<tag>-<os>-<arch>[.exe]` asset
|
||||
# naming convention (see scripts/release/quick-downloads.sh),
|
||||
# instead of requiring a scroll through the flat, alphabetical
|
||||
# Assets list. Inline checksum link per row (à la Helm's release
|
||||
# notes) instead of sending people to the combined checksums file.
|
||||
QUICK_DOWNLOADS="$(scripts/release/quick-downloads.sh "$TAG_NAME" "${{ github.repository }}")"
|
||||
|
||||
# Short, accurate header. GitHub's auto-generated "What's Changed"
|
||||
# + "Full Changelog" are appended after this (generate_release_notes).
|
||||
cat > release_notes.md << EOF
|
||||
@@ -353,13 +361,15 @@ jobs:
|
||||
|
||||
**Bose SoundTouch Toolkit.** Keep your Bose SoundTouch speakers alive after the Bose cloud shutdown. No Bose infrastructure required.
|
||||
|
||||
$QUICK_DOWNLOADS
|
||||
|
||||
## What's included
|
||||
|
||||
Pre-built binaries for Linux (amd64, arm64, armv7), macOS (Intel & Apple Silicon), Windows (amd64), and FreeBSD (amd64):
|
||||
|
||||
- **soundtouch-service**: local server that replaces the Bose cloud. Point your speaker at it and you keep full control; the built-in web UI on port 8000 handles setup.
|
||||
- **soundtouch-service** (see above)
|
||||
- **soundtouch-cli** (see above)
|
||||
- **soundtouch-player**: standalone LAN web UI for device control: play/pause, volume, presets, live status. (Formerly \`soundtouch-web\`.)
|
||||
- **soundtouch-cli**: command-line control of any device: playback, presets, sources, multiroom zones, discovery, and migration. Good for scripting and home automation.
|
||||
- **soundtouch-backup**: back up your Bose cloud account and each speaker's local state. \`soundtouch-backup all\` captures everything in one step.
|
||||
|
||||
Not sure which file to grab? The [Downloads page](https://gesellix.github.io/Bose-SoundTouch/docs/downloads/) explains which tool you need and which \`<os>-<arch>\` build matches your computer.
|
||||
@@ -414,12 +424,62 @@ jobs:
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
|
||||
- name: Download release assets
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-assets
|
||||
path: ./release-assets
|
||||
|
||||
- name: Upgrade the Downloads footer with direct per-platform links
|
||||
# This is the path real releases take: a maintainer hand-writes
|
||||
# "Noteworthy" notes and publishes via the GitHub web UI, which
|
||||
# fires this job, not create_release (workflow_dispatch only).
|
||||
# _/releases/_TEMPLATE.md's convention is a trailing footer line:
|
||||
# ---
|
||||
# 📦 **Downloads / installation:** <downloads page URL>
|
||||
# Drop that line (if present) and append the quick-downloads
|
||||
# block in its place. Always goes through the same append path
|
||||
# (strip block + strip footer + append), whether or not a
|
||||
# footer line is still there, so re-runs stay byte-for-byte
|
||||
# idempotent instead of drifting on the 2nd run.
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG_NAME="${{ needs.validate.outputs.tag }}"
|
||||
|
||||
scripts/release/quick-downloads.sh "$TAG_NAME" "${{ github.repository }}" > quick_downloads.md
|
||||
gh release view "$TAG_NAME" --json body -q .body > existing_body.md
|
||||
|
||||
python3 - << 'PYEOF'
|
||||
import re
|
||||
|
||||
with open("existing_body.md") as f:
|
||||
body = f.read()
|
||||
with open("quick_downloads.md") as f:
|
||||
block = f.read().rstrip("\n")
|
||||
|
||||
# Drop a block this automation inserted on a previous run.
|
||||
body = re.sub(r"\n*<!-- quick-downloads:start -->.*?<!-- quick-downloads:end -->\n*", "\n", body, flags=re.DOTALL)
|
||||
|
||||
# Drop the hand-authored footer line (first run only) so both
|
||||
# cases converge on the same append below and re-runs stay
|
||||
# byte-for-byte idempotent.
|
||||
footer = re.compile(r"^📦 \*\*Downloads / installation:\*\*.*\n?", re.MULTILINE)
|
||||
body = footer.sub("", body, count=1)
|
||||
|
||||
body = body.rstrip("\n") + "\n\n" + block + "\n"
|
||||
|
||||
with open("combined_notes.md", "w") as f:
|
||||
f.write(body)
|
||||
PYEOF
|
||||
|
||||
gh release edit "$TAG_NAME" --notes-file combined_notes.md
|
||||
|
||||
- name: Upload additional assets to existing release
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
with:
|
||||
@@ -454,10 +514,10 @@ jobs:
|
||||
echo "commit=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
|
||||
- name: Upload Semgrep SARIF results
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
sarif_file: semgrep.sarif
|
||||
continue-on-error: true
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# golangci-lint configuration for Bose SoundTouch Go Library
|
||||
# Compatible with golangci-lint v2.8.0
|
||||
# Compatible with golangci-lint v2.13.1
|
||||
# See: https://golangci-lint.run/usage/configuration/
|
||||
|
||||
version: "2"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.5-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.27.0-alpine AS builder
|
||||
|
||||
# Declare automatic platform ARGs to make them available in build stage
|
||||
# See https://docs.docker.com/reference/dockerfile#automatic-platform-args-in-the-global-scope
|
||||
|
||||
@@ -243,7 +243,7 @@ vet:
|
||||
|
||||
lint:
|
||||
@echo "Running golangci-lint..."
|
||||
@which golangci-lint > /dev/null || (echo "golangci-lint not found. Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest" && exit 1)
|
||||
@which golangci-lint > /dev/null || (echo "golangci-lint not found. Install with: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest" && exit 1)
|
||||
golangci-lint run
|
||||
|
||||
tidy:
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<p style="margin-top: -10px; font-style: italic; color: #666;">Bose SoundTouch Toolkit</p>
|
||||
|
||||
[](https://pkg.go.dev/github.com/gesellix/bose-soundtouch)
|
||||
[](https://goreportcard.com/report/github.com/gesellix/bose-soundtouch)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
> Independent project. **Not affiliated with, endorsed by, sponsored
|
||||
@@ -113,6 +112,7 @@ See the [API Reference](https://gesellix.github.io/Bose-SoundTouch/docs/referenc
|
||||
- **[SoundTouch Plus](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus)** (Todd Lucas) — Home Assistant integration; extensive undocumented API documentation
|
||||
- **[ÜberBöse API](https://github.com/julius-d/ueberboese-api)** (Julius) — API research and advanced endpoint discovery
|
||||
- **[Bose SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook)** (Adrian Böckenkamp) — `LD_PRELOAD` hooking for reverse engineering device internals
|
||||
- **[STR, SoundTouch Reborn](https://github.com/JRpersonal/streborn)** ([st-reborn.de](https://st-reborn.de)) — on-device agent plus desktop app; its published `iptables` REDIRECT technique is what makes AfterTouch's on-device install reachable over the LAN on co-processor chassis (see [Model Support Matrix](https://gesellix.github.io/Bose-SoundTouch/docs/reference/MODEL-SUPPORT-MATRIX/))
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// updateCheckRepo is the GitHub repo checked for newer releases, matching
|
||||
// soundtouch-service's periodic background check (#591,
|
||||
// _/i591/design-update-check.md).
|
||||
const updateCheckRepo = "gesellix/Bose-SoundTouch"
|
||||
|
||||
// updateCheckCommand assembles the on-demand `soundtouch-backup
|
||||
// update-check` command, the CLI-side answer to that design doc's open
|
||||
// question 2 (CLI-only users get no update notice from the service's
|
||||
// background checker). Unlike the service's opt-in periodic check, running
|
||||
// this command *is* the opt-in: no config flag, no persisted state, just
|
||||
// one GitHub API request each time it's invoked.
|
||||
func updateCheckCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "update-check",
|
||||
Usage: "Check GitHub for a newer soundtouch-backup release",
|
||||
Action: runUpdateCheck,
|
||||
}
|
||||
}
|
||||
|
||||
func runUpdateCheck(c *cli.Context) error {
|
||||
checker := updatecheck.NewChecker(nil, updateCheckRepo, version)
|
||||
|
||||
result, err := checker.CheckNow(c.Context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update check failed: %w", err)
|
||||
}
|
||||
|
||||
printUpdateCheckResult(result)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printUpdateCheckResult(result updatecheck.Result) {
|
||||
if result.LatestVersion == "" {
|
||||
fmt.Printf("Running %s, not a released version, skipping comparison.\n", result.CurrentVersion)
|
||||
return
|
||||
}
|
||||
|
||||
if result.Available {
|
||||
fmt.Printf("A newer version is available: %s (you're on %s)\n", result.LatestVersion, result.CurrentVersion)
|
||||
fmt.Println(result.ReleaseURL)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("You're on the latest version (%s).\n", result.CurrentVersion)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
)
|
||||
|
||||
// TestUpdateCheckCommand_Registered checks the command is wired up with the
|
||||
// expected name and an Action, without making any real GitHub API calls.
|
||||
func TestUpdateCheckCommand_Registered(t *testing.T) {
|
||||
cmd := updateCheckCommand()
|
||||
|
||||
if cmd.Name != "update-check" {
|
||||
t.Errorf("command name = %q; want %q", cmd.Name, "update-check")
|
||||
}
|
||||
|
||||
if cmd.Action == nil {
|
||||
t.Error("expected an Action to be set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintUpdateCheckResult_DoesNotPanic exercises all three result shapes
|
||||
// (unparseable current version, update available, up to date) purely for
|
||||
// the "does not panic" guarantee; updatecheck.Checker's own tests already
|
||||
// cover the comparison logic itself.
|
||||
func TestPrintUpdateCheckResult_DoesNotPanic(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
result updatecheck.Result
|
||||
}{
|
||||
{"unparseable current version", updatecheck.Result{CurrentVersion: "dev"}},
|
||||
{"update available", updatecheck.Result{CurrentVersion: "v1.0.0", LatestVersion: "v1.1.0", Available: true, ReleaseURL: "https://example.invalid"}},
|
||||
{"up to date", updatecheck.Result{CurrentVersion: "v1.1.0", LatestVersion: "v1.1.0", Available: false}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
printUpdateCheckResult(tc.result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ func main() {
|
||||
allCommand(),
|
||||
cloudCommand(),
|
||||
localCommand(),
|
||||
updateCheckCommand(),
|
||||
},
|
||||
}
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
|
||||
+259
-13
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/urfave/cli/v2"
|
||||
"golang.org/x/term"
|
||||
@@ -52,10 +53,12 @@ func setupCommand() *cli.Command {
|
||||
setupRemoteServicesCmd(),
|
||||
setupInstallCACmd(),
|
||||
setupMigrateCmd(),
|
||||
setupRevertCmd(),
|
||||
setupRebootCmd(),
|
||||
setupVerifyCmd(),
|
||||
setupPlanCmd(),
|
||||
setupPairCmd(),
|
||||
setupSyncCmd(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -517,8 +520,13 @@ func setupSSHCheckCmd() *cli.Command {
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("port 22 not reachable: %v", err))
|
||||
fmt.Println()
|
||||
fmt.Println("Modern SoundTouch firmware (27.x) does not let us enable SSH from")
|
||||
fmt.Println("telnet — those commands were removed. To enable SSH on the speaker:")
|
||||
fmt.Println("Try enabling it over telnet first — this works on many (not all) FW 27.x")
|
||||
fmt.Println("speakers via the port-17000 envswitch trick (#471):")
|
||||
fmt.Println(" soundtouch-cli setup enable-ssh")
|
||||
fmt.Println("For stubborn devices (ST Portable, CineMate 520) where the default")
|
||||
fmt.Println("injection is accepted but sshd never starts, add --full-config.")
|
||||
fmt.Println()
|
||||
fmt.Println("If enable-ssh doesn't work on this device, fall back to the USB-stick method:")
|
||||
fmt.Println(" 1. Format a FAT32 USB stick.")
|
||||
fmt.Println(" 2. Create an empty file named `remote_services` at its root.")
|
||||
fmt.Println(" 3. Plug the stick into the speaker (rear USB port) while it is on.")
|
||||
@@ -541,17 +549,23 @@ func setupSSHCheckCmd() *cli.Command {
|
||||
// runEnableSSHInjection runs the port-17000 SSH-enable injection over telnet,
|
||||
// printing the device transcript as it goes. With fullConfig it sends the
|
||||
// #515 sequence (all four config URLs with the injection on margeServerUrl, not
|
||||
// just envswitch) and reboots afterwards; otherwise it sends the single-
|
||||
// envswitch default that fires on the speaker's next boseurls check.
|
||||
func runEnableSSHInjection(m *setup.Manager, host, serviceURL string, fullConfig bool) error {
|
||||
// just envswitch), pausing commandDelay between each of the 6 steps (5
|
||||
// commands + reboot) — see setup.DefaultTelnetCommandDelay for why the pause
|
||||
// exists — then reboots; otherwise it sends the single-envswitch default that
|
||||
// fires on the speaker's next boseurls check (no pause needed, it's one
|
||||
// command).
|
||||
func runEnableSSHInjection(m *setup.Manager, host, serviceURL string, fullConfig bool, commandDelay time.Duration) error {
|
||||
var (
|
||||
logs string
|
||||
err error
|
||||
)
|
||||
|
||||
if fullConfig {
|
||||
fmt.Printf("Enabling SSH on %s via telnet :17000 (full #515 sequence: all four config URLs with the injection on margeServerUrl, then reboot)...\n", host)
|
||||
logs, err = m.EnableSSHViaTelnetFullConfig(host, serviceURL)
|
||||
// 6 steps total (5 commands + reboot), so 6 gaps between/around them.
|
||||
fmt.Printf("Enabling SSH on %s via telnet :17000 (full #515 sequence: all four config URLs with "+
|
||||
"the injection on margeServerUrl, %s between each of 6 steps — about %s before the reboot fires "+
|
||||
"— then reboot)...\n", host, commandDelay, 6*commandDelay)
|
||||
logs, err = m.EnableSSHViaTelnetFullConfig(host, serviceURL, commandDelay)
|
||||
} else {
|
||||
fmt.Printf("Enabling SSH on %s via telnet :17000 (runs on the speaker's next boseurls check, up to ~60s)...\n", host)
|
||||
logs, err = m.EnableSSHViaTelnet(host, serviceURL)
|
||||
@@ -570,6 +584,10 @@ func runEnableSSHInjection(m *setup.Manager, host, serviceURL string, fullConfig
|
||||
return nil
|
||||
}
|
||||
|
||||
if commandDelay > 0 {
|
||||
time.Sleep(commandDelay)
|
||||
}
|
||||
|
||||
fmt.Println("Rebooting the speaker to apply the new configuration...")
|
||||
|
||||
rlogs, rerr := m.Reboot(host, setup.RebootMethodTelnet)
|
||||
@@ -585,6 +603,41 @@ func runEnableSSHInjection(m *setup.Manager, host, serviceURL string, fullConfig
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureMargeAccountPaired checks /info and pairs an unpaired device before
|
||||
// the SSH-enable injection runs — see setup.EnsureMargeAccountPaired for why.
|
||||
// Pairing failure is logged as a warning, not fatal: the claim that an
|
||||
// unpaired device never polls margeServerUrl is not yet confirmed on every
|
||||
// device this command targets, so the injection is still worth attempting
|
||||
// even if the pairing step itself couldn't be verified.
|
||||
func ensureMargeAccountPaired(m *setup.Manager, deviceIP, wantAccountID string) {
|
||||
var t setup.TelnetClient
|
||||
|
||||
if m.NewTelnet != nil {
|
||||
t = m.NewTelnet(deviceIP)
|
||||
|
||||
if dialErr := t.Dial(); dialErr != nil {
|
||||
t = nil
|
||||
} else {
|
||||
defer func() { _ = t.Close() }()
|
||||
}
|
||||
}
|
||||
|
||||
accountID, alreadyPaired, logs, err := m.EnsureMargeAccountPaired(deviceIP, wantAccountID, t)
|
||||
if logs != "" {
|
||||
fmt.Print(logs)
|
||||
}
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
PrintWarning(fmt.Sprintf("Pairing check failed (%v) — continuing anyway; the SSH-enable injection may not "+
|
||||
"fire on an unpaired device (#515).", err))
|
||||
case alreadyPaired:
|
||||
fmt.Printf("Device already paired (margeAccountUUID=%s).\n", accountID)
|
||||
default:
|
||||
fmt.Printf("Device was unpaired — paired it with generated account %s so margeServerUrl gets polled (#515).\n", accountID)
|
||||
}
|
||||
}
|
||||
|
||||
func setupEnableSSHCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "enable-ssh",
|
||||
@@ -608,6 +661,24 @@ func setupEnableSSHCmd() *cli.Command {
|
||||
Usage: "For stubborn devices (ST Portable, CineMate 520) where the default single-envswitch injection is accepted but sshd never starts: " +
|
||||
"replicate the #515 manual sequence — write all four sys configuration URL keys with the SSH-enable injection on margeServerUrl (not just envswitch), then reboot",
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "command-delay",
|
||||
Value: setup.DefaultTelnetCommandDelay,
|
||||
Usage: "Only affects --full-config: pause between each of its 6 steps (5 commands + reboot). " +
|
||||
"Raise this if the default doesn't work on your device; 0 sends everything back-to-back",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-auto-pair",
|
||||
Usage: "Skip the automatic pairing check: by default, enable-ssh reads /info first and pairs an unpaired " +
|
||||
"(factory-reset) device with an account ID, since an unpaired device reportedly never " +
|
||||
"polls margeServerUrl at all (#515) — the injection would have nothing to fire on otherwise",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "account",
|
||||
Usage: "Only used when the device is unpaired and --no-auto-pair is not set: account ID to pair with " +
|
||||
"(empty = generate a fresh 7-digit one). Use this if you already know which account this device " +
|
||||
"should end up on (e.g. to match one already in the datastore) rather than getting a random one now",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-reset-urls",
|
||||
Usage: "Skip restoring clean boseurls after SSH is up (leaves the injected marge URL in place)",
|
||||
@@ -641,7 +712,11 @@ func setupEnableSSHCmd() *cli.Command {
|
||||
serviceURL = "https://aftertouch.invalid"
|
||||
}
|
||||
|
||||
if err := runEnableSSHInjection(m, cfg.Host, serviceURL, c.Bool("full-config")); err != nil {
|
||||
if !c.Bool("no-auto-pair") {
|
||||
ensureMargeAccountPaired(m, cfg.Host, c.String("account"))
|
||||
}
|
||||
|
||||
if err := runEnableSSHInjection(m, cfg.Host, serviceURL, c.Bool("full-config"), c.Duration("command-delay")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -941,6 +1016,116 @@ func promptBasicAuth() (string, string, error) {
|
||||
return user, string(pass), nil
|
||||
}
|
||||
|
||||
// setupSyncCmd wraps POST /api/setup/sync/{deviceId} — the same operation
|
||||
// as the web UI's Devices → Sync Data button. It only reads from the
|
||||
// speaker (presets, recents, sources) into AfterTouch's datastore; it never
|
||||
// writes anything back to the speaker. Useful for scripting or reproducing
|
||||
// what Sync does in isolation (see issue #614: Sync's own code cannot wipe
|
||||
// the speaker's preset table, since it never sends anything back).
|
||||
func setupSyncCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "sync",
|
||||
Usage: "Pull presets/recents/sources from the speaker into AfterTouch's datastore (same as the web UI's \"Sync Data\" button)",
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "service-url", Required: true, Usage: "AfterTouch base URL"},
|
||||
&cli.StringFlag{Name: "auth", Usage: "Basic-auth credentials for AfterTouch as user:pass (omit to be prompted on 401)"},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
cfg := GetClientConfig(c)
|
||||
serviceURL := strings.TrimRight(c.String("service-url"), "/")
|
||||
|
||||
if err := validateServiceURL(serviceURL); err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := CreateSoundTouchClient(cfg)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
deviceInfo, err := client.GetDeviceInfo()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to get device info from speaker: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if deviceInfo.DeviceID == "" {
|
||||
err := fmt.Errorf("speaker at %s did not report a DeviceID", cfg.Host)
|
||||
PrintError(err.Error())
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Syncing %s into AfterTouch", deviceInfo.DeviceID), cfg.Host, cfg.Port)
|
||||
|
||||
if err := postSetupSync(serviceURL, deviceInfo.DeviceID, c.String("auth")); err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Synced presets, recents, and sources for %s.", deviceInfo.DeviceID))
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// postSetupSync POSTs to AfterTouch's /api/setup/sync/{deviceId}, prompting
|
||||
// for basic-auth credentials on 401 (matches fetchCACert's pattern).
|
||||
func postSetupSync(serviceURL, deviceID, authFlag string) error {
|
||||
endpoint := fmt.Sprintf("%s/api/setup/sync/%s", serviceURL, deviceID)
|
||||
|
||||
doRequest := func(user, pass string) (*http.Response, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
user, pass := splitAuth(authFlag)
|
||||
|
||||
resp, err := doRequest(user, pass)
|
||||
if err != nil {
|
||||
return fmt.Errorf("POST %s: %w", endpoint, err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
_ = resp.Body.Close()
|
||||
|
||||
fmt.Printf("%s requires basic auth.\n", endpoint)
|
||||
|
||||
user, pass, err = promptBasicAuth()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err = doRequest(user, pass)
|
||||
if err != nil {
|
||||
return fmt.Errorf("POST %s (with auth): %w", endpoint, err)
|
||||
}
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("POST %s returned %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupMigrateCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "migrate",
|
||||
@@ -951,6 +1136,10 @@ func setupMigrateCmd() *cli.Command {
|
||||
&cli.StringFlag{Name: "method", Value: string(setup.MigrationMethodTelnet), Usage: "telnet | hosts | resolv | xml"},
|
||||
&cli.StringFlag{Name: "proxy-url", Usage: "Optional upstream proxy URL (for --method=xml)"},
|
||||
&cli.BoolFlag{Name: "skip-preflight", Usage: "Skip the AfterTouch settings preflight (use when AfterTouch's settings endpoint is unreachable)"},
|
||||
&cli.StringFlag{Name: "marge-url", Usage: "Override margeServerUrl instead of deriving it from --service-url (e.g. to restore the original Bose cloud URL). Applies to --method=telnet and --method=xml"},
|
||||
&cli.StringFlag{Name: "stats-url", Usage: "Override statsServerUrl (telnet/xml)"},
|
||||
&cli.StringFlag{Name: "sw-update-url", Usage: "Override swUpdateUrl (telnet/xml)"},
|
||||
&cli.StringFlag{Name: "bmx-url", Usage: "Override bmxRegistryUrl (telnet/xml)"},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
cfg := GetClientConfig(c)
|
||||
@@ -962,6 +1151,13 @@ func setupMigrateCmd() *cli.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
options := map[string]string{
|
||||
"marge_url": c.String("marge-url"),
|
||||
"stats_url": c.String("stats-url"),
|
||||
"sw_update_url": c.String("sw-update-url"),
|
||||
"bmx_url": c.String("bmx-url"),
|
||||
}
|
||||
|
||||
m := setup.NewManager(serviceURL, nil, nil)
|
||||
|
||||
// For DNS-redirect methods check that AfterTouch's DNS listener
|
||||
@@ -985,7 +1181,7 @@ func setupMigrateCmd() *cli.Command {
|
||||
|
||||
fmt.Printf("Migrating %s → %s using method=%s\n", cfg.Host, serviceURL, method)
|
||||
|
||||
logs, err := m.MigrateSpeaker(cfg.Host, serviceURL, c.String("proxy-url"), nil, method)
|
||||
logs, err := m.MigrateSpeaker(cfg.Host, serviceURL, c.String("proxy-url"), options, method)
|
||||
if logs != "" {
|
||||
fmt.Print(logs)
|
||||
}
|
||||
@@ -1314,6 +1510,45 @@ func renderMigrationSummary(deviceIP, serviceURL string, s *setup.MigrationSumma
|
||||
}
|
||||
}
|
||||
|
||||
// setupRevertCmd wraps setup.Manager.RevertMigration — the same operation
|
||||
// as the web UI's "Revert to Defaults" button (Migrate tab). Restores
|
||||
// SoundTouchSdkPrivateCfg.xml, /etc/hosts, and /etc/resolv.conf from their
|
||||
// .original backups, removes the AfterTouch DNS-hook artifacts, and strips
|
||||
// just the AfterTouch-labeled cert out of the trust bundle. No --service-url
|
||||
// needed: everything it touches already lives on the speaker.
|
||||
//
|
||||
// Deliberately out of scope (matches the web UI button): SSH/remote_services
|
||||
// persistence (use `setup remote-services --remove`) and account pairing
|
||||
// (use `account unpair`) — see #614 self-test notes for the full checklist.
|
||||
func setupRevertCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "revert",
|
||||
Usage: "Undo a migration: restore SoundTouchSdkPrivateCfg.xml/hosts/resolv.conf from backups and remove the AfterTouch CA cert",
|
||||
Before: RequireHost,
|
||||
Action: func(c *cli.Context) error {
|
||||
cfg := GetClientConfig(c)
|
||||
m := setup.NewManager("", nil, nil)
|
||||
|
||||
fmt.Printf("Reverting migration on %s...\n", cfg.Host)
|
||||
|
||||
logs, err := m.RevertMigration(cfg.Host)
|
||||
if logs != "" {
|
||||
fmt.Print(logs)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess("Migration reverted. SSH access and account pairing are untouched by this — " +
|
||||
"see `setup remote-services --remove` and `account unpair` if you want those cleared too.")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func setupRebootCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "reboot",
|
||||
@@ -1740,7 +1975,7 @@ func setupPairCmd() *cli.Command {
|
||||
Usage: "Pair the speaker with an account via WebSocket SETUP state machine",
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "account", Usage: "7-digit account ID (empty = generate)"},
|
||||
&cli.StringFlag{Name: "account", Usage: "Account ID to pair with (empty = generate a fresh 7-digit one)"},
|
||||
&cli.StringFlag{Name: "mode", Value: "full", Usage: "full (state machine) or bare (setMargeAccount only — experimental)"},
|
||||
&cli.StringFlag{Name: "service-url", Value: "http://aftertouch.local:8000", Usage: "AfterTouch base URL (also populates <boseServer>/<updateServer> in setMargeAccount)"},
|
||||
&cli.StringFlag{Name: "name", Usage: "Speaker name to set during pairing (empty = keep current)"},
|
||||
@@ -1764,8 +1999,8 @@ func setupPairCmd() *cli.Command {
|
||||
fmt.Printf("Generated account id: %s\n", accountID)
|
||||
}
|
||||
|
||||
if !setup.IsValidAccountID(accountID) {
|
||||
return fmt.Errorf("invalid account id %q: must be 7 digits", accountID)
|
||||
if !datastore.IsSafeIdentifier(accountID) {
|
||||
return fmt.Errorf("invalid account id %q: must be a non-empty, path-safe identifier", accountID)
|
||||
}
|
||||
|
||||
switch mode {
|
||||
@@ -1847,6 +2082,17 @@ func runPairBare(c *cli.Context, deviceIP, accountID string) error {
|
||||
func runPairFull(c *cli.Context, deviceIP, accountID string) error {
|
||||
m := setup.NewManager(c.String("service-url"), nil, nil)
|
||||
|
||||
needed, status, err := m.PreflightInitPlan(deviceIP)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("preflight: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if !needed {
|
||||
PrintSuccess(fmt.Sprintf("Device already configured (status=%s) — nothing to do.", status))
|
||||
return nil
|
||||
}
|
||||
|
||||
plan := setup.InitPlan{
|
||||
DeviceIP: deviceIP,
|
||||
ServiceURL: c.String("service-url"),
|
||||
@@ -1860,7 +2106,7 @@ func runPairFull(c *cli.Context, deviceIP, accountID string) error {
|
||||
ctx, cancel := context.WithTimeout(c.Context, 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := m.ExecuteInitPlan(ctx, plan, func(e setup.StepEvent) {
|
||||
_, err = m.ExecuteInitPlan(ctx, plan, func(e setup.StepEvent) {
|
||||
switch e.Status {
|
||||
case setup.StatusOK:
|
||||
fmt.Printf("[%d] %s — ok\n", e.Kind, e.Name)
|
||||
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -43,6 +45,46 @@ func captureStdout(t *testing.T, fn func()) string {
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestPostSetupSync_PostsToDeviceScopedURL(t *testing.T) {
|
||||
var gotMethod, gotPath string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok": true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if err := postSetupSync(srv.URL, "DEVICEID01", ""); err != nil {
|
||||
t.Fatalf("postSetupSync: %v", err)
|
||||
}
|
||||
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", gotMethod)
|
||||
}
|
||||
|
||||
if want := "/api/setup/sync/DEVICEID01"; gotPath != want {
|
||||
t.Errorf("expected path %q, got %q", want, gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSetupSync_PropagatesServerError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "device not found", http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := postSetupSync(srv.URL, "DEVICEID01", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a 404 response")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "device not found") {
|
||||
t.Errorf("expected error to include server body, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSourceTable_AlignsColumnsAndDedupsDisplayName(t *testing.T) {
|
||||
items := []models.SourceItem{
|
||||
// displayName != account → kept as "AUX (AUX IN)"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// updateCheckRepo is the GitHub repo checked for newer releases, matching
|
||||
// soundtouch-service's periodic background check (#591,
|
||||
// _/i591/design-update-check.md).
|
||||
const updateCheckRepo = "gesellix/Bose-SoundTouch"
|
||||
|
||||
// updateCheckCommand assembles the on-demand `soundtouch-cli update-check`
|
||||
// command, the CLI-side answer to that design doc's open question 2
|
||||
// (CLI-only users get no update notice from the service's background
|
||||
// checker). Unlike the service's opt-in periodic check, running this
|
||||
// command *is* the opt-in: no config flag, no persisted state, just one
|
||||
// GitHub API request each time it's invoked.
|
||||
func updateCheckCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "update-check",
|
||||
Usage: "Check GitHub for a newer soundtouch-cli release",
|
||||
Action: runUpdateCheck,
|
||||
}
|
||||
}
|
||||
|
||||
func runUpdateCheck(c *cli.Context) error {
|
||||
checker := updatecheck.NewChecker(nil, updateCheckRepo, version)
|
||||
|
||||
result, err := checker.CheckNow(c.Context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update check failed: %w", err)
|
||||
}
|
||||
|
||||
printUpdateCheckResult(result)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printUpdateCheckResult(result updatecheck.Result) {
|
||||
if result.LatestVersion == "" {
|
||||
fmt.Printf("Running %s, not a released version, skipping comparison.\n", result.CurrentVersion)
|
||||
return
|
||||
}
|
||||
|
||||
if result.Available {
|
||||
fmt.Printf("A newer version is available: %s (you're on %s)\n", result.LatestVersion, result.CurrentVersion)
|
||||
fmt.Println(result.ReleaseURL)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("You're on the latest version (%s).\n", result.CurrentVersion)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
)
|
||||
|
||||
// TestUpdateCheckCommand_Registered checks the command is wired up with the
|
||||
// expected name and an Action, without making any real GitHub API calls.
|
||||
func TestUpdateCheckCommand_Registered(t *testing.T) {
|
||||
cmd := updateCheckCommand()
|
||||
|
||||
if cmd.Name != "update-check" {
|
||||
t.Errorf("command name = %q; want %q", cmd.Name, "update-check")
|
||||
}
|
||||
|
||||
if cmd.Action == nil {
|
||||
t.Error("expected an Action to be set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintUpdateCheckResult_DoesNotPanic exercises all three result shapes
|
||||
// (unparseable current version, update available, up to date) purely for
|
||||
// the "does not panic" guarantee; updatecheck.Checker's own tests already
|
||||
// cover the comparison logic itself.
|
||||
func TestPrintUpdateCheckResult_DoesNotPanic(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
result updatecheck.Result
|
||||
}{
|
||||
{"unparseable current version", updatecheck.Result{CurrentVersion: "dev"}},
|
||||
{"update available", updatecheck.Result{CurrentVersion: "v1.0.0", LatestVersion: "v1.1.0", Available: true, ReleaseURL: "https://example.invalid"}},
|
||||
{"up to date", updatecheck.Result{CurrentVersion: "v1.1.0", LatestVersion: "v1.1.0", Available: false}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
printUpdateCheckResult(tc.result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2339,6 +2339,9 @@ func main() {
|
||||
// Defined in cmd_library.go.
|
||||
app.Commands = append(app.Commands, libraryCommand())
|
||||
|
||||
// On-demand GitHub release check (#591). Defined in cmd_updatecheck.go.
|
||||
app.Commands = append(app.Commands, updateCheckCommand())
|
||||
|
||||
// Sort commands alphabetically (including subcommands and flags recursively)
|
||||
sortCommands(app.Commands)
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ go build -o soundtouch-player
|
||||
./soundtouch-player -port 8888
|
||||
|
||||
# Connect to specific device
|
||||
./soundtouch-player -host 192.0.2.100
|
||||
./soundtouch-player --devices 192.0.2.100
|
||||
```
|
||||
|
||||
### Command Line Options
|
||||
@@ -137,7 +137,7 @@ device datastore).
|
||||
The application automatically discovers SoundTouch devices using:
|
||||
- **mDNS discovery** for local network devices
|
||||
- **UPnP/SSDP discovery** as fallback
|
||||
- **Manual device addition** via IP address
|
||||
- **Configured devices** via `--devices`, retried whenever discovery runs
|
||||
|
||||
### Real-time Updates
|
||||
The interface maintains WebSocket connections to each device for instant updates of:
|
||||
|
||||
@@ -161,7 +161,7 @@ func main() {
|
||||
log.Printf("Trusting AfterTouch service CA from %s", sanitizeLog(caPath))
|
||||
}
|
||||
|
||||
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName)
|
||||
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName, manualHosts...)
|
||||
|
||||
// Discover devices on startup
|
||||
go func() {
|
||||
@@ -170,6 +170,11 @@ func main() {
|
||||
|
||||
webApp.BroadcastDiscoveryStatus("starting", webApp.DeviceCount())
|
||||
|
||||
// Register configured devices immediately rather than waiting for
|
||||
// the full mDNS/UPnP sweep below (bounded by cfg.DiscoveryTimeout,
|
||||
// currently 10s) to complete. manualHosts are also folded into
|
||||
// discoveryService's PreferredDevices so a host that's offline
|
||||
// right now still gets retried on every subsequent discovery pass.
|
||||
for _, host := range manualHosts {
|
||||
webApp.AddDeviceByHost(host, 8090, "manual")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
)
|
||||
|
||||
// TestAdminAreaAuthGate is the wiring-level regression test for #419: it
|
||||
// exercises the real production router (setupRouter), not just the
|
||||
// BasicAuthAdmin middleware in isolation, to pin two things at once:
|
||||
// 1. /admin and /api/setup/* (and their /setup/* legacy aliases) are open
|
||||
// by default and become gated once AdminAreaAuth is "enabled".
|
||||
// 2. A handful of routes deliberately stay reachable WITHOUT credentials
|
||||
// regardless of the gate: ca.crt/tts/speak/tts/config because
|
||||
// soundtouch-cli/soundtouch-player call them directly (the whole reason
|
||||
// mountSetupAPI was split into mountSetupAPIShared/mountSetupAPIAdmin),
|
||||
// and /api/announcements because it specifically needs to reach
|
||||
// operators who haven't set up credentials yet.
|
||||
func TestAdminAreaAuthGate(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
// A real setup.Manager (with an actual CA) so /setup/ca.crt genuinely
|
||||
// succeeds instead of failing on a nil dependency for an unrelated
|
||||
// reason, which would make the "stays reachable" assertion meaningless.
|
||||
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
|
||||
_ = cm.EnsureCA()
|
||||
sm := setup.NewManager("http://localhost:8000", ds, cm)
|
||||
|
||||
server := handlers.NewServer(ds, sm, "http://localhost:8000", true, false, false)
|
||||
server.SetMgmtConfig("custom-admin", "custom-password")
|
||||
|
||||
r := setupRouter(server, nil, nil)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
adminGatedPaths := []string{
|
||||
"/admin",
|
||||
"/setup/settings",
|
||||
"/api/setup/settings",
|
||||
}
|
||||
alwaysUngatedPaths := []string{
|
||||
"/setup/ca.crt",
|
||||
"/api/setup/ca.crt",
|
||||
"/setup/tts/config",
|
||||
"/api/setup/tts/config",
|
||||
"/api/announcements?target=admin",
|
||||
}
|
||||
|
||||
t.Run("open by default (AdminAreaAuth unset)", func(t *testing.T) {
|
||||
for _, path := range adminGatedPaths {
|
||||
status := getStatus(t, ts.URL, path, "", "")
|
||||
if status == http.StatusUnauthorized {
|
||||
t.Errorf("%s: expected open access by default, got 401", path)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
server.SetAdminAreaAuth("enabled")
|
||||
defer server.SetAdminAreaAuth("")
|
||||
|
||||
t.Run("gated paths reject without credentials once enabled", func(t *testing.T) {
|
||||
for _, path := range adminGatedPaths {
|
||||
status := getStatus(t, ts.URL, path, "", "")
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("%s: expected 401 without credentials once enabled, got %d", path, status)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gated paths accept correct credentials once enabled", func(t *testing.T) {
|
||||
for _, path := range adminGatedPaths {
|
||||
status := getStatus(t, ts.URL, path, "custom-admin", "custom-password")
|
||||
if status == http.StatusUnauthorized {
|
||||
t.Errorf("%s: expected access with correct credentials, got 401", path)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("routes intentionally left outside the gate stay reachable without credentials", func(t *testing.T) {
|
||||
for _, path := range alwaysUngatedPaths {
|
||||
status := getStatus(t, ts.URL, path, "", "")
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("%s: expected 200 without credentials even with the gate enabled, got %d", path, status)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func getStatus(t *testing.T, base, path, user, pass string) int {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, base+path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build request for %s: %v", path, err)
|
||||
}
|
||||
if user != "" || pass != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request to %s failed: %v", path, err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
return res.StatusCode
|
||||
}
|
||||
+511
-264
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -32,6 +33,7 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/stockholm"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -222,6 +224,258 @@ func logBufferCapacityFromEnv(defaultCap int) int {
|
||||
return v
|
||||
}
|
||||
|
||||
// serviceFlags is the full flag/env-var surface for soundtouch-service.
|
||||
// Extracted to a package-level var (rather than inlined in main()'s
|
||||
// cli.App literal) so tests can build a real *cli.Context against the
|
||||
// exact same flags loadConfig reads, instead of hand-duplicating them.
|
||||
var serviceFlags = []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "port",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "HTTP port to bind the service to",
|
||||
Value: "8000",
|
||||
EnvVars: []string{"PORT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "bind",
|
||||
Usage: "Network interface to bind to",
|
||||
EnvVars: []string{"BIND_ADDR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "data-dir",
|
||||
Usage: "Directory for persistent data",
|
||||
Value: "data",
|
||||
EnvVars: []string{"DATA_DIR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "server-url",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "External URL of this service",
|
||||
EnvVars: []string{"SERVER_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "deployment-mode",
|
||||
Usage: "Where this service runs: on-device, private-network, or public-network " +
|
||||
"- informs the server-url fallback when --server-url isn't set",
|
||||
EnvVars: []string{"DEPLOYMENT_MODE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "https-port",
|
||||
Usage: "HTTPS port to bind the service to",
|
||||
Value: "8443",
|
||||
EnvVars: []string{"HTTPS_PORT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "https-server-url",
|
||||
Aliases: []string{"S"},
|
||||
Usage: "External HTTPS URL",
|
||||
EnvVars: []string{"HTTPS_SERVER_URL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "redact-logs",
|
||||
Usage: "Redact sensitive data in proxy logs",
|
||||
Value: true,
|
||||
EnvVars: []string{"REDACT_PROXY_LOGS"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "log-bodies",
|
||||
Usage: "Log full request/response bodies",
|
||||
EnvVars: []string{"LOG_PROXY_BODY"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "record-interactions",
|
||||
Usage: "Record HTTP interactions to disk",
|
||||
Value: true,
|
||||
EnvVars: []string{"RECORD_INTERACTIONS"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "discovery-enabled",
|
||||
Usage: "Enable periodic device discovery",
|
||||
Value: true,
|
||||
EnvVars: []string{"DISCOVERY_ENABLED"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "discovery-interval",
|
||||
Usage: "Device discovery interval",
|
||||
Value: "5m",
|
||||
EnvVars: []string{"DISCOVERY_INTERVAL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "update-check-enabled",
|
||||
Usage: "Periodically check GitHub for a newer release (opt-in; the only network call this makes beyond speaker/provider traffic)",
|
||||
Value: false,
|
||||
EnvVars: []string{"UPDATE_CHECK_ENABLED"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "update-check-interval",
|
||||
Usage: "Update check interval",
|
||||
Value: "24h",
|
||||
EnvVars: []string{"UPDATE_CHECK_INTERVAL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "dns-discovery",
|
||||
Usage: "Enable DNS discovery server",
|
||||
EnvVars: []string{"ENABLE_DNS_DISCOVERY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dns-upstream",
|
||||
Usage: "Upstream DNS server(s) for non-Bose queries (comma-separated). If empty, /etc/resolv.conf is used.",
|
||||
Value: "",
|
||||
EnvVars: []string{"DNS_UPSTREAM"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dns-bind",
|
||||
Usage: "Bind address for the DNS discovery server",
|
||||
Value: ":53",
|
||||
EnvVars: []string{"DNS_BIND_ADDR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-client-id",
|
||||
Usage: "Spotify OAuth client ID",
|
||||
EnvVars: []string{"SPOTIFY_CLIENT_ID"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-client-secret",
|
||||
Usage: "Spotify OAuth client secret",
|
||||
EnvVars: []string{"SPOTIFY_CLIENT_SECRET"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-redirect-uri",
|
||||
Usage: "Spotify OAuth redirect URI (defaults to <server-url>/mgmt/spotify/callback)",
|
||||
EnvVars: []string{"SPOTIFY_REDIRECT_URI"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-token-url",
|
||||
Usage: "Spotify OAuth token URL (for testing)",
|
||||
EnvVars: []string{"SPOTIFY_TOKEN_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-api-base",
|
||||
Usage: "Spotify API base URL (for testing)",
|
||||
EnvVars: []string{"SPOTIFY_API_BASE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-client-id",
|
||||
Usage: "Amazon LWA OAuth client ID",
|
||||
EnvVars: []string{"AMAZON_CLIENT_ID"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-client-secret",
|
||||
Usage: "Amazon LWA OAuth client secret",
|
||||
EnvVars: []string{"AMAZON_CLIENT_SECRET"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-redirect-uri",
|
||||
Usage: "Amazon LWA OAuth redirect URI (defaults to <server-url>/mgmt/amazon/callback)",
|
||||
EnvVars: []string{"AMAZON_REDIRECT_URI"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-token-url",
|
||||
Usage: "Amazon LWA token URL (for testing)",
|
||||
EnvVars: []string{"AMAZON_TOKEN_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-profile-url",
|
||||
Usage: "Amazon LWA profile URL (for testing)",
|
||||
EnvVars: []string{"AMAZON_PROFILE_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tunein-opml-url",
|
||||
Usage: "TuneIn OPML base URL, covering Tune.ashx/describe.ashx/navigate (for testing / local mock; defaults to opml.radiotime.com)",
|
||||
EnvVars: []string{"TUNEIN_OPML_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tunein-api-url",
|
||||
Usage: "TuneIn API base URL, covering search and profile contents (for testing / local mock; defaults to api.radiotime.com)",
|
||||
EnvVars: []string{"TUNEIN_API_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-provider",
|
||||
Usage: "Text-to-speech provider: 'translate' (Google Translate, no credentials, default) or 'google-cloud' (Google Cloud TTS, needs an API key). Empty falls back to translate; leave unset to let a value saved in the settings UI take effect",
|
||||
EnvVars: []string{"TTS_PROVIDER"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-google-api-key",
|
||||
Usage: "Google Cloud Text-to-Speech API key (required when --tts-provider=google-cloud)",
|
||||
EnvVars: []string{"TTS_GOOGLE_API_KEY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-google-endpoint",
|
||||
Usage: "Google Cloud TTS synthesize endpoint override (for testing)",
|
||||
EnvVars: []string{"TTS_GOOGLE_ENDPOINT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-language",
|
||||
Usage: "Default TTS language code. Provider-specific: 'EN'/'DE' for translate, BCP-47 like 'en-US' for google-cloud",
|
||||
EnvVars: []string{"TTS_LANGUAGE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-voice",
|
||||
Usage: "Default Google Cloud TTS voice name (e.g. en-US-Neural2-C); ignored by the translate provider",
|
||||
EnvVars: []string{"TTS_VOICE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-app-key",
|
||||
Usage: "Bose /speaker app_key used to play TTS notifications on speakers",
|
||||
EnvVars: []string{"TTS_APP_KEY"},
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "tts-volume",
|
||||
Usage: "Default TTS playback volume (0-100, 0 = keep current volume)",
|
||||
Value: 0,
|
||||
EnvVars: []string{"TTS_VOLUME"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mgmt-username",
|
||||
Usage: "Management API username for HTTP Basic Auth",
|
||||
Value: "admin",
|
||||
EnvVars: []string{"MGMT_USERNAME"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mgmt-password",
|
||||
Usage: "Management API password for HTTP Basic Auth",
|
||||
Value: "change_me!",
|
||||
EnvVars: []string{"MGMT_PASSWORD"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "base-url",
|
||||
Usage: "External base URL for OAuth callbacks behind reverse proxy",
|
||||
EnvVars: []string{"BASE_URL"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "internal-paths",
|
||||
Usage: "Paths for internal requests (comma-separated or multiple flags)",
|
||||
EnvVars: []string{"INTERNAL_PATHS"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "tls-extra-host",
|
||||
Usage: "Additional DNS name or IP to include in the server TLS certificate SAN list (repeatable)",
|
||||
EnvVars: []string{"TLS_EXTRA_HOST"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migration-enabled",
|
||||
Usage: "Enable device directory migration from serial to MAC-based structure",
|
||||
Value: true,
|
||||
EnvVars: []string{"MIGRATION_ENABLED"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migration-dry-run",
|
||||
Usage: "Log what would be migrated without actually doing it",
|
||||
EnvVars: []string{"MIGRATION_DRY_RUN"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "stockholm-dir",
|
||||
Usage: "Path to the extracted Stockholm frontend directory (enables Stockholm UI when set)",
|
||||
EnvVars: []string{"STOCKHOLM_DIR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "stockholm-base-path",
|
||||
Usage: "URL prefix under which the Stockholm UI is served (e.g. /stockholm). Empty serves at root.",
|
||||
Value: "/stockholm",
|
||||
EnvVars: []string{"STOCKHOLM_BASE_PATH"},
|
||||
},
|
||||
}
|
||||
|
||||
func main() {
|
||||
updateBuildInfo()
|
||||
|
||||
@@ -248,237 +502,13 @@ func main() {
|
||||
Name: "Tobias Gesellchen, and the Bose-SoundTouch Contributors",
|
||||
},
|
||||
},
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "port",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "HTTP port to bind the service to",
|
||||
Value: "8000",
|
||||
EnvVars: []string{"PORT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "bind",
|
||||
Usage: "Network interface to bind to",
|
||||
EnvVars: []string{"BIND_ADDR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "data-dir",
|
||||
Usage: "Directory for persistent data",
|
||||
Value: "data",
|
||||
EnvVars: []string{"DATA_DIR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "server-url",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "External URL of this service",
|
||||
EnvVars: []string{"SERVER_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "https-port",
|
||||
Usage: "HTTPS port to bind the service to",
|
||||
Value: "8443",
|
||||
EnvVars: []string{"HTTPS_PORT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "https-server-url",
|
||||
Aliases: []string{"S"},
|
||||
Usage: "External HTTPS URL",
|
||||
EnvVars: []string{"HTTPS_SERVER_URL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "redact-logs",
|
||||
Usage: "Redact sensitive data in proxy logs",
|
||||
Value: true,
|
||||
EnvVars: []string{"REDACT_PROXY_LOGS"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "log-bodies",
|
||||
Usage: "Log full request/response bodies",
|
||||
EnvVars: []string{"LOG_PROXY_BODY"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "record-interactions",
|
||||
Usage: "Record HTTP interactions to disk",
|
||||
Value: true,
|
||||
EnvVars: []string{"RECORD_INTERACTIONS"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "discovery-enabled",
|
||||
Usage: "Enable periodic device discovery",
|
||||
Value: true,
|
||||
EnvVars: []string{"DISCOVERY_ENABLED"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "discovery-interval",
|
||||
Usage: "Device discovery interval",
|
||||
Value: "5m",
|
||||
EnvVars: []string{"DISCOVERY_INTERVAL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "dns-discovery",
|
||||
Usage: "Enable DNS discovery server",
|
||||
EnvVars: []string{"ENABLE_DNS_DISCOVERY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dns-upstream",
|
||||
Usage: "Upstream DNS server(s) for non-Bose queries (comma-separated). If empty, /etc/resolv.conf is used.",
|
||||
Value: "",
|
||||
EnvVars: []string{"DNS_UPSTREAM"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dns-bind",
|
||||
Usage: "Bind address for the DNS discovery server",
|
||||
Value: ":53",
|
||||
EnvVars: []string{"DNS_BIND_ADDR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-client-id",
|
||||
Usage: "Spotify OAuth client ID",
|
||||
EnvVars: []string{"SPOTIFY_CLIENT_ID"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-client-secret",
|
||||
Usage: "Spotify OAuth client secret",
|
||||
EnvVars: []string{"SPOTIFY_CLIENT_SECRET"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-redirect-uri",
|
||||
Usage: "Spotify OAuth redirect URI (defaults to <server-url>/mgmt/spotify/callback)",
|
||||
EnvVars: []string{"SPOTIFY_REDIRECT_URI"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-token-url",
|
||||
Usage: "Spotify OAuth token URL (for testing)",
|
||||
EnvVars: []string{"SPOTIFY_TOKEN_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-api-base",
|
||||
Usage: "Spotify API base URL (for testing)",
|
||||
EnvVars: []string{"SPOTIFY_API_BASE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-client-id",
|
||||
Usage: "Amazon LWA OAuth client ID",
|
||||
EnvVars: []string{"AMAZON_CLIENT_ID"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-client-secret",
|
||||
Usage: "Amazon LWA OAuth client secret",
|
||||
EnvVars: []string{"AMAZON_CLIENT_SECRET"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-redirect-uri",
|
||||
Usage: "Amazon LWA OAuth redirect URI (defaults to <server-url>/mgmt/amazon/callback)",
|
||||
EnvVars: []string{"AMAZON_REDIRECT_URI"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-token-url",
|
||||
Usage: "Amazon LWA token URL (for testing)",
|
||||
EnvVars: []string{"AMAZON_TOKEN_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-profile-url",
|
||||
Usage: "Amazon LWA profile URL (for testing)",
|
||||
EnvVars: []string{"AMAZON_PROFILE_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tunein-opml-url",
|
||||
Usage: "TuneIn OPML base URL, covering Tune.ashx/describe.ashx/navigate (for testing / local mock; defaults to opml.radiotime.com)",
|
||||
EnvVars: []string{"TUNEIN_OPML_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tunein-api-url",
|
||||
Usage: "TuneIn API base URL, covering search and profile contents (for testing / local mock; defaults to api.radiotime.com)",
|
||||
EnvVars: []string{"TUNEIN_API_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-provider",
|
||||
Usage: "Text-to-speech provider: 'translate' (Google Translate, no credentials, default) or 'google-cloud' (Google Cloud TTS, needs an API key). Empty falls back to translate; leave unset to let a value saved in the settings UI take effect",
|
||||
EnvVars: []string{"TTS_PROVIDER"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-google-api-key",
|
||||
Usage: "Google Cloud Text-to-Speech API key (required when --tts-provider=google-cloud)",
|
||||
EnvVars: []string{"TTS_GOOGLE_API_KEY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-google-endpoint",
|
||||
Usage: "Google Cloud TTS synthesize endpoint override (for testing)",
|
||||
EnvVars: []string{"TTS_GOOGLE_ENDPOINT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-language",
|
||||
Usage: "Default TTS language code. Provider-specific: 'EN'/'DE' for translate, BCP-47 like 'en-US' for google-cloud",
|
||||
EnvVars: []string{"TTS_LANGUAGE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-voice",
|
||||
Usage: "Default Google Cloud TTS voice name (e.g. en-US-Neural2-C); ignored by the translate provider",
|
||||
EnvVars: []string{"TTS_VOICE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-app-key",
|
||||
Usage: "Bose /speaker app_key used to play TTS notifications on speakers",
|
||||
EnvVars: []string{"TTS_APP_KEY"},
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "tts-volume",
|
||||
Usage: "Default TTS playback volume (0-100, 0 = keep current volume)",
|
||||
Value: 0,
|
||||
EnvVars: []string{"TTS_VOLUME"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mgmt-username",
|
||||
Usage: "Management API username for HTTP Basic Auth",
|
||||
Value: "admin",
|
||||
EnvVars: []string{"MGMT_USERNAME"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mgmt-password",
|
||||
Usage: "Management API password for HTTP Basic Auth",
|
||||
Value: "change_me!",
|
||||
EnvVars: []string{"MGMT_PASSWORD"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "base-url",
|
||||
Usage: "External base URL for OAuth callbacks behind reverse proxy",
|
||||
EnvVars: []string{"BASE_URL"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "internal-paths",
|
||||
Usage: "Paths for internal requests (comma-separated or multiple flags)",
|
||||
EnvVars: []string{"INTERNAL_PATHS"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "tls-extra-host",
|
||||
Usage: "Additional DNS name or IP to include in the server TLS certificate SAN list (repeatable)",
|
||||
EnvVars: []string{"TLS_EXTRA_HOST"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migration-enabled",
|
||||
Usage: "Enable device directory migration from serial to MAC-based structure",
|
||||
Value: true,
|
||||
EnvVars: []string{"MIGRATION_ENABLED"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migration-dry-run",
|
||||
Usage: "Log what would be migrated without actually doing it",
|
||||
EnvVars: []string{"MIGRATION_DRY_RUN"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "stockholm-dir",
|
||||
Usage: "Path to the extracted Stockholm frontend directory (enables Stockholm UI when set)",
|
||||
EnvVars: []string{"STOCKHOLM_DIR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "stockholm-base-path",
|
||||
Usage: "URL prefix under which the Stockholm UI is served (e.g. /stockholm). Empty serves at root.",
|
||||
Value: "/stockholm",
|
||||
EnvVars: []string{"STOCKHOLM_BASE_PATH"},
|
||||
},
|
||||
},
|
||||
Flags: serviceFlags,
|
||||
Action: func(c *cli.Context) error {
|
||||
config := loadConfig(c)
|
||||
config, err := loadConfig(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ds := initDataStore(config.dataDir)
|
||||
|
||||
// Detect a genuinely fresh data dir by the ABSENCE of settings.json,
|
||||
@@ -503,13 +533,11 @@ func main() {
|
||||
persisted = createDefaultSettings(ds, config)
|
||||
}
|
||||
|
||||
// Recalculate domains if settings changed
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname, config.tlsExtraHosts)
|
||||
// Recalculate domains if settings changed. Reuses the same mode-aware
|
||||
// fallback host loadConfig already resolved, rather than a raw
|
||||
// os.Hostname() call, so an on-device install doesn't leak its
|
||||
// unresolvable variant codename back in here (see issue #546).
|
||||
config.domains = getDomains(config.serverURL, config.httpsServerURL, config.hostname, config.tlsExtraHosts)
|
||||
|
||||
cm := initCertificateManager(config.dataDir, config.hostname)
|
||||
sm := setup.NewManager(config.serverURL, ds, cm)
|
||||
@@ -523,12 +551,18 @@ func main() {
|
||||
server.SetExpectedHosts(config.domains)
|
||||
server.SetVersionInfo(version, commit, date, repoURL)
|
||||
server.SetDiscoverySettings(config.discoveryInterval, config.discoveryEnabled)
|
||||
server.SetUpdateCheckSettings(config.updateCheckInterval, config.updateCheckEnabled)
|
||||
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
|
||||
server.SetInternalPaths(persisted.InternalPaths)
|
||||
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
|
||||
server.SetAmazonConfig(config.amazonClientID, config.amazonClientSecret, config.amazonRedirectURI)
|
||||
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
|
||||
|
||||
// Invalid values (e.g. a hand-edited settings.json) fall back to the
|
||||
// unset default rather than failing startup.
|
||||
adminAreaAuth, _ := handlers.NormalizeAdminAreaAuth(persisted.AdminAreaAuth)
|
||||
server.SetAdminAreaAuth(adminAreaAuth)
|
||||
|
||||
initMusicServices(config, server)
|
||||
initTTSService(config, server)
|
||||
|
||||
@@ -595,6 +629,10 @@ func main() {
|
||||
|
||||
startDeviceDiscovery(server)
|
||||
|
||||
updateChecker := updatecheck.NewChecker(ds, "gesellix/Bose-SoundTouch", version)
|
||||
server.SetUpdateChecker(updateChecker)
|
||||
startUpdateCheck(server, updateChecker)
|
||||
|
||||
var stockholmHandler *stockholm.Handler
|
||||
|
||||
if config.stockholmDir != "" {
|
||||
@@ -698,6 +736,8 @@ type serviceConfig struct {
|
||||
tlsExtraHosts []string
|
||||
discoveryEnabled bool
|
||||
discoveryInterval time.Duration
|
||||
updateCheckEnabled bool
|
||||
updateCheckInterval time.Duration
|
||||
domains []string
|
||||
spotifyClientID string
|
||||
spotifyClientSecret string
|
||||
@@ -726,7 +766,32 @@ type serviceConfig struct {
|
||||
stockholmBasePath string
|
||||
}
|
||||
|
||||
func loadConfig(c *cli.Context) serviceConfig {
|
||||
// resolveFallbackHost picks the host used to guess a server URL when
|
||||
// --server-url/SERVER_URL isn't set, based on where this service runs.
|
||||
// On-device (running on the speaker's own Linux) is the one case where
|
||||
// os.Hostname() is guaranteed useless: it returns the speaker's internal
|
||||
// variant codename (e.g. "spotty", "mojo"), which nothing can resolve, not
|
||||
// even the speaker itself (see issue #546). warnOnUse reports whether
|
||||
// falling back to the returned host is risky enough to warrant a startup
|
||||
// warning.
|
||||
func resolveFallbackHost(deploymentMode string) (host string, warnOnUse bool) {
|
||||
switch deploymentMode {
|
||||
case "on-device":
|
||||
return "localhost", false
|
||||
case "public-network":
|
||||
// Caller must refuse to guess a publicly reachable address.
|
||||
return "", false
|
||||
default: // "private-network", "", or any unrecognized value: today's behavior.
|
||||
h, _ := os.Hostname()
|
||||
if h == "" {
|
||||
h = "localhost"
|
||||
}
|
||||
|
||||
return strings.ToLower(h), true
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig(c *cli.Context) (serviceConfig, error) {
|
||||
port := c.String("port")
|
||||
bindAddr := c.String("bind")
|
||||
|
||||
@@ -737,16 +802,23 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
|
||||
dataDir := c.String("data-dir")
|
||||
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
hostname = strings.ToLower(hostname)
|
||||
deploymentMode := c.String("deployment-mode")
|
||||
fallbackHost, warnOnFallback := resolveFallbackHost(deploymentMode)
|
||||
|
||||
serverURL := c.String("server-url")
|
||||
if serverURL == "" {
|
||||
serverURL = "http://" + hostname + ":" + port
|
||||
if deploymentMode == "public-network" {
|
||||
return serviceConfig{}, fmt.Errorf(
|
||||
"--server-url (or SERVER_URL) is required when --deployment-mode=public-network; refusing to guess a public address")
|
||||
}
|
||||
|
||||
serverURL = "http://" + fallbackHost + ":" + port
|
||||
|
||||
if warnOnFallback {
|
||||
log.Printf("Warning: --server-url not set; defaulting to %s using this host's own hostname. "+
|
||||
"If your SoundTouch speakers can't reach this address, set --server-url/SERVER_URL explicitly, "+
|
||||
"or pass --deployment-mode=on-device if this runs on the speaker itself.", sanitizeLog(serverURL))
|
||||
}
|
||||
}
|
||||
// Strip a trailing slash so it cannot leak into the BMX registry base or the
|
||||
// margeServerUrl/bmxRegistryUrl pushed to speakers during migration.
|
||||
@@ -761,14 +833,14 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
|
||||
// The HTTPS URL is an override (from the flag/env); when empty it is
|
||||
// derived from serverURL + https port so one setting (Target Domain)
|
||||
// drives both. httpsDefaultURL is the hostname-based fallback used
|
||||
// drives both. httpsDefaultURL is the same mode-aware fallback used
|
||||
// before a Target Domain is configured.
|
||||
httpsOverride := c.String("https-server-url")
|
||||
httpsDefaultURL := "https://" + hostname + ":" + httpsPort
|
||||
httpsDefaultURL := "https://" + fallbackHost + ":" + httpsPort
|
||||
httpsServerURL := handlers.DeriveHTTPSURL(serverURL, httpsOverride, httpsPort, httpsDefaultURL)
|
||||
|
||||
tlsExtraHosts := c.StringSlice("tls-extra-host")
|
||||
domains := getDomains(serverURL, httpsServerURL, hostname, tlsExtraHosts)
|
||||
domains := getDomains(serverURL, httpsServerURL, fallbackHost, tlsExtraHosts)
|
||||
|
||||
redact := c.Bool("redact-logs")
|
||||
logBody := c.Bool("log-bodies")
|
||||
@@ -788,6 +860,16 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
discoveryInterval = 5 * time.Minute
|
||||
}
|
||||
|
||||
updateCheckEnabled := c.Bool("update-check-enabled")
|
||||
updateCheckIntervalStr := c.String("update-check-interval")
|
||||
|
||||
updateCheckInterval, err := time.ParseDuration(updateCheckIntervalStr)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to parse update check interval %s, using default 24h: %v", sanitizeLog(updateCheckIntervalStr), err)
|
||||
|
||||
updateCheckInterval = 24 * time.Hour
|
||||
}
|
||||
|
||||
spotifyClientID := c.String("spotify-client-id")
|
||||
spotifyClientSecret := c.String("spotify-client-secret")
|
||||
spotifyRedirectURI := c.String("spotify-redirect-uri")
|
||||
@@ -820,7 +902,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
bindAddr: bindAddr,
|
||||
addr: addr,
|
||||
dataDir: dataDir,
|
||||
hostname: hostname,
|
||||
hostname: fallbackHost,
|
||||
serverURL: serverURL,
|
||||
httpsServerURL: httpsServerURL,
|
||||
httpsOverride: httpsOverride,
|
||||
@@ -837,6 +919,8 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
tlsExtraHosts: tlsExtraHosts,
|
||||
discoveryEnabled: discoveryEnabled,
|
||||
discoveryInterval: discoveryInterval,
|
||||
updateCheckEnabled: updateCheckEnabled,
|
||||
updateCheckInterval: updateCheckInterval,
|
||||
domains: domains,
|
||||
spotifyClientID: spotifyClientID,
|
||||
spotifyClientSecret: spotifyClientSecret,
|
||||
@@ -863,7 +947,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
migrationDryRun: migrationDryRun,
|
||||
stockholmDir: stockholmDir,
|
||||
stockholmBasePath: stockholmBasePath,
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getDomains(serverURL, httpsServerURL, hostname string, extraHosts []string) []string {
|
||||
@@ -984,6 +1068,22 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
|
||||
}
|
||||
}
|
||||
|
||||
// Installs upgraded from a build that predates the Settings-page toggle
|
||||
// have no update_check_* keys at all, and an absent JSON bool decodes as
|
||||
// false — taking it at face value would silently switch the check off for
|
||||
// everyone who had opted in via UPDATE_CHECK_ENABLED. The interval is
|
||||
// always written together with the flag (createDefaultSettings and
|
||||
// HandleUpdateSettings both set both, and a time.Duration never
|
||||
// stringifies to ""), so a non-empty interval is the marker for
|
||||
// "settings.json genuinely carries an update-check preference".
|
||||
if persisted.UpdateCheckInterval != "" {
|
||||
config.updateCheckEnabled = persisted.UpdateCheckEnabled
|
||||
|
||||
if d, durErr := time.ParseDuration(persisted.UpdateCheckInterval); durErr == nil {
|
||||
config.updateCheckInterval = d
|
||||
}
|
||||
}
|
||||
|
||||
config.redact = persisted.RedactLogs
|
||||
config.logBody = persisted.LogBodies
|
||||
config.record = persisted.RecordInteractions
|
||||
@@ -1101,10 +1201,15 @@ func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datast
|
||||
RecordInteractions: config.record,
|
||||
DiscoveryEnabled: config.discoveryEnabled,
|
||||
DiscoveryInterval: config.discoveryInterval.String(),
|
||||
DNSEnabled: config.dnsEnabled,
|
||||
DNSUpstream: strings.Split(config.dnsUpstream, ","),
|
||||
DNSBindAddr: config.dnsBind,
|
||||
InternalPaths: config.internalPaths,
|
||||
// Seed the update-check preference from the CLI/env flags so a fresh
|
||||
// install's settings.json matches what the operator asked for (and so
|
||||
// the Settings page shows it) instead of silently reverting to off.
|
||||
UpdateCheckEnabled: config.updateCheckEnabled,
|
||||
UpdateCheckInterval: config.updateCheckInterval.String(),
|
||||
DNSEnabled: config.dnsEnabled,
|
||||
DNSUpstream: strings.Split(config.dnsUpstream, ","),
|
||||
DNSBindAddr: config.dnsBind,
|
||||
InternalPaths: config.internalPaths,
|
||||
Shortcuts: map[string]int{
|
||||
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
|
||||
"/sw.js": http.StatusNotFound,
|
||||
@@ -1193,6 +1298,123 @@ func startDeviceDiscovery(server *handlers.Server) {
|
||||
}()
|
||||
}
|
||||
|
||||
// updateCheckPollTick is how often the background update-check goroutine
|
||||
// re-reads the live settings. It is deliberately much shorter than the
|
||||
// check interval itself: sleeping a full (possibly 24h) interval between
|
||||
// reads would make flipping the Settings-page toggle on appear to do
|
||||
// nothing for up to a day.
|
||||
const updateCheckPollTick = time.Minute
|
||||
|
||||
// shouldRunUpdateCheckNow reports whether the background goroutine should
|
||||
// perform a real GitHub request on this poll tick. Pure/testable — no
|
||||
// sleeping, no I/O.
|
||||
//
|
||||
// A zero interval is treated as "don't check": with interval 0 every tick
|
||||
// would look due (shouldCheckImmediately), so an enabled check would hit
|
||||
// GitHub once a minute forever. HandleUpdateSettings also refuses to keep
|
||||
// the check enabled with a zero interval; this is the same guard for values
|
||||
// that arrive via the CLI flag or a hand-edited settings.json.
|
||||
func shouldRunUpdateCheckNow(
|
||||
enabled bool,
|
||||
lastCheckedAt time.Time,
|
||||
interval time.Duration,
|
||||
lastErrorAt, now time.Time,
|
||||
) bool {
|
||||
return enabled &&
|
||||
interval > 0 &&
|
||||
shouldCheckImmediately(lastCheckedAt, interval, now) &&
|
||||
!shouldSkipDueToBackoff(lastErrorAt, now)
|
||||
}
|
||||
|
||||
// startUpdateCheck runs the opt-in periodic check against GitHub Releases in
|
||||
// the background (#591, _/i591/design-update-check.md). Unlike the original
|
||||
// v1 design, enabled/interval are now live-reloadable from Settings (see
|
||||
// handlers.Server.SetUpdateCheckSettings) — so this goroutine always runs,
|
||||
// mirroring startDeviceDiscovery's live-settings pattern, and re-reads the
|
||||
// current settings every updateCheckPollTick. It only performs the actual
|
||||
// GitHub request when the check is enabled and the configured interval has
|
||||
// elapsed since the last check, so "always running" does not mean "always
|
||||
// talking to GitHub": with the check disabled it does nothing but wake up
|
||||
// once a minute and go back to sleep.
|
||||
func startUpdateCheck(server *handlers.Server, checker *updatecheck.Checker) {
|
||||
go func() {
|
||||
time.Sleep(randomJitter(5 * time.Minute))
|
||||
|
||||
lastResult := checker.LastResult()
|
||||
lastLoggedVersion := lastResult.LatestVersion
|
||||
|
||||
var lastErrorAt time.Time
|
||||
|
||||
for {
|
||||
interval, enabled := server.GetUpdateCheckSettings()
|
||||
|
||||
if shouldRunUpdateCheckNow(enabled, lastResult.CheckedAt, interval, lastErrorAt, time.Now()) {
|
||||
lastErrorAt, lastLoggedVersion = runUpdateCheckTick(checker, lastLoggedVersion)
|
||||
lastResult = checker.LastResult()
|
||||
}
|
||||
|
||||
time.Sleep(updateCheckPollTick)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// randomJitter returns a random duration in [0, upperBound) — the startup
|
||||
// delay so many installs restarting together (e.g. after a Docker image
|
||||
// bump) don't all hit GitHub at once. Not a security-sensitive use of
|
||||
// randomness.
|
||||
func randomJitter(upperBound time.Duration) time.Duration {
|
||||
if upperBound <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used
|
||||
return time.Duration(rand.Int63n(int64(upperBound))) //nolint:gosec
|
||||
}
|
||||
|
||||
// shouldCheckImmediately reports whether a check should run right at
|
||||
// startup (after jitter) rather than waiting a full interval: true when
|
||||
// there's no persisted last-check time, or it's stale (older than one
|
||||
// interval). Pure/testable — no sleeping.
|
||||
func shouldCheckImmediately(lastCheckedAt time.Time, interval time.Duration, now time.Time) bool {
|
||||
return lastCheckedAt.IsZero() || now.Sub(lastCheckedAt) >= interval
|
||||
}
|
||||
|
||||
// shouldSkipDueToBackoff reports whether a tick should be skipped because
|
||||
// the last attempt failed less than an hour ago — so a short
|
||||
// UPDATE_CHECK_INTERVAL doesn't hammer GitHub while it's erroring. A zero
|
||||
// lastErrorAt means "no recent failure", never skip. Pure/testable.
|
||||
func shouldSkipDueToBackoff(lastErrorAt, now time.Time) bool {
|
||||
return !lastErrorAt.IsZero() && now.Sub(lastErrorAt) < time.Hour
|
||||
}
|
||||
|
||||
// logUpdateIfNewlyAvailable logs once when result reports a version newer
|
||||
// than lastLoggedVersion, and returns the version to remember as "already
|
||||
// logged" — unchanged when there's nothing new, so a persistently-available
|
||||
// update doesn't spam the log every tick. Pure/testable.
|
||||
func logUpdateIfNewlyAvailable(result updatecheck.Result, lastLoggedVersion string) string {
|
||||
if result.Available && result.LatestVersion != "" && result.LatestVersion != lastLoggedVersion {
|
||||
log.Printf("[UpdateCheck] Update available: %s (current %s) — %s",
|
||||
result.LatestVersion, result.CurrentVersion, result.ReleaseURL)
|
||||
|
||||
return result.LatestVersion
|
||||
}
|
||||
|
||||
return lastLoggedVersion
|
||||
}
|
||||
|
||||
// runUpdateCheckTick performs one check, logs on failure, and returns the
|
||||
// updated (lastErrorAt, lastLoggedVersion) pair for the caller to carry
|
||||
// into the next iteration.
|
||||
func runUpdateCheckTick(checker *updatecheck.Checker, lastLoggedVersion string) (time.Time, string) {
|
||||
result, err := checker.CheckNow(context.Background())
|
||||
if err != nil {
|
||||
log.Printf("[UpdateCheck] check failed: %v", err)
|
||||
return time.Now(), lastLoggedVersion
|
||||
}
|
||||
|
||||
return time.Time{}, logUpdateIfNewlyAvailable(result, lastLoggedVersion)
|
||||
}
|
||||
|
||||
// newEmbeddedWebApp builds the soundtouch-player application for embedding in the
|
||||
// service router: release metadata from the build vars, the service's public
|
||||
// ServiceURL (used by Play URL for speaker-fetched stream URLs and shown in the
|
||||
@@ -1290,8 +1512,12 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w
|
||||
r.Use(server.RecordMiddleware)
|
||||
|
||||
r.Get("/", server.HandleRoot)
|
||||
r.Get("/admin", server.HandleAdmin)
|
||||
r.With(server.BasicAuthAdmin()).Get("/admin", server.HandleAdmin)
|
||||
r.Get("/health", server.HandleHealth)
|
||||
// Deliberately not behind BasicAuthAdmin — see HandleListAnnouncements'
|
||||
// doc comment. #419.
|
||||
r.Get("/api/announcements", server.HandleListAnnouncements)
|
||||
r.Post("/api/announcements/{id}/dismiss", server.HandleDismissAnnouncement)
|
||||
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||
// The favicon lives in the embedded web/img bundle, not under
|
||||
// static/media — HandleMedia would 404. HandleWeb serves from
|
||||
@@ -1577,7 +1803,23 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w
|
||||
// /api/setup (new canonical) from one shared registration. The Stockholm
|
||||
// setup-wizard static catch-all is a frontend concern and stays under /setup
|
||||
// only — /api/setup serves data only.
|
||||
mountSetupAPI := func(r chi.Router) {
|
||||
//
|
||||
// Split in two: mountSetupAPIShared is reachable regardless of
|
||||
// AdminAreaAuth — soundtouch-cli and the embedded player call these
|
||||
// directly without Management API credentials (ca.crt for `setup
|
||||
// install-ca`, tts/speak+tts/config for the Play URL / TTS integration
|
||||
// surface). mountSetupAPIAdmin is everything else — genuinely admin-UI-only,
|
||||
// gated by BasicAuthAdmin() once #419's admin-area toggle is enabled.
|
||||
mountSetupAPIShared := func(r chi.Router) {
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
// TTS lives under /setup (LAN-trust, like the rest of the integration
|
||||
// surface and Play URL), not /mgmt: the API key is already configured
|
||||
// via /setup/settings, and -web/CLI reach this without mgmt credentials.
|
||||
r.Post("/tts/speak", server.HandleTTSSpeak)
|
||||
r.Get("/tts/config", server.HandleTTSConfig)
|
||||
}
|
||||
|
||||
mountSetupAPIAdmin := func(r chi.Router) {
|
||||
r.Get("/devices", server.HandleListDiscoveredDevices)
|
||||
r.Post("/devices", server.HandleAddManualDevice)
|
||||
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
|
||||
@@ -1585,11 +1827,6 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w
|
||||
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
|
||||
r.Get("/settings", server.HandleGetSettings)
|
||||
r.Post("/settings", server.HandleUpdateSettings)
|
||||
// TTS lives under /setup (LAN-trust, like the rest of the integration
|
||||
// surface and Play URL), not /mgmt: the API key is already configured
|
||||
// via /setup/settings, and -web/CLI reach this without mgmt credentials.
|
||||
r.Post("/tts/speak", server.HandleTTSSpeak)
|
||||
r.Get("/tts/config", server.HandleTTSConfig)
|
||||
r.Get("/info/{deviceId}", server.HandleGetDeviceInfo)
|
||||
r.Get("/summary/{deviceId}", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
|
||||
@@ -1611,7 +1848,6 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w
|
||||
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
|
||||
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
|
||||
r.Post("/test-dns/{deviceId}", server.HandleTestDNSRedirection)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
r.Get("/logging-settings", server.HandleGetLoggingSettings)
|
||||
r.Post("/logging-settings", server.HandleUpdateLoggingSettings)
|
||||
r.Get("/version", server.HandleGetVersionInfo)
|
||||
@@ -1643,12 +1879,19 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w
|
||||
// Stockholm wizard catch-all below is frontend, not a deprecated API path.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.DeprecatedRouteMiddleware)
|
||||
mountSetupAPI(r)
|
||||
mountSetupAPIShared(r)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.DeprecatedRouteMiddleware)
|
||||
r.Use(server.BasicAuthAdmin())
|
||||
mountSetupAPIAdmin(r)
|
||||
})
|
||||
|
||||
// Serve Stockholm setup wizard pages for paths not matched by the
|
||||
// management API. The Stockholm frontend has a setup/ directory that must
|
||||
// be accessible at /setup/*. Frontend-only — not mirrored under /api/setup.
|
||||
// Not gated by AdminAreaAuth: Stockholm is a separate, off-by-default
|
||||
// (--stockholm-dir) legacy wizard, out of scope for #419.
|
||||
if stockholmHandler != nil {
|
||||
r.Get("/*", stockholmHandler.HandleStatic)
|
||||
r.Get("/", stockholmHandler.HandleStatic)
|
||||
@@ -1656,7 +1899,11 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w
|
||||
})
|
||||
|
||||
r.Route("/api/setup", func(r chi.Router) {
|
||||
mountSetupAPI(r)
|
||||
mountSetupAPIShared(r)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.BasicAuthAdmin())
|
||||
mountSetupAPIAdmin(r)
|
||||
})
|
||||
})
|
||||
|
||||
// Embedded web UI: control API under /api/control and the SPA under /app
|
||||
|
||||
@@ -1,13 +1,147 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// newTestServiceContext builds a real *cli.Context against serviceFlags (the
|
||||
// exact flags soundtouch-service registers), so loadConfig tests exercise the
|
||||
// same parsing/env-var wiring production code does, instead of a hand-rolled
|
||||
// stand-in that could silently drift from it.
|
||||
func newTestServiceContext(t *testing.T, args ...string) *cli.Context {
|
||||
t.Helper()
|
||||
|
||||
app := &cli.App{Flags: serviceFlags}
|
||||
set := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
|
||||
for _, f := range serviceFlags {
|
||||
if err := f.Apply(set); err != nil {
|
||||
t.Fatalf("apply flag %v: %v", f.Names(), err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := set.Parse(args); err != nil {
|
||||
t.Fatalf("parse args %v: %v", args, err)
|
||||
}
|
||||
|
||||
return cli.NewContext(app, set, nil)
|
||||
}
|
||||
|
||||
func TestResolveFallbackHost(t *testing.T) {
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
hostname = strings.ToLower(hostname)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
deploymentMode string
|
||||
wantHost string
|
||||
wantWarn bool
|
||||
}{
|
||||
{"on-device uses localhost, no warning", "on-device", "localhost", false},
|
||||
{"public-network returns no fallback, no warning (caller must fail fast)", "public-network", "", false},
|
||||
{"private-network uses this host's own hostname, with warning", "private-network", hostname, true},
|
||||
{"unset/legacy behaves like private-network", "", hostname, true},
|
||||
{"unrecognized mode behaves like private-network", "some-typo", hostname, true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotHost, gotWarn := resolveFallbackHost(tc.deploymentMode)
|
||||
if gotHost != tc.wantHost {
|
||||
t.Errorf("host: got %q, want %q", gotHost, tc.wantHost)
|
||||
}
|
||||
|
||||
if gotWarn != tc.wantWarn {
|
||||
t.Errorf("warnOnUse: got %v, want %v", gotWarn, tc.wantWarn)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_DeploymentMode(t *testing.T) {
|
||||
t.Run("on-device with no --server-url defaults to localhost", func(t *testing.T) {
|
||||
config, err := loadConfig(newTestServiceContext(t, "--deployment-mode=on-device", "--port=8000"))
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if config.serverURL != "http://localhost:8000" {
|
||||
t.Errorf("serverURL: got %q, want %q", config.serverURL, "http://localhost:8000")
|
||||
}
|
||||
|
||||
if config.httpsDefaultURL != "https://localhost:8443" {
|
||||
t.Errorf("httpsDefaultURL: got %q, want %q", config.httpsDefaultURL, "https://localhost:8443")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("public-network with no --server-url fails fast instead of guessing", func(t *testing.T) {
|
||||
_, err := loadConfig(newTestServiceContext(t, "--deployment-mode=public-network"))
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "public-network") {
|
||||
t.Errorf("expected error to mention public-network, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("public-network with an explicit --server-url succeeds", func(t *testing.T) {
|
||||
config, err := loadConfig(newTestServiceContext(t,
|
||||
"--deployment-mode=public-network", "--server-url=https://soundtouch.example.com"))
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if config.serverURL != "https://soundtouch.example.com" {
|
||||
t.Errorf("serverURL: got %q, want %q", config.serverURL, "https://soundtouch.example.com")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unset deployment-mode with no --server-url keeps today's hostname fallback", func(t *testing.T) {
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
hostname = strings.ToLower(hostname)
|
||||
|
||||
config, err := loadConfig(newTestServiceContext(t, "--port=8000"))
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
want := "http://" + hostname + ":8000"
|
||||
if config.serverURL != want {
|
||||
t.Errorf("serverURL: got %q, want %q (legacy installs must keep working without --deployment-mode)", config.serverURL, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit --server-url always wins regardless of deployment-mode", func(t *testing.T) {
|
||||
for _, mode := range []string{"", "on-device", "private-network", "public-network"} {
|
||||
config, err := loadConfig(newTestServiceContext(t,
|
||||
"--deployment-mode="+mode, "--server-url=http://198.51.100.7:8000"))
|
||||
if err != nil {
|
||||
t.Fatalf("mode %q: loadConfig: unexpected error: %v", mode, err)
|
||||
}
|
||||
|
||||
if config.serverURL != "http://198.51.100.7:8000" {
|
||||
t.Errorf("mode %q: serverURL: got %q, want explicit override unchanged", mode, config.serverURL)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyPersistedSettings(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "main-test")
|
||||
if err != nil {
|
||||
|
||||
@@ -39,11 +39,8 @@ func TestPrintRoutes(t *testing.T) {
|
||||
// Now we might have "soundtouch-service.setupRouter.func1"
|
||||
// or "command-line-arguments.setupRouter.func1"
|
||||
// or "main.setupRouter.func1"
|
||||
// Let's remove the first part if it's a known varying package name
|
||||
if idx := strings.Index(handlerName, "setupRouter"); idx != -1 {
|
||||
handlerName = handlerName[idx:]
|
||||
}
|
||||
// In case it's not setupRouter but still has a package prefix
|
||||
// Remove the leading package/binary-name segment(s), whatever form
|
||||
// they take.
|
||||
for {
|
||||
dotIdx := strings.Index(handlerName, ".")
|
||||
if dotIdx == -1 {
|
||||
|
||||
+3
-1
@@ -36,6 +36,7 @@ GET /accounts/{account}/devices/{device}/recents handlers.(
|
||||
GET /accounts/{account}/full handlers.(*Server).HandleUnsupported-fm
|
||||
GET /accounts/{account}/sources handlers.(*Server).HandleUnsupported-fm
|
||||
GET /admin handlers.(*Server).HandleAdmin-fm
|
||||
GET /api/announcements handlers.(*Server).HandleListAnnouncements-fm
|
||||
GET /api/control/devices/ soundtouchweb.(*WebApp).HandleAPIDevices-fm
|
||||
GET /api/control/devices/{id}/ soundtouchweb.(*WebApp).HandleAPIDevice-fm
|
||||
GET /api/control/devices/{id}/action/{action} soundtouchweb.(*WebApp).HandleAPIControl-fm
|
||||
@@ -168,7 +169,7 @@ GET /streaming/sourceproviders handlers.(
|
||||
GET /updates/soundtouch handlers.(*Server).HandleMargeSoftwareUpdate-fm
|
||||
GET /v1/auth handlers.(*Server).HandleSpeakerAuth-fm
|
||||
GET /v1/blacklist/{deviceId} setupRouter
|
||||
GET /web/* setupRouter.(*Server).HandleWeb
|
||||
GET /web/* handlers.(*Server).HandleWeb
|
||||
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
@@ -182,6 +183,7 @@ POST /accounts/{account}/group handlers.(
|
||||
POST /accounts/{account}/group/ handlers.(*Server).HandleUnsupported-fm
|
||||
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleUnsupported-fm
|
||||
POST /alexa/certificate handlers.(*Server).HandleAlexaCertificate-fm
|
||||
POST /api/announcements/{id}/dismiss handlers.(*Server).HandleDismissAnnouncement-fm
|
||||
POST /api/control/devices/{id}/action/{action} soundtouchweb.(*WebApp).HandleAPIControl-fm
|
||||
POST /api/control/devices/{id}/key/{key} soundtouchweb.(*WebApp).HandleDeviceKey-fm
|
||||
POST /api/control/devices/{id}/library/play soundtouchweb.(*WebApp).HandlePlayLibrary-fm
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
)
|
||||
|
||||
func TestShouldCheckImmediately(t *testing.T) {
|
||||
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
|
||||
interval := 24 * time.Hour
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
lastCheckedAt time.Time
|
||||
want bool
|
||||
}{
|
||||
{"never checked", time.Time{}, true},
|
||||
{"stale (older than interval)", now.Add(-25 * time.Hour), true},
|
||||
{"exactly one interval ago", now.Add(-interval), true},
|
||||
{"recent (within interval)", now.Add(-1 * time.Hour), false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := shouldCheckImmediately(tc.lastCheckedAt, interval, now); got != tc.want {
|
||||
t.Errorf("%s: shouldCheckImmediately() = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkipDueToBackoff(t *testing.T) {
|
||||
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
lastErrorAt time.Time
|
||||
want bool
|
||||
}{
|
||||
{"no recent failure", time.Time{}, false},
|
||||
{"failed 30 minutes ago", now.Add(-30 * time.Minute), true},
|
||||
{"failed exactly 1 hour ago", now.Add(-time.Hour), false},
|
||||
{"failed 2 hours ago", now.Add(-2 * time.Hour), false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := shouldSkipDueToBackoff(tc.lastErrorAt, now); got != tc.want {
|
||||
t.Errorf("%s: shouldSkipDueToBackoff() = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogUpdateIfNewlyAvailable(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
result updatecheck.Result
|
||||
lastLoggedVersion string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nothing available",
|
||||
result: updatecheck.Result{Available: false},
|
||||
lastLoggedVersion: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "newly available",
|
||||
result: updatecheck.Result{Available: true, LatestVersion: "v1.1.0"},
|
||||
lastLoggedVersion: "",
|
||||
want: "v1.1.0",
|
||||
},
|
||||
{
|
||||
name: "already logged this version",
|
||||
result: updatecheck.Result{Available: true, LatestVersion: "v1.1.0"},
|
||||
lastLoggedVersion: "v1.1.0",
|
||||
want: "v1.1.0",
|
||||
},
|
||||
{
|
||||
name: "a newer version than what was logged",
|
||||
result: updatecheck.Result{Available: true, LatestVersion: "v1.2.0"},
|
||||
lastLoggedVersion: "v1.1.0",
|
||||
want: "v1.2.0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := logUpdateIfNewlyAvailable(tc.result, tc.lastLoggedVersion); got != tc.want {
|
||||
t.Errorf("%s: logUpdateIfNewlyAvailable() = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomJitter(t *testing.T) {
|
||||
if got := randomJitter(0); got != 0 {
|
||||
t.Errorf("randomJitter(0) = %v, want 0", got)
|
||||
}
|
||||
|
||||
upperBound := 5 * time.Minute
|
||||
for i := 0; i < 20; i++ {
|
||||
got := randomJitter(upperBound)
|
||||
if got < 0 || got >= upperBound {
|
||||
t.Fatalf("randomJitter(%v) = %v, want in [0, %v)", upperBound, got, upperBound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// There is deliberately no test for startUpdateCheck itself, matching
|
||||
// startDeviceDiscovery (its equally untested sibling): both are thin,
|
||||
// forever-looping goroutine wrappers whose only decisions live in pure
|
||||
// helpers, which is what the tests above and below cover. The former
|
||||
// TestStartUpdateCheck_DisabledIsANoOp asserted a contract that no longer
|
||||
// exists — the goroutine now always starts, precisely so that enabling the
|
||||
// check from the Settings page takes effect without a restart, and an
|
||||
// early return for "disabled" would defeat that.
|
||||
func TestShouldRunUpdateCheckNow(t *testing.T) {
|
||||
now := time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC)
|
||||
interval := 24 * time.Hour
|
||||
stale := now.Add(-25 * time.Hour)
|
||||
fresh := now.Add(-1 * time.Hour)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
enabled bool
|
||||
lastCheckedAt time.Time
|
||||
interval time.Duration
|
||||
lastErrorAt time.Time
|
||||
want bool
|
||||
}{
|
||||
{"disabled, never checked", false, time.Time{}, interval, time.Time{}, false},
|
||||
{"disabled, due", false, stale, interval, time.Time{}, false},
|
||||
{"enabled, never checked", true, time.Time{}, interval, time.Time{}, true},
|
||||
{"enabled, due", true, stale, interval, time.Time{}, true},
|
||||
{"enabled, not due yet", true, fresh, interval, time.Time{}, false},
|
||||
{"enabled and due, but in error backoff", true, stale, interval, now.Add(-30 * time.Minute), false},
|
||||
{"enabled and due, backoff expired", true, stale, interval, now.Add(-2 * time.Hour), true},
|
||||
// A zero interval must not turn every poll tick into a GitHub request.
|
||||
{"enabled with a zero interval", true, stale, 0, time.Time{}, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := shouldRunUpdateCheckNow(tc.enabled, tc.lastCheckedAt, tc.interval, tc.lastErrorAt, now)
|
||||
if got != tc.want {
|
||||
t.Errorf("%s: shouldRunUpdateCheckNow() = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCheckPollTickIsShorterThanTheDefaultInterval guards the property
|
||||
// that makes the Settings-page toggle feel live: the goroutine must re-read
|
||||
// the settings far more often than the check interval itself, otherwise
|
||||
// switching the check on would appear to do nothing for up to a day.
|
||||
func TestUpdateCheckPollTickIsShorterThanTheDefaultInterval(t *testing.T) {
|
||||
if updateCheckPollTick >= 24*time.Hour {
|
||||
t.Errorf("updateCheckPollTick = %v, want well below the 24h default interval", updateCheckPollTick)
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,7 @@ default/
|
||||
dns/
|
||||
interactions/
|
||||
parity_mismatches/
|
||||
stats/
|
||||
patterns.json
|
||||
settings.json
|
||||
update-check.json
|
||||
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
start_period: 3s
|
||||
|
||||
spotify-mock:
|
||||
image: golang:1.26.5-alpine
|
||||
image: golang:1.27.0-alpine
|
||||
container_name: spotify-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
start_period: 3s
|
||||
|
||||
amazon-mock:
|
||||
image: golang:1.26.5-alpine
|
||||
image: golang:1.27.0-alpine
|
||||
container_name: amazon-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
@@ -71,7 +71,7 @@ services:
|
||||
start_period: 3s
|
||||
|
||||
tunein-mock:
|
||||
image: golang:1.26.5-alpine
|
||||
image: golang:1.27.0-alpine
|
||||
container_name: tunein-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
|
||||
@@ -112,6 +112,14 @@ Factory-reset the same speaker again and run the full state machine — the same
|
||||
|
||||
This drives `setup.Manager.ExecuteInitPlan` with `SkipURLRewrite=true`, which runs:
|
||||
|
||||
> **Update (#615):** `--mode=full` now preflights via `Manager.PreflightInitPlan`
|
||||
> before opening the WebSocket — it checks `/supportedURLs` for
|
||||
> `/setMargeAccount` and requires `/soundTouchConfigurationStatus` to read
|
||||
> `SOUNDTOUCH_NOT_CONFIGURED`, and no-ops on an already-configured device.
|
||||
> A freshly factory-reset speaker (as in this experiment) reports
|
||||
> `SOUNDTOUCH_NOT_CONFIGURED`, so the preflight passes through unchanged;
|
||||
> see `docs/content/docs/reference/DEVICE-PAIRING-FLOW.md`.
|
||||
|
||||
```
|
||||
SETUP_START
|
||||
SETUP_IDENTIFY_DEVICE_ENTER
|
||||
|
||||
@@ -100,16 +100,16 @@ 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 |
|
||||
| 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. On Lifestyle/CineMate console devices this is **not** a no-op: it puts the console into standby and, on waking, returns it to the console's own input rather than SoundTouch — see [Lifestyle / Console Device Behavior](../guides/TROUBLESHOOTING.md#lifestyle-console-devices) and #597. | 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.
|
||||
|
||||
@@ -150,11 +150,15 @@ Each `sys configuration` setter is reported by users to return `OK` on success.
|
||||
|
||||
`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) |
|
||||
**It's a commit point, not just a two-field setter.** `envswitch boseurls set` persists whatever is currently in the runtime layer at the moment it runs — not only its own two arguments. Confirmed on five variants (`lisa`, `mojo`, `spotty`, `ginger`, `taigan`; [#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)): a `sys configuration` write survives a reboot **if and only if** an `envswitch boseurls set` runs after it. The same command sequence in reverse order silently loses the later `sys configuration` values on reboot — every command still answers, nothing looks wrong until the reboot. This is why our migration and SSH-enable sequences always issue all four `sys configuration` writes first and `envswitch boseurls set` last (see `telnetURLs.Commands()` / `EnableSSHViaTelnetFullConfig`).
|
||||
|
||||
**It does not acknowledge with `OK`.** Unlike `sys configuration` (which does), `envswitch boseurls set` responds with a different string (observed: `Setting Bose Server URLs to <a> and <b> ->`, no `OK` substring). An implementation that waits for the literal token `OK` will hit its own timeout on this exact command. Our `pkg/telnet.Client.SendCommand` doesn't string-match at all — it reads until the connection goes idle — so this only matters if you're hand-typing the sequence or reimplementing the client elsewhere.
|
||||
|
||||
| Command | Purpose | Source |
|
||||
|-------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|
|
||||
| `envswitch boseurls set <margeUrl> <swUpdateUrl>` | Persist the marge and update URLs, committing the runtime layer as it stands (see above). **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`, bare `envswitch`, `envswitch boseurls` | **Confirmed unsupported** — all answer `Invalid Command Option` on `lisa`/`mojo`/`spotty` ([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)). `envswitch` has no read form on any variant tested; the persisted layer can only be written, then observed indirectly after a reboot (e.g. via `getpdo`, which then reflects the *new* value). | (probe) |
|
||||
|
||||
---
|
||||
|
||||
@@ -166,6 +170,8 @@ Each `sys configuration` setter is reported by users to return `OK` on success.
|
||||
|-------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|
|
||||
| `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 two layers are inverted in `getpdo` visibility around a reboot** ([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)): *before* a reboot, `getpdo` shows the runtime (`sys configuration`) values immediately, while an `envswitch`-written value isn't visible yet; *after* a reboot, the `sys configuration` values are gone and the `envswitch`-persisted values are what's now applied. So a `getpdo` check run before rebooting confirms the writes were accepted, but it is **not** evidence the configuration will survive the reboot — only the `envswitch` write (in the right order, see above) determines that. This is why our own migration verification (`migrateViaTelnet`) checks `getpdo` before reboot only to confirm the runtime layer accepted the values, and never claims persistence from it.
|
||||
|
||||
---
|
||||
|
||||
## The `scm` family — service control
|
||||
@@ -295,6 +301,14 @@ sys reboot
|
||||
|
||||
**Which devices need `--full-config`:** observed on the **SoundTouch Portable (Series I, model 412540, FW `27.0.6.46330.5043500`)** (#515) and on some **CineMate 520** units where the default path leaves `sshd` down. The structural differences from the default path that appear to matter are (1) the injection riding `sys configuration margeServerUrl`, not just `envswitch`, and (2) the explicit `sys reboot`. The `--full-config` automation is **candidate behaviour awaiting reporter confirmation** — the manual sequence is confirmed working on the ST Portable, but the flag that automates it has not yet been re-confirmed on hardware. Not every device responds even to the manual sequence (some ST10 and CineMate 520 units never start `sshd` over telnet at all and need the serial / U-Boot route).
|
||||
|
||||
**On the `--command-delay` between steps:** originally added because a reporter's back-to-back run left `sshd` down while a ~7s-gapped run succeeded ([#515 comment 5228449448](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5228449448)). That theory was **retracted** by the same reporter after a controlled A/B across three variants showed identical outcomes at 0s and 5s gaps ([comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)) — the delay itself doesn't appear to matter. The default is kept small and non-zero (`setup.DefaultTelnetCommandDelay`) as a low-cost hedge for untested variants, not because the delay is known to help.
|
||||
|
||||
**The account-pairing precondition** (raised by `Henri-be`, [#515 comment 5230785528](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5230785528), tracing back to [#471 comment 4903016740](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-4903016740); confirmed empirically by `bitranox`, [#515 comment 5232241580](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5232241580)): a genuinely unpaired (factory-reset, empty `margeAccountUUID`) device does not poll `margeServerUrl` **at all** — confirmed by pointing a reset device's marge URL at a listener and observing zero requests over 10+ minutes. The SSH-enable injection has no read cycle to fire on until the device is paired. `enable-ssh` handles this automatically by default (`EnsureMargeAccountPaired`, `--no-auto-pair` to skip).
|
||||
|
||||
**Factory reset does not remove root access, if it was ever persisted.** Confirmed on a genuinely factory-reset `spotty` ([#471 comment 5232232575](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5232232575)): after the reset, `margeAccountUUID` was empty, all four service URLs were back to `streaming.bose.com`, and presets were gone — but `/etc/remote_services` and `/mnt/nv/remote_services` **survived**, and SSH (:22) and telnet (:17000) stayed open. So once a device has been through `setup enable-ssh` with persistence (`EnsureRemoteServices`, the default), a later factory reset only wipes configuration, not root access — recovery is re-migrate + re-pair + rename + restore presets, with **no USB stick and no re-running the injection**.
|
||||
|
||||
**Readiness after a reboot is per-port, not a single moment.** `JRpersonal` first measured that the firmware needs roughly 60s after a cold boot before `:8090`'s `/info` answers and marge state is ready — a booting device answers a bare `HTTP 400` with an empty body before its services are up, which is easy to misread as a rejection rather than "too early" ([#471 comment 5231997551](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5231997551)). `bitranox` refined this across three variants: `:8090` and the diagnostic `:17000` shell (and the config subsystem behind it that `getpdo` reads) do **not** become ready at the same time — waiting for `:8090` and then immediately reading over `:17000` returned an empty response even though the box was otherwise up. Ten observed reboots: down in 2.3–5.3s, ready (able to answer `getpdo` correctly) in 55.1–91.8s, median ~69.8s ([#471 comment 5232046477](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5232046477)). Anything automated should wait for the specific interface it's about to use, not for a different port to answer first — see the troubleshooting guide's [power-cycle retry note](../guides/TROUBLESHOOTING.md) for the user-facing version of this.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope here, but worth recording
|
||||
|
||||
@@ -82,6 +82,16 @@ Three important details from the discussion:
|
||||
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.**
|
||||
|
||||
A later, more precise measurement ([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569), confirmed on
|
||||
five variants: `lisa`/`mojo`/`spotty`/`ginger`/`taigan`) explains *why*
|
||||
order matters: `envswitch boseurls set` is not just a two-field setter, it
|
||||
**commits whatever is currently in the runtime layer at the moment it
|
||||
runs**. A `sys configuration` write only survives a reboot if `envswitch
|
||||
boseurls set` runs **after** it; the same commands in reverse order lose
|
||||
the `sys configuration` values silently on reboot, with every individual
|
||||
command still answering normally. This is why the sequence above is
|
||||
ordered all-four-`sys-configuration`-then-`envswitch`, never the reverse.
|
||||
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`
|
||||
@@ -91,8 +101,15 @@ Three important details from the discussion:
|
||||
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).
|
||||
3. **Each command must be sent one at a time, waiting for the device's
|
||||
response** before sending the next one (`foob61451`'s original warning).
|
||||
Note the exception: `sys configuration` commands ack with `OK`, but
|
||||
`envswitch boseurls set` does **not** — it acks with a different string
|
||||
entirely (observed: `Setting Bose Server URLs to <a> and <b> ->`, no `OK`
|
||||
substring; [#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)). An implementation that waits for the
|
||||
literal token `OK` will time out on exactly that command. Wait for the
|
||||
shell's prompt (or, as our own `pkg/telnet.Client` does, for the
|
||||
connection to go idle) rather than string-matching `OK`.
|
||||
|
||||
### 2.2 Account pairing fallback
|
||||
|
||||
@@ -106,7 +123,11 @@ in-band equivalent to the HTTP `/setMargeAccount` call, useful when the
|
||||
about.
|
||||
- Useful read-only verification command: `getpdo CurrentSystemConfiguration` —
|
||||
prints the URLs after the changes have been applied so we can verify before
|
||||
rebooting.
|
||||
rebooting. **It only reflects the runtime (`sys configuration`) layer, not
|
||||
the `envswitch`-persisted layer, so a matching `getpdo` here confirms the
|
||||
writes were accepted, not that they will survive the reboot** — see the
|
||||
layer-visibility caveat in
|
||||
[TELNET-COMMAND-REFERENCE.md](TELNET-COMMAND-REFERENCE.md).
|
||||
- `sys reboot` is the trigger that re-reads both layers.
|
||||
|
||||
### 2.4 What Telnet:17000 cannot do
|
||||
@@ -145,6 +166,22 @@ 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.
|
||||
|
||||
> **Booby trap, confirmed on hardware: never send an empty or truncated body
|
||||
> to this endpoint.** On one firmware, a `POST /setMargeAccount` with an
|
||||
> empty body returned `HTTP 200` and cleared `margeAccountUUID`, un-pairing
|
||||
> an already-working speaker
|
||||
> ([#471 comment 5231977172](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5231977172)).
|
||||
> A later retry on the same device instead returned `400` and changed
|
||||
> nothing, so the same reporter corrected the finding to
|
||||
> **state-dependent, not a reliable rule you can rely on either way**
|
||||
> ([#471 comment 5232232575](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5232232575)). A `400` is not proof the
|
||||
> endpoint rejected a bad request (a booting device also answers a bare
|
||||
> `400` with an empty body before its services are ready, per
|
||||
> `JRpersonal`), and a `200` is not proof it did what you wanted. Practical
|
||||
> takeaway: our own `postSetMargeAccount` always sends a well-formed XML
|
||||
> body, so this doesn't affect the CLI/service — but don't probe this
|
||||
> endpoint by hand against a speaker that currently works.
|
||||
|
||||
### 3.2 Why it's broken in practice
|
||||
|
||||
There are **three independent failure modes** observed:
|
||||
@@ -189,10 +226,16 @@ control:
|
||||
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.
|
||||
- **Telnet read-back: confirmed unsupported.** `envswitch accountid get` was
|
||||
originally listed as "plausible by symmetry with `envswitch accountid set`
|
||||
(#221), not yet confirmed." It's now confirmed the other way: on
|
||||
`lisa`/`mojo`/`spotty`, `envswitch` has **no read form at all** — both bare
|
||||
`envswitch` and `envswitch boseurls` answer `Invalid Command Option`
|
||||
([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)).
|
||||
The persisted layer can only be written, then
|
||||
observed indirectly after a reboot (e.g. via `getpdo`, mindful of the
|
||||
layer-visibility caveat in
|
||||
[TELNET-COMMAND-REFERENCE.md](TELNET-COMMAND-REFERENCE.md)).
|
||||
|
||||
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
|
||||
|
||||
@@ -22,6 +22,8 @@ The encrypted `.age` file decrypts to a `.tar.gz` archive with:
|
||||
Source, SourceID, location), device product code, firmware version, IP, name
|
||||
- `datastore/accounts/{id}/devices/{id}/*.xml` — raw XML files verbatim from
|
||||
the sender's datastore (`Presets.xml`, `Sources.xml`, `Recents.xml`, …)
|
||||
- `stats/activity/{kind}/*.json` — the local admin-UI activity log (e.g.
|
||||
announcement-banner dismissals), verbatim, one file per recorded event
|
||||
|
||||
Having both the structured JSON and the raw XML lets you compare what the
|
||||
service serves via HTTP against what is actually stored on disk.
|
||||
@@ -31,6 +33,24 @@ secrets, Spotify refresh tokens. The raw XML files are included as-is.
|
||||
|
||||
---
|
||||
|
||||
## Local activity log
|
||||
|
||||
AfterTouch records a small local activity log for admin-UI actions —
|
||||
today, just announcement-banner dismissals (e.g. the admin-area-gate notice
|
||||
from issue #419) — under `stats/activity/{kind}/` in the data directory.
|
||||
Each event is its own plain JSON file (id, timestamp, and any detail),
|
||||
readable with a text editor; there is no encoding or opaque format to
|
||||
decode.
|
||||
|
||||
This follows the same "[all data stays on your
|
||||
network](SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md)" principle as the rest of
|
||||
AfterTouch: nothing here is ever transmitted automatically. The only way it
|
||||
leaves the operator's network is the same as everything else in this
|
||||
document — an explicitly-triggered diagnostic export, which the operator
|
||||
has to click a button and choose to send.
|
||||
|
||||
---
|
||||
|
||||
## Maintainer setup (one-time)
|
||||
|
||||
> This section is for the project maintainer only.
|
||||
|
||||
@@ -566,7 +566,7 @@ func (m *MockClient) GetNowPlaying() (*models.NowPlaying, error) {
|
||||
|
||||
```dockerfile
|
||||
# test/docker/Dockerfile
|
||||
FROM golang:1.25-alpine
|
||||
FROM golang:1.27.0-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
@@ -17,12 +17,17 @@ then **which build** matches your computer.
|
||||
AfterTouch is a small set of separate programs. Most people run one or
|
||||
two of them.
|
||||
|
||||
| Tool | What it does | You want this if… |
|
||||
|----------------------|-----------------------------------------------------------------------------------------------|----------------------------------------------------------|
|
||||
| `soundtouch-service` | The local cloud replacement ("AfterTouch"). Runs always-on and takes over from the Bose cloud. | You are migrating speakers off the Bose cloud. |
|
||||
| `soundtouch-player` | A browser control panel (radio browsing, device control). | You want a web UI to browse radio and control speakers. |
|
||||
| `soundtouch-cli` | Command-line control and setup (status, play, presets, groups, **migration**, …). | You want to script things, or run a migration by hand. |
|
||||
| `soundtouch-backup` | Backs up your Bose cloud account and each speaker's local state. | You are preparing before a shutdown / factory reset. |
|
||||
| Tool | What it does | You want this if… |
|
||||
|----------------------|------------------------------------------------------------------------------------------------|---------------------------------------------------------|
|
||||
| `soundtouch-service` | The local cloud replacement ("AfterTouch"). Runs always-on and takes over from the Bose cloud. | You are migrating speakers off the Bose cloud. |
|
||||
| `soundtouch-cli` | Command-line control and setup (status, play, presets, groups, **migration**, …). | You want to script things, or run a migration by hand. |
|
||||
| `soundtouch-player` | A browser control panel (radio browsing, device control). | You want a web UI to browse radio and control speakers. |
|
||||
| `soundtouch-backup` | Backs up your Bose cloud account and each speaker's local state. | You are preparing before a shutdown / factory reset. |
|
||||
|
||||
Most people only need **`soundtouch-service`** and **`soundtouch-cli`** — the
|
||||
release notes on each [GitHub release](https://github.com/gesellix/Bose-SoundTouch/releases/latest)
|
||||
link those two directly, one row per platform, so you don't have to hunt
|
||||
through the flat Assets list below.
|
||||
|
||||
> Running a migration from the command line (for example the telnet
|
||||
> re-migration in the
|
||||
|
||||
@@ -592,6 +592,9 @@ soundtouch-cli --host <device> account remove-amazon --user <USER>
|
||||
soundtouch-cli --host <device> account remove-deezer --user <USER>
|
||||
soundtouch-cli --host <device> account remove-iheart --user <USER>
|
||||
soundtouch-cli --host <device> account remove-nas --user <GUID/0> [--name <NAME>]
|
||||
|
||||
# Unpair the device from its Marge cloud account entirely
|
||||
soundtouch-cli --host <device> account unpair
|
||||
```
|
||||
|
||||
**Supported Services:**
|
||||
@@ -648,6 +651,11 @@ soundtouch-cli --host 192.0.2.10 account remove \
|
||||
- Network music libraries (STORED_MUSIC) don't require passwords, only the UPnP server GUID
|
||||
- After adding an account, use `source list` to verify it appears as available
|
||||
- Some services may require additional authentication steps through their mobile apps
|
||||
- `account unpair` is different from the above: it sends `UnPairDeviceWithAccount`
|
||||
over the speaker's own local WebSocket to remove its **Marge cloud account**
|
||||
pairing entirely (`margeAccountUUID`), not a single streaming-service login.
|
||||
See `setup revert` for the related "undo a migration" operation, which
|
||||
deliberately does *not* call this — the two are separate steps.
|
||||
|
||||
### Bass Control
|
||||
|
||||
@@ -1157,6 +1165,290 @@ soundtouch-cli --host 192.0.2.10 events subscribe --filter zone --no-reconnect
|
||||
- Events are displayed in real-time with emoji indicators
|
||||
- Verbose mode shows additional technical details
|
||||
|
||||
### Update Check
|
||||
|
||||
#### `update-check`
|
||||
|
||||
Check GitHub Releases for a newer `soundtouch-cli` version. Unlike
|
||||
`soundtouch-service`'s periodic background check, this doesn't need a
|
||||
`--host` or any device on the network: it's a single, on-demand GitHub API
|
||||
request. Running the command is itself the opt-in, so there's no config
|
||||
flag or persisted state.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
soundtouch-cli update-check
|
||||
```
|
||||
|
||||
**Example output:**
|
||||
```
|
||||
A newer version is available: v1.3.0 (you're on v1.2.0)
|
||||
https://github.com/gesellix/Bose-SoundTouch/releases/tag/v1.3.0
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- `soundtouch-backup` has the same `update-check` command.
|
||||
- If the running binary isn't a released version (e.g. a dev build),
|
||||
the command reports that and skips the comparison.
|
||||
|
||||
### Setup & Migration
|
||||
|
||||
The `setup <subcommand>` group provisions a speaker end-to-end: enabling
|
||||
SSH, factory-reset + Wi-Fi re-provisioning, pointing it at AfterTouch, CA
|
||||
trust, account pairing, reverting, and one-shot data sync. Each subcommand
|
||||
wraps an existing `pkg/service/setup` helper directly — there's no separate
|
||||
business logic in the CLI layer. Manual provisioning-loop background:
|
||||
[docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md](../analysis/SETUP-WEBSOCKET-EXPERIMENT.md)
|
||||
and [Device Initial Setup](DEVICE-INITIAL-SETUP.md).
|
||||
|
||||
#### `setup inspect`
|
||||
|
||||
Non-destructive snapshot of the speaker: identity, pairing state, Wi-Fi,
|
||||
sources, presets, and (with `--telnet`) the runtime URL configuration via
|
||||
`getpdo`. Good first command to run against an unfamiliar speaker.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup inspect
|
||||
soundtouch-cli --host <device> setup inspect --telnet # also reads runtime URLs (slower)
|
||||
```
|
||||
|
||||
#### `setup ssh-check`
|
||||
|
||||
Probes whether port 22 is reachable. On failure, prints the `enable-ssh`
|
||||
suggestion and the USB-stick fallback procedure.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup ssh-check [--timeout 3s]
|
||||
```
|
||||
|
||||
#### `setup enable-ssh`
|
||||
|
||||
Bootstraps SSH on a speaker with no prior access, via the port-17000
|
||||
`envswitch` trick (#471) — no USB stick needed. Auto-pairs an unpaired
|
||||
(factory-reset) device first by default (the injection needs something to
|
||||
poll), waits for `:22`, and persists the `remote_services` marker so SSH
|
||||
survives a reboot.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup enable-ssh
|
||||
soundtouch-cli --host <device> setup enable-ssh --service-url https://192.0.2.10:8443
|
||||
```
|
||||
|
||||
Flags:
|
||||
- `--service-url` — optional; only the vehicle for the injection, no live
|
||||
server required. Set the real URL later via `setup migrate`.
|
||||
- `--wait` (default `90s`) — how long to wait for `:22` after injection.
|
||||
- `--full-config` — for stubborn devices (ST Portable, CineMate 520) where
|
||||
the default injection is accepted but `sshd` never starts: writes all
|
||||
four config URLs (the #515 sequence) and reboots.
|
||||
- `--command-delay` — only affects `--full-config`; pause between its 6
|
||||
steps.
|
||||
- `--no-auto-pair` / `--account` — skip or control the automatic pairing
|
||||
check.
|
||||
- `--no-reset-urls` — skip restoring clean `boseurls` after SSH is up.
|
||||
- `--no-persist` — skip persisting `remote_services` (SSH won't survive a
|
||||
reboot).
|
||||
- `--authorized-key` — opt-in hardening: install an SSH public key instead
|
||||
of relying on the empty-password login.
|
||||
- `--close-17000` — opt-in hardening: firewall off port 17000 from the LAN
|
||||
(loopback access kept).
|
||||
|
||||
#### `setup remote-services`
|
||||
|
||||
Enables (default) or removes the `remote_services` SSH-enablement marker.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup remote-services # ensure it's present
|
||||
soundtouch-cli --host <device> setup remote-services --remove # disable SSH after next reboot
|
||||
```
|
||||
|
||||
#### `setup factory-reset`
|
||||
|
||||
Issues `sys factorydefault` over telnet — wipes account, presets, and
|
||||
Wi-Fi, and reboots the speaker into its own setup-mode AP. Prints the next
|
||||
steps (`wait-ap`, then `wifi-push`).
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup factory-reset
|
||||
```
|
||||
|
||||
> **Heads-up:** just before resetting, the speaker sends
|
||||
> `DELETE /streaming/account/{id}/device/{id}` to whatever `margeURL` is
|
||||
> *currently* configured. If that still points at `streaming.bose.com`
|
||||
> (not AfterTouch), AfterTouch keeps a stale datastore entry — migrate
|
||||
> first if you want a clean record.
|
||||
|
||||
#### `setup wait-ap`
|
||||
|
||||
Polls the speaker's setup-mode AP (default `192.0.2.1`) until `/info`
|
||||
responds, after a factory reset.
|
||||
|
||||
```bash
|
||||
soundtouch-cli setup wait-ap [--ap-host 192.0.2.1] [--interval 2s] [--timeout 5m]
|
||||
```
|
||||
|
||||
#### `setup wifi-push`
|
||||
|
||||
POSTs `AddWirelessProfile` to the speaker's setup-mode endpoint — pushes
|
||||
your home Wi-Fi credentials while connected to the speaker's AP.
|
||||
|
||||
```bash
|
||||
soundtouch-cli setup wifi-push --ssid="YourHomeSSID" --pass='your-password'
|
||||
```
|
||||
|
||||
Flags: `--security` (default `wpa_or_wpa2`), `--ap-host` (default
|
||||
`192.0.2.1`), `--request-timeout` (default `30s` — the speaker can be slow
|
||||
to ACK before tearing down AP mode; 10s often races).
|
||||
|
||||
#### `setup wait-online`
|
||||
|
||||
Polls mDNS until a speaker matching `--match` comes online on the home
|
||||
network — run this after switching back from the speaker's AP.
|
||||
|
||||
```bash
|
||||
soundtouch-cli setup wait-online --match=<last-6-hex-of-deviceID>
|
||||
```
|
||||
|
||||
`--match` is empty by default (first speaker seen); `--interval` (`3s`) and
|
||||
`--timeout` (`5m`) control the poll.
|
||||
|
||||
#### `setup install-ca`
|
||||
|
||||
Fetches AfterTouch's CA cert from `/api/setup/ca.crt` and injects it into
|
||||
the speaker's trust store via SSH.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup install-ca --service-url https://192.0.2.10:8443
|
||||
```
|
||||
|
||||
`--auth` (`user:pass`) supplies basic-auth credentials up front; omit it to
|
||||
be prompted interactively if the endpoint returns 401.
|
||||
|
||||
#### `setup migrate`
|
||||
|
||||
Applies a migration method to point the speaker at AfterTouch — the CLI
|
||||
equivalent of the web UI's Migrate tab.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup migrate --service-url http://192.0.2.10:8000 --method telnet
|
||||
```
|
||||
|
||||
`--method` is one of `telnet` (default) | `hosts` | `resolv` | `xml`.
|
||||
`--proxy-url` sets an optional upstream proxy (only used by `--method=xml`).
|
||||
`--skip-preflight` skips AfterTouch's settings preflight check (useful when
|
||||
that endpoint is unreachable).
|
||||
|
||||
`--marge-url`/`--stats-url`/`--sw-update-url`/`--bmx-url` override the
|
||||
corresponding field instead of deriving it from `--service-url` (applies to
|
||||
both `--method=telnet` and `--method=xml`). Useful beyond soundcork-style
|
||||
setups: e.g. pointing a speaker back at the **original Bose cloud URLs**
|
||||
without a full `setup revert` — telnet writes both the runtime and
|
||||
persisted layers in a single connection, no SSH or `.original` backup
|
||||
needed:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup migrate --method telnet \
|
||||
--service-url https://streaming.bose.com \
|
||||
--marge-url https://streaming.bose.com \
|
||||
--stats-url https://events.api.bosecm.com \
|
||||
--sw-update-url https://worldwide.bose.com/updates/soundtouch \
|
||||
--bmx-url https://content.api.bose.io/bmx/registry/v1/services
|
||||
```
|
||||
|
||||
#### `setup revert`
|
||||
|
||||
Undoes a migration — the CLI equivalent of the web UI's "Revert to
|
||||
Defaults" button. Restores `SoundTouchSdkPrivateCfg.xml`, `/etc/hosts`, and
|
||||
`/etc/resolv.conf` from their `.original` backups, removes the AfterTouch
|
||||
DNS-hook artifacts, and strips just the AfterTouch-labeled certificate out
|
||||
of the trust bundle. No `--service-url` needed — everything it touches
|
||||
already lives on the speaker.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup revert
|
||||
```
|
||||
|
||||
**Out of scope for this command** (matches the web UI button): SSH /
|
||||
`remote_services` persistence (use `setup remote-services --remove`) and
|
||||
account pairing (use `account unpair`) are untouched — revert them
|
||||
separately if you want a fully clean speaker.
|
||||
|
||||
#### `setup reboot`
|
||||
|
||||
Reboots the speaker — useful to force the envswitch parallel-persistence
|
||||
layer to apply after a migration.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup reboot [--method telnet|ssh]
|
||||
```
|
||||
|
||||
`--method` defaults to `telnet`, which works without SSH on modern
|
||||
firmware.
|
||||
|
||||
#### `setup verify`
|
||||
|
||||
Read-only status probe across every migration axis (transports, URL
|
||||
configuration, DNS interception, CA/TLS, pairing) — doubles as a preflight
|
||||
check before applying changes and a verification step afterward. Exits
|
||||
non-zero if nothing reports migrated, so it's usable as a CI gate.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup verify --service-url http://192.0.2.10:8000
|
||||
```
|
||||
|
||||
#### `setup plan`
|
||||
|
||||
Recommends the next setup/migration steps based on `inspect` + `verify`
|
||||
state — prints a ready-to-run command for each recommended step.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup plan --service-url http://192.0.2.10:8000
|
||||
soundtouch-cli --host <device> setup plan --service-url http://192.0.2.10:8000 --reset # plan a full factory-reset → Wi-Fi → migrate → pair flow
|
||||
```
|
||||
|
||||
`--wifi-ssid` overrides the SSID used for the `wifi-push` step in a reset
|
||||
plan (default: reuse the SSID `inspect` found). `--include-pair` (default
|
||||
`true`) can be disabled if you'll pair manually.
|
||||
|
||||
#### `setup pair`
|
||||
|
||||
Pairs the speaker with an account via the WebSocket `SETUP` state machine
|
||||
(`--mode=full`, matching the Bose app's own flow) or a minimal
|
||||
`setMargeAccount`-only call (`--mode=bare`, the same underlying call the
|
||||
Health tab's "empty margeAccountUUID" QuickFix uses).
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup pair --mode=full --account=1111111 --service-url http://192.0.2.10:8000
|
||||
soundtouch-cli --host <device> setup pair --mode=bare --account=1111111 --service-url http://192.0.2.10:8000
|
||||
```
|
||||
|
||||
`--account` empty generates a fresh 7-digit ID. `--name` sets the speaker
|
||||
name during pairing (empty keeps current). `--language` defaults to `2`
|
||||
(English). `--token` defaults to a built-in placeholder matching the Bose
|
||||
app's token shape.
|
||||
|
||||
`--mode=full` first reads `/supportedURLs` and `/soundTouchConfigurationStatus`
|
||||
and only runs the state machine when the device reports
|
||||
`SOUNDTOUCH_NOT_CONFIGURED` (see [#615](https://github.com/gesellix/Bose-SoundTouch/issues/615):
|
||||
a speaker can be reachable, named, and already account-paired yet still
|
||||
report `SOUNDTOUCH_NOT_CONFIGURED`, leaving the "install the Bose app"
|
||||
prompt on screen — only a full pass through the state machine clears it).
|
||||
An already-configured device is a no-op; an unsupported route or an
|
||||
unrecognised status value fails the command instead of guessing.
|
||||
|
||||
#### `setup sync`
|
||||
|
||||
Pulls presets, recents, and sources from the speaker into AfterTouch's
|
||||
datastore — the CLI equivalent of the web UI's Devices → Sync Data button.
|
||||
Read-only towards the speaker: it never writes anything back.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup sync --service-url http://192.0.2.10:8000
|
||||
```
|
||||
|
||||
`--auth` (`user:pass`) supplies basic-auth credentials up front; omit it to
|
||||
be prompted interactively if the endpoint returns 401.
|
||||
|
||||
## Common Usage Patterns
|
||||
|
||||
### Quick Device Setup
|
||||
|
||||
@@ -115,25 +115,25 @@ type ProductionSoundTouchService struct {
|
||||
type Config struct {
|
||||
// Server settings
|
||||
ListenAddr string `env:"LISTEN_ADDR" default:":8080"`
|
||||
|
||||
|
||||
// SoundTouch settings
|
||||
DeviceHosts []string `env:"DEVICE_HOSTS" separator:","`
|
||||
DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"30s"`
|
||||
RequestTimeout time.Duration `env:"REQUEST_TIMEOUT" default:"15s"`
|
||||
MaxRetries int `env:"MAX_RETRIES" default:"3"`
|
||||
|
||||
|
||||
// Connection pool
|
||||
MaxConnections int `env:"MAX_CONNECTIONS" default:"10"`
|
||||
IdleTimeout time.Duration `env:"IDLE_TIMEOUT" default:"5m"`
|
||||
|
||||
|
||||
// Monitoring
|
||||
MetricsEnabled bool `env:"METRICS_ENABLED" default:"true"`
|
||||
HealthCheckInterval time.Duration `env:"HEALTH_CHECK_INTERVAL" default:"30s"`
|
||||
|
||||
|
||||
// Logging
|
||||
LogLevel string `env:"LOG_LEVEL" default:"info"`
|
||||
LogFormat string `env:"LOG_FORMAT" default:"json"`
|
||||
|
||||
|
||||
// Security
|
||||
EnableTLS bool `env:"ENABLE_TLS" default:"false"`
|
||||
TLSCertFile string `env:"TLS_CERT_FILE"`
|
||||
@@ -145,7 +145,7 @@ func LoadConfig() (*Config, error) {
|
||||
if err := env.Parse(cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
|
||||
return cfg, cfg.Validate()
|
||||
}
|
||||
|
||||
@@ -153,15 +153,15 @@ func (c *Config) Validate() error {
|
||||
if len(c.DeviceHosts) == 0 {
|
||||
return fmt.Errorf("at least one device host must be specified")
|
||||
}
|
||||
|
||||
|
||||
if c.RequestTimeout < time.Second {
|
||||
return fmt.Errorf("request timeout must be at least 1 second")
|
||||
}
|
||||
|
||||
|
||||
if c.EnableTLS && (c.TLSCertFile == "" || c.TLSKeyFile == "") {
|
||||
return fmt.Errorf("TLS cert and key files required when TLS is enabled")
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
@@ -191,7 +191,7 @@ pool:
|
||||
monitoring:
|
||||
metrics_enabled: true
|
||||
health_check_interval: "30s"
|
||||
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
format: "json"
|
||||
@@ -203,12 +203,12 @@ func LoadConfigFromFile(path string) (*Config, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
return &cfg, cfg.Validate()
|
||||
}
|
||||
```
|
||||
@@ -224,14 +224,14 @@ func LoadConfigFromFile(path string) (*Config, error) {
|
||||
type SecureNetworkConfig struct {
|
||||
// Allowed source IP ranges
|
||||
AllowedCIDRs []string
|
||||
|
||||
|
||||
// Rate limiting
|
||||
RateLimit int
|
||||
RateLimitWindow time.Duration
|
||||
|
||||
|
||||
// TLS configuration
|
||||
TLSConfig *tls.Config
|
||||
|
||||
|
||||
// Timeouts for security
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
@@ -240,7 +240,7 @@ type SecureNetworkConfig struct {
|
||||
|
||||
func NewSecureServer(config SecureNetworkConfig) *http.Server {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
|
||||
// Add middleware
|
||||
handler := applyMiddleware(mux,
|
||||
corsMiddleware(),
|
||||
@@ -249,7 +249,7 @@ func NewSecureServer(config SecureNetworkConfig) *http.Server {
|
||||
loggingMiddleware(),
|
||||
metricsMiddleware(),
|
||||
)
|
||||
|
||||
|
||||
return &http.Server{
|
||||
Handler: handler,
|
||||
TLSConfig: config.TLSConfig,
|
||||
@@ -275,12 +275,12 @@ func (r *DeviceControlRequest) Validate() error {
|
||||
if err := validate.Struct(r); err != nil {
|
||||
return fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
|
||||
|
||||
// Additional business logic validation
|
||||
if r.Action == "volume" && r.Volume == nil {
|
||||
return fmt.Errorf("volume value required for volume action")
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
@@ -302,12 +302,12 @@ func loadSecretsFromK8s() (*SecretsConfig, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
tlsKey, err := os.ReadFile("/etc/secrets/tls.key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
return &SecretsConfig{
|
||||
TLSCert: string(tlsCert),
|
||||
TLSKey: string(tlsKey),
|
||||
@@ -335,21 +335,21 @@ type Logger struct {
|
||||
|
||||
func NewLogger(level, format, component string) (*Logger, error) {
|
||||
logger := logrus.New()
|
||||
|
||||
|
||||
// Set level
|
||||
logLevel, err := logrus.ParseLevel(level)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.SetLevel(logLevel)
|
||||
|
||||
|
||||
// Set format
|
||||
if format == "json" {
|
||||
logger.SetFormatter(&logrus.JSONFormatter{
|
||||
TimestampFormat: time.RFC3339,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return &Logger{
|
||||
Logger: logger,
|
||||
component: component,
|
||||
@@ -376,15 +376,15 @@ type Metrics struct {
|
||||
RequestsTotal prometheus.CounterVec
|
||||
RequestDuration prometheus.HistogramVec
|
||||
RequestsInFlight prometheus.GaugeVec
|
||||
|
||||
|
||||
// Device metrics
|
||||
DevicesConnected prometheus.Gauge
|
||||
DeviceHealth prometheus.GaugeVec
|
||||
WebSocketConnections prometheus.Gauge
|
||||
|
||||
|
||||
// Error metrics
|
||||
ErrorsTotal prometheus.CounterVec
|
||||
|
||||
|
||||
// Business metrics
|
||||
VolumeChanges prometheus.CounterVec
|
||||
SourceChanges prometheus.CounterVec
|
||||
@@ -400,7 +400,7 @@ func NewMetrics() *Metrics {
|
||||
},
|
||||
[]string{"method", "endpoint", "status"},
|
||||
),
|
||||
|
||||
|
||||
RequestDuration: *prometheus.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "soundtouch_request_duration_seconds",
|
||||
@@ -409,14 +409,14 @@ func NewMetrics() *Metrics {
|
||||
},
|
||||
[]string{"method", "endpoint"},
|
||||
),
|
||||
|
||||
|
||||
DevicesConnected: prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "soundtouch_devices_connected",
|
||||
Help: "Number of connected devices",
|
||||
},
|
||||
),
|
||||
|
||||
|
||||
DeviceHealth: *prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "soundtouch_device_health",
|
||||
@@ -425,7 +425,7 @@ func NewMetrics() *Metrics {
|
||||
[]string{"device_id", "device_name"},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
// Register metrics
|
||||
prometheus.MustRegister(
|
||||
m.RequestsTotal,
|
||||
@@ -433,7 +433,7 @@ func NewMetrics() *Metrics {
|
||||
m.DevicesConnected,
|
||||
m.DeviceHealth,
|
||||
)
|
||||
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -457,7 +457,7 @@ type HealthChecker struct {
|
||||
func (hc *HealthChecker) Start(ctx context.Context) {
|
||||
ticker := time.NewTicker(hc.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -470,7 +470,7 @@ func (hc *HealthChecker) Start(ctx context.Context) {
|
||||
|
||||
func (hc *HealthChecker) checkAllDevices() {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
|
||||
for deviceID, device := range hc.manager.devices {
|
||||
wg.Add(1)
|
||||
go func(id string, dev *DeviceInfo) {
|
||||
@@ -478,18 +478,18 @@ func (hc *HealthChecker) checkAllDevices() {
|
||||
hc.checkDevice(id, dev)
|
||||
}(deviceID, device)
|
||||
}
|
||||
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (hc *HealthChecker) checkDevice(deviceID string, device *DeviceInfo) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), hc.timeout)
|
||||
defer cancel()
|
||||
|
||||
|
||||
start := time.Now()
|
||||
err := device.Client.Ping()
|
||||
duration := time.Since(start)
|
||||
|
||||
|
||||
if err != nil {
|
||||
device.Status = DeviceStatusUnhealthy
|
||||
hc.metrics.DeviceHealth.WithLabelValues(deviceID, device.Name).Set(0)
|
||||
@@ -507,14 +507,14 @@ func (hc *HealthChecker) HealthHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
healthy := 0
|
||||
total := 0
|
||||
|
||||
|
||||
for _, device := range hc.manager.devices {
|
||||
total++
|
||||
if device.Status == DeviceStatusHealthy {
|
||||
healthy++
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
status := map[string]interface{}{
|
||||
"status": "ok",
|
||||
"devices": map[string]interface{}{
|
||||
@@ -524,14 +524,14 @@ func (hc *HealthChecker) HealthHandler() http.HandlerFunc {
|
||||
},
|
||||
"timestamp": time.Now().UTC(),
|
||||
}
|
||||
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
|
||||
if healthy < total {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
status["status"] = "degraded"
|
||||
}
|
||||
|
||||
|
||||
json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
}
|
||||
@@ -560,16 +560,16 @@ func NewConnectionPool(maxIdle, maxActive int, idleTimeout time.Duration) *Conne
|
||||
maxActive: maxActive,
|
||||
idleTimeout: idleTimeout,
|
||||
}
|
||||
|
||||
|
||||
// Start cleanup goroutine
|
||||
go cp.cleanup()
|
||||
|
||||
|
||||
return cp
|
||||
}
|
||||
|
||||
func (cp *ConnectionPool) Get(host string, port int) (*client.Client, error) {
|
||||
key := fmt.Sprintf("%s:%d", host, port)
|
||||
|
||||
|
||||
// Check if connection exists and is valid
|
||||
if val, ok := cp.clients.Load(key); ok {
|
||||
conn := val.(*pooledConnection)
|
||||
@@ -580,35 +580,35 @@ func (cp *ConnectionPool) Get(host string, port int) (*client.Client, error) {
|
||||
// Connection expired, remove it
|
||||
cp.clients.Delete(key)
|
||||
}
|
||||
|
||||
|
||||
// Check active connection limit
|
||||
if atomic.LoadInt64(&cp.activeCount) >= int64(cp.maxActive) {
|
||||
return nil, fmt.Errorf("connection pool exhausted")
|
||||
}
|
||||
|
||||
|
||||
// Create new connection
|
||||
config := client.ClientConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
|
||||
newClient := client.NewClient(config)
|
||||
|
||||
|
||||
// Test connection
|
||||
if err := newClient.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to %s:%d: %w", host, port, err)
|
||||
}
|
||||
|
||||
|
||||
conn := &pooledConnection{
|
||||
client: newClient,
|
||||
lastUsed: time.Now(),
|
||||
created: time.Now(),
|
||||
}
|
||||
|
||||
|
||||
cp.clients.Store(key, conn)
|
||||
atomic.AddInt64(&cp.activeCount, 1)
|
||||
|
||||
|
||||
return newClient, nil
|
||||
}
|
||||
|
||||
@@ -621,7 +621,7 @@ type pooledConnection struct {
|
||||
func (cp *ConnectionPool) cleanup() {
|
||||
ticker := time.NewTicker(cp.idleTimeout / 2)
|
||||
defer ticker.Stop()
|
||||
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now()
|
||||
cp.clients.Range(func(key, val interface{}) bool {
|
||||
@@ -649,10 +649,10 @@ func NewCacheManager() *CacheManager {
|
||||
return &CacheManager{
|
||||
// Device info rarely changes, cache for 1 hour
|
||||
deviceInfoCache: cache.New(1*time.Hour, 2*time.Hour),
|
||||
|
||||
|
||||
// Capabilities never change, cache for 24 hours
|
||||
capabilitiesCache: cache.New(24*time.Hour, 48*time.Hour),
|
||||
|
||||
|
||||
// Volume changes frequently, cache for 5 seconds
|
||||
volumeCache: cache.New(5*time.Second, 10*time.Second),
|
||||
}
|
||||
@@ -662,12 +662,12 @@ func (cm *CacheManager) GetDeviceInfo(deviceID string, fetcher func() (*models.D
|
||||
if cached, found := cm.deviceInfoCache.Get(deviceID); found {
|
||||
return cached.(*models.DeviceInfo), nil
|
||||
}
|
||||
|
||||
|
||||
info, err := fetcher()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
cm.deviceInfoCache.Set(deviceID, info, cache.DefaultExpiration)
|
||||
return info, nil
|
||||
}
|
||||
@@ -702,7 +702,7 @@ func NewResilientSoundTouchService(client *client.Client) *ResilientSoundTouchSe
|
||||
log.Printf("Circuit breaker '%s' changed from '%s' to '%s'", name, from, to)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
return &ResilientSoundTouchService{
|
||||
client: client,
|
||||
cb: gobreaker.NewCircuitBreaker(settings),
|
||||
@@ -713,12 +713,12 @@ func (r *ResilientSoundTouchService) SetVolume(deviceID string, volume int) erro
|
||||
result, err := r.cb.Execute(func() (interface{}, error) {
|
||||
return nil, r.client.SetVolume(volume)
|
||||
})
|
||||
|
||||
|
||||
if err != nil {
|
||||
r.metrics.ErrorsTotal.WithLabelValues("circuit_breaker", "volume").Inc()
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
return result.(error)
|
||||
}
|
||||
```
|
||||
@@ -730,16 +730,16 @@ func (app *Application) Run(ctx context.Context) error {
|
||||
// Setup signal handling
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
|
||||
// Start services
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
|
||||
|
||||
// HTTP server
|
||||
server := &http.Server{
|
||||
Addr: app.config.ListenAddr,
|
||||
Handler: app.handler,
|
||||
}
|
||||
|
||||
|
||||
g.Go(func() error {
|
||||
app.logger.Info("Starting HTTP server", "addr", app.config.ListenAddr)
|
||||
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
||||
@@ -747,38 +747,38 @@ func (app *Application) Run(ctx context.Context) error {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
// Health checker
|
||||
g.Go(func() error {
|
||||
return app.healthChecker.Start(ctx)
|
||||
})
|
||||
|
||||
|
||||
// WebSocket manager
|
||||
g.Go(func() error {
|
||||
return app.wsManager.Start(ctx)
|
||||
})
|
||||
|
||||
|
||||
// Wait for shutdown signal
|
||||
go func() {
|
||||
<-sigChan
|
||||
app.logger.Info("Shutdown signal received")
|
||||
|
||||
|
||||
// Graceful shutdown with timeout
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
|
||||
// Shutdown HTTP server
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
app.logger.Error("HTTP server shutdown error", "error", err)
|
||||
}
|
||||
|
||||
|
||||
// Close WebSocket connections
|
||||
app.wsManager.Shutdown(shutdownCtx)
|
||||
|
||||
|
||||
// Close connection pool
|
||||
app.connectionPool.Close()
|
||||
}()
|
||||
|
||||
|
||||
return g.Wait()
|
||||
}
|
||||
```
|
||||
@@ -791,7 +791,7 @@ func (app *Application) Run(ctx context.Context) error {
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM golang:1.25-alpine AS builder
|
||||
FROM golang:1.27.0-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
@@ -830,7 +830,7 @@ services:
|
||||
networks:
|
||||
- soundtouch-net
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
ports:
|
||||
@@ -839,7 +839,7 @@ services:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
networks:
|
||||
- soundtouch-net
|
||||
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
ports:
|
||||
@@ -1011,7 +1011,7 @@ groups:
|
||||
annotations:
|
||||
summary: "SoundTouch device {{ $labels.device_name }} is unhealthy"
|
||||
description: "Device {{ $labels.device_id }} has been unhealthy for more than 2 minutes"
|
||||
|
||||
|
||||
- alert: HighErrorRate
|
||||
expr: rate(soundtouch_errors_total[5m]) > 0.1
|
||||
for: 5m
|
||||
@@ -1020,7 +1020,7 @@ groups:
|
||||
annotations:
|
||||
summary: "High error rate detected"
|
||||
description: "Error rate is {{ $value }} errors/second over the last 5 minutes"
|
||||
|
||||
|
||||
- alert: ServiceDown
|
||||
expr: up{job="soundtouch"} == 0
|
||||
for: 1m
|
||||
@@ -1040,33 +1040,33 @@ func (m *Manager) BackupConfigurations() error {
|
||||
Timestamp: time.Now(),
|
||||
Devices: make(map[string]DeviceConfig),
|
||||
}
|
||||
|
||||
|
||||
for deviceID, device := range m.devices {
|
||||
config := DeviceConfig{}
|
||||
|
||||
|
||||
// Backup presets
|
||||
if presets, err := device.Client.GetPresets(); err == nil {
|
||||
config.Presets = presets
|
||||
}
|
||||
|
||||
|
||||
// Backup settings
|
||||
if volume, err := device.Client.GetVolume(); err == nil {
|
||||
config.Volume = volume.TargetVolume
|
||||
}
|
||||
|
||||
|
||||
if bass, err := device.Client.GetBass(); err == nil {
|
||||
config.Bass = bass.TargetBass
|
||||
}
|
||||
|
||||
|
||||
backup.Devices[deviceID] = config
|
||||
}
|
||||
|
||||
|
||||
// Save to file
|
||||
data, err := json.MarshalIndent(backup, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
filename := fmt.Sprintf("backup_%s.json", time.Now().Format("2006-01-02_15-04-05"))
|
||||
return os.WriteFile(filepath.Join(m.config.BackupDir, filename), data, 0644)
|
||||
}
|
||||
@@ -1083,7 +1083,7 @@ func init() {
|
||||
runtime.GOMAXPROCS(int(limit))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Set GC target percentage
|
||||
if os.Getenv("GOGC") == "" {
|
||||
debug.SetGCPerc
|
||||
|
||||
@@ -99,18 +99,28 @@ After factory restore the speaker enters setup mode automatically; no power-cycl
|
||||
|
||||
## 6. AP Mode Wi-Fi Provisioning via Console
|
||||
|
||||
When BLE is unavailable (e.g. when using an Android emulator), use AP mode to push Wi-Fi credentials from the Mac command line.
|
||||
When BLE is unavailable (e.g. when using an Android emulator), use AP mode to push Wi-Fi credentials from the command line. The HTTP steps below (6.2, 6.3) are OS-agnostic; only the Wi-Fi-network-switching commands (6.1, 6.4) are platform-specific — macOS is shown inline, with Linux and Windows equivalents alongside.
|
||||
|
||||
### 6.1 Connect Mac to Speaker AP
|
||||
### 6.1 Connect your machine to the Speaker AP
|
||||
|
||||
After factory reset the speaker broadcasts an SSID like `Bose SoundTouch XXXX`. Connect the Mac to it:
|
||||
After factory reset the speaker broadcasts an SSID like `Bose SoundTouch XXXX`. Connect to it:
|
||||
|
||||
```bash
|
||||
# List nearby SSIDs — use System Settings → Wi-Fi (the airport command was removed in macOS Sequoia+)
|
||||
# Connect (replace with actual SSID)
|
||||
# macOS — list nearby SSIDs via System Settings → Wi-Fi (the `airport`
|
||||
# command was removed in macOS Sequoia+); connect (replace with actual SSID):
|
||||
networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux (NetworkManager) — one-shot connect, no password (open AP):
|
||||
nmcli device wifi connect "Bose SoundTouch XXXX"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows — connect via the built-in Wi-Fi menu, or from PowerShell:
|
||||
netsh wlan connect name="Bose SoundTouch XXXX"
|
||||
```
|
||||
|
||||
The speaker's web UI gateway is at `192.0.2.1` (verified: ST10 assigns `192.0.2.2` to the client via DHCP).
|
||||
|
||||
```bash
|
||||
@@ -143,20 +153,37 @@ Expected response: `<?xml version="1.0" encoding="UTF-8" ?><AddWirelessProfileRe
|
||||
|
||||
The speaker will disconnect from AP mode and join the home network within ~15–30 s.
|
||||
|
||||
### 6.4 Reconnect Mac to Home Network
|
||||
### 6.4 Reconnect to your Home Network
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
networksetup -setairportnetwork en0 "MyHomeNetwork" "MyPassword"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux (NetworkManager) — assumes the connection profile already exists
|
||||
# (e.g. from a prior manual connect); use `nmcli device wifi connect
|
||||
# "MyHomeNetwork" password "MyPassword"` instead for a first-time connect.
|
||||
nmcli connection up "MyHomeNetwork"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows
|
||||
netsh wlan connect name="MyHomeNetwork"
|
||||
```
|
||||
|
||||
Wait ~15 s for the speaker to join the home network, then verify:
|
||||
|
||||
```bash
|
||||
# Discover the speaker's new IP via mDNS
|
||||
dns-sd -B _soundtouch._tcp local &
|
||||
sleep 5 ; kill %1
|
||||
# macOS/Linux — discover the speaker's new IP via mDNS.
|
||||
# macOS: dns-sd ships with the OS. Linux: use avahi-browse (avahi-utils package).
|
||||
dns-sd -B _soundtouch._tcp local & # macOS
|
||||
avahi-browse -r _soundtouch._tcp # Linux — Ctrl-C to stop
|
||||
sleep 5 ; kill %1 2>/dev/null # only needed for the dns-sd form
|
||||
```
|
||||
|
||||
Windows has no equivalent built-in mDNS browser; use `soundtouch-cli discover devices` (this repo's own mDNS/UPnP discovery, cross-platform) or check your router's DHCP client list instead.
|
||||
|
||||
---
|
||||
|
||||
## Comparison: Initial Setup vs. Migration
|
||||
|
||||
@@ -40,7 +40,7 @@ systemd unit that starts on boot.
|
||||
To pin a specific version instead of the latest:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh v0.111.3
|
||||
sudo bash install.sh v0.123.0
|
||||
```
|
||||
|
||||
Check that the service is running:
|
||||
@@ -278,7 +278,7 @@ curl -s http://192.0.2.1:8090/presets
|
||||
|
||||
```bash
|
||||
sudo bash install.sh # updates to latest release
|
||||
sudo bash install.sh v0.111.3 # updates to a specific version
|
||||
sudo bash install.sh v0.123.0 # updates to a specific version
|
||||
```
|
||||
|
||||
The installer stops the service, downloads the new binary, and restarts
|
||||
|
||||
@@ -107,6 +107,10 @@ Open `http://<server>:8000` and go to the **Settings** tab.
|
||||
|
||||
Set the **Target Domain** to the address your speakers can reach — for example `https://soundtouch.fritz.box` or `http://192.0.2.100:8000`. This must be the host's address on your local network, not `localhost`.
|
||||
|
||||
> **Changing this later?** Saving Settings only updates AfterTouch's own record of its address — it does **not** reach out to any already-migrated speaker. Each speaker only learns a new address when you (re-)run Migrate for it (Step 5 below), regardless of migration method. If you change Target Domain after some speakers are already migrated, re-migrate each of them too, or they'll keep using whatever address they were originally migrated with. See [Troubleshooting: Changing Target Domain doesn't change what a speaker actually uses](TROUBLESHOOTING.md#settings-vs-migrate).
|
||||
|
||||
> **On-device install:** this "not `localhost`" rule is for the local-network-host and cloud/VPS scenarios above, where the service runs on a *different* machine than the speaker. If you're running AfterTouch directly on the speaker itself (see the [On-Device Install Walkthrough](ON-DEVICE-INSTALL-WALKTHROUGH.md)), the speaker and the service are the same machine — `http://localhost:8000` is exactly right there, and is the recommended value: it needs no DNS/mDNS to resolve and survives DHCP address changes since it never depends on the LAN address at all. Installs built after issue #546's fix set this automatically (via `DEPLOYMENT_MODE=on-device`); on older installs, or if the field still shows the speaker's own unresolvable Linux hostname (e.g. `http://spotty:8000`), set it here by hand.
|
||||
|
||||
If you plan to use DNS/DHCP redirect, enable the **DNS Discovery Server** and set the **DNS Bind Address** to `:53`. The upstream DNS should be your router's IP, not the service's own address.
|
||||
|
||||
> **Tip**: If you change settings and they don't seem to take effect, check `data/settings.json` — settings saved in the UI take precedence over environment variables.
|
||||
@@ -127,6 +131,14 @@ The XML migration writes updated configuration to the speaker's filesystem, whic
|
||||
4. Power-cycle the speaker (unplug the power cable, wait 10 seconds, reconnect).
|
||||
5. After boot, root SSH is available with no password: `ssh -oHostKeyAlgorithms=+ssh-rsa root@<SPEAKER-IP>`
|
||||
|
||||
**Or, without a USB stick:** `soundtouch-cli setup enable-ssh` (#471) bootstraps SSH purely over the network, using the speaker's telnet:17000 diagnostic shell (open by default on most firmware) to inject the SSH-enable command:
|
||||
|
||||
```shell
|
||||
soundtouch-cli --host <SPEAKER-IP> setup enable-ssh
|
||||
```
|
||||
|
||||
It waits for `:22` to come up and persists the change (survives a reboot) by default. Falls back to the USB-stick method above if telnet:17000 is closed or the injection doesn't take on your model.
|
||||
|
||||
You only need to do this once per speaker. SSH can remain enabled for future maintenance or be disabled after migration — your choice.
|
||||
|
||||
**To disable SSH after migration:**
|
||||
|
||||
@@ -14,7 +14,9 @@ documenting a successful fresh installation on a SoundTouch 20 Series I.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- SSH enabled on the speaker (the usual "Stick with remote_services" procedure).
|
||||
- SSH enabled on the speaker — either the usual "USB stick with
|
||||
`remote_services`" procedure, or `soundtouch-cli setup enable-ssh`
|
||||
(no stick needed, see Step 1).
|
||||
- Your machine can reach the speaker on the LAN.
|
||||
- The speaker's LAN IP address — replace `192.0.2.1` throughout with the
|
||||
actual address shown in your router or `arp -a`.
|
||||
@@ -29,6 +31,23 @@ documenting a successful fresh installation on a SoundTouch 20 Series I.
|
||||
|
||||
## Step 1 — Connect to the speaker via SSH
|
||||
|
||||
If SSH isn't enabled yet, you don't need a USB stick: `soundtouch-cli` can
|
||||
bootstrap it purely over the network (#471), using the speaker's
|
||||
telnet:17000 diagnostic shell (open by default on most firmware) to inject
|
||||
the SSH-enable command:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host 192.0.2.1 setup enable-ssh
|
||||
```
|
||||
|
||||
This waits for `:22` to come up and persists it (survives a reboot) by
|
||||
default. The USB-stick method (format FAT32, create an empty
|
||||
`remote_services` file in its root, insert, power-cycle) still works as a
|
||||
fallback if telnet:17000 is closed or the injection doesn't take on your
|
||||
model.
|
||||
|
||||
Either way, connect the same way:
|
||||
|
||||
```bash
|
||||
ssh -oHostKeyAlgorithms=+ssh-rsa root@192.0.2.1
|
||||
```
|
||||
@@ -65,7 +84,7 @@ rm -f /mnt/nv/aftertouch/soundtouch-cli
|
||||
df -h /mnt/nv # confirm space recovered
|
||||
```
|
||||
|
||||
> **From v0.89.0 onwards the installer prunes stale artefacts automatically**
|
||||
> **From v0.93.0 onwards the installer prunes stale artefacts automatically**
|
||||
> during every upgrade — manual cleanup should no longer be necessary on
|
||||
> fresh installs.
|
||||
|
||||
@@ -85,11 +104,14 @@ By default this installs the **latest release** — the script resolves it from
|
||||
GitHub's `releases/latest` redirect. To target a specific version instead:
|
||||
|
||||
```bash
|
||||
# Via environment variable (works with pipe-to-sh)
|
||||
VERSION=0.111.3 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
# Via environment variable — note it goes on `sh`, not `curl`: shell
|
||||
# variable-assignment prefixes only apply to the one command they're
|
||||
# attached to, and in a pipe each command is a separate process.
|
||||
# `VERSION=0.123.0 curl ... | sh` silently does NOT set it for `sh`.
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | VERSION=0.123.0 sh
|
||||
|
||||
# Via command-line flag (pass args after sh -s --)
|
||||
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.111.3
|
||||
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.123.0
|
||||
```
|
||||
|
||||
Verify the installed version:
|
||||
@@ -98,7 +120,7 @@ Verify the installed version:
|
||||
wget -qO- http://localhost:8000/health
|
||||
```
|
||||
|
||||
The JSON response should include `"version":"v0.111.3"` (or whichever
|
||||
The JSON response should include `"version":"v0.123.0"` (or whichever
|
||||
version you installed).
|
||||
|
||||
---
|
||||
@@ -131,13 +153,69 @@ ssh -oHostKeyAlgorithms=+ssh-rsa -L 8000:localhost:8000 root@192.0.2.1
|
||||
Keep this terminal open. Navigate to **http://localhost:8000** in your
|
||||
browser.
|
||||
|
||||
> Skip this step if your speaker's firmware exposes port 8000 on the LAN
|
||||
> directly — you can reach `http://192.0.2.1:8000` without a tunnel in that
|
||||
> case.
|
||||
> **You may not need the tunnel at all.** Try `http://192.0.2.1:8000` first.
|
||||
> If that doesn't load, try **`http://192.0.2.1:17008`**: on speakers whose
|
||||
> Wi-Fi co-processor refuses to pass `:8000` through (the ST20 and likely
|
||||
> others), the installer automatically redirects port `17008` to AfterTouch,
|
||||
> so the Admin UI is reachable from the LAN without any tunnel. Check with
|
||||
> `/etc/init.d/aftertouch status` on the speaker, which reports the LAN port
|
||||
> when the redirect is active. Details and per-model status:
|
||||
> [Model Support Matrix](../reference/MODEL-SUPPORT-MATRIX.md).
|
||||
>
|
||||
> Keep the tunnel in mind anyway for **linking music-service accounts**:
|
||||
> Spotify only accepts `https://` or *loopback* OAuth redirect URIs, so
|
||||
> `http://localhost:8000` through a tunnel succeeds where a plain LAN
|
||||
> address is rejected.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Run the Health QuickFix for empty `margeAccountUUID`
|
||||
## Step 6 — Migrate (point the speaker at itself)
|
||||
|
||||
The speaker isn't pointed at the AfterTouch instance you just installed yet
|
||||
— this step does that. On-device, the speaker and the AfterTouch instance
|
||||
are the same machine, so **loopback is the correct and recommended Target
|
||||
Domain value**: `http://localhost:8000`. This is the one case where the
|
||||
general migration guide's "must not be `localhost`" warning does not
|
||||
apply — that warning is about the external-host/cloud scenarios, where
|
||||
`localhost` would resolve on the wrong machine (the service host, not the
|
||||
speaker). Here there is no wrong machine to resolve on.
|
||||
|
||||
> **Note:** as of the fix for issue #546, the on-device init script already
|
||||
> sets `DEPLOYMENT_MODE=on-device`, so a fresh (or reinstalled/updated)
|
||||
> on-device install's own Target Domain already defaults to
|
||||
> `http://localhost:8000` automatically — no manual Settings-tab step
|
||||
> needed for that part. Older installs still default to the speaker's own
|
||||
> unresolvable Linux hostname (e.g. `http://spotty:8000`) until reinstalled
|
||||
> with a build that includes the fix, or until the Target Domain is
|
||||
> corrected by hand. Either way, you still need to run Migrate below — that
|
||||
> step tells the *speaker* to use this address, which is separate from what
|
||||
> the service defaults its own identity to.
|
||||
|
||||
**Via the Admin UI:**
|
||||
|
||||
1. Go to **Settings**, set **Target Domain** to `http://localhost:8000`.
|
||||
2. Go to **Devices**, find your speaker (it self-discovers on its own LAN
|
||||
IP), click **Migrate**.
|
||||
3. Accept the suggested plan and let it apply.
|
||||
4. Reboot to apply the change:
|
||||
```bash
|
||||
sync
|
||||
reboot
|
||||
```
|
||||
|
||||
**Or via the CLI** (equivalent, no browser needed — grab `soundtouch-cli`
|
||||
from Step 9 below first if you want this path):
|
||||
|
||||
```bash
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 setup migrate \
|
||||
--service-url http://localhost:8000 --method telnet
|
||||
sync
|
||||
reboot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Run the Health QuickFix for empty `margeAccountUUID`
|
||||
|
||||
In the AfterTouch UI:
|
||||
|
||||
@@ -148,6 +226,14 @@ In the AfterTouch UI:
|
||||
4. Click the **QuickFix** button (labelled "Fix", "Pair account", or
|
||||
"Apply QuickFix" depending on the version) and confirm.
|
||||
|
||||
Or via the CLI (same underlying pairing call, `--mode=bare` matches what
|
||||
the QuickFix does — see Step 9 to grab `soundtouch-cli` first):
|
||||
|
||||
```bash
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 setup pair \
|
||||
--mode=bare --account=1111111 --service-url http://localhost:8000
|
||||
```
|
||||
|
||||
Then reboot again to let the pairing take effect:
|
||||
|
||||
```bash
|
||||
@@ -157,7 +243,7 @@ reboot
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Verify pairing and sources
|
||||
## Step 8 — Verify pairing and sources
|
||||
|
||||
After the reboot reconnect via SSH and check:
|
||||
|
||||
@@ -171,32 +257,37 @@ wget -qO- http://localhost:8090/info | grep margeAccountUUID
|
||||
wget -qO- http://localhost:8090/sources
|
||||
```
|
||||
|
||||
If `margeAccountUUID` is still empty, re-run the Health QuickFix (Step 6)
|
||||
If `margeAccountUUID` is still empty, re-run the Health QuickFix (Step 7)
|
||||
and reboot again.
|
||||
|
||||
---
|
||||
|
||||
## Step 8 — Download soundtouch-cli (optional, for preset setup)
|
||||
## Step 9 — Download soundtouch-cli (optional, for preset setup)
|
||||
|
||||
If you want to program preset buttons from the command line, download the
|
||||
CLI binary to the speaker's `/tmp` (tmpfs, so it survives only until the
|
||||
next reboot — which is fine for a one-time setup run):
|
||||
CLI binary to `/mnt/nv/aftertouch` (the same persistent partition
|
||||
AfterTouch itself lives on) rather than `/tmp`: `/tmp` is tmpfs and gets
|
||||
wiped on every reboot, and if you used the CLI alternatives in Steps 6/7
|
||||
above, it needs to survive those steps' reboots too, not just the final
|
||||
one:
|
||||
|
||||
```bash
|
||||
cd /tmp
|
||||
cd /mnt/nv/aftertouch
|
||||
|
||||
curl -L --fail -o soundtouch-cli \
|
||||
https://github.com/gesellix/Bose-SoundTouch/releases/download/v0.111.3/soundtouch-cli-v0.111.3-linux-armv7
|
||||
https://github.com/gesellix/Bose-SoundTouch/releases/download/v0.123.0/soundtouch-cli-v0.123.0-linux-armv7
|
||||
chmod +x soundtouch-cli
|
||||
|
||||
/tmp/soundtouch-cli --version
|
||||
/mnt/nv/aftertouch/soundtouch-cli --version
|
||||
```
|
||||
|
||||
Replace `v0.111.3` with the version you installed.
|
||||
Replace `v0.123.0` with the version you installed. If you want the CLI
|
||||
alternatives in Steps 6/7, download it here first, before doing those
|
||||
steps — it'll be in place and already persistent either way.
|
||||
|
||||
---
|
||||
|
||||
## Step 9 — Store custom radio streams to preset buttons
|
||||
## Step 10 — Store custom radio streams to preset buttons
|
||||
|
||||
Each station must be playing before it can be saved. The `sleep 5` gives
|
||||
the speaker time to buffer and confirm the stream before storing.
|
||||
@@ -206,52 +297,52 @@ the speaker time to buffer and confirm the stream before storing.
|
||||
|
||||
```bash
|
||||
# Preset 1 — Hitradio OE3
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "http://orf-live.ors-shoutcast.at/oe3-q2a" \
|
||||
--name "Hitradio OE3" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 1
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 1
|
||||
|
||||
# Preset 2 — Lounge FM
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "http://188.138.9.183/digital.mp3" \
|
||||
--name "Lounge FM" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 2
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 2
|
||||
|
||||
# Preset 3 — Country Nonstop
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "https://stream.laut.fm/country-nonstop" \
|
||||
--name "Country Nonstop" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 3
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 3
|
||||
|
||||
# Preset 4 — Radio Piterpan
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "https://klasse1.fluidstream.eu/piterpan.mp3?FLID=8" \
|
||||
--name "Radio Piterpan" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 4
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 4
|
||||
|
||||
# Preset 5 — kronehit
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "https://secureonair.krone.at/kronehit-hp.mp3" \
|
||||
--name "kronehit" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 5
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 5
|
||||
|
||||
# Preset 6 — Radio Niederösterreich
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "http://orf-live.ors-shoutcast.at/noe-q2a" \
|
||||
--name "Radio Niederoesterreich" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 6
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 6
|
||||
```
|
||||
|
||||
These are the stations from weissigera's setup (Austrian public and
|
||||
@@ -260,7 +351,7 @@ pattern is the same regardless of station.
|
||||
|
||||
---
|
||||
|
||||
## Step 10 — Verify presets and final reboot
|
||||
## Step 11 — Verify presets and final reboot
|
||||
|
||||
```bash
|
||||
wget -qO- http://localhost:8090/presets
|
||||
@@ -286,7 +377,7 @@ should start playing the corresponding stream.
|
||||
| SSH "no matching host key type" | Add `-oHostKeyAlgorithms=+ssh-rsa` |
|
||||
| Port 8000 not reachable from LAN | Use the SSH tunnel (Step 5) |
|
||||
| `margeAccountUUID` still empty after reboot | Re-run Health QuickFix, reboot again |
|
||||
| Radio source error 1005 | `margeAccountUUID` is empty — complete Step 6 first |
|
||||
| Radio source error 1005 | `margeAccountUUID` is empty — complete Step 7 first |
|
||||
| `http://localhost:8000` not responding after install | `logread \| grep aftertouch \| tail -20` |
|
||||
| No space left on device during install | Run the cleanup in Step 2; check `df -h /mnt/nv` |
|
||||
|
||||
@@ -305,14 +396,21 @@ older artefacts to keep `/mnt/nv` free:
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
|
||||
# Update to a specific version — three equivalent forms
|
||||
VERSION=0.111.3 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | VERSION=0.123.0 sh
|
||||
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.111.3
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.123.0
|
||||
|
||||
curl -sSLo install.sh https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh
|
||||
sh install.sh --version 0.111.3
|
||||
sh install.sh --version 0.123.0
|
||||
```
|
||||
|
||||
The script's own final output already confirms the new version came up and
|
||||
is answering on `:8000`. If you separately check the version yourself
|
||||
(`wget -qO- http://localhost:8000/health`, or the Admin UI), **reboot the
|
||||
speaker first**: an Admin UI tab left open from before the update, or a
|
||||
browser cache of the previous page load, can otherwise still show the old
|
||||
version even though the new binary is already running.
|
||||
|
||||
**Rollback:** the installer keeps a `.backup` file alongside the binary:
|
||||
|
||||
```bash
|
||||
@@ -322,6 +420,39 @@ cp /mnt/nv/aftertouch/aftertouch-service.<old-version>.backup \
|
||||
/etc/init.d/aftertouch restart
|
||||
```
|
||||
|
||||
**Testing a pre-release build (from `main`, not yet tagged):** `install.sh`
|
||||
only ever downloads from GitHub Releases, so there's no one-line installer
|
||||
for an unreleased commit. Cross-compile and swap the binary manually
|
||||
instead — this is a direct extension of the rollback procedure above:
|
||||
|
||||
```bash
|
||||
# On your own machine, from a checkout of the branch/commit you want:
|
||||
make build-linux-armv7 # builds build/soundtouch-service-linux-armv7,
|
||||
# build/soundtouch-cli-linux-armv7, and
|
||||
# build/soundtouch-backup-linux-armv7
|
||||
|
||||
scp build/soundtouch-service-linux-armv7 root@192.0.2.1:/mnt/nv/aftertouch/aftertouch-service.new
|
||||
ssh -oHostKeyAlgorithms=+ssh-rsa root@192.0.2.1
|
||||
|
||||
rw
|
||||
/etc/init.d/aftertouch stop
|
||||
cp /mnt/nv/aftertouch/aftertouch-service /mnt/nv/aftertouch/aftertouch-service.pre-test.backup
|
||||
mv /mnt/nv/aftertouch/aftertouch-service.new /mnt/nv/aftertouch/aftertouch-service
|
||||
chmod +x /mnt/nv/aftertouch/aftertouch-service
|
||||
/etc/init.d/aftertouch start
|
||||
```
|
||||
|
||||
If you're testing an unreleased `soundtouch-cli` change (not just the
|
||||
service), swap that binary too — same idea, and it lands in the same
|
||||
`/mnt/nv/aftertouch` directory Step 9 above uses:
|
||||
|
||||
```bash
|
||||
scp build/soundtouch-cli-linux-armv7 root@192.0.2.1:/mnt/nv/aftertouch/soundtouch-cli
|
||||
ssh -oHostKeyAlgorithms=+ssh-rsa root@192.0.2.1 chmod +x /mnt/nv/aftertouch/soundtouch-cli
|
||||
```
|
||||
|
||||
Roll back the same way as above, using the `.pre-test.backup` file.
|
||||
|
||||
---
|
||||
|
||||
## Service management
|
||||
|
||||
@@ -40,14 +40,14 @@ sudo bash install.sh
|
||||
Install a specific version:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh v0.111.3
|
||||
sudo bash install.sh v0.123.0
|
||||
```
|
||||
|
||||
Override defaults at install time:
|
||||
|
||||
```bash
|
||||
sudo \
|
||||
VERSION=v0.111.3 \
|
||||
VERSION=v0.123.0 \
|
||||
HOSTNAME_FQDN=soundtouch.local \
|
||||
HTTP_PORT=80 \
|
||||
HTTPS_PORT=443 \
|
||||
@@ -105,7 +105,7 @@ journalctl -u soundtouch-service -b # this boot only
|
||||
|
||||
```bash
|
||||
sudo bash install.sh # update to latest release
|
||||
sudo bash install.sh v0.111.3 # update to a specific version
|
||||
sudo bash install.sh v0.123.0 # update to a specific version
|
||||
```
|
||||
|
||||
The script stops the service, downloads the new binary (backs up the old one to
|
||||
@@ -157,14 +157,14 @@ sudo bash install-player.sh
|
||||
Install a specific version:
|
||||
|
||||
```bash
|
||||
sudo bash install-player.sh v0.111.3
|
||||
sudo bash install-player.sh v0.123.0
|
||||
```
|
||||
|
||||
Override defaults at install time:
|
||||
|
||||
```bash
|
||||
sudo \
|
||||
VERSION=v0.111.3 \
|
||||
VERSION=v0.123.0 \
|
||||
HTTP_PORT=8081 \
|
||||
bash install-player.sh
|
||||
```
|
||||
@@ -252,7 +252,7 @@ journalctl -u soundtouch-player -f
|
||||
|
||||
```bash
|
||||
sudo bash install-player.sh # update to latest release
|
||||
sudo bash install-player.sh v0.111.3 # update to a specific version
|
||||
sudo bash install-player.sh v0.123.0 # update to a specific version
|
||||
```
|
||||
|
||||
### Removal
|
||||
|
||||
@@ -156,30 +156,33 @@ The service supports multiple ways to configure its behavior. When multiple sour
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Variable | Flag | Description | Default |
|
||||
|------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
|
||||
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
|
||||
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
|
||||
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
|
||||
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
|
||||
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL. An override: when empty it is derived from `SERVER_URL` (same host, `https`, on `HTTPS_PORT`), and can also be viewed/overridden in Settings. | derived from `SERVER_URL` |
|
||||
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
|
||||
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
|
||||
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
|
||||
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
|
||||
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
|
||||
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
|
||||
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
|
||||
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
|
||||
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
|
||||
| `MGMT_USERNAME` | `--mgmt-username` | Username for HTTP Basic Auth on the Management API (`/api/mgmt/*`, `/mgmt/*`) — Spotify/Amazon account linking, Local Accounts | `admin` |
|
||||
| `MGMT_PASSWORD` | `--mgmt-password` | Password for the same Management API Basic Auth. **Change this if AfterTouch is reachable beyond a trusted LAN** — the default is published in this doc. | `change_me!` |
|
||||
| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* |
|
||||
| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* |
|
||||
| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* |
|
||||
| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* |
|
||||
| Variable | Flag | Description | Default |
|
||||
|------------------------------------|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------|
|
||||
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
|
||||
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
|
||||
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
|
||||
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
|
||||
| `DEPLOYMENT_MODE` | `--deployment-mode` | Where this service runs: `on-device`, `private-network`, or `public-network`. Only changes behavior when `SERVER_URL` is *not* set: `on-device` defaults to `http://localhost:<port>` instead of guessing a hostname (the speaker's own Linux hostname is never resolvable — see issue #546); `public-network` refuses to start rather than guess a publicly reachable address; unset/`private-network` keeps the previous hostname-guessing behavior, now with a startup warning. The on-device install script sets this automatically. | unset (legacy hostname guess, with warning) |
|
||||
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
|
||||
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL. An override: when empty it is derived from `SERVER_URL` (same host, `https`, on `HTTPS_PORT`), and can also be viewed/overridden in Settings. | derived from `SERVER_URL` |
|
||||
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
|
||||
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
|
||||
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
|
||||
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
|
||||
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
|
||||
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
|
||||
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
|
||||
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
|
||||
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
|
||||
| `UPDATE_CHECK_ENABLED` | `--update-check-enabled` | Periodically check GitHub Releases for a newer version and show a dismissible notice in the admin UI and Player when one is found. **Opt-in**: this is the only network call AfterTouch makes beyond speaker/provider traffic when enabled, so it defaults off. One unauthenticated `GET` per interval to `api.github.com`, nothing else leaves the box. Also available as an "Update Check" toggle on the admin Settings page, which applies without a restart; the env var/flag is the seed value for a fresh install with no `settings.json` yet. | `false` |
|
||||
| `UPDATE_CHECK_INTERVAL` | `--update-check-interval` | Update check interval. Also editable on the admin Settings page (applies without a restart). | `24h` |
|
||||
| `MGMT_USERNAME` | `--mgmt-username` | Username for HTTP Basic Auth on the Management API (`/api/mgmt/*`, `/mgmt/*`) — Spotify/Amazon account linking, Local Accounts | `admin` |
|
||||
| `MGMT_PASSWORD` | `--mgmt-password` | Password for the same Management API Basic Auth. **Change this if AfterTouch is reachable beyond a trusted LAN** — the default is published in this doc. | `change_me!` |
|
||||
| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* |
|
||||
| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* |
|
||||
| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* |
|
||||
| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* |
|
||||
|
||||
### Configuration Examples
|
||||
|
||||
|
||||
@@ -456,6 +456,63 @@ client.SelectAux()
|
||||
|
||||
---
|
||||
|
||||
## 🎛️ **Lifestyle / Console Device Behavior** {#lifestyle-console-devices}
|
||||
|
||||
### ❌ "Console-style device (Lifestyle, CineMate) plays the first test station but every later one reports INVALID_SOURCE"
|
||||
|
||||
On a Bose Lifestyle or CineMate console, the SoundTouch module is one input
|
||||
among several (TV, AUX, Bluetooth, ...). As already established in #160,
|
||||
the console's active input cannot be switched from the SoundTouch side —
|
||||
there is no API call that forces it back onto SoundTouch.
|
||||
|
||||
**Symptoms:**
|
||||
- `/now_playing` reports `source="LOCAL"` with an empty `ContentItem`:
|
||||
```xml
|
||||
<nowPlaying deviceID="..." source="LOCAL">
|
||||
<ContentItem source="LOCAL" isPresetable="true" />
|
||||
</nowPlaying>
|
||||
```
|
||||
- `LOCAL` does not appear in `/sources` at all.
|
||||
- `POST /select` and `POST /key` (e.g. `PRESET_1`) are accepted
|
||||
(`<status>/select</status>`) but have no observable effect.
|
||||
|
||||
This means the console is sitting on its own (non-SoundTouch) input, not
|
||||
that the content/station itself is invalid. The input has to be selected
|
||||
on the console's own remote or front panel; there is no way to do it via
|
||||
the SoundTouch API.
|
||||
|
||||
**The trap:** `POST /key POWER` does not behave like it does on a plain
|
||||
speaker. On a speaker, `POWER` is a harmless way to stop playback between
|
||||
test runs. On a console, it puts the whole unit into standby — and on
|
||||
waking, the console returns to **its own** input, not back to SoundTouch.
|
||||
A test loop that stops playback with `POWER` between trials silently
|
||||
switches the device off SoundTouch after the *first* trial, so every
|
||||
station from the second one onward reports `INVALID_SOURCE` — including
|
||||
stations that would otherwise play perfectly fine. This is easy to
|
||||
misread as a per-station problem (e.g. "this console can't handle TLS/
|
||||
https streams") when it is actually a test-methodology artifact: whichever
|
||||
station happens to run first in the loop is the only one actually tested
|
||||
against SoundTouch input.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Before testing anything, select the SoundTouch input on the console
|
||||
itself (remote or front panel), not via the API.
|
||||
2. Do not use `POST /key POWER` to stop playback between trials on these
|
||||
devices. If you need to interrupt playback, use a different key
|
||||
(e.g. `PAUSE`/`STOP`) or simply move directly to selecting the next
|
||||
station.
|
||||
3. If `/now_playing` shows `source="LOCAL"` with `LOCAL` absent from
|
||||
`/sources`, treat that as "console is on a different input" — re-select
|
||||
SoundTouch on the console and retest before concluding anything about
|
||||
the station or migration itself.
|
||||
|
||||
See #597 for the original report, including a packet capture confirming a
|
||||
station that appeared to fail actually completed a full TLS handshake and
|
||||
streamed normally once the console was back on the SoundTouch input.
|
||||
|
||||
---
|
||||
|
||||
## 🎶 **Music Service & Preset Issues**
|
||||
|
||||
### ❌ Spotify preset fails with "Current content cannot be saved as preset"
|
||||
@@ -537,6 +594,31 @@ Once the source plays once, it gets persisted to `/mnt/nv/BoseApp-Persistence/1/
|
||||
|
||||
If `soundtouch-cli source content --source TUNEIN ...` returns `1005` on a reset device that has never had TuneIn, the speaker is refusing because the source isn't registered yet — chicken-and-egg. The SoundTouch app is then the only practical path to register it; we can't write `Sources.xml` directly over telnet on most models.
|
||||
|
||||
### ❌ Changing Target Domain in Settings doesn't change what a speaker actually uses {#settings-vs-migrate}
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- You update **Settings → Target Domain / Server URL** (via the Admin UI, `SERVER_URL`, or `--deployment-mode`), and the Admin UI confirms the new value with no warning.
|
||||
- An already-migrated speaker's own behavior is unchanged: playback/BMX requests still go to the *old* address, and `soundtouch-cli setup inspect --telnet` still shows the old `margeServerUrl`/`statsServerUrl`/`bmxRegistryUrl`/`swUpdateUrl`.
|
||||
|
||||
**Cause:** Settings only updates the *service's own* record of its address (`s.serverURL`, persisted to `settings.json`) — the save handler never contacts any device. A speaker only learns a new address at migrate time: the telnet method writes it via `sys configuration ...` plus a closing `envswitch boseurls set ...` for the reboot-persisted layer; the XML/SSH method uploads a fresh `SoundTouchSdkPrivateCfg.xml`. Both write **once**, with no mechanism for a speaker to later re-fetch its own config from the service — this is equally true for either migration method. A "Sync" or `sourcesUpdated` notification only refreshes the speaker's source *list*, not its server URL configuration.
|
||||
|
||||
**Fix:** Any Target Domain change that needs to reach an already-migrated speaker requires a fresh Migrate afterward — Settings alone is never enough for a speaker that's been migrated before:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <speaker-ip> setup migrate --method telnet --service-url <new-target-domain>
|
||||
```
|
||||
|
||||
Confirm it took:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <speaker-ip> setup inspect --telnet
|
||||
```
|
||||
|
||||
`margeServerUrl`/`statsServerUrl`/`bmxRegistryUrl`/`swUpdateUrl` should all match the new value. Repeat per speaker — Settings is one service-wide value, but each speaker keeps its own independently-migrated copy, so a multi-speaker household needs a re-migrate for each one.
|
||||
|
||||
This also applies to a freshly-fixed on-device default (see `DEPLOYMENT_MODE`, #546): the installer now gets the *default* right for new installs automatically, but an install that was already migrated before you updated still needs the explicit re-migrate above — the fix only stops a *new* bad value from being written, it doesn't retroactively correct an already-migrated speaker.
|
||||
|
||||
### ❌ Radio sources never activate after an in-place migration {#radio-sources-after-migration}
|
||||
|
||||
**Symptoms:**
|
||||
@@ -571,12 +653,92 @@ Notes:
|
||||
|
||||
If the telnet method isn't available for your model, factory reset the speaker, then re-migrate it:
|
||||
|
||||
1. Factory reset (on most models: hold `1` + `−` for ~10 seconds).
|
||||
1. Factory reset (on most models: hold `1` + `−` for ~10 seconds — confirmed
|
||||
identical on the SoundTouch 30 Series III, not just the original ST30).
|
||||
2. Reconnect the speaker to your network.
|
||||
3. Re-migrate it in AfterTouch.
|
||||
|
||||
After this the radio sources activate normally. Note the factory reset rewrites the speaker's `Sources.xml` to defaults, so any **account-bound** source (for example a music-streaming login) has to be re-added afterwards; your presets for it come back once the source is present again.
|
||||
|
||||
### ❌ `setup enable-ssh` (or a telnet command) fails right after a power-cycle, but works if you wait
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- You power-cycled the speaker — as our own retry guidance suggests after a `setup enable-ssh` timeout — and immediately re-ran the command (or a telnet migration/pairing step).
|
||||
- You get `telnet dial <ip>:17000: connection refused` or the command otherwise fails as if the port were closed.
|
||||
- Running the exact same command again a minute or two later works fine, on the same device.
|
||||
|
||||
**Cause:**
|
||||
|
||||
Confirmed on hardware across five device variants (2026-08-09): different ports on the same speaker become ready at very different times after a cold boot. HTTP `:8090` typically answers first, but the diagnostic telnet shell on `:17000` — and the config subsystem behind it that `getpdo` reads — takes longer: 55–92 seconds observed, median ~70s. "The box answers on one port" is a weaker signal than "the box can answer on the specific port you need." See [TELNET-COMMAND-REFERENCE.md](../analysis/TELNET-COMMAND-REFERENCE.md) for the underlying mechanism.
|
||||
|
||||
**Fix:** After a power-cycle, wait at least 90 seconds before retrying any telnet-based command. If it still fails after that, wait a full 2 minutes before assuming the port is genuinely closed on that firmware rather than just slow to come up.
|
||||
|
||||
### ❌ Speaker gets slower/less responsive over time after `setup enable-ssh` with no `--service-url`
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- You ran `soundtouch-cli setup enable-ssh` without `--service-url` (or via the Admin UI's equivalent) to bootstrap SSH, and never followed up with a real `setup migrate`.
|
||||
- Over time (hours to days), the speaker becomes progressively less responsive — slow to answer `:8090`, SSH connections time out, the Admin UI shows it as flaky or offline.
|
||||
|
||||
**Cause:**
|
||||
|
||||
`enable-ssh` without `--service-url` writes a deliberately-invalid placeholder (`https://aftertouch.invalid`) into `margeServerUrl`/`swUpdateUrl`/etc — by design, since the SSH-enable injection only needs *a* URL to round-trip through, not a working one. But unless you run `setup migrate` (or the Admin UI's Migrate step) afterward, that placeholder **stays persisted** — the command's own success message says so explicitly. The firmware then retries a failing DNS/curl lookup against it on a background loop (same class of failure as the `mojo`/`taigan` unresolvable-hostname case, #546) — an ongoing resource drain that isn't dramatic on its own, but confirmed on real hardware (2026-08-16) to compound badly if anything else (e.g. a burst of SSH connections — see the `setup revert` entry below) puts the speaker under load at the same time.
|
||||
|
||||
**Fix:** Always follow `enable-ssh` (when run without `--service-url`) with a real `setup migrate` before walking away. If you're recovering a speaker that's already stuck like this: power-cycle it, confirm it's reachable (`ping`, `curl :8090/info`, a single plain `ssh ... echo ok`) before doing anything else, then run `setup migrate` with the real URLs. If you want to point it back at the **original Bose cloud** URLs instead of AfterTouch (e.g. to fully decommission it), use the per-field overrides on `--method=telnet` — see the `setup migrate` section of [CLI-REFERENCE.md](CLI-REFERENCE.md) — which writes over a single telnet connection, no SSH required:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <SPEAKER-IP> setup migrate --method telnet \
|
||||
--service-url https://streaming.bose.com \
|
||||
--marge-url https://streaming.bose.com \
|
||||
--stats-url https://events.api.bosecm.com \
|
||||
--sw-update-url https://worldwide.bose.com/updates/soundtouch \
|
||||
--bmx-url https://content.api.bose.io/bmx/registry/v1/services
|
||||
```
|
||||
|
||||
### ❌ `setup revert` (or the Admin UI's "Revert to Defaults") fails with "backup .original not found" even though the file exists
|
||||
|
||||
**Status: fixed** (branch `docs-ondevice-install-gaps`, not yet in a numbered release as of this writing) — kept below for anyone hitting this on an older build, and because the underlying "don't hammer a struggling speaker" advice is still good practice generally.
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- You confirm via a separate SSH session that `/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original` genuinely exists.
|
||||
- `setup revert` (or clicking "Revert to Defaults") still reports `backup .../SoundTouchSdkPrivateCfg.xml.original not found, cannot revert`.
|
||||
- A follow-up plain SSH command to the same speaker fails with `Operation timed out` at the TCP level — not an auth or shell error.
|
||||
|
||||
**Cause:** `RevertMigration`'s full call graph opened **17 separate SSH connections** in rapid succession (`pkg/ssh.Client.Run()` dialed fresh every call, with no connection reuse across `revertXMLConfig`/`revertHosts`/`revertResolvConf`/`revertAftertouchHook`/`removeRcLocalHooks`/`revertCACert`). Hitting a resource-constrained embedded speaker with that many rapid reconnects could overwhelm it — confirmed on real hardware (2026-08-16), where the speaker became unreachable shortly after. On top of that, `revertXMLConfig`'s error handling collapses *any* non-nil error from its file-existence check into "not found," so a dial failure got misreported as a missing backup — the message didn't mean what it said.
|
||||
|
||||
**Fix:** `pkg/ssh.Client` now supports an opt-in persistent connection (`Connect()`/`Close()`) that `RevertMigration` uses to collapse those 17 connections into 1 — confirmed on the same real hardware (2026-08-16): a subsequent `setup revert` completed quickly, and the restored config file diffed byte-identical against `.original`. If you're on a build that predates this fix, don't retry `setup revert` back-to-back — if it fails, wait a minute and confirm the speaker is reachable again (`ping`, a single plain `ssh ... echo ok`) before retrying. If all you actually need is to point the speaker's URLs somewhere else (back to AfterTouch, or back to the original Bose cloud), the lighter-weight `setup migrate --method telnet` with explicit URL overrides (previous entry) uses one telnet connection instead of SSH entirely.
|
||||
|
||||
### ❌ On-device install: AfterTouch answers on the speaker but not from other machines on the LAN
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- On the speaker itself, `curl http://localhost:8000/health` works and `/etc/init.d/aftertouch status` is green.
|
||||
- From any other machine, `http://<speaker-ip>:8000` fails immediately (connection refused/reset, not a timeout).
|
||||
- SSH to the same speaker works fine, so it is clearly reachable in general.
|
||||
|
||||
**Cause:**
|
||||
|
||||
Some SoundTouch chassis carry a BCO ("SMSC") Wi-Fi/Bluetooth co-processor, and inbound LAN traffic reaches the main Linux SoC only for a fixed set of Bose's *own* service ports, a list that appears to be compiled into the co-processor's firmware. AfterTouch's `:8000` was never part of that original design, so the connection never arrives at the SoC at all. Confirmed on an ST20 (`spotty`, FW 27.0.6) in 2026-08: `tcpdump -i eth0` on the speaker saw **zero packets** for `:8000` while Bose's `:8090`/`:8091`/`:17000` answered normally from the same client. This is not a firewall (the speaker's `iptables` is empty) and not a binding problem (the service does listen on `0.0.0.0:8000`).
|
||||
|
||||
**Fix:**
|
||||
|
||||
The on-device installer handles this automatically: on an affected speaker it redirects a relayed Bose port to AfterTouch, so use:
|
||||
|
||||
```
|
||||
http://<speaker-ip>:17008
|
||||
```
|
||||
|
||||
To check or change it, on the speaker:
|
||||
|
||||
```bash
|
||||
/etc/init.d/aftertouch status # reports the LAN port when active
|
||||
iptables -t nat -S PREROUTING # shows the redirect rule
|
||||
```
|
||||
|
||||
Set `AFTERTOUCH_LAN_PORT` in `/opt/aftertouch/aftertouch.conf` to a different port, or to `none` to disable the redirect and use an SSH tunnel instead; then `/etc/init.d/aftertouch restart`. Note that **linking music-service accounts still works best through the tunnel** (`http://localhost:8000`), because Spotify only accepts `https://` or loopback OAuth redirect URIs. If you also run the `streborn` project on the same speaker, note it defaults to the same port, so change one of them. Which models are affected is tracked in [MODEL-SUPPORT-MATRIX.md](../reference/MODEL-SUPPORT-MATRIX.md).
|
||||
|
||||
## 🔊 **Volume & Audio Issues**
|
||||
|
||||
### ❌ "Volume control not working"
|
||||
|
||||
@@ -100,6 +100,20 @@ All subsequent messages (except `selectLastWiFiSource`, see below) use this enve
|
||||
|
||||
## Phase 2 — Pairing a New Speaker
|
||||
|
||||
> **Preflight (AfterTouch's `setup pair --mode=full`).** Before opening the
|
||||
> WebSocket, AfterTouch reads `GET /supportedURLs` (must list
|
||||
> `/setMargeAccount`) and `GET /soundTouchConfigurationStatus`, and only
|
||||
> runs the state machine below when the status is exactly
|
||||
> `SOUNDTOUCH_NOT_CONFIGURED`. This matters because a speaker can be
|
||||
> reachable, named, and already have a `margeAccountUUID` set, yet still
|
||||
> report `SOUNDTOUCH_NOT_CONFIGURED` — the firmware keeps prompting to
|
||||
> install the Bose app until a full acknowledged pass through this state
|
||||
> machine runs, not just `setMargeAccount` on its own. Already-configured
|
||||
> devices are a no-op; an unsupported route or an unrecognised status value
|
||||
> aborts without writing anything. See
|
||||
> [#615](https://github.com/gesellix/Bose-SoundTouch/issues/615) and
|
||||
> `Manager.PreflightInitPlan` (`pkg/service/setup/marge_pairing.go`).
|
||||
|
||||
### 2.1 Setup State Machine
|
||||
|
||||
The pairing flow uses a setup state machine on the device. States must be sent in order.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
title: "Model Support Matrix"
|
||||
---
|
||||
A living record of how individual SoundTouch models behave with AfterTouch,
|
||||
built up from things actually observed on hardware.
|
||||
|
||||
**This table only claims what someone has verified.** Anything not tested is
|
||||
marked `?` rather than inferred from a similar-looking model. Bose used
|
||||
several different chassis designs across the SoundTouch line, and at least
|
||||
one behaviour (LAN reachability, below) differs between them in a way that is
|
||||
invisible from the outside. If you have a model that isn't filled in yet,
|
||||
[the commands below](#how-to-fill-in-a-row) produce everything a row needs.
|
||||
|
||||
## What the columns mean
|
||||
|
||||
- **variant / moduleType**: the speaker's own identifiers, straight out of
|
||||
`/info`. `variant` is Bose's internal codename for the product; `moduleType`
|
||||
distinguishes chassis generations (`scm` and `sm2` are the two seen so far).
|
||||
- **BCO**: whether the board carries a BCO co-processor (Bose's internal name
|
||||
for the SMSC Wi-Fi/Bluetooth combo chip that also handles AirPlay). Bose's
|
||||
own `has-bco` helper on the device is simply
|
||||
`[ "$(cat /proc/module_type)" = scm ]`.
|
||||
- **`:8000` from LAN**: whether AfterTouch's own port is reachable from
|
||||
another machine on the network *without* any workaround.
|
||||
- **Entry port**: when `:8000` isn't reachable, the port AfterTouch redirects
|
||||
to itself so the admin UI still works. See
|
||||
[LAN access on co-processor chassis](#lan-access-on-co-processor-chassis).
|
||||
|
||||
## Matrix
|
||||
|
||||
| Model | variant | moduleType | BCO | On-device install | `:8000` from LAN | Entry port | Evidence |
|
||||
|---------------------|----------|------------|-----|-------------------|------------------|------------|-----------------------------------------------------------------------|
|
||||
| SoundTouch 20 | `spotty` | `scm` | yes | works | ✗ blocked | `17008` | verified on hardware 2026-08-16 (FW 27.0.6), redirect survives reboot |
|
||||
| SoundTouch 10 | ? | ? | ? | reported working | ? | ? | not tested for LAN reachability |
|
||||
| SoundTouch 30 | ? | ? | ? | reported working | ? | ? | not tested for LAN reachability |
|
||||
| SoundTouch Portable | ? | ? | ? | ? | ? | ? | not tested |
|
||||
| Wave / SA-4 | ? | ? | ? | ? | ? | ? | not tested |
|
||||
|
||||
Not every SoundTouch shares one firmware image, so treat a `?` as genuinely
|
||||
unknown. In particular, do not assume a model is unaffected just because it is
|
||||
newer or older than a model that is.
|
||||
|
||||
## LAN access on co-processor chassis
|
||||
|
||||
On chassis with a BCO co-processor, inbound LAN traffic reaches the speaker's
|
||||
main Linux SoC only for a fixed set of Bose's *own* service ports. That list
|
||||
appears to be compiled into the co-processor's firmware, and AfterTouch's
|
||||
`:8000` is not on it, so a connection attempt never arrives at the SoC at
|
||||
all. On a verified ST20, `tcpdump -i eth0` on the speaker recorded **zero
|
||||
packets** for `:8000` while Bose's `:8090`, `:8091`, `:8200`, `:82`, `:8080`
|
||||
and `:17000` all answered normally from the same client.
|
||||
|
||||
This is not a firewall, and not something AfterTouch can fix by binding
|
||||
differently: the service already listens on `0.0.0.0:8000`, and the speaker's
|
||||
`iptables` is empty (there is no `nft` or `ebtables` at all).
|
||||
|
||||
The on-device installer works around it by redirecting one of the relayed
|
||||
ports to AfterTouch. **Credit for this technique goes to the
|
||||
[STR / SoundTouch Reborn](https://github.com/JRpersonal/streborn) project**,
|
||||
which documented and shipped it first (their agent uses the same entry port
|
||||
for the same reason); finding their prior art is what turned this from an
|
||||
apparent hardware dead end into a one-line fix:
|
||||
|
||||
```
|
||||
iptables -t nat -I PREROUTING 1 ! -i lo -p tcp --dport 17008 -j REDIRECT --to-ports 8000
|
||||
```
|
||||
|
||||
`17008` is Bose's `SoftwareUpdate` listener. Its cloud service no longer
|
||||
exists, so taking over its inbound traffic costs nothing in practice. Only
|
||||
external traffic is matched (`! -i lo`), so anything running on the speaker
|
||||
still reaches AfterTouch on `:8000` exactly as before.
|
||||
|
||||
The rule is re-applied by the init script on every start, so it survives
|
||||
reboots (confirmed on the ST20) without any background watchdog. It is
|
||||
removed again on `stop` and on uninstall.
|
||||
|
||||
The redirect is applied automatically on chassis that need it, and configured
|
||||
via `AFTERTOUCH_LAN_PORT` in `/opt/aftertouch/aftertouch.conf`:
|
||||
|
||||
| Value | Effect |
|
||||
|------------|-----------------------------------------------------------------|
|
||||
| `auto` | *(default)* redirect only where the co-processor blocks `:8000` |
|
||||
| `none` | never redirect; use an SSH tunnel instead |
|
||||
| *(a port)* | always redirect that inbound port to AfterTouch |
|
||||
|
||||
Two caveats worth knowing:
|
||||
|
||||
- **Account linking still prefers the SSH tunnel.** Spotify only accepts
|
||||
`https://` or *loopback* OAuth redirect URIs, so `http://localhost:8000`
|
||||
through a tunnel works for linking where a plain LAN address does not.
|
||||
- **The `streborn` project defaults to the same port** for the same reason. If
|
||||
you run both on one speaker, change `AFTERTOUCH_LAN_PORT`.
|
||||
|
||||
## How to fill in a row
|
||||
|
||||
Run these from a machine on the same network (replace the address), then open
|
||||
an issue or PR with the output:
|
||||
|
||||
```bash
|
||||
# variant, moduleType, and whether an SCM/SMSC component is listed
|
||||
curl -s http://<speaker-ip>:8090/info
|
||||
|
||||
# is AfterTouch's own port reachable directly? (only meaningful once
|
||||
# AfterTouch is installed on the device)
|
||||
curl -v --max-time 5 http://<speaker-ip>:8000/health
|
||||
|
||||
# which Bose ports the chassis relays at all
|
||||
for p in 82 8080 8090 8091 8200 17000 17008; do
|
||||
printf '%s: ' "$p"
|
||||
curl -s -o /dev/null -w '%{http_code}\n' --max-time 3 "http://<speaker-ip>:$p/" || echo unreachable
|
||||
done
|
||||
```
|
||||
|
||||
And on the speaker itself, if you have SSH access:
|
||||
|
||||
```bash
|
||||
has-bco; echo "has-bco exit status: $?" # 0 = BCO co-processor present
|
||||
cat /proc/module_type /proc/variant
|
||||
```
|
||||
@@ -1,8 +1,8 @@
|
||||
module navigation-station-demo
|
||||
|
||||
go 1.26.5
|
||||
go 1.27.0
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.118.0
|
||||
require github.com/gesellix/bose-soundtouch v0.128.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module preset-management-example
|
||||
|
||||
go 1.26.5
|
||||
go 1.27.0
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.118.0
|
||||
require github.com/gesellix/bose-soundtouch v0.128.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
module github.com/gesellix/bose-soundtouch
|
||||
|
||||
go 1.26.5
|
||||
go 1.27.0
|
||||
|
||||
require (
|
||||
filippo.io/age v1.3.1
|
||||
github.com/chromedp/chromedp v0.16.0
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/go-chi/chi/v5 v5.3.2
|
||||
github.com/google/gopacket v1.1.19
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hashicorp/mdns v1.0.7
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/miekg/dns v1.1.73
|
||||
github.com/russross/blackfriday/v2 v2.1.0
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
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.54.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/mod v0.40.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/term v0.45.0
|
||||
)
|
||||
|
||||
@@ -31,10 +32,7 @@ require (
|
||||
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.44.0 // indirect
|
||||
golang.org/x/mod v0.38.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/image v0.45.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
)
|
||||
|
||||
@@ -17,8 +17,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
|
||||
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
|
||||
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
@@ -27,8 +27,6 @@ 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=
|
||||
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -40,8 +38,8 @@ 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.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
|
||||
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
|
||||
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=
|
||||
@@ -63,18 +61,18 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAz
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
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.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||
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.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
@@ -86,11 +84,9 @@ golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
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=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
Generated
+4
-4
@@ -8,7 +8,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"htm": "3.1.1",
|
||||
"preact": "10.29.7"
|
||||
"preact": "10.29.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
@@ -21,9 +21,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.29.7",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz",
|
||||
"integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==",
|
||||
"version": "10.29.8",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz",
|
||||
"integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
|
||||
+1
-1
@@ -10,6 +10,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"htm": "3.1.1",
|
||||
"preact": "10.29.7"
|
||||
"preact": "10.29.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,9 @@ func TestClient_Post_ErrorsResponse(t *testing.T) {
|
||||
t.Errorf("expected message '%s', got '%s'", expectedMsg, errs.Errors[0].Message)
|
||||
}
|
||||
|
||||
if err.Error() != expectedMsg {
|
||||
t.Errorf("expected Error() to return '%s', got '%s'", expectedMsg, err.Error())
|
||||
expectedErr := "UNKNOWN_ACTION_ERROR: " + expectedMsg
|
||||
if err.Error() != expectedErr {
|
||||
t.Errorf("expected Error() to return '%s', got '%s'", expectedErr, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
-1
@@ -2,6 +2,8 @@ package models
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -80,7 +82,7 @@ type ErrorsResponse struct {
|
||||
// Error implements the error interface for ErrorsResponse
|
||||
func (e *ErrorsResponse) Error() string {
|
||||
if len(e.Errors) > 0 {
|
||||
return e.Errors[0].Message
|
||||
return e.Errors[0].Error()
|
||||
}
|
||||
|
||||
return "unknown API error"
|
||||
@@ -93,6 +95,27 @@ type DeviceError struct {
|
||||
Message string `xml:",chardata"`
|
||||
}
|
||||
|
||||
// Error implements the error interface for DeviceError. Some speakers
|
||||
// return a Message that just restates Value as text (e.g. a bare "1047"
|
||||
// for an error the firmware has no localized string for) — Name is the
|
||||
// only informative part in that case, so it's always included unless
|
||||
// Message already carries it.
|
||||
func (e DeviceError) Error() string {
|
||||
if e.Name == "" {
|
||||
if e.Message == "" {
|
||||
return fmt.Sprintf("device error %d", e.Value)
|
||||
}
|
||||
|
||||
return e.Message
|
||||
}
|
||||
|
||||
if e.Message == "" || e.Message == e.Name || e.Message == strconv.Itoa(e.Value) {
|
||||
return fmt.Sprintf("%s (%d)", e.Name, e.Value)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s: %s", e.Name, e.Message)
|
||||
}
|
||||
|
||||
// DiscoveredDevice represents a device found through network discovery
|
||||
type DiscoveredDevice struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDeviceError_Error(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err DeviceError
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "message repeats the numeric value (real speaker case)",
|
||||
err: DeviceError{Value: 1047, Name: "SOURCE_ALREADY_REMOVED", Message: "1047"},
|
||||
expected: "SOURCE_ALREADY_REMOVED (1047)",
|
||||
},
|
||||
{
|
||||
name: "message is empty",
|
||||
err: DeviceError{Value: 1047, Name: "SOURCE_ALREADY_REMOVED", Message: ""},
|
||||
expected: "SOURCE_ALREADY_REMOVED (1047)",
|
||||
},
|
||||
{
|
||||
name: "message is meaningful and distinct from name",
|
||||
err: DeviceError{Value: 1029, Name: "UNKNOWN_ACTION_ERROR", Message: "This version of SCM does not support spotify create account functionality."},
|
||||
expected: "UNKNOWN_ACTION_ERROR: This version of SCM does not support spotify create account functionality.",
|
||||
},
|
||||
{
|
||||
name: "name is empty, message carries the detail",
|
||||
err: DeviceError{Value: 500, Name: "", Message: "internal error"},
|
||||
expected: "internal error",
|
||||
},
|
||||
{
|
||||
name: "both name and message are empty",
|
||||
err: DeviceError{Value: 500, Name: "", Message: ""},
|
||||
expected: "device error 500",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.err.Error(); got != tt.expected {
|
||||
t.Errorf("expected %q, got %q", tt.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorsResponse_Error(t *testing.T) {
|
||||
t.Run("delegates to the first DeviceError", func(t *testing.T) {
|
||||
errs := &ErrorsResponse{
|
||||
Errors: []DeviceError{
|
||||
{Value: 1047, Name: "SOURCE_ALREADY_REMOVED", Message: "1047"},
|
||||
},
|
||||
}
|
||||
|
||||
expected := "SOURCE_ALREADY_REMOVED (1047)"
|
||||
if got := errs.Error(); got != expected {
|
||||
t.Errorf("expected %q, got %q", expected, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no errors", func(t *testing.T) {
|
||||
errs := &ErrorsResponse{}
|
||||
|
||||
expected := "unknown API error"
|
||||
if got := errs.Error(); got != expected {
|
||||
t.Errorf("expected %q, got %q", expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -672,6 +672,21 @@ type ErrorStats struct {
|
||||
Details string `json:"details,omitempty" xml:"details,omitempty"`
|
||||
}
|
||||
|
||||
// ActivityRecord is one entry in AfterTouch's local, append-only admin-UI
|
||||
// activity log (e.g. an announcement banner dismissal). Local-only: written
|
||||
// to plain JSON on disk, never transmitted automatically — the only way it
|
||||
// leaves the operator's network is an explicitly-triggered diagnostic
|
||||
// export. The same ID can recur with a new Timestamp (e.g. a dismissed
|
||||
// notification shown and dismissed again later); this is a log, not a
|
||||
// keyed map. Intentionally generic so it can back other admin-UI action
|
||||
// kinds beyond dismissals later, not just this one feature.
|
||||
type ActivityRecord struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Detail map[string]interface{} `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// DeviceEvent represents an event that occurred on a device.
|
||||
type DeviceEvent struct {
|
||||
Type string `json:"type"`
|
||||
|
||||
@@ -33,11 +33,30 @@ func exists(path string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// isSafeIdentifier returns true if the given identifier is safe to use
|
||||
// as a single path component (for account IDs, device IDs, etc.).
|
||||
// It rejects empty strings, path separators, and parent directory references.
|
||||
func isSafeIdentifier(id string) bool {
|
||||
if id == "" {
|
||||
// maxSafeIdentifierLength bounds account/device IDs accepted from a
|
||||
// speaker or third-party pairing tool. Well under typical filesystem
|
||||
// path-component limits (255 bytes); generous for any realistic
|
||||
// margeAccountUUID or MAC-derived device ID.
|
||||
const maxSafeIdentifierLength = 128
|
||||
|
||||
// IsSafeIdentifier returns true if the given identifier is safe to use
|
||||
// as a single path component (for account IDs, device IDs, etc.), and
|
||||
// safe to embed in the other places these values end up: XML sent to a
|
||||
// speaker, log lines, and datastore-key comparisons. It rejects empty
|
||||
// or overlong strings, path separators, and parent directory
|
||||
// references.
|
||||
//
|
||||
// The allowed character set intentionally excludes XML/HTML-special
|
||||
// characters (`< > & " '`), whitespace, and shell/URL metacharacters
|
||||
// (see #634's `postSetMargeAccount`, which interpolates an account ID
|
||||
// into an XML body, and `PairAccount`, which interpolates one into a
|
||||
// literal `envswitch accountid set <id>` telnet command line) even
|
||||
// though it accepts more than Bose's own 7-digit account format —
|
||||
// devices paired via third-party or manual tooling (e.g. the
|
||||
// USB-stick SSH-enable method) can report arbitrary margeAccountUUID
|
||||
// values such as "stick@local".
|
||||
func IsSafeIdentifier(id string) bool {
|
||||
if id == "" || len(id) > maxSafeIdentifierLength {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -46,14 +65,17 @@ func isSafeIdentifier(id string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Allow a conservative set of characters commonly found in IDs:
|
||||
// letters, digits, underscore, dash, dot, and colon (for MAC-like IDs).
|
||||
// Letters, digits, and a conservative set of punctuation seen in
|
||||
// real-world IDs: underscore, dash, dot, colon (MAC-like IDs), and
|
||||
// '@' (e.g. "stick@local"). Everything else — including all XML,
|
||||
// HTML, shell, and URL metacharacters, whitespace, and control
|
||||
// characters — is rejected.
|
||||
for i := 0; i < len(id); i++ {
|
||||
c := id[i]
|
||||
if (c >= 'a' && c <= 'z') ||
|
||||
(c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9') ||
|
||||
c == '_' || c == '-' || c == '.' || c == ':' {
|
||||
c == '_' || c == '-' || c == '.' || c == ':' || c == '@' {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -928,7 +950,10 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
|
||||
|
||||
// GetPresets retrieves all presets for the specified account and device.
|
||||
func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset, error) {
|
||||
presets, needsRewrite, err := ds.readPresetsLocked(account, device)
|
||||
ds.fileMutex.RLock()
|
||||
presets, needsRewrite, err := ds.readPresetsNoLock(account, device)
|
||||
ds.fileMutex.RUnlock()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -944,18 +969,48 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
|
||||
return presets, nil
|
||||
}
|
||||
|
||||
// readPresetsLocked is the locked read half of GetPresets. It returns the
|
||||
// parsed presets and a flag indicating whether the on-disk file used the
|
||||
// legacy <ContentItem> (capital C) format that needs rewriting.
|
||||
func (ds *DataStore) readPresetsLocked(account, device string) ([]models.ServicePreset, bool, error) {
|
||||
ds.fileMutex.RLock()
|
||||
defer ds.fileMutex.RUnlock()
|
||||
// MutatePresets atomically reads the current preset list, transforms it via
|
||||
// mutate, and persists the result — holding a single write lock for the
|
||||
// entire read-mutate-write cycle. Calling GetPresets followed by a separate
|
||||
// SavePresets leaves a lost-update window open: two concurrent callers can
|
||||
// each read the same starting list, mutate different entries, and the
|
||||
// second writer's SavePresets silently clobbers the first writer's update.
|
||||
// That's the exact interleave that dropped a preset during #614's rapid-fire
|
||||
// repro (overlapping PUT .../preset/N requests). Callers that read-then-write
|
||||
// a single device's presets should use this instead of GetPresets+SavePresets.
|
||||
func (ds *DataStore) MutatePresets(account, device string, mutate func(current []models.ServicePreset) ([]models.ServicePreset, error)) ([]models.ServicePreset, error) {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
current, _, err := ds.readPresetsNoLock(account, device)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
next, err := mutate(current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := ds.savePresetsNoLock(account, device, next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// readPresetsNoLock is the lock-free read half of GetPresets/MutatePresets.
|
||||
// Callers must already hold ds.fileMutex (for reading or writing). It
|
||||
// returns the parsed presets and a flag indicating whether the on-disk file
|
||||
// used the legacy <ContentItem> (capital C) format that needs rewriting.
|
||||
func (ds *DataStore) readPresetsNoLock(account, device string) ([]models.ServicePreset, bool, error) {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
|
||||
|
||||
data, err := ds.rootReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Printf("[Datastore] readPresetsNoLock: no Presets.xml at %s — reporting no presets", sanitizeLog(path))
|
||||
|
||||
return []models.ServicePreset{}, false, nil
|
||||
}
|
||||
|
||||
@@ -967,7 +1022,7 @@ func (ds *DataStore) readPresetsLocked(account, device string) ([]models.Service
|
||||
// error, so the device-level /presets endpoint returns an empty list
|
||||
// instead of HTTP 500. See #458.
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
log.Printf("[Datastore] readPresetsLocked: empty/0-byte Presets.xml at %s — treating as no presets (#458)", sanitizeLog(path))
|
||||
log.Printf("[Datastore] readPresetsNoLock: empty/0-byte Presets.xml at %s — treating as no presets (#458)", sanitizeLog(path))
|
||||
|
||||
return []models.ServicePreset{}, false, nil
|
||||
}
|
||||
@@ -999,7 +1054,7 @@ func (ds *DataStore) readPresetsLocked(account, device string) ([]models.Service
|
||||
needsRewrite := !bytes.Equal(normalized, data)
|
||||
|
||||
if err := xml.Unmarshal(normalized, &presetsWrap); err != nil {
|
||||
log.Printf("[Datastore] readPresetsLocked: malformed Presets.xml at %s (%s) — treating as no presets (#458)", sanitizeLog(path), sanitizeErr(err))
|
||||
log.Printf("[Datastore] readPresetsNoLock: malformed Presets.xml at %s (%s) — treating as no presets (#458)", sanitizeLog(path), sanitizeErr(err))
|
||||
|
||||
return []models.ServicePreset{}, false, nil
|
||||
}
|
||||
@@ -1053,7 +1108,7 @@ func repairLeakedSource(account, device, label, persistedSource, sourceID string
|
||||
return persistedSource
|
||||
}
|
||||
|
||||
sources, err := ds.getConfiguredSourcesLocked(account, device)
|
||||
sources, err := ds.getConfiguredSourcesNoLock(account, device)
|
||||
if err != nil {
|
||||
return persistedSource
|
||||
}
|
||||
@@ -1079,11 +1134,11 @@ func isLeakedSourceValue(s string) bool {
|
||||
return s == "" || s == "Audio"
|
||||
}
|
||||
|
||||
// getConfiguredSourcesLocked is GetConfiguredSources without the
|
||||
// getConfiguredSourcesNoLock is GetConfiguredSources without the
|
||||
// fileMutex.RLock() — callers must already hold it. Used by
|
||||
// repairLeakedSource from within GetPresets/GetRecents which already
|
||||
// hold the lock.
|
||||
func (ds *DataStore) getConfiguredSourcesLocked(account, device string) ([]models.ConfiguredSource, error) {
|
||||
func (ds *DataStore) getConfiguredSourcesNoLock(account, device string) ([]models.ConfiguredSource, error) {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
|
||||
|
||||
data, err := ds.rootReadFile(path)
|
||||
@@ -1131,6 +1186,12 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
return ds.savePresetsNoLock(account, device, presets)
|
||||
}
|
||||
|
||||
// savePresetsNoLock is the lock-free write half of
|
||||
// SavePresets/MutatePresets. Callers must already hold ds.fileMutex.Lock().
|
||||
func (ds *DataStore) savePresetsNoLock(account, device string, presets []models.ServicePreset) error {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
|
||||
if err := ds.rootMkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
@@ -1294,11 +1355,45 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
|
||||
ds.fileMutex.RLock()
|
||||
defer ds.fileMutex.RUnlock()
|
||||
|
||||
return ds.readRecentsNoLock(account, device)
|
||||
}
|
||||
|
||||
// MutateRecents atomically reads the current recents list, transforms it
|
||||
// via mutate, and persists the result — holding a single write lock for the
|
||||
// entire read-mutate-write cycle. See MutatePresets for why this matters: a
|
||||
// separate GetRecents followed by SaveRecents leaves a lost-update window
|
||||
// open between concurrent callers.
|
||||
func (ds *DataStore) MutateRecents(account, device string, mutate func(current []models.ServiceRecent) ([]models.ServiceRecent, error)) ([]models.ServiceRecent, error) {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
current, err := ds.readRecentsNoLock(account, device)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
next, err := mutate(current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := ds.saveRecentsNoLock(account, device, next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// readRecentsNoLock is the lock-free read half of GetRecents/MutateRecents.
|
||||
// Callers must already hold ds.fileMutex (for reading or writing).
|
||||
func (ds *DataStore) readRecentsNoLock(account, device string) ([]models.ServiceRecent, error) {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)
|
||||
|
||||
data, err := ds.rootReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Printf("[Datastore] GetRecents: no Recents.xml at %s — reporting no recents", sanitizeLog(path))
|
||||
|
||||
return []models.ServiceRecent{}, nil
|
||||
}
|
||||
|
||||
@@ -1404,6 +1499,12 @@ func (ds *DataStore) SaveRecents(account, device string, recents []models.Servic
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
return ds.saveRecentsNoLock(account, device, recents)
|
||||
}
|
||||
|
||||
// saveRecentsNoLock is the lock-free write half of
|
||||
// SaveRecents/MutateRecents. Callers must already hold ds.fileMutex.Lock().
|
||||
func (ds *DataStore) saveRecentsNoLock(account, device string, recents []models.ServiceRecent) error {
|
||||
dir := ds.AccountDeviceDir(account, device)
|
||||
if err := ds.rootMkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
@@ -1509,7 +1610,7 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
|
||||
return fmt.Errorf("device ID/name cannot be empty")
|
||||
}
|
||||
|
||||
if !isSafeIdentifier(device) {
|
||||
if !IsSafeIdentifier(device) {
|
||||
return fmt.Errorf("invalid device ID")
|
||||
}
|
||||
|
||||
@@ -1517,7 +1618,7 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
|
||||
return fmt.Errorf("account ID cannot be empty")
|
||||
}
|
||||
|
||||
if !isSafeIdentifier(account) {
|
||||
if !IsSafeIdentifier(account) {
|
||||
return fmt.Errorf("invalid account ID")
|
||||
}
|
||||
|
||||
@@ -1705,6 +1806,10 @@ func (ds *DataStore) SaveAccountInfo(accountID string, info *models.ServiceAccou
|
||||
return nil
|
||||
}
|
||||
|
||||
if !IsSafeIdentifier(accountID) {
|
||||
return fmt.Errorf("invalid account ID")
|
||||
}
|
||||
|
||||
dir := ds.AccountDir(accountID)
|
||||
if err := ds.rootMkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
@@ -1905,6 +2010,40 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
|
||||
ds.fileMutex.RLock()
|
||||
defer ds.fileMutex.RUnlock()
|
||||
|
||||
return ds.readConfiguredSourcesNoLock(account, device)
|
||||
}
|
||||
|
||||
// MutateConfiguredSources atomically reads the current configured-source
|
||||
// list, transforms it via mutate, and persists the result — holding a
|
||||
// single write lock for the entire read-mutate-write cycle. See
|
||||
// MutatePresets for why this matters: a separate GetConfiguredSources
|
||||
// followed by SaveConfiguredSources leaves a lost-update window open
|
||||
// between concurrent callers.
|
||||
func (ds *DataStore) MutateConfiguredSources(account, device string, mutate func(current []models.ConfiguredSource) ([]models.ConfiguredSource, error)) ([]models.ConfiguredSource, error) {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
current, err := ds.readConfiguredSourcesNoLock(account, device)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
next, err := mutate(current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := ds.saveConfiguredSourcesNoLock(account, device, next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// readConfiguredSourcesNoLock is the lock-free read half of
|
||||
// GetConfiguredSources/MutateConfiguredSources. Callers must already hold
|
||||
// ds.fileMutex (for reading or writing).
|
||||
func (ds *DataStore) readConfiguredSourcesNoLock(account, device string) ([]models.ConfiguredSource, error) {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
|
||||
|
||||
// defaultSources is the fallback used whenever there is no usable
|
||||
@@ -2076,6 +2215,13 @@ func (ds *DataStore) SaveConfiguredSources(account, device string, sources []mod
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
return ds.saveConfiguredSourcesNoLock(account, device, sources)
|
||||
}
|
||||
|
||||
// saveConfiguredSourcesNoLock is the lock-free write half of
|
||||
// SaveConfiguredSources/MutateConfiguredSources. Callers must already hold
|
||||
// ds.fileMutex.Lock().
|
||||
func (ds *DataStore) saveConfiguredSourcesNoLock(account, device string, sources []models.ConfiguredSource) error {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
|
||||
if err := ds.rootMkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
@@ -2594,6 +2740,8 @@ type Settings struct {
|
||||
RecordInteractions bool `json:"record_interactions"`
|
||||
DiscoveryInterval string `json:"discovery_interval,omitempty"`
|
||||
DiscoveryEnabled bool `json:"discovery_enabled"`
|
||||
UpdateCheckInterval string `json:"update_check_interval,omitempty"`
|
||||
UpdateCheckEnabled bool `json:"update_check_enabled"`
|
||||
DNSEnabled bool `json:"dns_enabled"`
|
||||
DNSUpstream []string `json:"dns_upstream,omitempty"`
|
||||
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
|
||||
@@ -2657,6 +2805,23 @@ type Settings struct {
|
||||
// API/speaker clients (non-HTML Accept) always get the version JSON
|
||||
// regardless of this setting.
|
||||
DefaultLanding string `json:"default_landing,omitempty"`
|
||||
|
||||
// AdminAreaAuth is a tri-state toggle for gating the entire admin area
|
||||
// (/admin, /setup, /api/setup — minus a small set of routes shared with
|
||||
// soundtouch-cli/soundtouch-player) behind the same Basic Auth used for
|
||||
// /api/mgmt/*, rather than just the Local Account / Spotify / Amazon
|
||||
// linking endpoints as today. Values:
|
||||
// "" — unset (default). Today this means "not enforced"; a
|
||||
// later release is expected to flip the *meaning* of ""
|
||||
// to "enforced" as the project moves the entire admin
|
||||
// area to require login by default. See #419.
|
||||
// "enabled" — the whole admin area requires Basic Auth now.
|
||||
// "disabled" — explicit opt-out. Kept open even after the default
|
||||
// flips, so an operator's deliberate choice survives
|
||||
// the upgrade.
|
||||
// The tri-state (rather than a plain bool) is what lets "never decided"
|
||||
// be told apart from "explicitly chose off" once that default flips.
|
||||
AdminAreaAuth string `json:"admin_area_auth,omitempty"`
|
||||
}
|
||||
|
||||
// GetSettings retrieves the global service settings.
|
||||
@@ -2703,6 +2868,63 @@ func (ds *DataStore) SaveSettings(settings Settings) error {
|
||||
return ds.atomicWriteFile(path, data)
|
||||
}
|
||||
|
||||
// UpdateCheckState is the small persisted state for the opt-in periodic
|
||||
// update check (#591, _/i591/design-update-check.md): when it last ran and
|
||||
// what it last saw, so a restart doesn't lose the "already logged this
|
||||
// version" and "don't hammer GitHub on every startup" context. Separate
|
||||
// from Settings, which is operator-editable config, not runtime state.
|
||||
type UpdateCheckState struct {
|
||||
LastCheckedAt string `json:"last_checked_at,omitempty"`
|
||||
LastSeenVersion string `json:"last_seen_version,omitempty"`
|
||||
LastReleaseURL string `json:"last_release_url,omitempty"`
|
||||
}
|
||||
|
||||
// GetUpdateCheckState retrieves the persisted update-check state. Same
|
||||
// missing-file-is-not-an-error shape as GetSettings — a fresh install (or
|
||||
// one that has never had the check enabled) has no file yet.
|
||||
func (ds *DataStore) GetUpdateCheckState() (UpdateCheckState, error) {
|
||||
if ds == nil || ds.DataDir == "" {
|
||||
return UpdateCheckState{}, nil
|
||||
}
|
||||
|
||||
path := filepath.Join(ds.DataDir, "update-check.json")
|
||||
if !ds.rootExists(path) {
|
||||
return UpdateCheckState{}, nil
|
||||
}
|
||||
|
||||
data, err := ds.rootReadFile(path)
|
||||
if err != nil {
|
||||
return UpdateCheckState{}, err
|
||||
}
|
||||
|
||||
var state UpdateCheckState
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return UpdateCheckState{}, err
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// SaveUpdateCheckState persists the update-check state.
|
||||
func (ds *DataStore) SaveUpdateCheckState(state UpdateCheckState) error {
|
||||
if ds == nil || ds.DataDir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := ds.rootMkdirAll(ds.DataDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(ds.DataDir, "update-check.json")
|
||||
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ds.atomicWriteFile(path, data)
|
||||
}
|
||||
|
||||
// SaveUsageStats saves usage statistics to the datastore.
|
||||
func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error {
|
||||
dir := filepath.Join(ds.DataDir, "stats", "usage")
|
||||
@@ -2721,6 +2943,85 @@ func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error {
|
||||
return ds.atomicWriteFile(path, data)
|
||||
}
|
||||
|
||||
// RecordActivity appends one entry to the local admin-UI activity log, under
|
||||
// DataDir/stats/activity/<kind>/, one file per event (same shape as
|
||||
// SaveUsageStats/SaveErrorStats above). kind is meant to be a small,
|
||||
// developer-defined constant (e.g. "notification_dismissed") used directly
|
||||
// as a directory name — callers must not pass untrusted/user-supplied
|
||||
// values. id may recur across calls with a new timestamp each time; this is
|
||||
// an append-only log, not a keyed store. See models.ActivityRecord for the
|
||||
// local-only/never-transmitted-automatically guarantee this backs.
|
||||
func (ds *DataStore) RecordActivity(kind, id string, detail map[string]interface{}) error {
|
||||
dir := filepath.Join(ds.DataDir, "stats", "activity", kind)
|
||||
if err := ds.rootMkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
record := models.ActivityRecord{
|
||||
Kind: kind,
|
||||
ID: id,
|
||||
Timestamp: now.UTC().Format(time.RFC3339Nano),
|
||||
Detail: detail,
|
||||
}
|
||||
|
||||
// The random suffix guards against two events for the same id landing in
|
||||
// the same nanosecond (observed as flaky on coarser-resolution clocks)
|
||||
// silently overwriting one another instead of both being recorded. Not
|
||||
// a security-sensitive use of randomness — only affects filename
|
||||
// uniqueness, not any value that's compared or kept secret.
|
||||
// nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used
|
||||
filename := fmt.Sprintf("%d_%d_%s.json", now.UnixNano(), rand.Int63n(1_000_000), id) //nolint:gosec
|
||||
path := filepath.Join(dir, filename)
|
||||
|
||||
data, err := json.MarshalIndent(record, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ds.atomicWriteFile(path, data)
|
||||
}
|
||||
|
||||
// GetActivityRecords reads back every entry recorded via RecordActivity for
|
||||
// the given kind. Unreadable or malformed files are skipped rather than
|
||||
// failing the whole read — a single corrupt event shouldn't make the rest of
|
||||
// the log unreadable. Returns an empty slice (not an error) when the
|
||||
// directory doesn't exist yet, matching the "nothing recorded yet" case.
|
||||
func (ds *DataStore) GetActivityRecords(kind string) ([]models.ActivityRecord, error) {
|
||||
dir := filepath.Join(ds.DataDir, "stats", "activity", kind)
|
||||
|
||||
entries, err := ds.rootReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
records := make([]models.ActivityRecord, 0, len(entries))
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
data, readErr := ds.rootReadFile(filepath.Join(dir, entry.Name()))
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var record models.ActivityRecord
|
||||
if unmarshalErr := json.Unmarshal(data, &record); unmarshalErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// SaveErrorStats saves error statistics to the datastore.
|
||||
func (ds *DataStore) SaveErrorStats(stats models.ErrorStats) error {
|
||||
dir := filepath.Join(ds.DataDir, "stats", "error")
|
||||
|
||||
@@ -428,10 +428,12 @@ func TestSettingsPersistence(t *testing.T) {
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
settings := Settings{
|
||||
ServerURL: "http://myserver:8000",
|
||||
LogBodies: true,
|
||||
DiscoveryInterval: "10m",
|
||||
DiscoveryEnabled: true,
|
||||
ServerURL: "http://myserver:8000",
|
||||
LogBodies: true,
|
||||
DiscoveryInterval: "10m",
|
||||
DiscoveryEnabled: true,
|
||||
UpdateCheckInterval: "12h",
|
||||
UpdateCheckEnabled: true,
|
||||
}
|
||||
|
||||
err = ds.SaveSettings(settings)
|
||||
@@ -456,6 +458,146 @@ func TestSettingsPersistence(t *testing.T) {
|
||||
if loaded.DiscoveryEnabled != settings.DiscoveryEnabled {
|
||||
t.Errorf("Expected DiscoveryEnabled %v, got %v", settings.DiscoveryEnabled, loaded.DiscoveryEnabled)
|
||||
}
|
||||
if loaded.UpdateCheckInterval != settings.UpdateCheckInterval {
|
||||
t.Errorf("Expected UpdateCheckInterval %s, got %s", settings.UpdateCheckInterval, loaded.UpdateCheckInterval)
|
||||
}
|
||||
if loaded.UpdateCheckEnabled != settings.UpdateCheckEnabled {
|
||||
t.Errorf("Expected UpdateCheckEnabled %v, got %v", settings.UpdateCheckEnabled, loaded.UpdateCheckEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCheckState_MissingFileReturnsZeroValue verifies a fresh install
|
||||
// (or one where the update check has never run) gets a zero-value state,
|
||||
// not an error — same shape as GetSettings on a missing settings.json.
|
||||
func TestUpdateCheckState_MissingFileReturnsZeroValue(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "update-check-missing-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
state, err := ds.GetUpdateCheckState()
|
||||
if err != nil {
|
||||
t.Fatalf("GetUpdateCheckState on a fresh install should not error, got: %v", err)
|
||||
}
|
||||
if state != (UpdateCheckState{}) {
|
||||
t.Errorf("Expected zero-value state, got %+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCheckState_Persistence is the roundtrip test, mirroring
|
||||
// TestSettingsPersistence.
|
||||
func TestUpdateCheckState_Persistence(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "update-check-persist-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
state := UpdateCheckState{
|
||||
LastCheckedAt: "2026-08-09T12:00:00Z",
|
||||
LastSeenVersion: "v0.122.0",
|
||||
LastReleaseURL: "https://github.com/gesellix/Bose-SoundTouch/releases/tag/v0.122.0",
|
||||
}
|
||||
|
||||
if err := ds.SaveUpdateCheckState(state); err != nil {
|
||||
t.Fatalf("SaveUpdateCheckState failed: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := ds.GetUpdateCheckState()
|
||||
if err != nil {
|
||||
t.Fatalf("GetUpdateCheckState failed: %v", err)
|
||||
}
|
||||
|
||||
if loaded != state {
|
||||
t.Errorf("Expected %+v, got %+v", state, loaded)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordActivity_EmptyKindReturnsNilNotError verifies GetActivityRecords
|
||||
// for a kind that was never recorded returns an empty, non-error result —
|
||||
// the "nothing recorded yet" case, not a failure.
|
||||
func TestRecordActivity_EmptyKindReturnsNilNotError(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "activity-empty-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
records, err := ds.GetActivityRecords("notification_dismissed")
|
||||
if err != nil {
|
||||
t.Fatalf("GetActivityRecords on empty kind should not error, got: %v", err)
|
||||
}
|
||||
if len(records) != 0 {
|
||||
t.Errorf("Expected no records, got %d", len(records))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordActivity_SameIDRecursWithNewTimestamp is the regression test for
|
||||
// the append-only shape agreed in the #419 design: dismissing the same
|
||||
// announcement twice must produce two records, not overwrite one — this is
|
||||
// a log, not a keyed map.
|
||||
func TestRecordActivity_SameIDRecursWithNewTimestamp(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "activity-recur-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
if err := ds.RecordActivity("notification_dismissed", "admin-gate-notice", nil); err != nil {
|
||||
t.Fatalf("First RecordActivity failed: %v", err)
|
||||
}
|
||||
if err := ds.RecordActivity("notification_dismissed", "admin-gate-notice", nil); err != nil {
|
||||
t.Fatalf("Second RecordActivity failed: %v", err)
|
||||
}
|
||||
|
||||
records, err := ds.GetActivityRecords("notification_dismissed")
|
||||
if err != nil {
|
||||
t.Fatalf("GetActivityRecords failed: %v", err)
|
||||
}
|
||||
if len(records) != 2 {
|
||||
t.Fatalf("Expected 2 records for the same recurring id, got %d: %+v", len(records), records)
|
||||
}
|
||||
for _, r := range records {
|
||||
if r.ID != "admin-gate-notice" || r.Kind != "notification_dismissed" || r.Timestamp == "" {
|
||||
t.Errorf("Unexpected record shape: %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordActivity_DetailRoundTrips verifies the optional detail payload
|
||||
// survives a write/read round trip.
|
||||
func TestRecordActivity_DetailRoundTrips(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "activity-detail-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
if err := ds.RecordActivity("some_kind", "some-id", map[string]interface{}{"note": "hello"}); err != nil {
|
||||
t.Fatalf("RecordActivity failed: %v", err)
|
||||
}
|
||||
|
||||
records, err := ds.GetActivityRecords("some_kind")
|
||||
if err != nil {
|
||||
t.Fatalf("GetActivityRecords failed: %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("Expected 1 record, got %d", len(records))
|
||||
}
|
||||
if records[0].Detail["note"] != "hello" {
|
||||
t.Errorf("Expected detail to round-trip, got: %+v", records[0].Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveDeviceMigratesData(t *testing.T) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package datastore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
@@ -19,6 +20,10 @@ func TestIsSafeIdentifier(t *testing.T) {
|
||||
{"abc-123", true},
|
||||
{"abc.123", true},
|
||||
{"00:11:22:33:44:55", true},
|
||||
// #634: third-party/manual pairing tools (e.g. the USB-stick
|
||||
// SSH-enable method) can report a non-numeric margeAccountUUID.
|
||||
{"stick@local", true},
|
||||
{strings.Repeat("a", maxSafeIdentifierLength), true},
|
||||
{"", false},
|
||||
{"/", false},
|
||||
{"\\", false},
|
||||
@@ -30,7 +35,6 @@ func TestIsSafeIdentifier(t *testing.T) {
|
||||
{"a..b", false},
|
||||
{"a b", false},
|
||||
{"a!b", false},
|
||||
{"a@b", false},
|
||||
{"a#b", false},
|
||||
{"a$b", false},
|
||||
{"a%b", false},
|
||||
@@ -39,12 +43,17 @@ func TestIsSafeIdentifier(t *testing.T) {
|
||||
{"a*b", false},
|
||||
{"a(b", false},
|
||||
{"a)b", false},
|
||||
{"a<b", false},
|
||||
{"a>b", false},
|
||||
{`a"b`, false},
|
||||
{"a'b", false},
|
||||
{strings.Repeat("a", maxSafeIdentifierLength+1), false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := isSafeIdentifier(test.id)
|
||||
result := IsSafeIdentifier(test.id)
|
||||
if result != test.expected {
|
||||
t.Errorf("isSafeIdentifier(%q) = %v; expected %v", test.id, result, test.expected)
|
||||
t.Errorf("IsSafeIdentifier(%q) = %v; expected %v", test.id, result, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +81,8 @@ func TestSaveDeviceInfo_Validation(t *testing.T) {
|
||||
{"acc1", "dev/1", true, "invalid device ID"},
|
||||
{"acc..1", "dev1", true, "invalid account ID"},
|
||||
{"acc1", "dev..1", true, "invalid device ID"},
|
||||
// #634: a non-numeric margeAccountUUID is now accepted.
|
||||
{"stick@local", "dev1", false, ""},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
@@ -85,3 +96,40 @@ func TestSaveDeviceInfo_Validation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAccountInfo_Validation(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "datastore-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
ds := NewDataStore(tmpDir)
|
||||
|
||||
tests := []struct {
|
||||
account string
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{"acc1", false, ""},
|
||||
// #634: a non-numeric margeAccountUUID reported via
|
||||
// POST /streaming/account (see HandleMargeCreateAccount) must
|
||||
// be validated the same way SaveDeviceInfo already validates
|
||||
// device-reported account IDs.
|
||||
{"stick@local", false, ""},
|
||||
{"acc/1", true, "invalid account ID"},
|
||||
{"acc..1", true, "invalid account ID"},
|
||||
{"a<b", true, "invalid account ID"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
err := ds.SaveAccountInfo(test.account, &models.ServiceAccountInfo{AccountID: test.account})
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Errorf("SaveAccountInfo(%q) error = %v, wantErr %v", test.account, err, test.wantErr)
|
||||
continue
|
||||
}
|
||||
if test.wantErr && err.Error() != test.errMsg {
|
||||
t.Errorf("SaveAccountInfo(%q) error message = %q, want %q", test.account, err.Error(), test.errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// TestIsAnnouncementDismissed_EmptyByDefault verifies a freshly-constructed
|
||||
// server (no prior activity log) reports nothing as dismissed.
|
||||
func TestIsAnnouncementDismissed_EmptyByDefault(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "dismissal-empty-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
|
||||
|
||||
if server.IsAnnouncementDismissed("admin-gate-notice") {
|
||||
t.Error("Expected no announcement to be dismissed on a fresh install")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordDismissal_UpdatesCacheAndPersists is a regression test for the
|
||||
// #419 design's performance requirement: after RecordDismissal, the
|
||||
// in-memory cache must reflect it immediately (no disk re-read needed), and
|
||||
// it must also be durably persisted via the activity log.
|
||||
func TestRecordDismissal_UpdatesCacheAndPersists(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "dismissal-record-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
|
||||
|
||||
if err := server.RecordDismissal("admin-gate-notice"); err != nil {
|
||||
t.Fatalf("RecordDismissal failed: %v", err)
|
||||
}
|
||||
|
||||
if !server.IsAnnouncementDismissed("admin-gate-notice") {
|
||||
t.Error("Expected admin-gate-notice to be dismissed after RecordDismissal")
|
||||
}
|
||||
|
||||
records, err := ds.GetActivityRecords(activityKindNotificationDismissed)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActivityRecords failed: %v", err)
|
||||
}
|
||||
if len(records) != 1 || records[0].ID != "admin-gate-notice" {
|
||||
t.Errorf("Expected exactly 1 persisted dismissal record, got: %+v", records)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadDismissedAnnouncements_ReadsPriorHistoryAtStartup verifies a
|
||||
// restarted server picks up dismissals recorded in a previous run — the
|
||||
// startup scan, not just the live write-through path.
|
||||
func TestLoadDismissedAnnouncements_ReadsPriorHistoryAtStartup(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "dismissal-startup-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
// Simulate a dismissal recorded in a prior run, before this process's
|
||||
// Server ever existed.
|
||||
if err := ds.RecordActivity(activityKindNotificationDismissed, "admin-gate-notice", nil); err != nil {
|
||||
t.Fatalf("Seeding activity record failed: %v", err)
|
||||
}
|
||||
|
||||
server := NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
|
||||
|
||||
if !server.IsAnnouncementDismissed("admin-gate-notice") {
|
||||
t.Error("Expected startup scan to pick up a dismissal recorded in a prior run")
|
||||
}
|
||||
if server.IsAnnouncementDismissed("some-other-notice") {
|
||||
t.Error("Expected an unrelated id to not be reported as dismissed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordDismissal_SameIDTwiceAppendsBothKeepsCacheSane verifies dismissing
|
||||
// the same announcement twice (e.g. re-shown, dismissed again) appends two
|
||||
// log entries but the in-memory cache still reports it dismissed exactly
|
||||
// once (a boolean check, not a count).
|
||||
func TestRecordDismissal_SameIDTwiceAppendsBothKeepsCacheSane(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "dismissal-recur-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
|
||||
|
||||
if err := server.RecordDismissal("admin-gate-notice"); err != nil {
|
||||
t.Fatalf("First RecordDismissal failed: %v", err)
|
||||
}
|
||||
if err := server.RecordDismissal("admin-gate-notice"); err != nil {
|
||||
t.Fatalf("Second RecordDismissal failed: %v", err)
|
||||
}
|
||||
|
||||
records, err := ds.GetActivityRecords(activityKindNotificationDismissed)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActivityRecords failed: %v", err)
|
||||
}
|
||||
if len(records) != 2 {
|
||||
t.Errorf("Expected 2 append-only log entries for a recurring dismissal, got %d", len(records))
|
||||
}
|
||||
if !server.IsAnnouncementDismissed("admin-gate-notice") {
|
||||
t.Error("Expected admin-gate-notice to still be reported dismissed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Announcement target values. Mirrors the vocabulary Settings.DefaultLanding
|
||||
// already uses (see defaultLanding() in handlers_media.go) rather than
|
||||
// inventing new names — "chooser" is the neutral welcome page, "app" is the
|
||||
// embedded player, "admin" is this admin console.
|
||||
const (
|
||||
announcementTargetChooser = "chooser"
|
||||
announcementTargetApp = "app"
|
||||
announcementTargetAdmin = "admin"
|
||||
)
|
||||
|
||||
// Announcement is one entry in the small, in-code (not admin-authored)
|
||||
// announcement list — see #419 design doc,
|
||||
// _/i419/design-admin-area-auth-gate.md. ShowWhile lets an entry key off
|
||||
// live server state (e.g. "only while the admin-area gate hasn't been
|
||||
// decided yet"); nil means always show (until dismissed).
|
||||
//
|
||||
// MessageFunc and DismissKeyFunc (added for #591,
|
||||
// _/i591/design-update-check.md) are the dynamic counterparts of Message
|
||||
// and ID: nil means "use the static field", as before; set means "compute
|
||||
// it from live state". The update-check notice needs both — its text names
|
||||
// a specific version, and dismissing the notice for v1.2.0 must not
|
||||
// suppress a later notice for v1.3.0, so its dismissal key has to change
|
||||
// with the detected version.
|
||||
type Announcement struct {
|
||||
ID string
|
||||
Message string
|
||||
MessageFunc func(*Server) string
|
||||
Level string
|
||||
Targets []string
|
||||
ShowWhile func(*Server) bool
|
||||
DismissKeyFunc func(*Server) string
|
||||
// LinkText/LinkURL add an optional link alongside Message — e.g. a
|
||||
// release's notes, or a docs page for a future announcement. LinkURLFunc
|
||||
// is the dynamic counterpart of LinkURL (nil = use the static field),
|
||||
// for links whose target depends on live state (e.g. which version was
|
||||
// detected). LinkText has no *Func counterpart: nothing here needs
|
||||
// dynamic link *text*, only a dynamic *URL* — add one only once
|
||||
// something actually needs it, per this project's KISS convention.
|
||||
LinkText string
|
||||
LinkURL string
|
||||
LinkURLFunc func(*Server) string
|
||||
}
|
||||
|
||||
// message returns the effective text: MessageFunc(s) if set, else the
|
||||
// static Message.
|
||||
func (a Announcement) message(s *Server) string {
|
||||
if a.MessageFunc != nil {
|
||||
return a.MessageFunc(s)
|
||||
}
|
||||
|
||||
return a.Message
|
||||
}
|
||||
|
||||
// linkURL returns the effective link URL: LinkURLFunc(s) if set, else the
|
||||
// static LinkURL (which may be "" — no link).
|
||||
func (a Announcement) linkURL(s *Server) string {
|
||||
if a.LinkURLFunc != nil {
|
||||
return a.LinkURLFunc(s)
|
||||
}
|
||||
|
||||
return a.LinkURL
|
||||
}
|
||||
|
||||
// dismissKey returns the effective dismissal/DTO id: DismissKeyFunc(s) if
|
||||
// set, else the static ID.
|
||||
func (a Announcement) dismissKey(s *Server) string {
|
||||
if a.DismissKeyFunc != nil {
|
||||
return a.DismissKeyFunc(s)
|
||||
}
|
||||
|
||||
return a.ID
|
||||
}
|
||||
|
||||
// announcements is the full, in-code list. announcementTargetChooser is
|
||||
// prepared as a valid target value (see the constant above) but no entry
|
||||
// here uses it yet: the chooser landing page (handlers_media.go, landingHTML)
|
||||
// is currently fully static with no JS at all, unlike /admin and /app, so it
|
||||
// can't render or dismiss a banner yet. Wire a chooser-targeted entry only
|
||||
// once that client-side logic exists.
|
||||
var announcements = []Announcement{
|
||||
{
|
||||
ID: "admin-area-auth-419",
|
||||
Level: "info",
|
||||
Targets: []string{announcementTargetAdmin},
|
||||
Message: "A future release will require login for this entire admin area by default (today, only " +
|
||||
"Spotify/Amazon linking and the Local Account tab do). You can opt in now in Settings, or " +
|
||||
"dismiss this once you've decided.",
|
||||
LinkText: "Issue #419",
|
||||
LinkURL: "https://github.com/gesellix/Bose-SoundTouch/issues/419",
|
||||
ShowWhile: func(s *Server) bool {
|
||||
return s.AdminAreaAuthMode() == ""
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "update-available",
|
||||
Level: "info",
|
||||
Targets: []string{announcementTargetApp, announcementTargetAdmin},
|
||||
ShowWhile: func(s *Server) bool {
|
||||
return s.UpdateCheckResult().Available
|
||||
},
|
||||
MessageFunc: func(s *Server) string {
|
||||
r := s.UpdateCheckResult()
|
||||
|
||||
return fmt.Sprintf("AfterTouch %s is available (you're on %s).", r.LatestVersion, r.CurrentVersion)
|
||||
},
|
||||
LinkText: "Release notes",
|
||||
LinkURLFunc: func(s *Server) string {
|
||||
return s.UpdateCheckResult().ReleaseURL
|
||||
},
|
||||
// Per-version, not per-family: dismissing the notice for one version
|
||||
// must not silently suppress a later, different version's notice.
|
||||
DismissKeyFunc: func(s *Server) string {
|
||||
return "update-available-" + s.UpdateCheckResult().LatestVersion
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// announcementDTO is the JSON shape returned by HandleListAnnouncements —
|
||||
// deliberately smaller than Announcement (no ShowWhile func, no Targets;
|
||||
// the caller already asked for a specific target).
|
||||
type announcementDTO struct {
|
||||
ID string `json:"id"`
|
||||
Message string `json:"message"`
|
||||
Level string `json:"level"`
|
||||
LinkText string `json:"link_text,omitempty"`
|
||||
LinkURL string `json:"link_url,omitempty"`
|
||||
}
|
||||
|
||||
func containsString(haystack []string, needle string) bool {
|
||||
for _, v := range haystack {
|
||||
if v == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// HandleListAnnouncements returns the announcements currently active for
|
||||
// the requested target (query param, one of "app" or "admin" — "chooser" is
|
||||
// a reserved value, not yet wired to any handler), filtered by ShowWhile and
|
||||
// excluding anything already dismissed. Deliberately NOT behind
|
||||
// BasicAuthAdmin: the admin-area-gate notice specifically needs to reach
|
||||
// operators who haven't set up credentials yet, the exact audience an
|
||||
// admin-only endpoint would exclude.
|
||||
func (s *Server) HandleListAnnouncements(w http.ResponseWriter, r *http.Request) {
|
||||
target := r.URL.Query().Get("target")
|
||||
|
||||
switch target {
|
||||
case announcementTargetApp, announcementTargetAdmin:
|
||||
default:
|
||||
http.Error(w, "target must be app or admin", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
active := make([]announcementDTO, 0, len(announcements))
|
||||
|
||||
for i := range announcements {
|
||||
a := &announcements[i]
|
||||
|
||||
if !containsString(a.Targets, target) {
|
||||
continue
|
||||
}
|
||||
|
||||
if a.ShowWhile != nil && !a.ShowWhile(s) {
|
||||
continue
|
||||
}
|
||||
|
||||
key := a.dismissKey(s)
|
||||
if s.IsAnnouncementDismissed(key) {
|
||||
continue
|
||||
}
|
||||
|
||||
active = append(active, announcementDTO{
|
||||
ID: key,
|
||||
Message: a.message(s),
|
||||
Level: a.Level,
|
||||
LinkText: a.LinkText,
|
||||
LinkURL: a.linkURL(s),
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"announcements": active}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDismissAnnouncement records a dismissal for the given announcement
|
||||
// id (see Server.RecordDismissal). The id is validated against the known
|
||||
// announcements list rather than accepted as arbitrary input — it ends up
|
||||
// as part of a filename in the local activity log (datastore.RecordActivity),
|
||||
// and this is the one call site where the id comes from an HTTP request
|
||||
// rather than a compile-time constant. Also not behind BasicAuthAdmin, for
|
||||
// the same reason as HandleListAnnouncements.
|
||||
func (s *Server) HandleDismissAnnouncement(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
found := false
|
||||
|
||||
for i := range announcements {
|
||||
if announcements[i].dismissKey(s) == id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
http.Error(w, "Unknown announcement id", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.RecordDismissal(id); err != nil {
|
||||
http.Error(w, "Failed to record dismissal: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func newAnnouncementsTestServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "announcements-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(tempDir) })
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
return NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
|
||||
}
|
||||
|
||||
func listAnnouncements(t *testing.T, s *Server, target string) (int, []announcementDTO) {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/announcements?target="+target, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
s.HandleListAnnouncements(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
return rr.Code, nil
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Announcements []announcementDTO `json:"announcements"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
return rr.Code, body.Announcements
|
||||
}
|
||||
|
||||
func TestHandleListAnnouncements_InvalidTarget(t *testing.T) {
|
||||
s := newAnnouncementsTestServer(t)
|
||||
|
||||
for _, target := range []string{"", "chooser", "bogus"} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/announcements?target="+target, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
s.HandleListAnnouncements(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("target=%q: expected 400 (chooser is reserved, not wired yet), got %d", target, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleListAnnouncements_AdminGateNotice is a regression test for the
|
||||
// #419 admin-gate announcement's ShowWhile/Targets/dismissal behavior end to
|
||||
// end: visible for "admin" while AdminAreaAuth is unset, invisible for
|
||||
// "app", invisible once the mode is set, and invisible once dismissed.
|
||||
func TestHandleListAnnouncements_AdminGateNotice(t *testing.T) {
|
||||
const noticeID = "admin-area-auth-419"
|
||||
|
||||
t.Run("visible for admin target while unset", func(t *testing.T) {
|
||||
s := newAnnouncementsTestServer(t)
|
||||
|
||||
status, active := listAnnouncements(t, s, announcementTargetAdmin)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
if !containsAnnouncementID(active, noticeID) {
|
||||
t.Errorf("expected %q to be active for target=admin while unset, got %+v", noticeID, active)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not visible for app target", func(t *testing.T) {
|
||||
s := newAnnouncementsTestServer(t)
|
||||
|
||||
_, active := listAnnouncements(t, s, announcementTargetApp)
|
||||
if containsAnnouncementID(active, noticeID) {
|
||||
t.Errorf("expected %q to NOT be active for target=app (Targets is admin-only), got %+v", noticeID, active)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not visible once AdminAreaAuth is decided", func(t *testing.T) {
|
||||
s := newAnnouncementsTestServer(t)
|
||||
s.SetAdminAreaAuth("enabled")
|
||||
|
||||
_, active := listAnnouncements(t, s, announcementTargetAdmin)
|
||||
if containsAnnouncementID(active, noticeID) {
|
||||
t.Errorf("expected %q to disappear once the mode is decided, got %+v", noticeID, active)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not visible once dismissed", func(t *testing.T) {
|
||||
s := newAnnouncementsTestServer(t)
|
||||
|
||||
if err := s.RecordDismissal(noticeID); err != nil {
|
||||
t.Fatalf("RecordDismissal failed: %v", err)
|
||||
}
|
||||
|
||||
_, active := listAnnouncements(t, s, announcementTargetAdmin)
|
||||
if containsAnnouncementID(active, noticeID) {
|
||||
t.Errorf("expected %q to disappear once dismissed, got %+v", noticeID, active)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func containsAnnouncementID(active []announcementDTO, id string) bool {
|
||||
for _, a := range active {
|
||||
if a.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func TestHandleDismissAnnouncement_UnknownID(t *testing.T) {
|
||||
s := newAnnouncementsTestServer(t)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/announcements/{id}/dismiss", s.HandleDismissAnnouncement)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/announcements/not-a-real-id/dismiss", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404 for an unknown announcement id, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleDismissAnnouncement_Success verifies dismissing a known
|
||||
// announcement both succeeds and is reflected by a subsequent list call —
|
||||
// end-to-end through the HTTP handlers, not just the underlying
|
||||
// Server.RecordDismissal/IsAnnouncementDismissed pair.
|
||||
func TestHandleDismissAnnouncement_Success(t *testing.T) {
|
||||
s := newAnnouncementsTestServer(t)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/announcements/{id}/dismiss", s.HandleDismissAnnouncement)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/announcements/admin-area-auth-419/dismiss", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
_, active := listAnnouncements(t, s, announcementTargetAdmin)
|
||||
if containsAnnouncementID(active, "admin-area-auth-419") {
|
||||
t.Errorf("expected the notice to be gone from the list after dismissal, got %+v", active)
|
||||
}
|
||||
}
|
||||
|
||||
// newServerWithUpdateAvailable builds a Server whose registered
|
||||
// updatecheck.Checker reports a newer version than currentVersion, via the
|
||||
// same persisted-state-seeding path a real restart would use (not a mock —
|
||||
// exercises the real NewChecker/UpdateCheckResult round trip).
|
||||
func newServerWithUpdateAvailable(t *testing.T, currentVersion, latestVersion string) *Server {
|
||||
t.Helper()
|
||||
|
||||
s := newAnnouncementsTestServer(t)
|
||||
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
if err := ds.SaveUpdateCheckState(datastore.UpdateCheckState{
|
||||
LastCheckedAt: "2026-08-09T00:00:00Z",
|
||||
LastSeenVersion: latestVersion,
|
||||
LastReleaseURL: "https://example.invalid/releases/" + latestVersion,
|
||||
}); err != nil {
|
||||
t.Fatalf("Failed to seed update-check state: %v", err)
|
||||
}
|
||||
|
||||
s.SetUpdateChecker(updatecheck.NewChecker(ds, "owner/repo", currentVersion))
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// TestHandleListAnnouncements_UpdateAvailable is the regression test for
|
||||
// #591's reuse of the #419 announcements mechanism: the update-available
|
||||
// entry's dynamic message/target/dismissal behavior end to end.
|
||||
func TestHandleListAnnouncements_UpdateAvailable(t *testing.T) {
|
||||
t.Run("visible for both admin and app targets when available", func(t *testing.T) {
|
||||
s := newServerWithUpdateAvailable(t, "v1.0.0", "v1.2.0")
|
||||
|
||||
for _, target := range []string{announcementTargetAdmin, announcementTargetApp} {
|
||||
_, active := listAnnouncements(t, s, target)
|
||||
|
||||
var found *announcementDTO
|
||||
for i := range active {
|
||||
if active[i].ID == "update-available-v1.2.0" {
|
||||
found = &active[i]
|
||||
}
|
||||
}
|
||||
|
||||
if found == nil {
|
||||
t.Fatalf("target=%s: expected an update-available-v1.2.0 entry, got %+v", target, active)
|
||||
}
|
||||
if found.Message == "" {
|
||||
t.Errorf("target=%s: expected a non-empty dynamic message", target)
|
||||
}
|
||||
// The release URL belongs in the structured link field, not
|
||||
// embedded as text in the message — the message must stay
|
||||
// generic across other future announcements too.
|
||||
if strings.Contains(found.Message, "http") {
|
||||
t.Errorf("target=%s: expected the URL out of Message, got %q", target, found.Message)
|
||||
}
|
||||
if found.LinkURL == "" {
|
||||
t.Errorf("target=%s: expected a non-empty LinkURL", target)
|
||||
}
|
||||
if found.LinkText == "" {
|
||||
t.Errorf("target=%s: expected a non-empty LinkText", target)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not visible when already up to date", func(t *testing.T) {
|
||||
s := newServerWithUpdateAvailable(t, "v1.2.0", "v1.2.0")
|
||||
|
||||
_, active := listAnnouncements(t, s, announcementTargetAdmin)
|
||||
if containsAnnouncementID(active, "update-available-v1.2.0") {
|
||||
t.Errorf("expected no update-available entry when up to date, got %+v", active)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dismissing one version does not suppress a later version", func(t *testing.T) {
|
||||
s := newServerWithUpdateAvailable(t, "v1.0.0", "v1.2.0")
|
||||
|
||||
if err := s.RecordDismissal("update-available-v1.2.0"); err != nil {
|
||||
t.Fatalf("RecordDismissal failed: %v", err)
|
||||
}
|
||||
|
||||
_, active := listAnnouncements(t, s, announcementTargetAdmin)
|
||||
if containsAnnouncementID(active, "update-available-v1.2.0") {
|
||||
t.Error("expected the v1.2.0 notice to be dismissed")
|
||||
}
|
||||
|
||||
// A later check finds a newer version still: must reappear under a
|
||||
// DIFFERENT dismissal key, not stay suppressed.
|
||||
newDS := datastore.NewDataStore(t.TempDir())
|
||||
if err := newDS.SaveUpdateCheckState(datastore.UpdateCheckState{
|
||||
LastCheckedAt: "2026-08-10T00:00:00Z",
|
||||
LastSeenVersion: "v1.3.0",
|
||||
}); err != nil {
|
||||
t.Fatalf("Failed to seed newer state: %v", err)
|
||||
}
|
||||
s.SetUpdateChecker(updatecheck.NewChecker(newDS, "owner/repo", "v1.0.0"))
|
||||
|
||||
_, active = listAnnouncements(t, s, announcementTargetAdmin)
|
||||
if !containsAnnouncementID(active, "update-available-v1.3.0") {
|
||||
t.Errorf("expected the v1.3.0 notice to appear despite v1.2.0 being dismissed, got %+v", active)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleDismissAnnouncement_UpdateAvailable(t *testing.T) {
|
||||
s := newServerWithUpdateAvailable(t, "v1.0.0", "v1.2.0")
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/announcements/{id}/dismiss", s.HandleDismissAnnouncement)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/announcements/update-available-v1.2.0/dismiss", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
_, active := listAnnouncements(t, s, announcementTargetAdmin)
|
||||
if containsAnnouncementID(active, "update-available-v1.2.0") {
|
||||
t.Errorf("expected the notice to be gone after dismissal, got %+v", active)
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func (s *Server) HandleTuneInPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
sanitizeLog(r.URL.Path), sanitizeLog(r.UserAgent()))
|
||||
}
|
||||
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
stationID := strings.TrimSpace(chi.URLParam(r, "stationID"))
|
||||
|
||||
resp, err := bmx.TuneInPlayback(stationID, s.tuneInStreamFormats())
|
||||
if err != nil {
|
||||
@@ -74,8 +74,8 @@ func (s *Server) HandleTuneInPodcastInfo(w http.ResponseWriter, r *http.Request)
|
||||
sanitizeLog(r.URL.Path), sanitizeLog(r.UserAgent()))
|
||||
}
|
||||
|
||||
podcastID := chi.URLParam(r, "podcastID")
|
||||
encodedName := r.URL.Query().Get("encoded_name")
|
||||
podcastID := strings.TrimSpace(chi.URLParam(r, "podcastID"))
|
||||
encodedName := strings.TrimSpace(r.URL.Query().Get("encoded_name"))
|
||||
|
||||
resp, err := bmx.TuneInPodcastInfo(podcastID, encodedName)
|
||||
if err != nil {
|
||||
@@ -102,7 +102,7 @@ func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Requ
|
||||
sanitizeLog(r.URL.Path), sanitizeLog(r.UserAgent()))
|
||||
}
|
||||
|
||||
podcastID := chi.URLParam(r, "podcastID")
|
||||
podcastID := strings.TrimSpace(chi.URLParam(r, "podcastID"))
|
||||
|
||||
resp, err := bmx.TuneInPlaybackPodcast(podcastID, s.tuneInStreamFormats())
|
||||
if err != nil {
|
||||
@@ -271,7 +271,7 @@ func (s *Server) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
|
||||
sanitizeLog(r.URL.Path), sanitizeLog(r.UserAgent()))
|
||||
}
|
||||
|
||||
query := r.URL.Query().Get("q")
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if query == "" {
|
||||
http.Error(w, "query parameter 'q' is required", http.StatusBadRequest)
|
||||
return
|
||||
@@ -298,7 +298,7 @@ func (s *Server) HandleTuneInSearchNext(w http.ResponseWriter, r *http.Request)
|
||||
sanitizeLog(r.URL.Path), sanitizeLog(r.UserAgent()))
|
||||
}
|
||||
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
cursor := strings.TrimSpace(r.URL.Query().Get("cursor"))
|
||||
if cursor == "" {
|
||||
http.Error(w, "cursor parameter required", http.StatusBadRequest)
|
||||
return
|
||||
@@ -319,7 +319,7 @@ func (s *Server) HandleTuneInSearchNext(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// HandleTuneInFavorite handles POST /bmx/tunein/v1/favorite/{stationID}.
|
||||
func (s *Server) HandleTuneInFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
stationID := strings.TrimSpace(chi.URLParam(r, "stationID"))
|
||||
if err := s.ds.SaveTuneInFavorite(stationID); err != nil {
|
||||
log.Printf("Failed to persist TuneIn favorite %s: %s", sanitizeLog(stationID), sanitizeErr(err))
|
||||
}
|
||||
@@ -331,7 +331,7 @@ func (s *Server) HandleTuneInFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleTuneInDeleteFavorite handles DELETE /bmx/tunein/v1/favorite/{stationID}.
|
||||
func (s *Server) HandleTuneInDeleteFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
stationID := chi.URLParam(r, "stationID")
|
||||
stationID := strings.TrimSpace(chi.URLParam(r, "stationID"))
|
||||
if err := s.ds.DeleteTuneInFavorite(stationID); err != nil {
|
||||
log.Printf("Failed to delete TuneIn favorite %s: %s", sanitizeLog(stationID), sanitizeErr(err))
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -189,6 +191,7 @@ func (s *Server) buildDiagnosticArchive() ([]byte, error) {
|
||||
s.addSystemFiles(tw)
|
||||
s.addServiceLog(tw)
|
||||
s.addSettingsJSON(tw)
|
||||
s.addActivityLog(tw)
|
||||
addEnvVars(tw)
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
@@ -253,7 +256,12 @@ func (s *Server) addServiceHTTP(tw *tar.Writer, client *http.Client, devices []m
|
||||
if !seenAccounts[dev.AccountID] {
|
||||
seenAccounts[dev.AccountID] = true
|
||||
pfx := "http/service/account-" + dev.AccountID
|
||||
acct := base + "/streaming/account/" + dev.AccountID
|
||||
// url.PathEscape, not raw concatenation: account/device IDs can
|
||||
// contain characters like '@' (#634) that are safe as datastore
|
||||
// keys but would otherwise need escaping to survive as URL path
|
||||
// segments intact (e.g. a literal '?' or '#' would truncate the
|
||||
// path here, though IsSafeIdentifier already excludes those).
|
||||
acct := base + "/streaming/account/" + url.PathEscape(dev.AccountID)
|
||||
tryAdd(pfx+"/full.xml", acct+"/full")
|
||||
tryAdd(pfx+"/sources.xml", acct+"/sources")
|
||||
tryAdd(pfx+"/presets.xml", acct+"/presets")
|
||||
@@ -264,7 +272,7 @@ func (s *Server) addServiceHTTP(tw *tar.Writer, client *http.Client, devices []m
|
||||
}
|
||||
|
||||
dpfx := "http/service/account-" + dev.AccountID + "/device-" + dev.DeviceID
|
||||
dpath := base + "/streaming/account/" + dev.AccountID + "/device/" + dev.DeviceID
|
||||
dpath := base + "/streaming/account/" + url.PathEscape(dev.AccountID) + "/device/" + url.PathEscape(dev.DeviceID)
|
||||
tryAdd(dpfx+"/presets.xml", dpath+"/presets")
|
||||
tryAdd(dpfx+"/recents.xml", dpath+"/recents")
|
||||
}
|
||||
@@ -698,6 +706,56 @@ type diagSettings struct {
|
||||
// addSettingsJSON serialises the service settings into the archive as
|
||||
// settings.json. OAuth client secrets are replaced with "[REDACTED]" so the
|
||||
// file is safe to share.
|
||||
// addActivityLog bundles the local admin-UI activity log (announcement
|
||||
// dismissals, and any other kind recorded via datastore.RecordActivity)
|
||||
// into the diagnostic archive verbatim, one file per event — same idea as
|
||||
// the per-device XML bundling above, but for stats/activity/. This is what
|
||||
// makes the "local-only, but included in an explicitly-triggered diagnostic
|
||||
// export" claim in DIAGNOSTIC-EXPORT.md actually true. A missing directory
|
||||
// (nothing recorded yet) is not an error.
|
||||
func (s *Server) addActivityLog(tw *tar.Writer) {
|
||||
if s.ds == nil || s.ds.DataDir == "" {
|
||||
return
|
||||
}
|
||||
|
||||
root := filepath.Join(s.ds.DataDir, "stats", "activity")
|
||||
|
||||
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
log.Printf("[Export] read activity log %s: %v", sanitizeLog(path), readErr)
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, relErr := filepath.Rel(s.ds.DataDir, path)
|
||||
if relErr != nil {
|
||||
log.Printf("[Export] rel path for %s: %v", sanitizeLog(path), relErr)
|
||||
return nil
|
||||
}
|
||||
|
||||
if addErr := addTarBytes(tw, rel, data); addErr != nil {
|
||||
log.Printf("[Export] add %s: %v", sanitizeLog(rel), addErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
log.Printf("[Export] walk activity log: %v", walkErr)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) addSettingsJSON(tw *tar.Writer) {
|
||||
st, err := s.ds.GetSettings()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// tarEntries reads every file name + content out of a tar written by
|
||||
// addActivityLog, for assertions.
|
||||
func tarEntries(t *testing.T, tw *tar.Writer, buf *bytes.Buffer) map[string]string {
|
||||
t.Helper()
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatalf("Failed to close tar writer: %v", err)
|
||||
}
|
||||
|
||||
entries := make(map[string]string)
|
||||
tr := tar.NewReader(buf)
|
||||
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tar entry: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tar entry content: %v", err)
|
||||
}
|
||||
|
||||
entries[hdr.Name] = string(data)
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// TestAddActivityLog_EmptyByDefault verifies a fresh install (nothing
|
||||
// recorded via datastore.RecordActivity yet — the common case, since
|
||||
// stats/activity/ won't exist at all) doesn't error and adds nothing.
|
||||
func TestAddActivityLog_EmptyByDefault(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "export-activity-empty-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
|
||||
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
server.addActivityLog(tw)
|
||||
|
||||
entries := tarEntries(t, tw, &buf)
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("Expected no tar entries for an empty activity log, got %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddActivityLog_IncludesRecordedDismissal is the regression test for
|
||||
// the #419 design's stated privacy guarantee: a dismissal recorded locally
|
||||
// must actually show up in the diagnostic export, not just in theory. This
|
||||
// closes the loop DIAGNOSTIC-EXPORT.md documents.
|
||||
func TestAddActivityLog_IncludesRecordedDismissal(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "export-activity-dismissal-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
|
||||
|
||||
if err := server.RecordDismissal("admin-area-auth-419"); err != nil {
|
||||
t.Fatalf("RecordDismissal failed: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
server.addActivityLog(tw)
|
||||
|
||||
entries := tarEntries(t, tw, &buf)
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("Expected exactly 1 tar entry, got %+v", entries)
|
||||
}
|
||||
|
||||
var (
|
||||
name string
|
||||
content string
|
||||
)
|
||||
for n, c := range entries {
|
||||
name, content = n, c
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(name, "stats/activity/notification_dismissed/") {
|
||||
t.Errorf("Expected entry under stats/activity/notification_dismissed/, got %q", name)
|
||||
}
|
||||
if !strings.Contains(content, "admin-area-auth-419") {
|
||||
t.Errorf("Expected entry content to reference the dismissed id, got %q", content)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -64,6 +65,11 @@ func (s *Server) HandleMargeCreateAccount(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}
|
||||
|
||||
if !datastore.IsSafeIdentifier(id) {
|
||||
http.Error(w, "Invalid account ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
info := &models.ServiceAccountInfo{
|
||||
AccountID: id,
|
||||
PreferredLanguage: req.PreferredLanguage,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -29,6 +30,46 @@ func (s *Server) BasicAuthMgmt() func(http.Handler) http.Handler {
|
||||
return middleware.BasicAuth("Management API", map[string]string{username: password})
|
||||
}
|
||||
|
||||
// BasicAuthAdmin returns a middleware gating the whole admin area (/admin,
|
||||
// /setup, /api/setup — minus the small set of routes shared with
|
||||
// soundtouch-cli/soundtouch-player) behind the same credentials as
|
||||
// BasicAuthMgmt. Unlike BasicAuthMgmt, which captures username/password once
|
||||
// at router-setup time (main.go builds the router once at startup),
|
||||
// BasicAuthAdmin reads the live AdminAreaAuth mode and credentials on every
|
||||
// request, so toggling the setting via the Settings UI (HandleUpdateSettings
|
||||
// -> SetAdminAreaAuth) takes effect immediately — no restart. When the mode
|
||||
// isn't "enabled", every request passes through unauthenticated, i.e.
|
||||
// today's default behavior. See #419 and
|
||||
// _/i419/design-admin-area-auth-gate.md.
|
||||
func (s *Server) BasicAuthAdmin() func(http.Handler) http.Handler {
|
||||
const realm = "Admin Area"
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
mode := s.adminAreaAuth
|
||||
username := s.mgmtUsername
|
||||
password := s.mgmtPassword
|
||||
s.mu.RUnlock()
|
||||
|
||||
if mode != "enabled" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != username || subtle.ConstantTimeCompare([]byte(pass), []byte(password)) != 1 {
|
||||
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, realm))
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMgmtListSpeakers returns discovered speakers for the given account.
|
||||
func (s *Server) HandleMgmtListSpeakers(w http.ResponseWriter, r *http.Request) {
|
||||
_ = chi.URLParam(r, "accountId")
|
||||
|
||||
@@ -265,3 +265,113 @@ func TestBasicAuthMgmt(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBasicAuthAdmin covers the #419 admin-area gate: unlike BasicAuthMgmt
|
||||
// (credentials captured once at router-setup time), BasicAuthAdmin must
|
||||
// read the live AdminAreaAuth mode and credentials on every request, so a
|
||||
// live toggle via the Settings UI takes effect without a restart.
|
||||
func TestBasicAuthAdmin(t *testing.T) {
|
||||
s := NewServer(nil, nil, "http://localhost", false, false, false)
|
||||
s.SetMgmtConfig("admin", "secret123")
|
||||
|
||||
handler := s.BasicAuthAdmin()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("OK"))
|
||||
}))
|
||||
|
||||
t.Run("Unset mode passes through unauthenticated (today's default)", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d for unset mode, got %d", http.StatusOK, rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Disabled mode passes through unauthenticated", func(t *testing.T) {
|
||||
s.SetAdminAreaAuth("disabled")
|
||||
defer s.SetAdminAreaAuth("")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d for disabled mode, got %d", http.StatusOK, rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Enabled mode requires valid credentials", func(t *testing.T) {
|
||||
s.SetAdminAreaAuth("enabled")
|
||||
defer s.SetAdminAreaAuth("")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
|
||||
req.SetBasicAuth("admin", "secret123")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d with valid credentials, got %d", http.StatusOK, rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Enabled mode rejects missing credentials", func(t *testing.T) {
|
||||
s.SetAdminAreaAuth("enabled")
|
||||
defer s.SetAdminAreaAuth("")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status %d with no credentials, got %d", http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
if rr.Header().Get("WWW-Authenticate") == "" {
|
||||
t.Error("expected WWW-Authenticate header to be set")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Enabled mode rejects wrong credentials", func(t *testing.T) {
|
||||
s.SetAdminAreaAuth("enabled")
|
||||
defer s.SetAdminAreaAuth("")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
|
||||
req.SetBasicAuth("admin", "wrongpass")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status %d with wrong credentials, got %d", http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Toggling mode live changes behavior without rebuilding the handler", func(t *testing.T) {
|
||||
// The whole point of reading s.adminAreaAuth per-request rather than
|
||||
// capturing it once: the same handler value must reflect a live change.
|
||||
s.SetAdminAreaAuth("")
|
||||
reqOpen := httptest.NewRequest(http.MethodGet, "/admin", nil)
|
||||
rrOpen := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rrOpen, reqOpen)
|
||||
|
||||
if rrOpen.Code != http.StatusOK {
|
||||
t.Fatalf("expected open access before toggling, got %d", rrOpen.Code)
|
||||
}
|
||||
|
||||
s.SetAdminAreaAuth("enabled")
|
||||
defer s.SetAdminAreaAuth("")
|
||||
|
||||
reqGated := httptest.NewRequest(http.MethodGet, "/admin", nil)
|
||||
rrGated := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rrGated, reqGated)
|
||||
|
||||
if rrGated.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected the SAME handler to enforce auth immediately after toggling, got %d", rrGated.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/health"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -61,12 +62,12 @@ type pairAccountResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// HandlePairAccount associates the device with the supplied 7-digit account ID,
|
||||
// HandlePairAccount associates the device with the supplied account ID,
|
||||
// trying HTTP /setMargeAccount first and falling back to telnet
|
||||
// `envswitch accountid set`.
|
||||
//
|
||||
// Query params:
|
||||
// - account_id (required) — must pass setup.IsValidAccountID
|
||||
// - account_id (required) — must pass datastore.IsSafeIdentifier
|
||||
func (s *Server) HandlePairAccount(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
@@ -75,8 +76,8 @@ func (s *Server) HandlePairAccount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
accountID := r.URL.Query().Get("account_id")
|
||||
if !setup.IsValidAccountID(accountID) {
|
||||
writeJSONError(w, http.StatusBadRequest, "account_id must be exactly 7 digits")
|
||||
if !datastore.IsSafeIdentifier(accountID) {
|
||||
writeJSONError(w, http.StatusBadRequest, "account_id must be a non-empty, path-safe identifier")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -145,7 +146,7 @@ func (s *Server) completeSpeakerPairingFix(target health.Target) (string, error)
|
||||
}
|
||||
|
||||
accountID := target.Account
|
||||
if !setup.IsValidAccountID(accountID) {
|
||||
if !datastore.IsSafeIdentifier(accountID) {
|
||||
known, _ := s.ds.ListAccounts()
|
||||
|
||||
generated, genErr := setup.GenerateAccountID(known)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/health"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -165,11 +167,17 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
httpsOverride := s.httpsOverride
|
||||
discoveryInterval := s.discoveryInterval.String()
|
||||
discoveryEnabled := s.discoveryEnabled
|
||||
// Read the update-check fields directly rather than via
|
||||
// GetUpdateCheckSettings(): that getter takes s.mu.RLock itself, and Go's
|
||||
// sync.RWMutex is not reentrant-safe against a concurrent writer.
|
||||
updateCheckInterval := s.updateCheckInterval.String()
|
||||
updateCheckEnabled := s.updateCheckEnabled
|
||||
dnsEnabled := s.dnsEnabled
|
||||
dnsUpstream := s.dnsUpstream
|
||||
dnsBindAddr := s.dnsBindAddr
|
||||
internalPaths := s.internalPaths
|
||||
redact, logBody, record := s.redactLogs, s.logBodies, s.recordEnabled
|
||||
adminAreaAuth := s.adminAreaAuth
|
||||
shortcuts := s.shortcuts
|
||||
spotifyConfigured := s.spotifyService != nil
|
||||
spotifyClientID := s.spotifyClientID
|
||||
@@ -245,6 +253,8 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
"https_443_lan_host": probe443.LANHost,
|
||||
"discovery_interval": discoveryInterval,
|
||||
"discovery_enabled": discoveryEnabled,
|
||||
"update_check_interval": updateCheckInterval,
|
||||
"update_check_enabled": updateCheckEnabled,
|
||||
"dns_enabled": dnsEnabled,
|
||||
"dns_running": dnsRunning,
|
||||
"dns_actual_bind": actualBind,
|
||||
@@ -271,12 +281,66 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
"tts_voice": ttsVoice,
|
||||
"tts_volume": ttsVolume,
|
||||
"default_landing": defaultLanding,
|
||||
"admin_area_auth": adminAreaAuth,
|
||||
}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// parseDNSUpstreamList splits a comma-separated DNS upstream list into its
|
||||
// trimmed, non-empty entries. Returns nil for an empty input.
|
||||
func parseDNSUpstreamList(dnsUpstream string) []string {
|
||||
if dnsUpstream == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var upstreamList []string
|
||||
|
||||
for _, u := range strings.Split(dnsUpstream, ",") {
|
||||
u = strings.TrimSpace(u)
|
||||
if u != "" {
|
||||
upstreamList = append(upstreamList, u)
|
||||
}
|
||||
}
|
||||
|
||||
return upstreamList
|
||||
}
|
||||
|
||||
// parseOptionalDuration parses a duration string that the client is allowed to
|
||||
// omit. An empty value yields a zero duration and no error, so callers can
|
||||
// treat "field omitted" as "keep the current value" while still rejecting a
|
||||
// value that was supplied but is unparseable.
|
||||
func parseOptionalDuration(value string) (time.Duration, error) {
|
||||
if value == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return time.ParseDuration(value)
|
||||
}
|
||||
|
||||
// resolvePeriodicSetting computes the new (interval, enabled) pair for one of
|
||||
// the background pollers (device discovery, update check) from a settings
|
||||
// request. When the request omitted the interval, the current one is kept. A
|
||||
// zero interval always forces the task off: both pollers treat zero as
|
||||
// "always due", so leaving the task enabled would make their poll tick the
|
||||
// work rate.
|
||||
func resolvePeriodicSetting(
|
||||
currentInterval, requestedInterval time.Duration,
|
||||
requestedIntervalProvided, requestedEnabled bool,
|
||||
) (time.Duration, bool) {
|
||||
interval := currentInterval
|
||||
if requestedIntervalProvided {
|
||||
interval = requestedInterval
|
||||
}
|
||||
|
||||
if interval == 0 {
|
||||
return interval, false
|
||||
}
|
||||
|
||||
return interval, requestedEnabled
|
||||
}
|
||||
|
||||
// HandleUpdateSettings updates the service settings.
|
||||
func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var settings struct {
|
||||
@@ -284,6 +348,8 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
HTTPSServerURLOverride *string `json:"https_server_url_override"`
|
||||
DiscoveryInterval string `json:"discovery_interval"`
|
||||
DiscoveryEnabled bool `json:"discovery_enabled"`
|
||||
UpdateCheckInterval string `json:"update_check_interval"`
|
||||
UpdateCheckEnabled bool `json:"update_check_enabled"`
|
||||
DNSEnabled bool `json:"dns_enabled"`
|
||||
DNSUpstream string `json:"dns_upstream"`
|
||||
DNSBindAddr string `json:"dns_bind_addr"`
|
||||
@@ -303,6 +369,7 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
TTSVolume int `json:"tts_volume"`
|
||||
TLSExtraHosts *[]string `json:"tls_extra_hosts"`
|
||||
DefaultLanding string `json:"default_landing"`
|
||||
AdminAreaAuth string `json:"admin_area_auth"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
@@ -318,6 +385,14 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Normalise + validate the admin-area auth mode. Empty means "unset"
|
||||
// (today: not enforced — see datastore.Settings.AdminAreaAuth, #419).
|
||||
adminAreaAuth, validAdminAreaAuth := NormalizeAdminAreaAuth(settings.AdminAreaAuth)
|
||||
if !validAdminAreaAuth {
|
||||
http.Error(w, "Invalid admin_area_auth: must be empty, enabled, or disabled", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if settings.DNSEnabled && settings.DNSUpstream == "" {
|
||||
// No strict requirement for DNSUpstream here as SetDNSSettings will
|
||||
// try to fall back to system DNS. We only log it if both are empty later.
|
||||
@@ -338,42 +413,46 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
interval, err := time.ParseDuration(settings.DiscoveryInterval)
|
||||
if err != nil && settings.DiscoveryInterval != "" {
|
||||
interval, err := parseOptionalDuration(settings.DiscoveryInterval)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid discovery interval: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
updateCheckInterval, err := parseOptionalDuration(settings.UpdateCheckInterval)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid update check interval: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
|
||||
// Guard rail: refuse to enable the admin-area gate while the Management
|
||||
// API credentials are still the published default — that would let
|
||||
// anyone in with admin/change_me! anyway, just with extra friction.
|
||||
if blocksAdminAreaAuthEnable(adminAreaAuth, s.mgmtUsername, s.mgmtPassword) {
|
||||
s.mu.Unlock()
|
||||
http.Error(w, "Cannot enable admin_area_auth while Management API credentials are still the "+
|
||||
"published default (admin/change_me!). Set MGMT_USERNAME and MGMT_PASSWORD to your own "+
|
||||
"values first.", http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
s.adminAreaAuth = adminAreaAuth
|
||||
s.serverURL = settings.ServerURL
|
||||
// nil override = "field omitted, preserve"; recompute regardless, since
|
||||
// the Target Domain (which the derived URL follows) may have changed.
|
||||
s.applyHTTPSOverrideLocked(settings.HTTPSServerURLOverride)
|
||||
|
||||
s.discoveryEnabled = settings.DiscoveryEnabled
|
||||
if settings.DiscoveryInterval != "" {
|
||||
s.discoveryInterval = interval
|
||||
}
|
||||
s.discoveryInterval, s.discoveryEnabled = resolvePeriodicSetting(
|
||||
s.discoveryInterval, interval, settings.DiscoveryInterval != "", settings.DiscoveryEnabled)
|
||||
|
||||
if s.discoveryInterval == 0 {
|
||||
s.discoveryEnabled = false
|
||||
}
|
||||
s.updateCheckInterval, s.updateCheckEnabled = resolvePeriodicSetting(
|
||||
s.updateCheckInterval, updateCheckInterval, settings.UpdateCheckInterval != "", settings.UpdateCheckEnabled)
|
||||
|
||||
s.dnsEnabled = settings.DNSEnabled
|
||||
|
||||
// Handle comma-separated upstream DNS servers
|
||||
var upstreamList []string
|
||||
|
||||
if settings.DNSUpstream != "" {
|
||||
for _, u := range strings.Split(settings.DNSUpstream, ",") {
|
||||
u = strings.TrimSpace(u)
|
||||
if u != "" {
|
||||
upstreamList = append(upstreamList, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.dnsUpstream = upstreamList
|
||||
s.dnsUpstream = parseDNSUpstreamList(settings.DNSUpstream)
|
||||
s.dnsBindAddr = settings.DNSBindAddr
|
||||
|
||||
s.internalPaths = settings.InternalPaths
|
||||
@@ -407,42 +486,58 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
// HTTPS URL keeps following the Target Domain across restarts.
|
||||
currentHTTPS := s.httpsOverride
|
||||
|
||||
// Load the persisted settings first and overlay only the fields this
|
||||
// handler owns, instead of building a fresh struct from scratch. Fields
|
||||
// with no in-memory counterpart on Server (e.g. TrustForwardedHeaders,
|
||||
// TrustedProxyCIDRs, TuneInStreamFormats) are only ever set by hand-editing
|
||||
// settings.json; overwriting with a fresh struct would silently drop them
|
||||
// (issue #589).
|
||||
persisted, err := s.ds.GetSettings()
|
||||
if err != nil {
|
||||
s.mu.Unlock()
|
||||
http.Error(w, "Failed to load existing settings: "+err.Error(), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve TLS extra hosts: nil pointer means "field omitted, preserve existing";
|
||||
// non-nil (even empty) means "replace with this list".
|
||||
resolvedTLSExtraHosts := s.persistedTLSExtraHosts()
|
||||
resolvedTLSExtraHosts := persisted.TLSExtraHosts
|
||||
if settings.TLSExtraHosts != nil {
|
||||
resolvedTLSExtraHosts = normaliseTLSExtraHosts(*settings.TLSExtraHosts)
|
||||
}
|
||||
|
||||
log.Printf("Saving updated settings to %s/settings.json", s.ds.DataDir)
|
||||
err = s.ds.SaveSettings(datastore.Settings{
|
||||
ServerURL: s.serverURL,
|
||||
HTTPServerURL: currentHTTPS,
|
||||
RedactLogs: currentRedact,
|
||||
LogBodies: currentLogBody,
|
||||
RecordInteractions: currentRecord,
|
||||
DiscoveryInterval: s.discoveryInterval.String(),
|
||||
DiscoveryEnabled: s.discoveryEnabled,
|
||||
DNSEnabled: s.dnsEnabled,
|
||||
DNSUpstream: s.dnsUpstream,
|
||||
DNSBindAddr: s.dnsBindAddr,
|
||||
InternalPaths: s.internalPaths,
|
||||
Shortcuts: s.shortcuts,
|
||||
SpotifyClientID: s.spotifyClientID,
|
||||
SpotifyClientSecret: s.spotifyClientSecret,
|
||||
SpotifyRedirectURI: s.spotifyRedirectURI,
|
||||
AmazonClientID: s.amazonClientID,
|
||||
AmazonClientSecret: s.amazonClientSecret,
|
||||
AmazonRedirectURI: s.amazonRedirectURI,
|
||||
TTSProvider: s.ttsProvider,
|
||||
TTSGoogleAPIKey: s.ttsGoogleAPIKey,
|
||||
TTSAppKey: s.ttsAppKey,
|
||||
TTSLanguage: s.ttsLanguage,
|
||||
TTSVoice: s.ttsVoice,
|
||||
TTSVolume: s.ttsVolume,
|
||||
TLSExtraHosts: resolvedTLSExtraHosts,
|
||||
DefaultLanding: defaultLanding,
|
||||
})
|
||||
persisted.ServerURL = s.serverURL
|
||||
persisted.HTTPServerURL = currentHTTPS
|
||||
persisted.RedactLogs = currentRedact
|
||||
persisted.LogBodies = currentLogBody
|
||||
persisted.RecordInteractions = currentRecord
|
||||
persisted.DiscoveryInterval = s.discoveryInterval.String()
|
||||
persisted.DiscoveryEnabled = s.discoveryEnabled
|
||||
persisted.UpdateCheckInterval = s.updateCheckInterval.String()
|
||||
persisted.UpdateCheckEnabled = s.updateCheckEnabled
|
||||
persisted.DNSEnabled = s.dnsEnabled
|
||||
persisted.DNSUpstream = s.dnsUpstream
|
||||
persisted.DNSBindAddr = s.dnsBindAddr
|
||||
persisted.InternalPaths = s.internalPaths
|
||||
persisted.Shortcuts = s.shortcuts
|
||||
persisted.SpotifyClientID = s.spotifyClientID
|
||||
persisted.SpotifyClientSecret = s.spotifyClientSecret
|
||||
persisted.SpotifyRedirectURI = s.spotifyRedirectURI
|
||||
persisted.AmazonClientID = s.amazonClientID
|
||||
persisted.AmazonClientSecret = s.amazonClientSecret
|
||||
persisted.AmazonRedirectURI = s.amazonRedirectURI
|
||||
persisted.TTSProvider = s.ttsProvider
|
||||
persisted.TTSGoogleAPIKey = s.ttsGoogleAPIKey
|
||||
persisted.TTSAppKey = s.ttsAppKey
|
||||
persisted.TTSLanguage = s.ttsLanguage
|
||||
persisted.TTSVoice = s.ttsVoice
|
||||
persisted.TTSVolume = s.ttsVolume
|
||||
persisted.TLSExtraHosts = resolvedTLSExtraHosts
|
||||
persisted.DefaultLanding = defaultLanding
|
||||
persisted.AdminAreaAuth = s.adminAreaAuth
|
||||
err = s.ds.SaveSettings(persisted)
|
||||
|
||||
dnsEnabled := s.dnsEnabled
|
||||
dnsUpstreamStr := strings.Join(s.dnsUpstream, ",")
|
||||
@@ -479,6 +574,32 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeAdminAreaAuth trims/lowercases the admin-area auth mode and
|
||||
// reports whether it's one of the three valid tri-state values ("", the
|
||||
// unset default; "enabled"; "disabled" — see datastore.Settings.AdminAreaAuth,
|
||||
// #419). Exported so main.go can apply the same validation to a persisted
|
||||
// settings.json value at startup that HandleUpdateSettings applies on write.
|
||||
func NormalizeAdminAreaAuth(v string) (string, bool) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(v))
|
||||
|
||||
switch normalized {
|
||||
case "", "enabled", "disabled":
|
||||
return normalized, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// blocksAdminAreaAuthEnable reports whether enabling the admin-area gate
|
||||
// must be refused because the Management API credentials are still the
|
||||
// published default (admin/change_me!) — enabling it in that state would
|
||||
// give a false sense of security. Extracted from HandleUpdateSettings to
|
||||
// keep its cyclomatic complexity in check.
|
||||
func blocksAdminAreaAuthEnable(mode, mgmtUsername, mgmtPassword string) bool {
|
||||
return mode == "enabled" &&
|
||||
mgmtUsername == health.DefaultMgmtUsername && mgmtPassword == health.DefaultMgmtPassword
|
||||
}
|
||||
|
||||
// normaliseTLSExtraHosts trims whitespace from each entry, drops empty
|
||||
// values, and deduplicates while preserving the first occurrence's
|
||||
// position. The settings endpoint applies this before persisting so the
|
||||
@@ -1021,17 +1142,31 @@ func (s *Server) HandleUpdateLoggingSettings(w http.ResponseWriter, r *http.Requ
|
||||
discoveryInterval := s.discoveryInterval.String()
|
||||
discoveryEnabled := s.discoveryEnabled
|
||||
|
||||
// Load the persisted settings first and overlay only the fields this
|
||||
// handler owns, instead of building a fresh struct from scratch. This
|
||||
// handler's DTO only ever covers 3 of ~25 fields, so a from-scratch
|
||||
// struct used to reset everything else (credentials, DNS config,
|
||||
// TrustForwardedHeaders/TrustedProxyCIDRs, ...) to its zero value on
|
||||
// every save (issue #589).
|
||||
persisted, err := s.ds.GetSettings()
|
||||
if err != nil {
|
||||
s.mu.Unlock()
|
||||
http.Error(w, "Failed to load existing settings: "+err.Error(), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
persisted.ServerURL = serverURL
|
||||
persisted.HTTPServerURL = httpsOverride
|
||||
persisted.RedactLogs = s.redactLogs
|
||||
persisted.LogBodies = s.logBodies
|
||||
persisted.RecordInteractions = s.recordEnabled
|
||||
persisted.DiscoveryInterval = discoveryInterval
|
||||
persisted.DiscoveryEnabled = discoveryEnabled
|
||||
persisted.Shortcuts = s.shortcuts
|
||||
|
||||
log.Printf("Saving updated proxy settings to %s/settings.json", s.ds.DataDir)
|
||||
err := s.ds.SaveSettings(datastore.Settings{
|
||||
ServerURL: serverURL,
|
||||
HTTPServerURL: httpsOverride,
|
||||
RedactLogs: s.redactLogs,
|
||||
LogBodies: s.logBodies,
|
||||
RecordInteractions: s.recordEnabled,
|
||||
DiscoveryInterval: discoveryInterval,
|
||||
DiscoveryEnabled: discoveryEnabled,
|
||||
Shortcuts: s.shortcuts,
|
||||
})
|
||||
err = s.ds.SaveSettings(persisted)
|
||||
s.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
@@ -1139,7 +1274,16 @@ func (s *Server) HandleTestDNSRedirection(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}
|
||||
|
||||
// HandleInitialSync fetches presets, recents and sources from the device and saves them to the datastore.
|
||||
// HandleInitialSync fetches presets, recents and sources from the device
|
||||
// and saves them to the datastore.
|
||||
//
|
||||
// If applying the fetched presets/recents would shrink what's already
|
||||
// stored, the sync is not applied — the response comes back 409 with the
|
||||
// diff describing what would be removed — unless the caller passes
|
||||
// ?confirmed=true, in which case it's applied unconditionally. Every call
|
||||
// re-fetches live from the speaker at that moment (see
|
||||
// setup.SyncDeviceData), so a confirmed retry re-checks current reality
|
||||
// rather than replaying a possibly-stale earlier response.
|
||||
func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
@@ -1153,13 +1297,25 @@ func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.sm.SyncDeviceData(deviceIP); err != nil {
|
||||
confirmed := r.URL.Query().Get("confirmed") == "true"
|
||||
|
||||
result, err := s.sm.SyncDeviceData(deviceIP, confirmed)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok": true}`))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if !result.Applied {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(result); encodeErr != nil {
|
||||
log.Printf("HandleInitialSync: failed to encode result for device %s: %s", sanitizeLog(deviceID), sanitizeErr(encodeErr))
|
||||
}
|
||||
}
|
||||
|
||||
// HandleRebootDevice reboots a device.
|
||||
@@ -1271,6 +1427,19 @@ func (s *Server) HandleGetVersionInfo(w http.ResponseWriter, _ *http.Request) {
|
||||
repoURL := s.RepoURL
|
||||
s.mu.RUnlock()
|
||||
|
||||
var dataDir string
|
||||
|
||||
if s.ds != nil && s.ds.DataDir != "" {
|
||||
// Resolve to absolute: the default ("data") and any relative
|
||||
// --data-dir/DATA_DIR value are otherwise ambiguous without knowing
|
||||
// the process's working directory at startup.
|
||||
if abs, err := filepath.Abs(s.ds.DataDir); err == nil {
|
||||
dataDir = abs
|
||||
} else {
|
||||
dataDir = s.ds.DataDir
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
var (
|
||||
@@ -1288,13 +1457,22 @@ func (s *Server) HandleGetVersionInfo(w http.ResponseWriter, _ *http.Request) {
|
||||
releaseURL = fmt.Sprintf("%s/releases/tag/%s", repoURL, version)
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"version": version,
|
||||
"commit": commit,
|
||||
"date": date,
|
||||
"repo_url": repoURL,
|
||||
"release_url": releaseURL,
|
||||
"commit_url": commitURL,
|
||||
// Opt-in periodic update check (#591) — UpdateCheckResult is nil-safe and
|
||||
// returns the zero value (Available: false) when the check was never
|
||||
// enabled, which is the common case.
|
||||
updateCheck := s.UpdateCheckResult()
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"version": version,
|
||||
"commit": commit,
|
||||
"date": date,
|
||||
"repo_url": repoURL,
|
||||
"release_url": releaseURL,
|
||||
"commit_url": commitURL,
|
||||
"data_dir": dataDir,
|
||||
"update_available": updateCheck.Available,
|
||||
"latest_version": updateCheck.LatestVersion,
|
||||
"latest_release_url": updateCheck.ReleaseURL,
|
||||
}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -10,10 +10,12 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/health"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
)
|
||||
|
||||
@@ -162,6 +164,529 @@ func TestProxySettingsAPI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsSavePreservesUnmanagedFields is the regression test for
|
||||
// issue #589: saving settings via either the main settings form or the
|
||||
// logging/proxy panel must not drop fields that have no counterpart in
|
||||
// their respective request DTOs (e.g. hand-edited trust_forwarded_headers /
|
||||
// trusted_proxy_cidrs, or the other handler's owned fields).
|
||||
func TestSettingsSavePreservesUnmanagedFields(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "settings-preserve-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
// Seed settings.json with fields neither handler's DTO exposes.
|
||||
seeded := datastore.Settings{
|
||||
ServerURL: "http://127.0.0.1:8000",
|
||||
TrustForwardedHeaders: true,
|
||||
TrustedProxyCIDRs: []string{"10.42.0.0/16"},
|
||||
}
|
||||
if err := ds.SaveSettings(seeded); err != nil {
|
||||
t.Fatalf("Failed to seed settings: %v", err)
|
||||
}
|
||||
|
||||
r, server := setupRouter("http://127.0.0.1:8000", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// Simulate a credential already loaded into the running server (as
|
||||
// main.go's startup wiring does) but not managed by the logging panel's
|
||||
// DTO, to catch HandleUpdateLoggingSettings resetting fields it doesn't
|
||||
// own back to their zero value.
|
||||
server.spotifyClientID = "seeded-spotify-client-id"
|
||||
|
||||
// Saving the main settings form (which knows nothing about
|
||||
// trust_forwarded_headers / trusted_proxy_cidrs) must not drop them.
|
||||
sysUpdate := map[string]string{"server_url": "http://127.0.0.1:8000"}
|
||||
sysBody, err := json.Marshal(sysUpdate)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal update: %v", err)
|
||||
}
|
||||
|
||||
res, err := http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(sysBody))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("POST /setup/settings: expected status OK, got %v", res.Status)
|
||||
}
|
||||
|
||||
persisted, err := ds.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to reload settings: %v", err)
|
||||
}
|
||||
if !persisted.TrustForwardedHeaders {
|
||||
t.Errorf("POST /setup/settings dropped TrustForwardedHeaders: %+v", persisted)
|
||||
}
|
||||
if len(persisted.TrustedProxyCIDRs) != 1 || persisted.TrustedProxyCIDRs[0] != "10.42.0.0/16" {
|
||||
t.Errorf("POST /setup/settings dropped TrustedProxyCIDRs: %+v", persisted)
|
||||
}
|
||||
|
||||
// Saving the logging/proxy panel (which only knows redact/log_body/record)
|
||||
// must not drop these fields, or the SpotifyClientID it also doesn't manage.
|
||||
logUpdate := map[string]bool{"redact": true, "log_body": true, "record": false}
|
||||
logBody, err := json.Marshal(logUpdate)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal logging update: %v", err)
|
||||
}
|
||||
|
||||
res, err = http.Post(ts.URL+"/setup/logging-settings", "application/json", bytes.NewBuffer(logBody))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("POST /setup/logging-settings: expected status OK, got %v", res.Status)
|
||||
}
|
||||
|
||||
persisted, err = ds.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to reload settings: %v", err)
|
||||
}
|
||||
if !persisted.TrustForwardedHeaders {
|
||||
t.Errorf("POST /setup/logging-settings dropped TrustForwardedHeaders: %+v", persisted)
|
||||
}
|
||||
if len(persisted.TrustedProxyCIDRs) != 1 || persisted.TrustedProxyCIDRs[0] != "10.42.0.0/16" {
|
||||
t.Errorf("POST /setup/logging-settings dropped TrustedProxyCIDRs: %+v", persisted)
|
||||
}
|
||||
if persisted.SpotifyClientID != "seeded-spotify-client-id" {
|
||||
t.Errorf("POST /setup/logging-settings dropped SpotifyClientID: %+v", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAreaAuthInvalidValue is a regression test for #419: an
|
||||
// unrecognised admin_area_auth value must be rejected outright, not
|
||||
// silently coerced to the unset default.
|
||||
func TestAdminAreaAuthInvalidValue(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "admin-area-auth-invalid-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
r, _ := setupRouter("http://127.0.0.1:8000", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"server_url": "http://127.0.0.1:8000",
|
||||
"admin_area_auth": "sometimes",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal request body: %v", err)
|
||||
}
|
||||
|
||||
res, err := http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for invalid admin_area_auth, got %v", res.Status)
|
||||
}
|
||||
|
||||
persisted, err := ds.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to reload settings: %v", err)
|
||||
}
|
||||
if persisted.AdminAreaAuth != "" {
|
||||
t.Errorf("Invalid admin_area_auth must not be persisted, got %q", persisted.AdminAreaAuth)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAreaAuthGuardRailBlocksDefaultCreds is a regression test for
|
||||
// #419: enabling the admin-area gate while the Management API credentials
|
||||
// are still the published default (admin/change_me!) must be rejected —
|
||||
// otherwise the gate would give a false sense of security.
|
||||
func TestAdminAreaAuthGuardRailBlocksDefaultCreds(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "admin-area-auth-guard-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
r, server := setupRouter("http://127.0.0.1:8000", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
server.mgmtUsername = health.DefaultMgmtUsername
|
||||
server.mgmtPassword = health.DefaultMgmtPassword
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"server_url": "http://127.0.0.1:8000",
|
||||
"admin_area_auth": "enabled",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal request body: %v", err)
|
||||
}
|
||||
|
||||
res, err := http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 when enabling admin_area_auth with default creds, got %v", res.Status)
|
||||
}
|
||||
|
||||
if server.AdminAreaAuthMode() != "" {
|
||||
t.Errorf("Guard rail must not flip the live mode, got %q", server.AdminAreaAuthMode())
|
||||
}
|
||||
|
||||
persisted, err := ds.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to reload settings: %v", err)
|
||||
}
|
||||
if persisted.AdminAreaAuth != "" {
|
||||
t.Errorf("Guard rail must not persist the change, got %q", persisted.AdminAreaAuth)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAreaAuthRoundTrip verifies enabling (with non-default creds) and
|
||||
// later disabling admin_area_auth updates both the live server field and
|
||||
// the persisted settings.json, and is reflected back by GET /setup/settings.
|
||||
func TestAdminAreaAuthRoundTrip(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "admin-area-auth-roundtrip-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
r, server := setupRouter("http://127.0.0.1:8000", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
server.mgmtUsername = "custom-admin"
|
||||
server.mgmtPassword = "custom-password"
|
||||
|
||||
enableBody, err := json.Marshal(map[string]string{
|
||||
"server_url": "http://127.0.0.1:8000",
|
||||
"admin_area_auth": "Enabled", // mixed case must normalise
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal request body: %v", err)
|
||||
}
|
||||
|
||||
res, err := http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(enableBody))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("POST /setup/settings (enable): expected 200, got %v", res.Status)
|
||||
}
|
||||
|
||||
if server.AdminAreaAuthMode() != "enabled" {
|
||||
t.Errorf("Expected live mode \"enabled\", got %q", server.AdminAreaAuthMode())
|
||||
}
|
||||
|
||||
persisted, err := ds.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to reload settings: %v", err)
|
||||
}
|
||||
if persisted.AdminAreaAuth != "enabled" {
|
||||
t.Errorf("Expected persisted admin_area_auth \"enabled\", got %q", persisted.AdminAreaAuth)
|
||||
}
|
||||
|
||||
res, err = http.Get(ts.URL + "/setup/settings")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("Failed to decode GET /setup/settings: %v", err)
|
||||
}
|
||||
if got["admin_area_auth"] != "enabled" {
|
||||
t.Errorf("GET /setup/settings: expected admin_area_auth \"enabled\", got %+v", got["admin_area_auth"])
|
||||
}
|
||||
|
||||
disableBody, err := json.Marshal(map[string]string{
|
||||
"server_url": "http://127.0.0.1:8000",
|
||||
"admin_area_auth": "disabled",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal request body: %v", err)
|
||||
}
|
||||
|
||||
res, err = http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(disableBody))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("POST /setup/settings (disable): expected 200, got %v", res.Status)
|
||||
}
|
||||
|
||||
if server.AdminAreaAuthMode() != "disabled" {
|
||||
t.Errorf("Expected live mode \"disabled\" after explicit opt-out, got %q", server.AdminAreaAuthMode())
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolvePeriodicSetting covers the shared interval/enabled resolution
|
||||
// used by both background pollers (device discovery, update check).
|
||||
func TestResolvePeriodicSetting(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
current time.Duration
|
||||
requested time.Duration
|
||||
provided bool
|
||||
enabled bool
|
||||
wantInterval time.Duration
|
||||
wantEnabledState bool
|
||||
}{
|
||||
{"interval omitted keeps the current one", 24 * time.Hour, 0, false, true, 24 * time.Hour, true},
|
||||
{"interval supplied replaces the current one", 24 * time.Hour, 6 * time.Hour, true, true, 6 * time.Hour, true},
|
||||
{"disabling keeps the interval", 24 * time.Hour, 0, false, false, 24 * time.Hour, false},
|
||||
{"a zero interval forces it off", 24 * time.Hour, 0, true, true, 0, false},
|
||||
{"a zero current interval forces it off too", 0, 0, false, true, 0, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
gotInterval, gotEnabled := resolvePeriodicSetting(tc.current, tc.requested, tc.provided, tc.enabled)
|
||||
if gotInterval != tc.wantInterval || gotEnabled != tc.wantEnabledState {
|
||||
t.Errorf("%s: resolvePeriodicSetting() = %v/%v, want %v/%v",
|
||||
tc.name, gotInterval, gotEnabled, tc.wantInterval, tc.wantEnabledState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseOptionalDuration verifies an omitted duration is not an error,
|
||||
// while a supplied-but-invalid one is.
|
||||
func TestParseOptionalDuration(t *testing.T) {
|
||||
if d, err := parseOptionalDuration(""); err != nil || d != 0 {
|
||||
t.Errorf("parseOptionalDuration(\"\") = %v/%v, want 0/nil", d, err)
|
||||
}
|
||||
|
||||
if d, err := parseOptionalDuration("90m"); err != nil || d != 90*time.Minute {
|
||||
t.Errorf("parseOptionalDuration(\"90m\") = %v/%v, want 1h30m0s/nil", d, err)
|
||||
}
|
||||
|
||||
if _, err := parseOptionalDuration("nope"); err == nil {
|
||||
t.Error("parseOptionalDuration(\"nope\") = nil error, want a parse error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCheckSettingsRoundTrip covers the Settings-page control for the
|
||||
// opt-in update check (#591 follow-up): POST /setup/settings must update the
|
||||
// live values the background poller reads, persist them, and hand them back
|
||||
// on GET so the UI reflects what was saved.
|
||||
func TestUpdateCheckSettingsRoundTrip(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "update-check-settings-roundtrip-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
r, server := setupRouter("http://127.0.0.1:8000", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// Default state: opted out, with a nonzero interval so enabling it later
|
||||
// doesn't need an interval to be supplied.
|
||||
if interval, enabled := server.GetUpdateCheckSettings(); enabled || interval == 0 {
|
||||
t.Fatalf("Expected the check to default to disabled with a nonzero interval, got %v/%v", interval, enabled)
|
||||
}
|
||||
|
||||
enableBody, err := json.Marshal(map[string]interface{}{
|
||||
"server_url": "http://127.0.0.1:8000",
|
||||
"update_check_enabled": true,
|
||||
"update_check_interval": "6h",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal request body: %v", err)
|
||||
}
|
||||
|
||||
res, err := http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(enableBody))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("POST /setup/settings (enable): expected 200, got %v", res.Status)
|
||||
}
|
||||
|
||||
interval, enabled := server.GetUpdateCheckSettings()
|
||||
if !enabled || interval != 6*time.Hour {
|
||||
t.Errorf("Expected live settings 6h/true, got %v/%v", interval, enabled)
|
||||
}
|
||||
|
||||
persisted, err := ds.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to reload settings: %v", err)
|
||||
}
|
||||
if !persisted.UpdateCheckEnabled || persisted.UpdateCheckInterval != "6h0m0s" {
|
||||
t.Errorf("Expected persisted 6h0m0s/true, got %q/%v",
|
||||
persisted.UpdateCheckInterval, persisted.UpdateCheckEnabled)
|
||||
}
|
||||
|
||||
res, err = http.Get(ts.URL + "/setup/settings")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("Failed to decode GET /setup/settings: %v", err)
|
||||
}
|
||||
if got["update_check_enabled"] != true {
|
||||
t.Errorf("GET /setup/settings: expected update_check_enabled true, got %+v", got["update_check_enabled"])
|
||||
}
|
||||
if got["update_check_interval"] != "6h0m0s" {
|
||||
t.Errorf("GET /setup/settings: expected update_check_interval 6h0m0s, got %+v", got["update_check_interval"])
|
||||
}
|
||||
|
||||
// An unparseable interval must be rejected before anything is applied.
|
||||
badBody, err := json.Marshal(map[string]interface{}{
|
||||
"server_url": "http://127.0.0.1:8000",
|
||||
"update_check_enabled": true,
|
||||
"update_check_interval": "not-a-duration",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal request body: %v", err)
|
||||
}
|
||||
|
||||
res, err = http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(badBody))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("POST /setup/settings (bad interval): expected 400, got %v", res.Status)
|
||||
}
|
||||
|
||||
// A zero interval must force the check off rather than leave the poller
|
||||
// hitting GitHub on every tick.
|
||||
zeroBody, err := json.Marshal(map[string]interface{}{
|
||||
"server_url": "http://127.0.0.1:8000",
|
||||
"update_check_enabled": true,
|
||||
"update_check_interval": "0s",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal request body: %v", err)
|
||||
}
|
||||
|
||||
res, err = http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(zeroBody))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("POST /setup/settings (zero interval): expected 200, got %v", res.Status)
|
||||
}
|
||||
|
||||
if _, enabled := server.GetUpdateCheckSettings(); enabled {
|
||||
t.Error("Expected a zero interval to disable the update check")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleGetVersionInfo_IncludesAbsoluteDataDir verifies /api/setup/version
|
||||
// reports the actual data directory in use, resolved to an absolute path —
|
||||
// added so operators running the service locally (not in Docker, where the
|
||||
// path is obvious from the bind mount) can find it without having to
|
||||
// inspect the running process. See NEXT.md/#419 session notes.
|
||||
func TestHandleGetVersionInfo_IncludesAbsoluteDataDir(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "version-info-datadir-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
r, _ := setupRouter("http://127.0.0.1:8000", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
res, err := http.Get(ts.URL + "/setup/version")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
wantAbs, err := filepath.Abs(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to resolve expected absolute path: %v", err)
|
||||
}
|
||||
|
||||
if got["data_dir"] != wantAbs {
|
||||
t.Errorf("Expected data_dir %q, got %q", wantAbs, got["data_dir"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleGetVersionInfo_UpdateCheckFields verifies the #591 fields are
|
||||
// present and reflect a nil-checker default (Available: false) when the
|
||||
// update check was never enabled — the common case.
|
||||
func TestHandleGetVersionInfo_UpdateCheckFields(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "version-info-updatecheck-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
r, _ := setupRouter("http://127.0.0.1:8000", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
res, err := http.Get(ts.URL + "/setup/version")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if got["update_available"] != false {
|
||||
t.Errorf("Expected update_available=false by default, got %v", got["update_available"])
|
||||
}
|
||||
if _, ok := got["latest_version"]; !ok {
|
||||
t.Error("Expected a latest_version key in the response")
|
||||
}
|
||||
if _, ok := got["latest_release_url"]; !ok {
|
||||
t.Error("Expected a latest_release_url key in the response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationAndCA(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "handlers-test")
|
||||
if err != nil {
|
||||
@@ -440,3 +965,9 @@ func (m *mockSSH) UploadContent(content []byte, remotePath string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Connect/Close are no-ops here — the mock has no real connection to
|
||||
// reuse, and every test call already goes through Run/UploadContent above
|
||||
// regardless of whether Connect was called first.
|
||||
func (m *mockSSH) Connect() error { return nil }
|
||||
func (m *mockSSH) Close() error { return nil }
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// TestHandleInitialSync_DestructiveSyncReturns409ThenAppliesWhenConfirmed is
|
||||
// an HTTP-level regression test for #614's Sync-button data-loss bug (see
|
||||
// setup.TestSyncDeviceData_DestructiveSyncRequiresConfirmation for the
|
||||
// lower-level coverage of the same fix): a device already has more presets
|
||||
// stored than the mock speaker's live /presets now reports. The first,
|
||||
// unconfirmed sync request must come back 409 with the diff and must not
|
||||
// write anything; a retry with ?confirmed=true must apply it.
|
||||
func TestHandleInitialSync_DestructiveSyncReturns409ThenAppliesWhenConfirmed(t *testing.T) {
|
||||
const (
|
||||
accountID = "1234567"
|
||||
deviceID = "AABBCCDDEEFF"
|
||||
)
|
||||
|
||||
// A real local server, not a black-hole IP: notifySpeakerSourcesUpdated
|
||||
// (part of the confirmed-apply path) uses its own HTTP client rather
|
||||
// than the injectable sm.HTTPGet, so it needs somewhere real to fail
|
||||
// fast against (404) instead of timing out.
|
||||
mockDevice := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/info":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?><info deviceID="%s"><name>Test Device</name><type>SoundTouch 20</type><margeAccountUUID>%s</margeAccountUUID></info>`, deviceID, accountID)
|
||||
case "/presets":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?><presets><preset id="1"><ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="/x" isPresetable="true"><itemName>Station 1</itemName></ContentItem></preset></presets>`)
|
||||
case "/recents":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?><recents></recents>`)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer mockDevice.Close()
|
||||
|
||||
deviceIP := mockDevice.Listener.Addr().String()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "handlers-sync-destructive-guard-*")
|
||||
if err != nil {
|
||||
t.Fatalf("tempdir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
seeded := []models.ServicePreset{
|
||||
{ID: "1", ButtonNumber: "1", ServiceContentItem: models.ServiceContentItem{Name: "Station 1"}},
|
||||
{ID: "2", ButtonNumber: "2", ServiceContentItem: models.ServiceContentItem{Name: "Station 2"}},
|
||||
}
|
||||
if err := ds.SavePresets(accountID, deviceID, seeded); err != nil {
|
||||
t.Fatalf("seed SavePresets: %v", err)
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(accountID, deviceID, &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: accountID,
|
||||
IPAddress: deviceIP,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
sm := setup.NewManager("http://localhost:8000", ds, nil)
|
||||
|
||||
server := NewServer(ds, sm, "http://localhost:8000", false, false, false)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/setup/sync/{deviceId}", server.HandleInitialSync)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// First, unconfirmed request: must be refused with 409.
|
||||
resp, err := http.Post(ts.URL+"/api/setup/sync/"+deviceID, "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("POST sync (unconfirmed): %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 409 for a destructive unconfirmed sync, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var result setup.SyncResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode 409 body: %v", err)
|
||||
}
|
||||
|
||||
if result.Applied {
|
||||
t.Fatal("expected Applied=false in the 409 response")
|
||||
}
|
||||
|
||||
if !result.Destructive {
|
||||
t.Fatal("expected Destructive=true in the 409 response")
|
||||
}
|
||||
|
||||
presetsAfterRefusal, err := ds.GetPresets(accountID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPresets after refused sync: %v", err)
|
||||
}
|
||||
|
||||
if len(presetsAfterRefusal) != 2 {
|
||||
t.Fatalf("expected the original 2 presets to survive the refused sync, got %d", len(presetsAfterRefusal))
|
||||
}
|
||||
|
||||
// Retry, confirmed: must apply.
|
||||
resp2, err := http.Post(ts.URL+"/api/setup/sync/"+deviceID+"?confirmed=true", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("POST sync (confirmed): %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp2.Body.Close() }()
|
||||
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp2.Body)
|
||||
t.Fatalf("expected 200 for a confirmed sync, got %d: %s", resp2.StatusCode, body)
|
||||
}
|
||||
|
||||
var confirmedResult setup.SyncResult
|
||||
if err := json.NewDecoder(resp2.Body).Decode(&confirmedResult); err != nil {
|
||||
t.Fatalf("decode 200 body: %v", err)
|
||||
}
|
||||
|
||||
if !confirmedResult.Applied {
|
||||
t.Fatal("expected Applied=true after confirming")
|
||||
}
|
||||
|
||||
presetsAfterConfirm, err := ds.GetPresets(accountID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPresets after confirmed sync: %v", err)
|
||||
}
|
||||
|
||||
if len(presetsAfterConfirm) != 1 {
|
||||
t.Fatalf("expected confirmed sync to shrink to 1 preset, got %d", len(presetsAfterConfirm))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
)
|
||||
|
||||
// TestUpdateCheckResult_NilCheckerIsSafe verifies the default (opt-in
|
||||
// checker never registered) returns a safe zero value rather than
|
||||
// panicking — the common case, since UPDATE_CHECK_ENABLED defaults to
|
||||
// false.
|
||||
func TestUpdateCheckResult_NilCheckerIsSafe(t *testing.T) {
|
||||
s := NewServer(nil, nil, "http://localhost", false, false, false)
|
||||
|
||||
result := s.UpdateCheckResult()
|
||||
if result.Available {
|
||||
t.Error("Expected a nil checker to report Available=false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCheckResult_ReflectsRegisteredChecker verifies SetUpdateChecker
|
||||
// wires the checker in and UpdateCheckResult reads through to it.
|
||||
func TestUpdateCheckResult_ReflectsRegisteredChecker(t *testing.T) {
|
||||
s := NewServer(nil, nil, "http://localhost", false, false, false)
|
||||
|
||||
checker := updatecheck.NewChecker(nil, "owner/repo", "v1.0.0")
|
||||
s.SetUpdateChecker(checker)
|
||||
|
||||
result := s.UpdateCheckResult()
|
||||
if result.CurrentVersion != "v1.0.0" {
|
||||
t.Errorf("Expected UpdateCheckResult to read through to the registered checker, got %+v", result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
)
|
||||
|
||||
// TestIssue634_NonNumericMargeAccountUUIDDoesNotLoseDevice reproduces
|
||||
// https://github.com/gesellix/Bose-SoundTouch/issues/634
|
||||
//
|
||||
// A SoundTouch 10 had SSH enabled via the USB-stick method (rather than
|
||||
// AfterTouch's own telnet-based enable-ssh flow) and, when discovered,
|
||||
// reported a `margeAccountUUID` of `stick@local` instead of the usual
|
||||
// 7-digit numeric Bose account ID. `handleDiscoveredDevice`
|
||||
// (pkg/service/handlers/server.go) passes MargeAccountUUID straight
|
||||
// through to DataStore.SaveDeviceInfo, which used to reject anything
|
||||
// containing "@" as an "invalid account ID" via isSafeIdentifier's
|
||||
// strict alnum-only allowlist. The device was never persisted at all.
|
||||
//
|
||||
// The fix widened datastore.IsSafeIdentifier to accept any device-reported
|
||||
// identifier that's safe to use as a path component / XML value /
|
||||
// telnet-command token, rather than requiring Bose's own 7-digit numeric
|
||||
// format. setup's separate, stricter 7-digit-only IsValidAccountID was
|
||||
// deleted outright in favor of calling datastore.IsSafeIdentifier directly
|
||||
// everywhere an account ID needs validating — one validator, not two. So
|
||||
// handleDiscoveredDevice needed no changes: it already passed
|
||||
// MargeAccountUUID through unmodified, and now the datastore accepts it.
|
||||
//
|
||||
// What this test locks in:
|
||||
//
|
||||
// - A speaker reporting a non-numeric margeAccountUUID is saved
|
||||
// under that account verbatim (not coerced to "default" — "default"
|
||||
// remains reserved for a genuinely empty/unpaired margeAccountUUID).
|
||||
//
|
||||
// What this test would catch if it flipped:
|
||||
//
|
||||
// - If IsSafeIdentifier's allowlist regresses to reject "@" again,
|
||||
// GetDeviceInfo below would error with "invalid account ID" instead
|
||||
// of returning the device — the #634 symptom.
|
||||
func TestIssue634_NonNumericMargeAccountUUIDDoesNotLoseDevice(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "issue634-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
const deviceInfoXML = `<info deviceID="001122334455">
|
||||
<name>Kitchen SoundTouch</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<margeAccountUUID>stick@local</margeAccountUUID>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</softwareVersion>
|
||||
<serialNumber>I6332527703739342000020</serialNumber>
|
||||
</component>
|
||||
<component>
|
||||
<componentCategory>PackagedProduct</componentCategory>
|
||||
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</softwareVersion>
|
||||
<serialNumber>069231P63364828AE</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<margeURL>https://streaming.bose.com</margeURL>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>001122334455</macAddress>
|
||||
<ipAddress>203.0.113.10</ipAddress>
|
||||
</networkInfo>
|
||||
<moduleType>sm2</moduleType>
|
||||
<variant>rhino</variant>
|
||||
<variantMode>normal</variantMode>
|
||||
<countryCode>US</countryCode>
|
||||
<regionCode>US</regionCode>
|
||||
</info>`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/info" {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, deviceInfoXML)
|
||||
} else {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
deviceIP := server.URL[len("http://"):]
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
sm := setup.NewManager(server.URL, ds, nil)
|
||||
|
||||
srv := NewServer(ds, sm, server.URL, false, false, false)
|
||||
|
||||
discoveredDevice := models.DiscoveredDevice{
|
||||
Host: deviceIP,
|
||||
Name: "Legacy Discovery Name",
|
||||
ModelID: "SoundTouch 10",
|
||||
SerialNo: "",
|
||||
DiscoveryMethod: "UPnP",
|
||||
}
|
||||
|
||||
t.Logf("Test scenario: /info reports non-numeric margeAccountUUID %q", "stick@local")
|
||||
|
||||
srv.handleDiscoveredDevice(discoveredDevice)
|
||||
|
||||
const (
|
||||
expectedAccountID = "stick@local"
|
||||
expectedDeviceID = "001122334455"
|
||||
)
|
||||
|
||||
deviceInfo, err := ds.GetDeviceInfo(expectedAccountID, expectedDeviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("device was not saved under account %q: %v (this is the #634 symptom — "+
|
||||
"SaveDeviceInfo rejects the raw margeAccountUUID as an invalid account ID)",
|
||||
expectedAccountID, err)
|
||||
}
|
||||
|
||||
if deviceInfo.Name != "Kitchen SoundTouch" {
|
||||
t.Errorf("Name = %q, want %q", deviceInfo.Name, "Kitchen SoundTouch")
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,7 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
|
||||
r.Get("/settings", server.HandleGetSettings)
|
||||
r.Post("/settings", server.HandleUpdateSettings)
|
||||
r.Get("/version", server.HandleGetVersionInfo)
|
||||
r.Get("/logging-settings", server.HandleGetLoggingSettings)
|
||||
r.Post("/logging-settings", server.HandleUpdateLoggingSettings)
|
||||
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/tts"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/ssh"
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
@@ -51,6 +52,8 @@ type Server struct {
|
||||
recordEnabled bool
|
||||
discoveryInterval time.Duration
|
||||
discoveryEnabled bool
|
||||
updateCheckInterval time.Duration // live update-check interval; see SetUpdateCheckSettings
|
||||
updateCheckEnabled bool // live update-check opt-in; defaults off (#591)
|
||||
dnsEnabled bool
|
||||
dnsUpstream []string
|
||||
dnsBindAddr string
|
||||
@@ -68,6 +71,9 @@ type Server struct {
|
||||
RepoURL string
|
||||
mgmtUsername string
|
||||
mgmtPassword string
|
||||
adminAreaAuth string // "" (unset) / "enabled" / "disabled" — see datastore.Settings.AdminAreaAuth
|
||||
dismissedAnnouncements map[string]time.Time // announcement id -> most recent dismissal; see RecordDismissal
|
||||
updateChecker *updatecheck.Checker // the HTTP-checking object; nil unless SetUpdateChecker was called
|
||||
spotifyClientID string
|
||||
spotifyClientSecret string
|
||||
spotifyRedirectURI string
|
||||
@@ -137,10 +143,14 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
|
||||
recordEnabled: recordEnabled,
|
||||
discoveryInterval: 5 * time.Minute,
|
||||
discoveryEnabled: true,
|
||||
peerObserver: newPeerObserver(),
|
||||
healthRegistry: health.NewRegistry(),
|
||||
authProbes: newAuthProbeRegistry(defaultAuthProbeTTL),
|
||||
deprecatedRoutes: newDeprecatedRouteTracker(),
|
||||
// The update check is opt-in (#591): only the interval gets a default,
|
||||
// updateCheckEnabled stays false so no install starts making outbound
|
||||
// GitHub calls without an explicit yes.
|
||||
updateCheckInterval: 24 * time.Hour,
|
||||
peerObserver: newPeerObserver(),
|
||||
healthRegistry: health.NewRegistry(),
|
||||
authProbes: newAuthProbeRegistry(defaultAuthProbeTTL),
|
||||
deprecatedRoutes: newDeprecatedRouteTracker(),
|
||||
}
|
||||
|
||||
health.RegisterSourcesXMLPresent(s.healthRegistry, ds)
|
||||
@@ -220,6 +230,7 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
|
||||
s.healthRegistry,
|
||||
func() (string, string) { return s.mgmtUsername, s.mgmtPassword },
|
||||
)
|
||||
health.RegisterAdminAreaAuthCheck(s.healthRegistry, s.AdminAreaAuthMode)
|
||||
|
||||
// Health QuickFix executor for the empty-margeAccountUUID
|
||||
// finding from RegisterSpeakerInfoReachable. Lives here (not in
|
||||
@@ -317,6 +328,8 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
|
||||
},
|
||||
)
|
||||
|
||||
s.dismissedAnnouncements = loadDismissedAnnouncements(ds)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -576,6 +589,28 @@ func (s *Server) SetDiscoverySettings(interval time.Duration, enabled bool) {
|
||||
s.discoveryEnabled = enabled
|
||||
}
|
||||
|
||||
// SetUpdateCheckSettings sets the live update-check settings for the server.
|
||||
//
|
||||
// Kept adjacent to its getter (rather than next to GetDiscoverySettings
|
||||
// further down) so the pair reads as one unit; the background goroutine in
|
||||
// soundtouch-service re-reads them on every poll, which is what makes the
|
||||
// Settings-page toggle take effect without a restart.
|
||||
func (s *Server) SetUpdateCheckSettings(interval time.Duration, enabled bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.updateCheckInterval = interval
|
||||
s.updateCheckEnabled = enabled
|
||||
}
|
||||
|
||||
// GetUpdateCheckSettings returns the current update-check interval and enabled state.
|
||||
func (s *Server) GetUpdateCheckSettings() (time.Duration, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.updateCheckInterval, s.updateCheckEnabled
|
||||
}
|
||||
|
||||
// SetDevicesChangedHook registers a callback fired after the known device set
|
||||
// changes (a discovery sweep or a manual add). The embedded web UI uses it to
|
||||
// re-sync its registry from the shared datastore — the single source of truth —
|
||||
@@ -1027,6 +1062,128 @@ func (s *Server) SetMgmtConfig(username, password string) {
|
||||
s.mgmtPassword = password
|
||||
}
|
||||
|
||||
// SetAdminAreaAuth sets the live admin-area auth mode ("" / "enabled" /
|
||||
// "disabled", see datastore.Settings.AdminAreaAuth). Does not validate —
|
||||
// callers (HandleUpdateSettings, startup settings application) are
|
||||
// responsible for only passing already-validated values.
|
||||
func (s *Server) SetAdminAreaAuth(mode string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.adminAreaAuth = mode
|
||||
}
|
||||
|
||||
// AdminAreaAuthMode returns the live admin-area auth mode. Exported so
|
||||
// packages that can't import handlers directly (e.g. health checks, which
|
||||
// take it via a callback to avoid a circular import) can read it.
|
||||
func (s *Server) AdminAreaAuthMode() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.adminAreaAuth
|
||||
}
|
||||
|
||||
// activityKindNotificationDismissed is the datastore.RecordActivity "kind"
|
||||
// used for announcement-banner dismissals (see #419 design,
|
||||
// _/i419/design-admin-area-auth-gate.md).
|
||||
const activityKindNotificationDismissed = "notification_dismissed"
|
||||
|
||||
// loadDismissedAnnouncements scans the local activity log once at startup
|
||||
// and folds it into an id -> most-recent-dismissal-timestamp map. Called
|
||||
// from NewServer so the read path (IsAnnouncementDismissed) never touches
|
||||
// disk — only this one, scoped, boot-time scan does, regardless of how
|
||||
// large the log grows over time. Errors are logged, not fatal: a missing or
|
||||
// unreadable activity log means "nothing dismissed yet", not a startup failure.
|
||||
func loadDismissedAnnouncements(ds *datastore.DataStore) map[string]time.Time {
|
||||
dismissed := make(map[string]time.Time)
|
||||
|
||||
if ds == nil {
|
||||
return dismissed
|
||||
}
|
||||
|
||||
records, err := ds.GetActivityRecords(activityKindNotificationDismissed)
|
||||
if err != nil {
|
||||
log.Printf("[Announcements] Failed to load dismissal history, treating as none: %v", err)
|
||||
return dismissed
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
ts, parseErr := time.Parse(time.RFC3339Nano, record.Timestamp)
|
||||
if parseErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if existing, ok := dismissed[record.ID]; !ok || ts.After(existing) {
|
||||
dismissed[record.ID] = ts
|
||||
}
|
||||
}
|
||||
|
||||
return dismissed
|
||||
}
|
||||
|
||||
// RecordDismissal marks an announcement as dismissed: appends to the local
|
||||
// activity log (write-through) and updates the in-memory cache immediately,
|
||||
// so IsAnnouncementDismissed reflects it without re-reading disk. The same
|
||||
// id can be dismissed again later (e.g. if re-shown) — each call is a new
|
||||
// log entry, not an overwrite.
|
||||
func (s *Server) RecordDismissal(id string) error {
|
||||
if s.ds != nil {
|
||||
if err := s.ds.RecordActivity(activityKindNotificationDismissed, id, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.dismissedAnnouncements == nil {
|
||||
s.dismissedAnnouncements = make(map[string]time.Time)
|
||||
}
|
||||
|
||||
s.dismissedAnnouncements[id] = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsAnnouncementDismissed reports whether the given announcement id has
|
||||
// been dismissed, from the in-memory cache only — never touches disk.
|
||||
func (s *Server) IsAnnouncementDismissed(id string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
_, ok := s.dismissedAnnouncements[id]
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
// SetUpdateChecker registers the update checker (#591). The checker itself is
|
||||
// always constructed and registered, regardless of whether the periodic check
|
||||
// is enabled, so /api/setup/version and the Announcements banner can read
|
||||
// LastResult() (e.g. a result persisted by an earlier run) even before the
|
||||
// periodic check has ever run. Only the periodic background check is gated by
|
||||
// the live enabled setting — see SetUpdateCheckSettings. Callers that leave
|
||||
// this nil are still safe: UpdateCheckResult returns the zero value.
|
||||
func (s *Server) SetUpdateChecker(c *updatecheck.Checker) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.updateChecker = c
|
||||
}
|
||||
|
||||
// UpdateCheckResult returns the last known update-check result, or the
|
||||
// zero value (Available: false) if the check was never enabled.
|
||||
func (s *Server) UpdateCheckResult() updatecheck.Result {
|
||||
s.mu.RLock()
|
||||
checker := s.updateChecker
|
||||
s.mu.RUnlock()
|
||||
|
||||
if checker == nil {
|
||||
return updatecheck.Result{}
|
||||
}
|
||||
|
||||
return checker.LastResult()
|
||||
}
|
||||
|
||||
// SetInternalPaths sets the internal paths for the server.
|
||||
func (s *Server) SetInternalPaths(paths []string) {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -129,6 +129,20 @@ pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px;
|
||||
background-color: #d32f2f;
|
||||
}
|
||||
|
||||
/* .btn-primary marks the one "do the thing" confirm action of a panel
|
||||
(Save Settings, Apply Suggested/Custom Plan, Enable SSH, …). Everything
|
||||
else stays the plain default button so color consistently signals the
|
||||
same two meanings everywhere: primary = confirm, danger = destructive. */
|
||||
.btn-primary {
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: #1769aa;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
Admin & Setup console: migration, settings, accounts, and diagnostics.
|
||||
</p>
|
||||
|
||||
<div id="announcements-banner"></div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab-buttons">
|
||||
<button class="tab-btn active" onclick="openTab(event, 'tab-overview')">
|
||||
@@ -169,6 +171,9 @@
|
||||
<!-- Tab 1: Settings -->
|
||||
<div id="tab-settings" class="tab-content">
|
||||
<h2>System Settings</h2>
|
||||
<p style="font-size: 0.85em; color: #666; margin: -8px 0 20px 0;">
|
||||
Data directory: <code id="settings-data-dir">-</code>
|
||||
</p>
|
||||
<div style="margin-bottom: 20px">
|
||||
<strong>Service URLs</strong>
|
||||
<p style="font-size: 0.9em; color: #555; margin: 6px 0 12px 0">
|
||||
@@ -224,6 +229,29 @@
|
||||
stay reachable at <code>/app</code> and <code>/admin</code> either way.
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px">
|
||||
<strong>Admin area login:</strong>
|
||||
<div style="margin-top: 6px">
|
||||
<select id="admin-area-auth">
|
||||
<option value="">Not decided yet (today: open to anyone on the network)</option>
|
||||
<option value="enabled">Require login for the entire admin area now</option>
|
||||
<option value="disabled">Keep the admin area open (explicit opt-out)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="font-size: 0.85em; color: #666; margin-top: 4px;">
|
||||
Today, only Spotify/Amazon linking and the Local Account tab
|
||||
require login (the same credentials as
|
||||
<code>MGMT_USERNAME</code>/<code>MGMT_PASSWORD</code>). This
|
||||
setting extends that login to the entire admin area —
|
||||
Overview, Devices, Settings, Migration, Health, Logs. A
|
||||
future release is expected to make this the default; set it
|
||||
to "Keep the admin area open" now if you want to opt out
|
||||
ahead of that. See
|
||||
<a href="https://github.com/gesellix/Bose-SoundTouch/issues/419" target="_blank" rel="noopener">issue #419</a>
|
||||
for background. Requires non-default management credentials
|
||||
to enable.
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px">
|
||||
<strong>TLS extra hosts:</strong>
|
||||
<span class="info-toggle" onclick="toggleInfo('tls-extra-hosts-info')">ⓘ</span>
|
||||
@@ -280,6 +308,35 @@
|
||||
<div style="margin-left: 20px">
|
||||
<label for="discovery-interval">Discovery Interval:</label>
|
||||
<input type="text" id="discovery-interval" placeholder="5m" style="width: 100px"/>
|
||||
<span class="info-toggle" onclick="toggleInfo('discovery-interval-info')">ⓘ</span>
|
||||
<div id="discovery-interval-info" class="info-details">
|
||||
Go duration syntax: a number followed by a unit, optionally combined
|
||||
(e.g. <code>5m</code>, <code>90s</code>, <code>1h30m</code>).<br/>
|
||||
Units: <code>h</code> (hours), <code>m</code> (minutes), <code>s</code> (seconds).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px">
|
||||
<strong>Update Check:</strong>
|
||||
<div style="margin-top: 5px">
|
||||
<label style="display: block; margin-bottom: 5px">
|
||||
<input type="checkbox" id="update-check-enabled"/> Check GitHub for new releases
|
||||
</label>
|
||||
<div style="margin-left: 20px">
|
||||
<label for="update-check-interval">Check Interval:</label>
|
||||
<input type="text" id="update-check-interval" placeholder="24h" style="width: 100px"/>
|
||||
<span class="info-toggle" onclick="toggleInfo('update-check-interval-info')">ⓘ</span>
|
||||
<div id="update-check-interval-info" class="info-details">
|
||||
Go duration syntax: a number followed by a unit, optionally combined
|
||||
(e.g. <code>24h</code>, <code>90m</code>, <code>1h30m</code>, <code>45s</code>).<br/>
|
||||
Units: <code>h</code> (hours), <code>m</code> (minutes), <code>s</code> (seconds).
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 0.8em; color: #666; margin-top: 4px;">
|
||||
Makes one unauthenticated GET request to api.github.com per interval when enabled.
|
||||
No other data leaves this install. Applies live, no restart needed — takes effect
|
||||
within a minute (worst case).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -476,7 +533,7 @@
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px">
|
||||
<button onclick="updateSettings()">Save Settings</button>
|
||||
<button class="btn-primary" onclick="updateSettings()">Save Settings</button>
|
||||
<span
|
||||
id="settings-status"
|
||||
style="margin-left: 10px; font-size: 0.9em"
|
||||
@@ -634,9 +691,27 @@
|
||||
class="summary-box"
|
||||
style="display: none"
|
||||
>
|
||||
<h3>
|
||||
Migration Summary for
|
||||
<span id="summary-device-display"></span>
|
||||
<h3 style="display: flex; align-items: baseline; justify-content: space-between">
|
||||
<span>
|
||||
Migration Summary for
|
||||
<span id="summary-device-display"></span>
|
||||
</span>
|
||||
<span style="display: flex; gap: 6px">
|
||||
<button
|
||||
type="button"
|
||||
onclick="refreshSummary()"
|
||||
title="Reload summary for this device"
|
||||
aria-label="Reload summary"
|
||||
style="padding: 2px 8px; font-size: 0.85em; line-height: 1; cursor: pointer; font-weight: normal"
|
||||
>↻ Reload</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick="document.getElementById('migration-summary').style.display = 'none'"
|
||||
title="Hide this summary — doesn't change anything on the speaker"
|
||||
aria-label="Hide summary"
|
||||
style="padding: 2px 8px; font-size: 0.85em; line-height: 1; cursor: pointer; font-weight: normal"
|
||||
>✕ Hide</button>
|
||||
</span>
|
||||
</h3>
|
||||
<input type="hidden" id="summary-device-id"/>
|
||||
<p>Migration Status: <span id="migration-status"></span></p>
|
||||
@@ -682,7 +757,8 @@
|
||||
<button
|
||||
id="trust-ca-btn"
|
||||
type="button"
|
||||
style="display: none; background-color: #607d8b; color: white; border: none; padding: 2px 8px; font-size: 0.85em"
|
||||
class="btn-primary"
|
||||
style="display: none; padding: 2px 8px; font-size: 0.85em"
|
||||
>Trust CA Now</button>
|
||||
<a
|
||||
href="/setup/ca.crt"
|
||||
@@ -703,7 +779,24 @@
|
||||
<tbody>
|
||||
<tr style="border-top: 1px solid #eee">
|
||||
<td style="padding: 4px 8px; width: 170px; color: #555" title="The remote_services file controls whether SSH is available after reboot">SSH (remote_services)</td>
|
||||
<td id="state-remote-services-cell" style="padding: 4px 8px"></td>
|
||||
<td id="state-remote-services-cell" style="padding: 4px 8px">
|
||||
<span id="state-remote-services-line"></span>
|
||||
<span style="margin-left: 12px; white-space: nowrap">
|
||||
<button
|
||||
id="ensure-remote-btn"
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
style="padding: 2px 8px; font-size: 0.85em"
|
||||
>Enable SSH (Persist remote_services)</button>
|
||||
<button
|
||||
id="remove-remote-btn"
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
title="Removes the remote_services file — SSH will be disabled after the next reboot"
|
||||
style="margin-left: 6px; padding: 2px 8px; font-size: 0.85em"
|
||||
>Disable SSH (Remove remote_services)</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="border-top: 1px solid #eee">
|
||||
<td style="padding: 4px 8px; color: #555">Account paired</td>
|
||||
@@ -718,6 +811,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Speaker controls: real device actions that don't depend on
|
||||
the Customize form below, kept always visible rather than
|
||||
behind its collapse (see #621 — Reboot was previously
|
||||
reachable only after expanding "Customize this migration"
|
||||
and scrolling past it). -->
|
||||
<div style="margin: 0 0 16px 0">
|
||||
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Speaker controls</h4>
|
||||
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
|
||||
<button
|
||||
id="revert-migrate-btn"
|
||||
class="btn-danger"
|
||||
style="padding: 10px 20px; display: none"
|
||||
>
|
||||
Revert to Defaults
|
||||
</button>
|
||||
<button
|
||||
id="reboot-speaker-btn"
|
||||
style="padding: 10px 20px"
|
||||
>
|
||||
Reboot Speaker
|
||||
</button>
|
||||
</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. -->
|
||||
@@ -751,7 +868,9 @@
|
||||
background-color: #eefbff;
|
||||
"
|
||||
>
|
||||
<strong>HTTPS Connection Test:</strong><br/>
|
||||
<strong>HTTPS Connection Test:</strong>
|
||||
<span id="connection-test-relevance-note" style="font-size: 0.85em"></span>
|
||||
<br/>
|
||||
<span style="font-size: 0.85em; color: #555"
|
||||
>Verify the device can reach the server over
|
||||
HTTPS.</span
|
||||
@@ -762,25 +881,13 @@
|
||||
<div style="margin-top: 10px">
|
||||
<button
|
||||
id="test-connection-explicit-btn"
|
||||
style="
|
||||
background-color: #607d8b;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
font-size: 0.9em;
|
||||
"
|
||||
style="font-size: 0.9em"
|
||||
>
|
||||
Test with Explicit CA.crt
|
||||
</button>
|
||||
<button
|
||||
id="test-connection-trusted-btn"
|
||||
style="
|
||||
background-color: #607d8b;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
font-size: 0.9em;
|
||||
"
|
||||
style="font-size: 0.9em"
|
||||
>
|
||||
Test with Shared Trust Store
|
||||
</button>
|
||||
@@ -822,13 +929,7 @@
|
||||
<div style="margin-top: 10px">
|
||||
<button
|
||||
id="test-dns-btn"
|
||||
style="
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
font-size: 0.9em;
|
||||
"
|
||||
style="font-size: 0.9em"
|
||||
>
|
||||
Test DNS Redirection
|
||||
</button>
|
||||
@@ -906,7 +1007,7 @@
|
||||
<input
|
||||
type="text"
|
||||
id="plan-marge-url"
|
||||
oninput="validatePlanURLs()"
|
||||
oninput="onPlanURLFieldEdited(this)"
|
||||
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
|
||||
/>
|
||||
</td>
|
||||
@@ -918,7 +1019,7 @@
|
||||
<input
|
||||
type="text"
|
||||
id="plan-stats-url"
|
||||
oninput="validatePlanURLs()"
|
||||
oninput="onPlanURLFieldEdited(this)"
|
||||
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
|
||||
/>
|
||||
</td>
|
||||
@@ -930,7 +1031,7 @@
|
||||
<input
|
||||
type="text"
|
||||
id="plan-sw_update-url"
|
||||
oninput="validatePlanURLs()"
|
||||
oninput="onPlanURLFieldEdited(this)"
|
||||
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
|
||||
/>
|
||||
</td>
|
||||
@@ -942,7 +1043,7 @@
|
||||
<input
|
||||
type="text"
|
||||
id="plan-bmx-url"
|
||||
oninput="validatePlanURLs()"
|
||||
oninput="onPlanURLFieldEdited(this)"
|
||||
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
|
||||
/>
|
||||
</td>
|
||||
@@ -1010,6 +1111,7 @@
|
||||
<button
|
||||
type="button"
|
||||
id="plan-apply-btn"
|
||||
class="btn-primary"
|
||||
onclick="applySuggestedPlan()"
|
||||
style="font-size: 0.95em"
|
||||
>Apply Suggested Plan</button>
|
||||
@@ -1093,8 +1195,9 @@
|
||||
<button
|
||||
type="button"
|
||||
id="customize-apply-btn"
|
||||
class="btn-primary"
|
||||
onclick="applyCustomPlan()"
|
||||
style="background-color: #4caf50; color: white; border: none; padding: 8px 14px; font-size: 0.95em"
|
||||
style="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>
|
||||
@@ -1179,64 +1282,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 15px">
|
||||
<button
|
||||
id="revert-migrate-btn"
|
||||
style="
|
||||
background-color: #ff9800;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
display: none;
|
||||
"
|
||||
>
|
||||
Revert to Defaults
|
||||
</button>
|
||||
<button
|
||||
id="reboot-speaker-btn"
|
||||
style="
|
||||
background-color: #607d8b;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
"
|
||||
>
|
||||
Reboot Speaker
|
||||
</button>
|
||||
<button
|
||||
id="ensure-remote-btn"
|
||||
style="
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
"
|
||||
>
|
||||
Enable SSH (Persist remote_services)
|
||||
</button>
|
||||
<button
|
||||
id="remove-remote-btn"
|
||||
title="Removes the remote_services file — SSH will be disabled after the next reboot"
|
||||
style="
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
"
|
||||
>
|
||||
Disable SSH (Remove remote_services)
|
||||
</button>
|
||||
<button
|
||||
onclick="
|
||||
document.getElementById(
|
||||
'migration-summary',
|
||||
).style.display = 'none'
|
||||
"
|
||||
style="padding: 10px 20px"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -354,9 +354,18 @@ async function fetchSettings() {
|
||||
if (settings.discovery_enabled !== undefined) {
|
||||
document.getElementById("discovery-enabled").checked = settings.discovery_enabled;
|
||||
}
|
||||
if (settings.update_check_interval) {
|
||||
document.getElementById("update-check-interval").value = settings.update_check_interval;
|
||||
}
|
||||
if (settings.update_check_enabled !== undefined) {
|
||||
document.getElementById("update-check-enabled").checked = settings.update_check_enabled;
|
||||
}
|
||||
if (settings.default_landing) {
|
||||
document.getElementById("default-landing").value = settings.default_landing;
|
||||
}
|
||||
if (settings.admin_area_auth !== undefined) {
|
||||
document.getElementById("admin-area-auth").value = settings.admin_area_auth || "";
|
||||
}
|
||||
if (settings.dns_enabled !== undefined) {
|
||||
document.getElementById("dns-enabled").checked = settings.dns_enabled;
|
||||
}
|
||||
@@ -498,8 +507,11 @@ async function updateSettings() {
|
||||
server_url: document.getElementById("target-domain").value,
|
||||
https_server_url_override: httpsOverrideEl ? httpsOverrideEl.value.trim() : "",
|
||||
default_landing: document.getElementById("default-landing").value,
|
||||
admin_area_auth: document.getElementById("admin-area-auth").value,
|
||||
discovery_interval: document.getElementById("discovery-interval").value,
|
||||
discovery_enabled: document.getElementById("discovery-enabled").checked,
|
||||
update_check_interval: document.getElementById("update-check-interval").value,
|
||||
update_check_enabled: document.getElementById("update-check-enabled").checked,
|
||||
dns_enabled: document.getElementById("dns-enabled").checked,
|
||||
dns_upstream: document.getElementById("dns-upstream").value,
|
||||
dns_bind_addr: document.getElementById("dns-bind").value,
|
||||
@@ -586,7 +598,19 @@ async function fetchDevices() {
|
||||
if (devices.length === 0) {
|
||||
container.innerHTML = "No devices known yet.";
|
||||
} else {
|
||||
let html = "<table><tr><th>Name & Model</th><th>IP Address</th><th>Device & Account ID</th><th>Firmware & Serial</th><th>Method</th><th>Action</th></tr>";
|
||||
// Built via DOM APIs rather than innerHTML/template strings: device
|
||||
// fields (name, IDs, serials, ...) come from speakers and third-party
|
||||
// pairing tools (see #634) and are not restricted to HTML/JS-safe
|
||||
// characters, so they must never be parsed as markup or concatenated
|
||||
// into inline event-handler attributes.
|
||||
const table = document.createElement("table");
|
||||
const headerRow = document.createElement("tr");
|
||||
for (const label of ["Name & Model", "IP Address", "Device & Account ID", "Firmware & Serial", "Method", "Action"]) {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = label;
|
||||
headerRow.appendChild(th);
|
||||
}
|
||||
table.appendChild(headerRow);
|
||||
|
||||
// Clear and repopulate selectors
|
||||
const currentSyncVal = syncSelector.value;
|
||||
@@ -600,25 +624,83 @@ async function fetchDevices() {
|
||||
|
||||
devices.forEach((d) => {
|
||||
const methodLabel = d.discovery_method === "manual" ? "👤 Manual" : "🔍 Auto";
|
||||
html += `
|
||||
<tr id="device-row-${d.device_id}">
|
||||
<td class="col-name-model"><div class="col-name">${d.name}</div><div class="col-model" style="font-size: 0.8em; color: #666;">${d.product_code}</div></td>
|
||||
<td class="col-ip">${d.ip_address}</td>
|
||||
<td class="col-ids"><div class="col-deviceid">${d.device_id}</div><div class="col-accountid" style="font-size: 0.8em; color: #666;">${d.account_id || "default"}</div></td>
|
||||
<td class="col-fw-serial"><div class="col-firmware">${d.firmware_version || "0.0.0"}</div><div class="col-serial" style="font-size: 0.8em; color: #666;">${d.device_serial_number}</div></td>
|
||||
<td class="col-method">${methodLabel}</td>
|
||||
<td>
|
||||
<button onclick="toggleDeviceSummary('${d.device_id}')">Inspect</button>
|
||||
<button onclick="prepareSync('${d.device_id}')">Sync Data</button>
|
||||
<button onclick="prepareMigration('${d.device_id}')">Migrate</button>
|
||||
<button id="prime-spotify-${d.device_id}" class="btn-spotify" style="display: none;" onclick="primeSpotify('${d.device_id}')">Prime Spotify</button>
|
||||
<button class="btn-danger" onclick="removeDevice('${d.device_id}', '${d.name}')">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="device-summary-${d.device_id}" style="display: none;">
|
||||
<td colspan="6" id="device-summary-cell-${d.device_id}" style="background: #fafafa; padding: 12px;"></td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
const nameModelCell = document.createElement("td");
|
||||
nameModelCell.className = "col-name-model";
|
||||
const nameDiv = document.createElement("div");
|
||||
nameDiv.className = "col-name";
|
||||
nameDiv.textContent = d.name;
|
||||
const modelDiv = document.createElement("div");
|
||||
modelDiv.className = "col-model";
|
||||
modelDiv.style.cssText = "font-size: 0.8em; color: #666;";
|
||||
modelDiv.textContent = d.product_code;
|
||||
nameModelCell.append(nameDiv, modelDiv);
|
||||
|
||||
const ipCell = document.createElement("td");
|
||||
ipCell.className = "col-ip";
|
||||
ipCell.textContent = d.ip_address;
|
||||
|
||||
const idsCell = document.createElement("td");
|
||||
idsCell.className = "col-ids";
|
||||
const deviceIdDiv = document.createElement("div");
|
||||
deviceIdDiv.className = "col-deviceid";
|
||||
deviceIdDiv.textContent = d.device_id;
|
||||
const accountIdDiv = document.createElement("div");
|
||||
accountIdDiv.className = "col-accountid";
|
||||
accountIdDiv.style.cssText = "font-size: 0.8em; color: #666;";
|
||||
accountIdDiv.textContent = d.account_id || "default";
|
||||
idsCell.append(deviceIdDiv, accountIdDiv);
|
||||
|
||||
const fwCell = document.createElement("td");
|
||||
fwCell.className = "col-fw-serial";
|
||||
const fwDiv = document.createElement("div");
|
||||
fwDiv.className = "col-firmware";
|
||||
fwDiv.textContent = d.firmware_version || "0.0.0";
|
||||
const serialDiv = document.createElement("div");
|
||||
serialDiv.className = "col-serial";
|
||||
serialDiv.style.cssText = "font-size: 0.8em; color: #666;";
|
||||
serialDiv.textContent = d.device_serial_number;
|
||||
fwCell.append(fwDiv, serialDiv);
|
||||
|
||||
const methodCell = document.createElement("td");
|
||||
methodCell.className = "col-method";
|
||||
methodCell.textContent = methodLabel;
|
||||
|
||||
const makeActionButton = (label, onClick, extra) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.textContent = label;
|
||||
btn.addEventListener("click", onClick);
|
||||
if (extra) Object.assign(btn, extra);
|
||||
return btn;
|
||||
};
|
||||
|
||||
const actionCell = document.createElement("td");
|
||||
actionCell.append(
|
||||
makeActionButton("Inspect", () => toggleDeviceSummary(d.device_id)),
|
||||
makeActionButton("Sync Data", () => prepareSync(d.device_id)),
|
||||
makeActionButton("Migrate", () => prepareMigration(d.device_id)),
|
||||
makeActionButton("Prime Spotify", () => primeSpotify(d.device_id), {
|
||||
id: `prime-spotify-${d.device_id}`,
|
||||
className: "btn-spotify",
|
||||
}),
|
||||
makeActionButton("Remove", () => removeDevice(d.device_id, d.name), {className: "btn-danger"}),
|
||||
);
|
||||
actionCell.querySelector(".btn-spotify").style.display = "none";
|
||||
|
||||
const row = document.createElement("tr");
|
||||
row.id = `device-row-${d.device_id}`;
|
||||
row.append(nameModelCell, ipCell, idsCell, fwCell, methodCell, actionCell);
|
||||
|
||||
const summaryRow = document.createElement("tr");
|
||||
summaryRow.id = `device-summary-${d.device_id}`;
|
||||
summaryRow.style.display = "none";
|
||||
const summaryCell = document.createElement("td");
|
||||
summaryCell.colSpan = 6;
|
||||
summaryCell.id = `device-summary-cell-${d.device_id}`;
|
||||
summaryCell.style.cssText = "background: #fafafa; padding: 12px;";
|
||||
summaryRow.appendChild(summaryCell);
|
||||
|
||||
table.append(row, summaryRow);
|
||||
|
||||
const optSync = document.createElement("option");
|
||||
optSync.value = d.device_id;
|
||||
@@ -637,8 +719,7 @@ async function fetchDevices() {
|
||||
eventSelector.appendChild(optEvent);
|
||||
}
|
||||
});
|
||||
html += "</table>";
|
||||
container.innerHTML = html;
|
||||
container.replaceChildren(table);
|
||||
|
||||
if (currentSyncVal) syncSelector.value = currentSyncVal;
|
||||
if (currentMigrationVal) migrationSelector.value = currentMigrationVal;
|
||||
@@ -795,6 +876,57 @@ function getDeviceDisplayName(deviceId) {
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
// buildSyncConfirmMessage renders a human-readable summary of a destructive
|
||||
// SyncResult (see setup.SyncResult/SyncResourceDiff) for window.confirm() —
|
||||
// e.g. "Sync would remove 1 preset: Ici Roussillon. Continue?".
|
||||
function buildSyncConfirmMessage(result) {
|
||||
const lines = ["This Data Sync would remove data that's currently stored:"];
|
||||
for (const diff of result.diffs || []) {
|
||||
if (!diff.destructive) {
|
||||
continue;
|
||||
}
|
||||
const removedNote = diff.removed && diff.removed.length ? ": " + diff.removed.join(", ") : "";
|
||||
lines.push("- " + diff.resource + ": " + diff.currentCount + " → " + diff.incomingCount + removedNote);
|
||||
}
|
||||
lines.push("This usually means the speaker's own live data was incomplete at this moment. Continue anyway?");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// renderSyncResultList builds a <ul> summarising a successful SyncResult —
|
||||
// one <li> per resource, e.g. "presets: 6 → 6", plus a sources count. Built
|
||||
// via DOM APIs (not innerHTML string concatenation) since preset/recent
|
||||
// names ultimately come from user-editable station names on the speaker.
|
||||
function renderSyncResultList(result) {
|
||||
const ul = document.createElement("ul");
|
||||
|
||||
for (const diff of result.diffs || []) {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = diff.resource + ": " + diff.currentCount + " → " + diff.incomingCount;
|
||||
ul.appendChild(li);
|
||||
}
|
||||
|
||||
const sourcesLi = document.createElement("li");
|
||||
sourcesLi.textContent = "sources: " + (result.sourcesCount >= 0 ? result.sourcesCount : "sync failed");
|
||||
ul.appendChild(sourcesLi);
|
||||
|
||||
return ul;
|
||||
}
|
||||
|
||||
async function requestSync(deviceId, confirmed) {
|
||||
let url = "/api/setup/sync/" + encodeURIComponent(deviceId);
|
||||
if (confirmed) {
|
||||
url += "?confirmed=true";
|
||||
}
|
||||
const response = await fetch(url, {method: "POST"});
|
||||
let result = null;
|
||||
try {
|
||||
result = await response.clone().json();
|
||||
} catch (e) {
|
||||
// Non-JSON error body (e.g. a plain-text 500) — handled below via response.text().
|
||||
}
|
||||
return {response, result};
|
||||
}
|
||||
|
||||
async function startSync() {
|
||||
const deviceId = document.getElementById("sync-device-list").value;
|
||||
if (!deviceId) {
|
||||
@@ -814,14 +946,30 @@ async function startSync() {
|
||||
log.innerHTML = "";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/setup/sync/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
if (response.ok) {
|
||||
let {response, result} = await requestSync(deviceId, false);
|
||||
|
||||
if (response.status === 409 && result) {
|
||||
if (!confirm(buildSyncConfirmMessage(result))) {
|
||||
status.style.backgroundColor = "#eef";
|
||||
status.textContent = "Sync cancelled for " + display + " — nothing was changed.";
|
||||
return;
|
||||
}
|
||||
|
||||
({response, result} = await requestSync(deviceId, true));
|
||||
}
|
||||
|
||||
if (response.ok && result) {
|
||||
status.style.backgroundColor = "#dfd";
|
||||
status.textContent = "✅ Sync completed successfully for " + display + "!";
|
||||
results.style.display = "block";
|
||||
log.textContent = "Data fetched and saved to local datastore for " + display + ".\nPresets: OK\nRecents: OK\nSources: OK";
|
||||
|
||||
log.innerHTML = "";
|
||||
const intro = document.createElement("p");
|
||||
intro.textContent = "Data fetched and saved to local datastore for " + display + ".";
|
||||
log.appendChild(intro);
|
||||
log.appendChild(renderSyncResultList(result));
|
||||
} else {
|
||||
const err = await response.text();
|
||||
const err = result ? JSON.stringify(result) : await response.text();
|
||||
throw new Error(err);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -830,6 +978,46 @@ async function startSync() {
|
||||
}
|
||||
}
|
||||
|
||||
// fetchAnnouncements loads and renders the active announcements for this
|
||||
// area (see #419 design doc, _/i419/design-admin-area-auth-gate.md). Not
|
||||
// tab-scoped: the container lives outside the tab-content divs so a banner
|
||||
// stays visible regardless of which tab is open.
|
||||
async function fetchAnnouncements() {
|
||||
const container = document.getElementById("announcements-banner");
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/announcements?target=admin");
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
const announcements = data.announcements || [];
|
||||
|
||||
container.innerHTML = announcements.map(a => `
|
||||
<div class="announcement-banner announcement-${a.level || "info"}" data-announcement-id="${a.id}" style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:10px 14px; margin-bottom:10px; border-radius:4px; background:#e7f3ff; border:1px solid #b6d9f7; color:#1a4a6e; font-size:0.9em;">
|
||||
<span>${escapeHtml(a.message)}${a.link_url ? ` <a href="${escapeHtml(a.link_url)}" target="_blank" rel="noopener" style="color:inherit; text-decoration:underline;">${escapeHtml(a.link_text || a.link_url)}</a>` : ""}</span>
|
||||
<button onclick="dismissAnnouncement('${a.id}')" title="Dismiss" style="background:none; border:none; cursor:pointer; font-size:1.1em; line-height:1; color:inherit; flex-shrink:0;">×</button>
|
||||
</div>
|
||||
`).join("");
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch announcements", error);
|
||||
}
|
||||
}
|
||||
|
||||
// dismissAnnouncement records the dismissal server-side (so it stays
|
||||
// dismissed across sessions/devices — not a client-only localStorage flag)
|
||||
// and removes it from the DOM immediately rather than waiting on a refetch.
|
||||
async function dismissAnnouncement(id) {
|
||||
try {
|
||||
await fetch(`/api/announcements/${encodeURIComponent(id)}/dismiss`, {method: "POST"});
|
||||
} catch (error) {
|
||||
console.error("Failed to dismiss announcement", error);
|
||||
}
|
||||
|
||||
const el = document.querySelector(`[data-announcement-id="${id}"]`);
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
async function fetchVersion() {
|
||||
try {
|
||||
const response = await fetch("/api/setup/version");
|
||||
@@ -847,6 +1035,11 @@ async function fetchVersion() {
|
||||
}
|
||||
info.innerHTML = `AfterTouch ${versionStr} (${commitStr}) • ${data.date}`;
|
||||
}
|
||||
|
||||
const dataDirEl = document.getElementById("settings-data-dir");
|
||||
if (dataDirEl && data.data_dir) {
|
||||
dataDirEl.textContent = data.data_dir;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch version info", error);
|
||||
}
|
||||
@@ -867,7 +1060,14 @@ async function fetchAccountList() {
|
||||
const data = await response.json();
|
||||
const selector = document.getElementById("account-selector");
|
||||
if (selector) {
|
||||
selector.innerHTML = data.accounts.map(acc => `<option value="${acc}">${acc}</option>`).join("");
|
||||
// Account IDs can contain non-alphanumeric characters (e.g.
|
||||
// "stick@local", #634) — built via DOM APIs, not innerHTML.
|
||||
selector.replaceChildren(...data.accounts.map(acc => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = acc;
|
||||
opt.textContent = acc;
|
||||
return opt;
|
||||
}));
|
||||
if (data.accounts.length > 0) {
|
||||
fetchAccountDetails(selector.value);
|
||||
}
|
||||
@@ -892,7 +1092,7 @@ async function fetchAccountDetails(accountId) {
|
||||
try {
|
||||
const response = await fetch(`/api/mgmt/accounts/${encodeURIComponent(accountId)}`);
|
||||
if (!response.ok) {
|
||||
if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Failed to load account details: ${response.statusText}</span>`;
|
||||
if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Failed to load account details: ${escapeHtml(response.statusText)}</span>`;
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
@@ -901,13 +1101,13 @@ async function fetchAccountDetails(accountId) {
|
||||
if (metadataEl) {
|
||||
const warningNotice = data.account.is_placeholder ?
|
||||
`<div style="background: #fff3cd; color: #856404; padding: 10px; border: 1px solid #ffeeba; border-radius: 4px; margin-bottom: 10px; font-size: 0.85em;">
|
||||
<strong>Notice:</strong> Account data (account.json) was not found in the expected location for this account ID.
|
||||
<strong>Notice:</strong> This account hasn't saved any custom settings yet (language, provider preferences). Defaults are in effect — they'll be saved once you change something below.
|
||||
</div>` : "";
|
||||
|
||||
metadataEl.innerHTML = `
|
||||
${warningNotice}
|
||||
<table style="width: 100%; font-size: 0.9em;">
|
||||
<tr><td style="padding: 4px"><strong>Account ID:</strong></td><td style="padding: 4px">${data.account.account_id}</td></tr>
|
||||
<tr><td style="padding: 4px"><strong>Account ID:</strong></td><td style="padding: 4px">${escapeHtml(data.account.account_id)}</td></tr>
|
||||
<tr><td style="padding: 4px"><strong>Language:</strong></td><td style="padding: 4px">
|
||||
<select id="account-language-select" style="font-size: 0.9em; padding: 2px;">
|
||||
<option value="en" ${data.account.preferred_language === "en" || !data.account.preferred_language ? "selected" : ""}>en</option>
|
||||
@@ -926,7 +1126,7 @@ async function fetchAccountDetails(accountId) {
|
||||
}, {});
|
||||
return Object.entries(grouped).map(([pName, settings]) => `
|
||||
<div style="margin-bottom: 8px;">
|
||||
<strong>${pName}</strong>
|
||||
<strong>${escapeHtml(pName)}</strong>
|
||||
<ul style="margin: 2px 0 0 0; padding-left: 20px; list-style-type: disc;">
|
||||
${settings.map(s => {
|
||||
if ((s.provider_name === "SPOTIFY" || s.provider_id === "15") && s.key_name === "STREAMING_QUALITY") {
|
||||
@@ -934,9 +1134,9 @@ async function fetchAccountDetails(accountId) {
|
||||
<li style="margin-bottom: 4px;">
|
||||
Music Streaming Quality:
|
||||
<select class="provider-setting-select"
|
||||
data-account-id="${data.account.account_id}"
|
||||
data-provider-id="${s.provider_id}"
|
||||
data-key="${s.key_name}"
|
||||
data-account-id="${escapeHtml(data.account.account_id)}"
|
||||
data-provider-id="${escapeHtml(s.provider_id)}"
|
||||
data-key="${escapeHtml(s.key_name)}"
|
||||
style="font-size: 0.9em; padding: 2px; margin-left: 4px;">
|
||||
<option value="1" ${s.value === "1" ? "selected" : ""}>Fastest Streaming - up to 128 kbit/s</option>
|
||||
<option value="2" ${s.value === "2" ? "selected" : ""}>Balanced Quality and Speed - up to 192 kbit/s</option>
|
||||
@@ -946,7 +1146,7 @@ async function fetchAccountDetails(accountId) {
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
return `<li>${s.key_name}: ${s.value}</li>`;
|
||||
return `<li>${escapeHtml(s.key_name)}: ${escapeHtml(s.value)}</li>`;
|
||||
}).join("")}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -967,7 +1167,7 @@ async function fetchAccountDetails(accountId) {
|
||||
statusEl.style.color = "#666";
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/mgmt/accounts/${data.account.account_id}/language`, {
|
||||
const response = await fetch(`/api/mgmt/accounts/${encodeURIComponent(data.account.account_id)}/language`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -1011,7 +1211,7 @@ async function fetchAccountDetails(accountId) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/mgmt/accounts/${accID}/provider-settings`, {
|
||||
const response = await fetch(`/api/mgmt/accounts/${encodeURIComponent(accID)}/provider-settings`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -1053,27 +1253,27 @@ async function fetchAccountDetails(accountId) {
|
||||
|
||||
devicesEl.innerHTML = data.devices.map(device => `
|
||||
<div class="summary-box" style="margin-bottom: 15px; border-left: 5px solid #007bff; padding: 15px;">
|
||||
<div style="display: flex; justify-content: space-between; cursor: pointer; align-items: center;" onclick="toggleInfo('device-details-${device.device_id}')">
|
||||
<h4 style="margin: 0">${device.name || "Unnamed Device"} (${device.product_code})</h4>
|
||||
<div class="device-summary-header" data-toggle-target="device-details-${escapeHtml(device.device_id)}" style="display: flex; justify-content: space-between; cursor: pointer; align-items: center;">
|
||||
<h4 style="margin: 0">${escapeHtml(device.name || "Unnamed Device")} (${escapeHtml(device.product_code)})</h4>
|
||||
<div style="font-size: 0.8em; color: #666">
|
||||
${device.ip_address} | ${device.device_id} <span style="font-size: 1.2em; vertical-align: middle;">▾</span>
|
||||
${escapeHtml(device.ip_address)} | ${escapeHtml(device.device_id)} <span style="font-size: 1.2em; vertical-align: middle;">▾</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="device-details-${device.device_id}" style="display: none; margin-top: 15px; padding-top: 10px; border-top: 1px solid #eee">
|
||||
<div id="device-details-${escapeHtml(device.device_id)}" style="display: none; margin-top: 15px; padding-top: 10px; border-top: 1px solid #eee">
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px">
|
||||
<div>
|
||||
<h5 style="margin: 10px 0 5px 0">Device Metadata</h5>
|
||||
<div style="font-size: 0.85em; background: #f8f9fa; padding: 8px; border-radius: 4px; border: 1px solid #e9ecef">
|
||||
<strong>Serial:</strong> ${device.device_serial_number || device.serial_number || "N/A"}<br>
|
||||
<strong>MAC:</strong> ${device.mac_address || "N/A"}<br>
|
||||
<strong>Version:</strong> ${device.firmware_version || "N/A"}<br>
|
||||
<strong>Discovery:</strong> ${device.discovery_method || "N/A"}
|
||||
<strong>Serial:</strong> ${escapeHtml(device.device_serial_number || device.serial_number || "N/A")}<br>
|
||||
<strong>MAC:</strong> ${escapeHtml(device.mac_address || "N/A")}<br>
|
||||
<strong>Version:</strong> ${escapeHtml(device.firmware_version || "N/A")}<br>
|
||||
<strong>Discovery:</strong> ${escapeHtml(device.discovery_method || "N/A")}
|
||||
</div>
|
||||
|
||||
<h5 style="margin: 15px 0 5px 0">Hardware Components</h5>
|
||||
<ul style="font-size: 0.8em; padding-left: 20px; margin: 0">
|
||||
${device.components ? device.components.map(c => `<li><strong>${c.category || c.type || 'Component'}</strong>: ${c.firmware_version || 'N/A'} <br><small style="color:#777">S/N: ${c.serial_number || 'N/A'}</small></li>`).join("") : "<li>No components found</li>"}
|
||||
${device.components ? device.components.map(c => `<li><strong>${escapeHtml(c.category || c.type || 'Component')}</strong>: ${escapeHtml(c.firmware_version || 'N/A')} <br><small style="color:#777">S/N: ${escapeHtml(c.serial_number || 'N/A')}</small></li>`).join("") : "<li>No components found</li>"}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1093,14 +1293,14 @@ async function fetchAccountDetails(accountId) {
|
||||
const account = (s.account && s.account !== s.username && s.account !== name) ? ` [${s.account}]` : "";
|
||||
const finalName = name || s.type || "Unknown Source";
|
||||
if (finalName) {
|
||||
sourceLabel = `<br><small style="color: #666; font-size: 0.85em;">via ${finalName}${account}</small>`;
|
||||
sourceLabel = `<br><small style="color: #666; font-size: 0.85em;">via ${escapeHtml(finalName)}${escapeHtml(account)}</small>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<div style="border: 1px solid #ddd; padding: 5px; font-size: 0.8em; background: ${p ? "#e6ffed" : "#f8f9fa"}; border-radius: 3px;">
|
||||
<strong>#${i + 1}</strong>: ${itemName}${sourceLabel}
|
||||
<strong>#${i + 1}</strong>: ${escapeHtml(itemName)}${sourceLabel}
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
@@ -1118,13 +1318,13 @@ async function fetchAccountDetails(accountId) {
|
||||
const account = (s.account && s.account !== s.username && s.account !== sName) ? ` [${s.account}]` : "";
|
||||
const finalSName = sName || s.type || "Unknown Source";
|
||||
if (finalSName) {
|
||||
sourceLabel = `<br><small style="color: #666; font-size: 0.9em;">via ${finalSName}${account}</small>`;
|
||||
sourceLabel = `<br><small style="color: #666; font-size: 0.9em;">via ${escapeHtml(finalSName)}${escapeHtml(account)}</small>`;
|
||||
}
|
||||
}
|
||||
const dateRaw = r.last_played_at || r.created_on;
|
||||
const dateObj = dateRaw ? (isNaN(Number(dateRaw)) ? new Date(dateRaw) : new Date(Number(dateRaw) * 1000)) : null;
|
||||
const dateStr = dateObj ? dateObj.toLocaleString('sv-SE') : 'N/A'; // sv-SE produces YYYY-MM-DD HH:MM:SS with 24h time
|
||||
return `<li>${name}${sourceLabel} <br><small style="color:#888">${dateStr}</small></li>`;
|
||||
return `<li>${escapeHtml(name)}${sourceLabel} <br><small style="color:#888">${escapeHtml(dateStr)}</small></li>`;
|
||||
}).join("") : "<li>No recents</li>"}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1139,8 +1339,8 @@ async function fetchAccountDetails(accountId) {
|
||||
const usernameSuffix = (s.username && s.username !== "Local") ? ` (${s.username})` : "";
|
||||
const accountSuffix = (s.account && s.account !== s.username && s.account !== sourceName) ? ` [${s.account}]` : "";
|
||||
return `
|
||||
<span style="background: #eefbff; color: #0056b3; border: 1px solid #b8daff; padding: 2px 8px; border-radius: 12px; font-size: 0.75em" title="Source Type: ${s.type}">
|
||||
${sourceName}${usernameSuffix}${accountSuffix}
|
||||
<span style="background: #eefbff; color: #0056b3; border: 1px solid #b8daff; padding: 2px 8px; border-radius: 12px; font-size: 0.75em" title="Source Type: ${escapeHtml(s.type)}">
|
||||
${escapeHtml(sourceName)}${escapeHtml(usernameSuffix)}${escapeHtml(accountSuffix)}
|
||||
</span>
|
||||
`;
|
||||
}).join("") : "<small style='color:#999'>None</small>"}
|
||||
@@ -1149,10 +1349,17 @@ async function fetchAccountDetails(accountId) {
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
// data-toggle-target (not an inline onclick) avoids re-embedding
|
||||
// speaker-controlled device_id inside a JS-string-in-HTML-attribute
|
||||
// context, which HTML-escaping alone cannot make safe.
|
||||
devicesEl.querySelectorAll(".device-summary-header").forEach(el => {
|
||||
el.addEventListener("click", () => toggleInfo(el.dataset.toggleTarget));
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Error: ${error.message}</span>`;
|
||||
if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Error: ${escapeHtml(error.message)}</span>`;
|
||||
console.error("Failed to fetch account details", error);
|
||||
}
|
||||
}
|
||||
@@ -1710,7 +1917,7 @@ async function fetchDeviceEvents(deviceId) {
|
||||
list.innerHTML = '<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Loading events...</td></tr>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/setup/devices/${deviceId}/events`);
|
||||
const response = await fetch(`/api/setup/devices/${encodeURIComponent(deviceId)}/events`);
|
||||
const data = await response.json();
|
||||
const events = data.events;
|
||||
|
||||
@@ -1797,6 +2004,7 @@ function formatXML(xml) {
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const cfg = await fetchSettings();
|
||||
fetchVersion();
|
||||
fetchAnnouncements();
|
||||
const deviceCount = await fetchDevices();
|
||||
// Only sweep automatically on a cold start (no devices known yet). When
|
||||
// devices are already in the store, rely on the cached list plus the
|
||||
@@ -1849,7 +2057,7 @@ async function removeDevice(deviceId, name) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/setup/devices/${deviceId}`, {
|
||||
const response = await fetch(`/api/setup/devices/${encodeURIComponent(deviceId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
@@ -2038,7 +2246,7 @@ async function showSummary(deviceId) {
|
||||
if (accountIdEl && summary.account_id) accountIdEl.innerText = summary.account_id;
|
||||
}
|
||||
|
||||
renderMigrationState(summary);
|
||||
renderMigrationState(summary, targetUrl);
|
||||
renderPlan(summary);
|
||||
renderPlanCurrentURLs(summary);
|
||||
renderPlanPairing(summary, deviceId);
|
||||
@@ -2092,6 +2300,20 @@ async function showSummary(deviceId) {
|
||||
connectionTestPane.style.display = summary.ssh_success ? "block" : "none";
|
||||
}
|
||||
|
||||
// Stays visible either way (the user may still want to check it),
|
||||
// but the default Suggested Plan never needs HTTPS — only note it
|
||||
// as required when the Target URL itself is https://.
|
||||
const connectionTestNote = document.getElementById("connection-test-relevance-note");
|
||||
if (connectionTestNote) {
|
||||
if (isHttpsTarget(targetUrl)) {
|
||||
connectionTestNote.innerText = "Required for your current plan (HTTPS)";
|
||||
connectionTestNote.style.color = "#c62828";
|
||||
} else {
|
||||
connectionTestNote.innerText = "Optional for your current plan (HTTP)";
|
||||
connectionTestNote.style.color = "#666";
|
||||
}
|
||||
}
|
||||
|
||||
const currentConfigElem = document.getElementById("current-config");
|
||||
currentConfigElem.innerText = summary.current_config;
|
||||
currentConfigElem.style.color = summary.ssh_success ? "black" : "red";
|
||||
@@ -2500,18 +2722,15 @@ async function migrate(deviceId, ip, method) {
|
||||
}),
|
||||
);
|
||||
|
||||
// Make reboot button available and prominent
|
||||
// Make reboot button available and prominent. It lives in the
|
||||
// always-visible "Speaker controls" row (see #621 — it used to
|
||||
// be reachable only after expanding "Customize this migration"),
|
||||
// so no need to force any collapsed container open here.
|
||||
const rebootBtn = document.getElementById("reboot-speaker-btn");
|
||||
rebootBtn.style.display = "inline-block";
|
||||
rebootBtn.disabled = false;
|
||||
rebootBtn.style.border = "2px solid #000";
|
||||
|
||||
// The Reboot button now lives inside the "Customize this
|
||||
// migration" <details>; expand it so the post-migration
|
||||
// reboot affordance is reachable from the Plan flow too.
|
||||
const customize = rebootBtn.closest("details");
|
||||
if (customize) customize.open = true;
|
||||
|
||||
// Re-show summary but with prominence on reboot
|
||||
summaryDiv.style.display = "block";
|
||||
} else {
|
||||
@@ -2718,6 +2937,26 @@ function onPlanTargetURLChange() {
|
||||
saved.innerText = "✏️ unsaved change — click \"Save as default\" to persist";
|
||||
saved.style.color = "#bf6900";
|
||||
}
|
||||
|
||||
// Re-derive the four service URL fields from the new Target URL, same
|
||||
// as the initial pre-fill on summary render. fillPlanURLInputs still
|
||||
// only overwrites fields the user hasn't hand-edited (tracked via
|
||||
// dataset.autofilled), so this doesn't clobber genuinely manual edits.
|
||||
// Without this, changing Target Domain to e.g. localhost left the four
|
||||
// fields pointed at a stale default with no warning until the user
|
||||
// edited them by hand (#621 follow-up).
|
||||
const soundcork = document.getElementById("plan-soundcork-mode") &&
|
||||
document.getElementById("plan-soundcork-mode").checked;
|
||||
fillPlanURLInputs(defaultServiceURLs(v, {soundcorkMode: soundcork}));
|
||||
}
|
||||
|
||||
// onPlanURLFieldEdited marks a Plan-card URL input as manually edited so
|
||||
// fillPlanURLInputs stops treating it as an auto-fillable default, then
|
||||
// re-validates. Wired from each of the four fields' oninput instead of
|
||||
// calling validatePlanURLs() directly.
|
||||
function onPlanURLFieldEdited(el) {
|
||||
el.dataset.autofilled = "";
|
||||
validatePlanURLs();
|
||||
}
|
||||
|
||||
// saveTargetURLAsDefault posts the current plan-target-url value to
|
||||
@@ -2780,8 +3019,12 @@ function defaultServiceURLs(targetUrl, options = {}) {
|
||||
|
||||
// fillPlanURLInputs writes the four URLs into the Plan card inputs.
|
||||
// force=true overwrites existing values (used by Reset and the
|
||||
// Soundcork toggle); force=false only fills empties (used on summary
|
||||
// render so manual edits survive a refresh).
|
||||
// Soundcork toggle); force=false only fills empties and fields still
|
||||
// flagged dataset.autofilled=true (used on summary render and on Target
|
||||
// URL changes, so manual edits survive but a still-default value tracks
|
||||
// Target URL). Every field this function writes to is (re-)flagged
|
||||
// autofilled; onPlanURLFieldEdited clears the flag the moment a user
|
||||
// types into a field directly.
|
||||
function fillPlanURLInputs(urls, {force = false} = {}) {
|
||||
const fields = [
|
||||
["plan-marge-url", urls.marge],
|
||||
@@ -2792,7 +3035,10 @@ function fillPlanURLInputs(urls, {force = false} = {}) {
|
||||
for (const [id, value] of fields) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) continue;
|
||||
if (force || !el.value) el.value = value;
|
||||
if (force || !el.value || el.dataset.autofilled === "true") {
|
||||
el.value = value;
|
||||
el.dataset.autofilled = "true";
|
||||
}
|
||||
}
|
||||
validatePlanURLs();
|
||||
}
|
||||
@@ -2824,7 +3070,16 @@ function readPlanURLOptions() {
|
||||
// on the speaker itself). For the typical "AfterTouch on a separate
|
||||
// host" deployment, the speaker can't reach loopback on a different
|
||||
// machine, so the URL must be a LAN-reachable IP or hostname.
|
||||
function validateURL(value) {
|
||||
//
|
||||
// referenceOrigin (optional) is the plan's own Target URL origin. A
|
||||
// loopback value that matches it is exempted from the warning: it means
|
||||
// this is exactly what the service itself is already configured to
|
||||
// answer as (e.g. an on-device install's `http://localhost:8000`,
|
||||
// auto-set since #546), not a mistaken paste. Without this exemption,
|
||||
// every on-device install's Suggested Plan fails validation by
|
||||
// default and silently disables Apply/Pre-flight before the user does
|
||||
// anything (#546 follow-up, reported via #621).
|
||||
function validateURL(value, referenceOrigin) {
|
||||
const v = (value || "").trim();
|
||||
if (!v) return {ok: true, error: ""};
|
||||
|
||||
@@ -2841,7 +3096,8 @@ function validateURL(value) {
|
||||
|
||||
if (!u.hostname) return {ok: false, error: "hostname is empty"};
|
||||
|
||||
if (u.hostname === "localhost" || u.hostname === "127.0.0.1") {
|
||||
const isLoopback = u.hostname === "localhost" || u.hostname === "127.0.0.1";
|
||||
if (isLoopback && u.origin !== referenceOrigin) {
|
||||
return {ok: false, error: "loopback URL — speakers can only reach this if AfterTouch is installed on the speaker itself (on-device install). For the typical multi-device setup, use a LAN-reachable IP or hostname."};
|
||||
}
|
||||
|
||||
@@ -2860,12 +3116,23 @@ function validatePlanURLs() {
|
||||
["bmxRegistryUrl", "plan-bmx-url"],
|
||||
];
|
||||
|
||||
const targetUrl = (document.getElementById("plan-target-url") || {}).value || "";
|
||||
let referenceOrigin = "";
|
||||
try {
|
||||
referenceOrigin = new URL(targetUrl).origin;
|
||||
} catch (e) {
|
||||
// Target URL isn't a valid absolute URL yet (e.g. empty) — leave
|
||||
// referenceOrigin empty, so a loopback field simply won't match
|
||||
// it and falls back to today's warning, same as before this
|
||||
// exemption existed.
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
|
||||
for (const [name, elemId] of fields) {
|
||||
const el = document.getElementById(elemId);
|
||||
if (!el) continue;
|
||||
const v = validateURL(el.value);
|
||||
const v = validateURL(el.value, referenceOrigin);
|
||||
el.style.borderColor = v.ok ? "" : "#c62828";
|
||||
if (!v.ok) errors.push(`${name}: ${v.error}`);
|
||||
}
|
||||
@@ -3723,7 +3990,11 @@ function looksTransient(msg) {
|
||||
// DNS interception, CA/TLS), and preconditions (remote_services,
|
||||
// pairing, backup). Reads only fields the backend already exposes —
|
||||
// is_migrated remains the OR of the per-axis booleans.
|
||||
function renderMigrationState(summary) {
|
||||
//
|
||||
// targetUrl is the current Target Domain value, used only to judge
|
||||
// whether CA/TLS is actually relevant to the current plan (see
|
||||
// isHttpsTarget) — the default Suggested Plan never needs it.
|
||||
function renderMigrationState(summary, targetUrl) {
|
||||
// --- Transports ---
|
||||
setStateChip("state-ssh", summary.ssh_success, "Reachable", "Unreachable");
|
||||
setStateChip("state-telnet", summary.telnet_reachable, "Reachable", "Unreachable");
|
||||
@@ -3804,16 +4075,19 @@ function renderMigrationState(summary) {
|
||||
const caLine = document.getElementById("state-ca-line");
|
||||
if (caLine) {
|
||||
caLine.replaceChildren();
|
||||
const v = caVerdict(summary);
|
||||
const v = caVerdict(summary, isHttpsTarget(targetUrl));
|
||||
caLine.appendChild(stateLine(v.icon, v.text, v.note));
|
||||
}
|
||||
|
||||
// --- Preconditions ---
|
||||
const remoteCell = document.getElementById("state-remote-services-cell");
|
||||
if (remoteCell) {
|
||||
remoteCell.replaceChildren();
|
||||
// Like CA/TLS above, the cell also hosts the Enable/Disable SSH
|
||||
// buttons as siblings of this line — only rewrite the verdict span so
|
||||
// they stay put across re-renders.
|
||||
const remoteLine = document.getElementById("state-remote-services-line");
|
||||
if (remoteLine) {
|
||||
remoteLine.replaceChildren();
|
||||
const v = remoteServicesVerdict(summary);
|
||||
remoteCell.appendChild(stateLine(v.icon, v.text, v.note));
|
||||
remoteLine.appendChild(stateLine(v.icon, v.text, v.note));
|
||||
}
|
||||
|
||||
const pairedCell = document.getElementById("state-paired");
|
||||
@@ -3935,9 +4209,22 @@ function dnsInterceptionVerdict(summary) {
|
||||
return {icon: "⚠️", text: "/etc/hosts redirects", note: "(deprecated method)"};
|
||||
}
|
||||
|
||||
function caVerdict(summary) {
|
||||
// isHttpsTarget reports whether a target/service URL uses the https
|
||||
// scheme. Used to distinguish "CA/TLS optional" (the default Suggested
|
||||
// Plan for both XML-over-SSH and Telnet migrates over plain HTTP, no CA
|
||||
// involved) from "CA/TLS required" (Target Domain is https://, or the
|
||||
// Customize form's DNS-interception method is chosen — that one always
|
||||
// targets https://*.bose.com).
|
||||
function isHttpsTarget(url) {
|
||||
return /^https:/i.test((url || "").trim());
|
||||
}
|
||||
|
||||
function caVerdict(summary, httpsRelevant) {
|
||||
if (summary.ca_cert_trusted) return {icon: "✅", text: "Local root CA installed", note: ""};
|
||||
return {icon: "❌", text: "Not installed", note: "(HTTPS to local service will fail TLS validation until injected via SSH)"};
|
||||
if (httpsRelevant) {
|
||||
return {icon: "❌", text: "Not installed", note: "(required — your Target URL is HTTPS; install it before migrating, or click Trust CA Now)"};
|
||||
}
|
||||
return {icon: "⚪", text: "Not installed", note: "(not needed — your Target URL is HTTP; only required if you switch to HTTPS or use the DNS-interception method)"};
|
||||
}
|
||||
|
||||
function remoteServicesVerdict(summary) {
|
||||
@@ -4667,14 +4954,14 @@ async function toggleDeviceSummary(deviceId) {
|
||||
const resp = await fetch(`/api/setup/device-summary/${encodeURIComponent(deviceId)}`);
|
||||
if (!resp.ok) {
|
||||
const txt = await resp.text();
|
||||
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${resp.status} ${escapeHTML(txt)}</span>`;
|
||||
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${resp.status} ${escapeHtml(txt)}</span>`;
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
cell.innerHTML = "";
|
||||
cell.appendChild(renderDeviceSummary(data));
|
||||
} catch (e) {
|
||||
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${escapeHTML(e.message || String(e))}</span>`;
|
||||
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${escapeHtml(e.message || String(e))}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4879,12 +5166,3 @@ function unreachableBlock(probe) {
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function escapeHTML(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package health
|
||||
|
||||
// CheckIDAdminAreaAuth is the registry id of the admin-area-gate
|
||||
// availability check. It fires when AdminAreaAuth is unset — the tri-state
|
||||
// setting that lets an operator opt in now to gating the entire admin area
|
||||
// (not just /api/mgmt/*) behind Basic Auth, ahead of a future release
|
||||
// flipping the default. See #419 and
|
||||
// _/i419/design-admin-area-auth-gate.md.
|
||||
//
|
||||
// This is a visibility-only nudge, same spirit as
|
||||
// CheckIDMgmtDefaultCredentials: it does not gate anything and does not
|
||||
// change default behavior. It exists so operators who dismissed the
|
||||
// in-app announcement banner (or never saw it, on an older release) can
|
||||
// still discover the option via the Health tab.
|
||||
const CheckIDAdminAreaAuth = "admin_area_auth_available"
|
||||
|
||||
// RegisterAdminAreaAuthCheck registers the check. getAdminAreaAuthMode
|
||||
// returns the live AdminAreaAuth mode (typically Server.AdminAreaAuthMode),
|
||||
// passed as a callback rather than importing the handlers package directly
|
||||
// to avoid a circular import (handlers already imports health).
|
||||
func RegisterAdminAreaAuthCheck(r *Registry, getAdminAreaAuthMode func() string) {
|
||||
r.Register(Check{
|
||||
ID: CheckIDAdminAreaAuth,
|
||||
Title: "Admin area can require login for the entire admin console, not just Spotify/Amazon linking",
|
||||
Run: func() []Finding {
|
||||
return runAdminAreaAuthCheck(getAdminAreaAuthMode)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runAdminAreaAuthCheck(getAdminAreaAuthMode func() string) []Finding {
|
||||
if getAdminAreaAuthMode() != "" {
|
||||
// Already decided (enabled or explicitly disabled) — nothing to nudge.
|
||||
return nil
|
||||
}
|
||||
|
||||
return []Finding{{
|
||||
Severity: SeverityInfo,
|
||||
Message: "Only Spotify/Amazon account linking and the Local Account tab currently require login. " +
|
||||
"The rest of the admin area (Devices, Settings, Migration, Health, Logs, ...) is open to anyone " +
|
||||
"on the network.",
|
||||
Details: "Set admin_area_auth to \"enabled\" in Settings to require the Management API login " +
|
||||
"(same credentials as MGMT_USERNAME/MGMT_PASSWORD) for the entire admin area. A future release " +
|
||||
"is expected to make this the default; you can opt in now, or set it to \"disabled\" to keep " +
|
||||
"today's behavior once that happens. See issue #419.",
|
||||
}}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package health
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAdminAreaAuthCheck_NoFindingWhenDecided(t *testing.T) {
|
||||
for _, mode := range []string{"enabled", "disabled"} {
|
||||
got := runAdminAreaAuthCheck(func() string { return mode })
|
||||
if len(got) != 0 {
|
||||
t.Errorf("mode=%q: expected no findings once decided, got %+v", mode, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAreaAuthCheck_NudgesWhenUnset(t *testing.T) {
|
||||
got := runAdminAreaAuthCheck(func() string { return "" })
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one finding for the unset default, got %+v", got)
|
||||
}
|
||||
|
||||
if got[0].Severity != SeverityInfo {
|
||||
t.Errorf("expected SeverityInfo (visibility-only nudge, not a gate), got %v", got[0].Severity)
|
||||
}
|
||||
}
|
||||
@@ -215,7 +215,7 @@ func detectOrphanDefaultEntries(ds *datastore.DataStore, paired []models.Service
|
||||
|
||||
if speakerAccount != info.account {
|
||||
log.Printf("[Health] consistency: speaker %s reports margeAccountUUID=%s but ListAllDevices picked %s — preferring the speaker's answer for orphan-deletion suggestions",
|
||||
deviceID, speakerAccount, info.account)
|
||||
sanitizeLog(deviceID), sanitizeLog(speakerAccount), sanitizeLog(info.account))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,21 +289,21 @@ func deleteOrphanAccountEntry(ds *datastore.DataStore, target Target) (string, e
|
||||
if speakerAccount := fetchSpeakerMargeAccount(ctx, speakerIP); speakerAccount != "" {
|
||||
if speakerAccount == target.Account {
|
||||
return "", fmt.Errorf("speaker %s reports margeAccountUUID=%s — refusing to delete <data-dir>/accounts/%s/devices/%s because it's the speaker's currently-active binding (re-paired since the consistency check ran?)",
|
||||
target.Device, speakerAccount, target.Account, target.Device)
|
||||
sanitizeLog(target.Device), sanitizeLog(speakerAccount), sanitizeLog(target.Account), sanitizeLog(target.Device))
|
||||
}
|
||||
|
||||
log.Printf("[Health] deleteOrphanAccountEntry: speaker %s confirmed margeAccountUUID=%s; target account %s is stale, proceeding with delete",
|
||||
target.Device, speakerAccount, target.Account)
|
||||
sanitizeLog(target.Device), sanitizeLog(speakerAccount), sanitizeLog(target.Account))
|
||||
} else {
|
||||
log.Printf("[Health] deleteOrphanAccountEntry: speaker %s at %s not reachable for re-confirmation; relying on operator's Confirm click",
|
||||
target.Device, speakerIP)
|
||||
sanitizeLog(target.Device), sanitizeLog(speakerIP))
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Health] deleteOrphanAccountEntry: no IP recorded for device %s — skipping speaker re-probe", target.Device)
|
||||
log.Printf("[Health] deleteOrphanAccountEntry: no IP recorded for device %s — skipping speaker re-probe", sanitizeLog(target.Device))
|
||||
}
|
||||
|
||||
if target.Account == accountIDDefaultPlaceholder {
|
||||
log.Printf("[Health] deleteOrphanAccountEntry: deleting the \"default\" placeholder entry for device %s; this is normal after pairing completed", target.Device)
|
||||
log.Printf("[Health] deleteOrphanAccountEntry: deleting the \"default\" placeholder entry for device %s; this is normal after pairing completed", sanitizeLog(target.Device))
|
||||
}
|
||||
|
||||
path := ds.AccountDeviceDir(target.Account, target.Device)
|
||||
@@ -316,7 +316,7 @@ func deleteOrphanAccountEntry(ds *datastore.DataStore, target Target) (string, e
|
||||
}
|
||||
|
||||
log.Printf("[Health] Removed orphan account entry %s (account=%s device=%s) at operator request",
|
||||
path, target.Account, target.Device)
|
||||
path, sanitizeLog(target.Account), sanitizeLog(target.Device))
|
||||
|
||||
return fmt.Sprintf("Removed stale account entry %s for device %s.", target.Account, target.Device), nil
|
||||
}
|
||||
@@ -479,7 +479,7 @@ func reclassifyCanonicalSourceIDs(ds *datastore.DataStore, target Target) (strin
|
||||
for i := range sources {
|
||||
if newID, ok := rename[sources[i].ID]; ok {
|
||||
log.Printf("[Health] Re-classify %s: id %s → %s (account=%s device=%s)",
|
||||
sources[i].SourceKeyType, sources[i].ID, newID, target.Account, target.Device)
|
||||
sanitizeLog(sources[i].SourceKeyType), sanitizeLog(sources[i].ID), sanitizeLog(newID), sanitizeLog(target.Account), sanitizeLog(target.Device))
|
||||
|
||||
sources[i].ID = newID
|
||||
|
||||
|
||||
@@ -4,8 +4,12 @@ import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
@@ -13,6 +17,11 @@ import (
|
||||
// preset count check.
|
||||
const CheckIDPresetsCount = "speaker_presets_count"
|
||||
|
||||
// FixIDRestorePresetsToSpeaker is the quick-fix that replays the
|
||||
// service's stored presets onto the speaker via its :8090/storePreset
|
||||
// endpoint, without requiring a reboot or re-entering them by hand.
|
||||
const FixIDRestorePresetsToSpeaker = "restore_presets_to_speaker"
|
||||
|
||||
// speakerPresetsXML mirrors just enough of the speaker's :8090/presets
|
||||
// XML to count slots. The schema is the same as on the service side
|
||||
// but with <ContentItem> (capitalised) inside <preset>.
|
||||
@@ -37,6 +46,21 @@ func RegisterPresetsCountCheck(r *Registry, ds *datastore.DataStore) {
|
||||
return runPresetsCountCheck(ds)
|
||||
},
|
||||
})
|
||||
|
||||
r.RegisterFix(CheckIDPresetsCount, FixIDRestorePresetsToSpeaker, func(target Target) (string, error) {
|
||||
return restorePresetsToSpeaker(ds, target)
|
||||
})
|
||||
|
||||
// Same underlying nudge as the refresh_sources check (FixIDPostSourcesUpdated,
|
||||
// checks_refresh_sources.go): POSTs sourcesUpdated so the speaker re-fetches
|
||||
// /full. Confirmed that /full carries presets alongside sources
|
||||
// (marge.AccountFullToXML); NOT confirmed that firmware re-applies the
|
||||
// presets section locally (see issue253_regression_test.go — that exact
|
||||
// link is documented as untested). Offered as a cheap, non-destructive
|
||||
// thing to try before the guaranteed-but-heavier restore-to-speaker push.
|
||||
r.RegisterFix(CheckIDPresetsCount, FixIDPostSourcesUpdated, func(target Target) (string, error) {
|
||||
return postSourcesUpdated(ds, target)
|
||||
})
|
||||
}
|
||||
|
||||
func runPresetsCountCheck(ds *datastore.DataStore) []Finding {
|
||||
@@ -127,11 +151,27 @@ func comparePresetsForDeviceWithURL(ds *datastore.DataStore, account, deviceID,
|
||||
}
|
||||
|
||||
severity := SeverityInfo
|
||||
|
||||
var quickFixes []QuickFix
|
||||
|
||||
if speakerCount == 0 && serviceCount > 0 {
|
||||
// Speaker shows nothing while the service has presets —
|
||||
// this is the post-reset preset-loss class from
|
||||
// discussion #295 and #235.
|
||||
// the post-reset preset-loss pattern confirmed in #614
|
||||
// (reboot and/or Sync leaving the speaker's own preset
|
||||
// slots empty while the service's Presets.xml is untouched).
|
||||
severity = SeverityWarning
|
||||
quickFixes = []QuickFix{
|
||||
{
|
||||
ID: FixIDPostSourcesUpdated,
|
||||
Label: "Try a sourcesUpdated nudge first",
|
||||
Confirm: "Asks the speaker to re-fetch /full (the same nudge used to refresh sources). /full does include presets, but whether the speaker applies them back to its own preset table isn't confirmed — this is free and non-destructive, worth trying before the push below.",
|
||||
},
|
||||
{
|
||||
ID: FixIDRestorePresetsToSpeaker,
|
||||
Label: "Restore presets to speaker",
|
||||
Confirm: "This pushes AfterTouch's stored presets onto the speaker's own preset slots, one at a time. Doesn't require a reboot.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return []Finding{{
|
||||
@@ -141,10 +181,86 @@ func comparePresetsForDeviceWithURL(ds *datastore.DataStore, account, deviceID,
|
||||
"Speaker shows %d preset slot(s); service Presets.xml has %d.",
|
||||
speakerCount, serviceCount,
|
||||
),
|
||||
Details: "If the speaker shows fewer than the service, a power-cycle or a sourcesUpdated notification usually re-syncs. If it shows more, the service may have stale entries or the speaker is still holding pre-migration state.",
|
||||
Details: "If the speaker shows fewer than the service, a sourcesUpdated notification sometimes re-syncs it. Don't power-cycle as a fix for this — it has itself been reported to wipe the speaker's presets (#614), so it may make things worse. If the speaker shows more than the service, the service may have stale entries or the speaker is still holding pre-migration state.",
|
||||
QuickFixes: quickFixes,
|
||||
}}
|
||||
}
|
||||
|
||||
// restorePresetsToSpeaker replays every preset in the service's
|
||||
// Presets.xml onto the live speaker via :8090/storePreset, one slot
|
||||
// at a time. Unlike Sync (which only ever reads from the speaker),
|
||||
// this is the one direction that can put presets back after they've
|
||||
// been wiped, without needing to re-enter them by hand — see #614.
|
||||
func restorePresetsToSpeaker(ds *datastore.DataStore, target Target) (string, error) {
|
||||
if target.Account == "" || target.Device == "" {
|
||||
return "", fmt.Errorf("account and device are required")
|
||||
}
|
||||
|
||||
dev, err := ds.GetDeviceInfo(target.Account, target.Device)
|
||||
if err != nil || dev == nil {
|
||||
return "", fmt.Errorf("device %s not found in datastore", target.Device)
|
||||
}
|
||||
|
||||
if dev.IPAddress == "" {
|
||||
return "", fmt.Errorf("device %s has no IP address recorded", target.Device)
|
||||
}
|
||||
|
||||
presets, err := ds.GetPresets(target.Account, target.Device)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read service Presets.xml: %w", err)
|
||||
}
|
||||
|
||||
if len(presets) == 0 {
|
||||
return "", fmt.Errorf("service has no presets recorded for %s", target.Device)
|
||||
}
|
||||
|
||||
c := client.NewClientFromHost(dev.IPAddress)
|
||||
|
||||
restored := 0
|
||||
|
||||
var failures []string
|
||||
|
||||
for i := range presets {
|
||||
p := &presets[i]
|
||||
|
||||
slot, atoiErr := strconv.Atoi(p.ID)
|
||||
if atoiErr != nil || slot < 1 || slot > 6 {
|
||||
failures = append(failures, fmt.Sprintf("slot %q: invalid preset id", p.ID))
|
||||
continue
|
||||
}
|
||||
|
||||
isPresetable, _ := strconv.ParseBool(p.IsPresetable)
|
||||
|
||||
ci := &models.ContentItem{
|
||||
Source: p.Source,
|
||||
Type: p.Type,
|
||||
Location: p.Location,
|
||||
SourceAccount: p.SourceAccount,
|
||||
IsPresetable: isPresetable,
|
||||
ItemName: p.Name,
|
||||
ContainerArt: p.ContainerArt,
|
||||
}
|
||||
|
||||
if storeErr := c.StorePreset(slot, ci); storeErr != nil {
|
||||
failures = append(failures, fmt.Sprintf("slot %d: %v", slot, storeErr))
|
||||
continue
|
||||
}
|
||||
|
||||
restored++
|
||||
}
|
||||
|
||||
if restored == 0 {
|
||||
return "", fmt.Errorf("failed to restore any presets: %s", strings.Join(failures, "; "))
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Restored %d/%d preset(s) to %s.", restored, len(presets), displayName(dev.Name, target.Device))
|
||||
if len(failures) > 0 {
|
||||
msg += " Some slots failed: " + strings.Join(failures, "; ")
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// countNonEmpty returns the number of <preset> entries with a
|
||||
// non-empty id. Empty slots in the speaker's response (e.g. the
|
||||
// six fixed buttons with no programmed preset) are not counted.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -174,6 +175,121 @@ func TestPresetsCount_UnreachableSpeaker(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetsCount_SpeakerEmptyOffersRestoreQuickFix(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
|
||||
ds := newPresetsCountDS(t, account, device)
|
||||
writeServicePresets(t, ds, account, device, 3)
|
||||
|
||||
probeURL := stubSpeakerPresetsServer(t, 0)
|
||||
|
||||
got := comparePresetsForDeviceWithURL(ds, account, device, probeURL)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one finding, got %+v", got)
|
||||
}
|
||||
|
||||
// Offers both the cheap, unconfirmed pull-style nudge (sourcesUpdated,
|
||||
// which makes the speaker re-fetch /full — /full does carry presets,
|
||||
// but firmware re-applying them locally is unconfirmed) and the
|
||||
// guaranteed push (restore_presets_to_speaker) — see #614 discussion.
|
||||
fixIDs := map[string]bool{}
|
||||
for _, qf := range got[0].QuickFixes {
|
||||
fixIDs[qf.ID] = true
|
||||
}
|
||||
|
||||
if len(got[0].QuickFixes) != 2 || !fixIDs[FixIDRestorePresetsToSpeaker] || !fixIDs[FixIDPostSourcesUpdated] {
|
||||
t.Errorf("expected both the sourcesUpdated nudge and the restore-presets QuickFix, got %+v", got[0].QuickFixes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetsCount_SpeakerHasMoreOffersNoQuickFix(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
|
||||
ds := newPresetsCountDS(t, account, device)
|
||||
writeServicePresets(t, ds, account, device, 1)
|
||||
|
||||
probeURL := stubSpeakerPresetsServer(t, 3)
|
||||
|
||||
got := comparePresetsForDeviceWithURL(ds, account, device, probeURL)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one finding, got %+v", got)
|
||||
}
|
||||
|
||||
if len(got[0].QuickFixes) != 0 {
|
||||
t.Errorf("expected no QuickFix when speaker has more than the service, got %+v", got[0].QuickFixes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestorePresetsToSpeaker_PushesEachSlot(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
|
||||
ds := newPresetsCountDS(t, account, device)
|
||||
writeServicePresets(t, ds, account, device, 3)
|
||||
|
||||
var storedSlots []string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/storePreset" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
storedSlots = append(storedSlots, string(body))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
if err := ds.SaveDeviceInfo(account, device, &models.ServiceDeviceInfo{
|
||||
DeviceID: device,
|
||||
AccountID: account,
|
||||
IPAddress: u.Host,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
msg, err := restorePresetsToSpeaker(ds, Target{Account: account, Device: device})
|
||||
if err != nil {
|
||||
t.Fatalf("restorePresetsToSpeaker: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(msg, "3/3") {
|
||||
t.Errorf("expected message to report 3/3 restored, got %q", msg)
|
||||
}
|
||||
|
||||
if len(storedSlots) != 3 {
|
||||
t.Fatalf("expected 3 /storePreset calls, got %d", len(storedSlots))
|
||||
}
|
||||
|
||||
for i, body := range storedSlots {
|
||||
if !strings.Contains(body, `id="`+itoa(i+1)+`"`) {
|
||||
t.Errorf("call %d: expected preset id %d in body, got %q", i, i+1, body)
|
||||
}
|
||||
|
||||
if !strings.Contains(body, `source="TUNEIN"`) {
|
||||
t.Errorf("call %d: expected source TUNEIN in body, got %q", i, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestorePresetsToSpeaker_NoServicePresets(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
ds := newPresetsCountDS(t, account, device)
|
||||
|
||||
if err := ds.SaveDeviceInfo(account, device, &models.ServiceDeviceInfo{
|
||||
DeviceID: device,
|
||||
AccountID: account,
|
||||
IPAddress: "127.0.0.1:1",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
if _, err := restorePresetsToSpeaker(ds, Target{Account: account, Device: device}); err == nil {
|
||||
t.Error("expected an error when the service has no presets to restore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetsCount_MalformedXML(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("nope"))
|
||||
|
||||
@@ -30,9 +30,12 @@ func suggestAccountForPairing(ds *datastore.DataStore, deviceID string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// isSevenDigitAccountID mirrors setup.IsValidAccountID without
|
||||
// importing the setup package (which would pull in SSH/telnet/certmgr
|
||||
// transitively — see the boundary comment near speakerInfoXML).
|
||||
// isSevenDigitAccountID is intentionally narrower than
|
||||
// datastore.IsSafeIdentifier: it filters suggestAccountForPairing's
|
||||
// candidates down to directories that look like a real Bose-issued
|
||||
// account, not merely safe-to-use ones (a device-reported value like
|
||||
// "stick@local", #634, is a safe identifier but not something to
|
||||
// suggest as a pre-existing "real" account to reuse).
|
||||
func isSevenDigitAccountID(s string) bool {
|
||||
if len(s) != 7 {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package health
|
||||
|
||||
import "strings"
|
||||
|
||||
// sanitizeLog strips newline characters from s to prevent log-injection
|
||||
// (CodeQL go/log-injection). Values from speakers (e.g. margeAccountUUID
|
||||
// read live via :8090/info) may contain attacker-controlled newlines.
|
||||
func sanitizeLog(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, "\r", `\r`)
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package marge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// TestConcurrentUpdatePresetNoLostUpdates is a regression test for #614's
|
||||
// 2026-08-23 reproduction: a reporter's script stored six presets via rapid,
|
||||
// overlapping PUT .../preset/N requests (visible in the speaker's own log as
|
||||
// interleaved connection IDs, never waiting for one PUT to complete before
|
||||
// firing the next). One preset silently vanished from Presets.xml.
|
||||
//
|
||||
// UpdatePreset used to do GetPresets, mutate one slot, SavePresets as three
|
||||
// separate steps with no lock spanning them — a classic lost-update race:
|
||||
// two concurrent calls can each read the same starting list, mutate
|
||||
// different slots, and the second writer's SavePresets clobbers the first
|
||||
// writer's update. Fixed by routing the write through
|
||||
// datastore.MutatePresets, which holds a single write lock for the whole
|
||||
// read-mutate-write cycle.
|
||||
func TestConcurrentUpdatePresetNoLostUpdates(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "marge-concurrent-update-preset-*")
|
||||
if err != nil {
|
||||
t.Fatalf("tempdir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
account := "1234567"
|
||||
device := "B0D5CC25479C"
|
||||
|
||||
const presetCount = 6
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
errs := make([]error, presetCount)
|
||||
|
||||
for i := 1; i <= presetCount; i++ {
|
||||
wg.Add(1)
|
||||
|
||||
go func(presetNumber int) {
|
||||
defer wg.Done()
|
||||
|
||||
// sourceid 10003 is the canonical LOCAL_INTERNET_RADIO built-in
|
||||
// (see CanonicalSourceByID) — same shape as Henri's own repro
|
||||
// script, which stored six LOCAL_INTERNET_RADIO presets.
|
||||
putXML := []byte(fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<preset>
|
||||
<name>Station %d</name>
|
||||
<sourceid>10003</sourceid>
|
||||
<location>/custom/v1/playback/station%d</location>
|
||||
<contentItemType>stationurl</contentItemType>
|
||||
</preset>`, presetNumber, presetNumber))
|
||||
|
||||
_, err := UpdatePreset(ds, account, device, presetNumber, putXML)
|
||||
errs[presetNumber-1] = err
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePreset(preset=%d) returned error: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
presets, err := ds.GetPresets(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPresets: %v", err)
|
||||
}
|
||||
|
||||
if len(presets) != presetCount {
|
||||
t.Fatalf("expected %d presets after %d concurrent UpdatePreset calls, got %d: %+v", presetCount, presetCount, len(presets), presets)
|
||||
}
|
||||
|
||||
for i, p := range presets {
|
||||
want := fmt.Sprintf("Station %d", i+1)
|
||||
if p.Name != want {
|
||||
t.Errorf("preset slot %d: expected name %q, got %q — a concurrent update was lost", i+1, want, p.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
-88
@@ -752,6 +752,13 @@ func CreateAccountDevice(ds *datastore.DataStore, account, deviceID string) (mod
|
||||
device.Presets = mapPresetsToFullResponse(presets, sources)
|
||||
device.Recents = mapRecentsToFullResponse(recents, sources)
|
||||
|
||||
if len(device.Presets) != len(presets) {
|
||||
log.Printf("[Marge] /full: device %s — read %d preset(s) from disk, embedding %d after source mapping",
|
||||
sanitizeLog(deviceID), len(presets), len(device.Presets))
|
||||
} else {
|
||||
log.Printf("[Marge] /full: device %s — embedding %d preset(s)", sanitizeLog(deviceID), len(device.Presets))
|
||||
}
|
||||
|
||||
return device, nil
|
||||
}
|
||||
|
||||
@@ -1501,19 +1508,18 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
|
||||
|
||||
// RemovePreset clears a preset for the specified account and device.
|
||||
func RemovePreset(ds *datastore.DataStore, account, device string, presetNumber int) error {
|
||||
presets, err := ds.GetPresets(account, device)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := ds.MutatePresets(account, device, func(presets []models.ServicePreset) ([]models.ServicePreset, error) {
|
||||
if presetNumber < 1 || presetNumber > len(presets) {
|
||||
// Preset doesn't exist or index out of range, nothing to do
|
||||
return presets, nil
|
||||
}
|
||||
|
||||
if presetNumber < 1 || presetNumber > len(presets) {
|
||||
// Preset doesn't exist or index out of range, nothing to do
|
||||
return nil
|
||||
}
|
||||
presets[presetNumber-1] = models.ServicePreset{}
|
||||
|
||||
presets[presetNumber-1] = models.ServicePreset{}
|
||||
return presets, nil
|
||||
})
|
||||
|
||||
return ds.SavePresets(account, device, presets)
|
||||
return err
|
||||
}
|
||||
|
||||
// resolvePresetSource resolves the source a preset PUT is referencing,
|
||||
@@ -1522,42 +1528,74 @@ func RemovePreset(ds *datastore.DataStore, account, device string, presetNumber
|
||||
// returns the matched source plus the possibly-extended sources slice
|
||||
// (since auto-add appends). Returns (nil, sources) when no match could be
|
||||
// resolved — UpdatePreset turns that into a 500 with a diagnostic log line.
|
||||
func resolvePresetSource(ds *datastore.DataStore, account, device string, sources []models.ConfiguredSource, sourceID string, presetNumber int) (*models.ConfiguredSource, []models.ConfiguredSource) {
|
||||
// findConfiguredSource looks up sourceID in sources, first by exact ID and
|
||||
// then — since the speaker sometimes sends the symbolic provider name (e.g.
|
||||
// <sourceid>TUNEIN</sourceid>) instead of a numeric ID — by SourceKeyType
|
||||
// for the handful of providers known to do that.
|
||||
func findConfiguredSource(sources []models.ConfiguredSource, sourceID string) *models.ConfiguredSource {
|
||||
for i := range sources {
|
||||
if sources[i].ID == sourceID {
|
||||
return &sources[i], sources
|
||||
return &sources[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: SourceID is the symbolic provider name (the speaker
|
||||
// sometimes sends e.g. <sourceid>TUNEIN</sourceid> instead of a
|
||||
// numeric ID); match by SourceKeyType.
|
||||
if sourceID == constants.ProviderInternetRadio || sourceID == constants.ProviderTunein || sourceID == constants.ProviderSpotify || sourceID == constants.ProviderAmazon {
|
||||
for i := range sources {
|
||||
if sources[i].SourceKeyType == sourceID {
|
||||
return &sources[i], sources
|
||||
return &sources[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolvePresetSource(ds *datastore.DataStore, account, device string, sources []models.ConfiguredSource, sourceID string, presetNumber int) (*models.ConfiguredSource, []models.ConfiguredSource) {
|
||||
if src := findConfiguredSource(sources, sourceID); src != nil {
|
||||
return src, sources
|
||||
}
|
||||
|
||||
// Auto-add a canonical built-in source the speaker referenced but
|
||||
// AfterTouch hasn't been told about (post-factory-reset state). For
|
||||
// account-bound sources (Spotify, Amazon) we can't synthesise
|
||||
// credentials, so the caller will reject the PUT instead.
|
||||
if canonical, ok := ds.CanonicalSourceByID(sourceID); ok {
|
||||
log.Printf("[Marge] UpdatePreset(preset=%d): auto-adding canonical source id=%s type=%s providerid=%s — speaker referenced a built-in source not yet in AfterTouch's configured-sources list; saving so the preset can land",
|
||||
presetNumber, sanitizeLog(canonical.ID), sanitizeLog(canonical.SourceKeyType), sanitizeLog(canonical.SourceProviderID))
|
||||
canonical, ok := ds.CanonicalSourceByID(sourceID)
|
||||
if !ok {
|
||||
return nil, sources
|
||||
}
|
||||
|
||||
log.Printf("[Marge] UpdatePreset(preset=%d): auto-adding canonical source id=%s type=%s providerid=%s — speaker referenced a built-in source not yet in AfterTouch's configured-sources list; saving so the preset can land",
|
||||
presetNumber, sanitizeLog(canonical.ID), sanitizeLog(canonical.SourceKeyType), sanitizeLog(canonical.SourceProviderID))
|
||||
|
||||
// Read-mutate-write atomically against the persisted list, not the
|
||||
// possibly-stale `sources` snapshot the caller already read — a
|
||||
// concurrent PUT for a different preset could be auto-adding (or have
|
||||
// just added) a source at the same time, and a plain Get+Save here
|
||||
// would silently lose whichever write landed second.
|
||||
updated, saveErr := ds.MutateConfiguredSources(account, device, func(current []models.ConfiguredSource) ([]models.ConfiguredSource, error) {
|
||||
if src := findConfiguredSource(current, sourceID); src != nil {
|
||||
// Another concurrent caller already added it; nothing to do.
|
||||
return current, nil
|
||||
}
|
||||
|
||||
return append(current, canonical), nil
|
||||
})
|
||||
if saveErr != nil {
|
||||
log.Printf("[Marge] UpdatePreset(preset=%d): SaveConfiguredSources after auto-add failed: %s — the preset will land but the source may not survive a service restart",
|
||||
presetNumber, sanitizeErr(saveErr))
|
||||
|
||||
sources = append(sources, canonical)
|
||||
if saveErr := ds.SaveConfiguredSources(account, device, sources); saveErr != nil {
|
||||
log.Printf("[Marge] UpdatePreset(preset=%d): SaveConfiguredSources after auto-add failed: %s — the preset will land but the source may not survive a service restart",
|
||||
presetNumber, sanitizeErr(saveErr))
|
||||
}
|
||||
|
||||
return &sources[len(sources)-1], sources
|
||||
}
|
||||
|
||||
return nil, sources
|
||||
if src := findConfiguredSource(updated, sourceID); src != nil {
|
||||
return src, updated
|
||||
}
|
||||
|
||||
updated = append(updated, canonical)
|
||||
|
||||
return &updated[len(updated)-1], updated
|
||||
}
|
||||
|
||||
// UpdatePreset updates or creates a preset for the specified account and device.
|
||||
@@ -1567,11 +1605,6 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
|
||||
return nil, err
|
||||
}
|
||||
|
||||
presets, err := ds.GetPresets(account, device)
|
||||
if err != nil {
|
||||
presets = []models.ServicePreset{}
|
||||
}
|
||||
|
||||
var newPresetElem struct {
|
||||
Name string `xml:"name"`
|
||||
Username string `xml:"username"`
|
||||
@@ -1637,14 +1670,19 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
|
||||
Username: newPresetElem.Name,
|
||||
}
|
||||
|
||||
// Ensure presets list is large enough
|
||||
for len(presets) < presetNumber {
|
||||
presets = append(presets, models.ServicePreset{})
|
||||
}
|
||||
// Read-mutate-write atomically: a concurrent PUT for a different preset
|
||||
// number racing this one must not be able to clobber it. See
|
||||
// MutatePresets — this is the exact interleave that dropped a preset
|
||||
// during #614's rapid-fire repro.
|
||||
if _, err = ds.MutatePresets(account, device, func(presets []models.ServicePreset) ([]models.ServicePreset, error) {
|
||||
for len(presets) < presetNumber {
|
||||
presets = append(presets, models.ServicePreset{})
|
||||
}
|
||||
|
||||
presets[presetNumber-1] = presetObj
|
||||
presets[presetNumber-1] = presetObj
|
||||
|
||||
if err = ds.SavePresets(account, device, presets); err != nil {
|
||||
return presets, nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1773,11 +1811,6 @@ func AddRecent(ds *datastore.DataStore, account, device string, sourceXML []byte
|
||||
return nil, err
|
||||
}
|
||||
|
||||
recents, err := ds.GetRecents(account, device)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var input recentInput
|
||||
if err := xml.Unmarshal(sourceXML, &input); err != nil {
|
||||
return nil, err
|
||||
@@ -1822,9 +1855,20 @@ func AddRecent(ds *datastore.DataStore, account, device string, sourceXML []byte
|
||||
syncMatchingSource(matchingSrc, input)
|
||||
|
||||
utcTime := parseLastPlayedAt(input.LastPlayedAt)
|
||||
recentObj, recents := updateOrCreateRecent(recents, input.Name, matchingSrc, input.ContentItemType, input.Location, device, utcTime)
|
||||
|
||||
if err := ds.SaveRecents(account, device, recents); err != nil {
|
||||
// Read-mutate-write atomically: a concurrent AddRecent/preset call for
|
||||
// the same device racing this one must not be able to clobber it. See
|
||||
// MutatePresets/MutateRecents for why a plain GetRecents+SaveRecents
|
||||
// isn't safe here.
|
||||
var recentObj *models.ServiceRecent
|
||||
|
||||
if _, err := ds.MutateRecents(account, device, func(recents []models.ServiceRecent) ([]models.ServiceRecent, error) {
|
||||
var updated []models.ServiceRecent
|
||||
|
||||
recentObj, updated = updateOrCreateRecent(recents, input.Name, matchingSrc, input.ContentItemType, input.Location, device, utcTime)
|
||||
|
||||
return updated, nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1852,7 +1896,7 @@ func learnSource(ds *datastore.DataStore, account, device string, sources []mode
|
||||
matchingSrc.SecretType = constants.CredentialTypeToken
|
||||
}
|
||||
|
||||
persistLearnedSource(ds, account, device, sources, matchingSrc)
|
||||
persistLearnedSource(ds, account, device, matchingSrc)
|
||||
}
|
||||
|
||||
return matchingSrc, sourceLearned
|
||||
@@ -2002,26 +2046,24 @@ func updateSourceFields(src *models.ConfiguredSource, credentialValue, sourceNam
|
||||
return learned
|
||||
}
|
||||
|
||||
func persistLearnedSource(ds *datastore.DataStore, account, device string, sources []models.ConfiguredSource, matchingSrc *models.ConfiguredSource) {
|
||||
updatedSources := make([]models.ConfiguredSource, len(sources))
|
||||
copy(updatedSources, sources)
|
||||
func persistLearnedSource(ds *datastore.DataStore, account, device string, matchingSrc *models.ConfiguredSource) {
|
||||
// Read-mutate-write atomically against the persisted list, not a
|
||||
// snapshot the caller read earlier — AddRecent and UpdatePreset can
|
||||
// both be learning/auto-adding sources for the same device
|
||||
// concurrently, and a plain Get+Save here would silently lose
|
||||
// whichever write landed second.
|
||||
_, err := ds.MutateConfiguredSources(account, device, func(sources []models.ConfiguredSource) ([]models.ConfiguredSource, error) {
|
||||
for i := range sources {
|
||||
if sources[i].ID == matchingSrc.ID {
|
||||
sources[i] = *matchingSrc
|
||||
|
||||
found := false
|
||||
|
||||
for i := range updatedSources {
|
||||
if updatedSources[i].ID == matchingSrc.ID {
|
||||
updatedSources[i] = *matchingSrc
|
||||
found = true
|
||||
|
||||
break
|
||||
return sources, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
updatedSources = append(updatedSources, *matchingSrc)
|
||||
}
|
||||
|
||||
if err := ds.SaveConfiguredSources(account, device, updatedSources); err != nil {
|
||||
return append(sources, *matchingSrc), nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[MARGE_ERR] Failed to persist learned source for %s: %s", sanitizeLog(device), sanitizeErr(err))
|
||||
}
|
||||
}
|
||||
@@ -2407,7 +2449,6 @@ func AddSource(ds *datastore.DataStore, account, username, providerID, secret, s
|
||||
}
|
||||
|
||||
devID := entry.Name()
|
||||
sources, _ := ds.GetConfiguredSources(account, devID)
|
||||
|
||||
newSrc := models.ConfiguredSource{
|
||||
ID: sourceID,
|
||||
@@ -2437,37 +2478,39 @@ func AddSource(ds *datastore.DataStore, account, username, providerID, secret, s
|
||||
|
||||
PrepareConfiguredSource(&newSrc)
|
||||
|
||||
// Update or append. Most providers are singletons (one account each), so
|
||||
// the same provider replaces the existing entry. STORED_MUSIC is the
|
||||
// exception: each DLNA media server is a separate account (username =
|
||||
// "<UDN>/0"), so it must only replace when the account also matches.
|
||||
// Otherwise registering a second media server overwrites the first, which
|
||||
// then vanishes from /full + /sources and the speaker drops it (only one
|
||||
// media server could ever stay registered).
|
||||
replaced := false
|
||||
// Read-mutate-write atomically against the persisted list, not a
|
||||
// snapshot read before the loop body — see MutateConfiguredSources.
|
||||
_, err := ds.MutateConfiguredSources(account, devID, func(sources []models.ConfiguredSource) ([]models.ConfiguredSource, error) {
|
||||
// Update or append. Most providers are singletons (one account
|
||||
// each), so the same provider replaces the existing entry.
|
||||
// STORED_MUSIC is the exception: each DLNA media server is a
|
||||
// separate account (username = "<UDN>/0"), so it must only
|
||||
// replace when the account also matches. Otherwise registering
|
||||
// a second media server overwrites the first, which then
|
||||
// vanishes from /full + /sources and the speaker drops it
|
||||
// (only one media server could ever stay registered).
|
||||
for i := range sources {
|
||||
sameProvider := sources[i].SourceProviderID == providerID
|
||||
if providerID == strconv.Itoa(constants.StoredMusicProviderID) {
|
||||
// Match on the persisted account identity
|
||||
// (SourceKey.Account), not Username, which does not
|
||||
// round-trip through the datastore.
|
||||
sameProvider = sameProvider && sources[i].SourceKey.Account == username
|
||||
}
|
||||
|
||||
for i := range sources {
|
||||
sameProvider := sources[i].SourceProviderID == providerID
|
||||
if providerID == strconv.Itoa(constants.StoredMusicProviderID) {
|
||||
// Match on the persisted account identity (SourceKey.Account),
|
||||
// not Username, which does not round-trip through the datastore.
|
||||
sameProvider = sameProvider && sources[i].SourceKey.Account == username
|
||||
if sameProvider ||
|
||||
(providerID == strconv.Itoa(constants.SpotifyProviderID) && sources[i].SourceKey.Type == constants.ProviderSpotify) {
|
||||
sources[i] = newSrc
|
||||
|
||||
return sources, nil
|
||||
}
|
||||
}
|
||||
|
||||
if sameProvider ||
|
||||
(providerID == strconv.Itoa(constants.SpotifyProviderID) && sources[i].SourceKey.Type == constants.ProviderSpotify) {
|
||||
sources[i] = newSrc
|
||||
replaced = true
|
||||
|
||||
break
|
||||
}
|
||||
return append(sources, newSrc), nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[Marge] AddSource: failed to save source %s for device %s: %s", sanitizeLog(newSrc.SourceKey.Type), sanitizeLog(devID), sanitizeErr(err))
|
||||
}
|
||||
|
||||
if !replaced {
|
||||
sources = append(sources, newSrc)
|
||||
}
|
||||
|
||||
_ = ds.SaveConfiguredSources(account, devID, sources)
|
||||
}
|
||||
|
||||
return sourceID, nil
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package marge
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
@@ -784,3 +786,172 @@ func TestAccountFullToXML_WithBackupStructure(t *testing.T) {
|
||||
t.Errorf("Expected <name/> or <name></name> or fallback name, got %s", string(fullXML2))
|
||||
}
|
||||
}
|
||||
|
||||
// extractSourceFragment returns the raw `<source id="id" ...>...</source>`
|
||||
// substring for one source out of a rendered account document. Tests need the
|
||||
// raw wire text, not an unmarshaled struct, because Go's XML decoder can't
|
||||
// distinguish "element present but empty" from "element absent" — and that
|
||||
// distinction is exactly what has broken parsing on real speakers before
|
||||
// (issue #195, #334).
|
||||
func extractSourceFragment(t *testing.T, doc, id string) string {
|
||||
t.Helper()
|
||||
|
||||
marker := `<source id="` + id + `"`
|
||||
|
||||
start := strings.Index(doc, marker)
|
||||
if start < 0 {
|
||||
t.Fatalf("source id=%q not found in document:\n%s", id, doc)
|
||||
}
|
||||
|
||||
end := strings.Index(doc[start:], "</source>")
|
||||
if end < 0 {
|
||||
t.Fatalf("source id=%q has no closing </source>:\n%s", id, doc)
|
||||
}
|
||||
|
||||
return doc[start : start+end+len("</source>")]
|
||||
}
|
||||
|
||||
// xmlElementNames returns the ordered sequence of start-tag element names in
|
||||
// an XML fragment (attributes and closing tags are ignored). Used to compare
|
||||
// the "shape" of two rendered <source> entries without caring about their
|
||||
// differing content.
|
||||
func xmlElementNames(fragment string) []string {
|
||||
var out []string
|
||||
|
||||
for _, part := range strings.Split(fragment, "<") {
|
||||
if i := strings.IndexAny(part, " >/"); i > 0 {
|
||||
out = append(out, part[:i])
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// TestSourceXMLShapeConsistencyAcrossTypes guards against a known Bose
|
||||
// firmware failure mode: a <source> entry that omits an element the firmware
|
||||
// expects makes the speaker reject the *whole* account document, not just
|
||||
// that entry (see the AccountFullToXML sourceproviderid comment above, and
|
||||
// issues #195/#334). A newly added source type (here STORED_MUSIC, as used by
|
||||
// the DLNA/UPnP media-library feature) must render with the exact same
|
||||
// element set, in the same order, as an existing known-good default source —
|
||||
// content may legitimately differ, element names may not.
|
||||
func TestSourceXMLShapeConsistencyAcrossTypes(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "marge-test-shape-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
account := "7654321"
|
||||
device := "AABBCCDDEE0B"
|
||||
|
||||
info := &models.ServiceDeviceInfo{
|
||||
DeviceID: device,
|
||||
Name: "Office SoundTouch",
|
||||
}
|
||||
_ = ds.SaveDeviceInfo(account, device, info)
|
||||
_ = ds.SavePresets(account, device, []models.ServicePreset{})
|
||||
_ = ds.SaveRecents(account, device, []models.ServiceRecent{})
|
||||
|
||||
// A STORED_MUSIC entry as HandleAddLibraryServer's registration flow would
|
||||
// produce it: no SourceProviderID set explicitly, so PrepareConfiguredSource
|
||||
// must resolve it via constants.StaticProviders at render time, exactly like
|
||||
// a freshly registered DLNA media server would.
|
||||
stored := models.ConfiguredSource{
|
||||
ID: "20001",
|
||||
DisplayName: "FRITZ!Mediaserver",
|
||||
Type: "Audio",
|
||||
Name: "FRITZ!Mediaserver",
|
||||
SourceName: constants.ProviderStoredMusic,
|
||||
Username: "fa095ecc-uuid/0",
|
||||
}
|
||||
stored.SourceKey.Type = constants.ProviderStoredMusic
|
||||
stored.SourceKey.Account = "fa095ecc-uuid/0"
|
||||
stored.SourceKeyType = constants.ProviderStoredMusic
|
||||
stored.SourceKeyAccount = "fa095ecc-uuid/0"
|
||||
|
||||
if err := ds.SaveConfiguredSources(account, device, []models.ConfiguredSource{stored}); err != nil {
|
||||
t.Fatalf("SaveConfiguredSources: %v", err)
|
||||
}
|
||||
|
||||
tunein := strconv.Itoa(constants.TuneinProviderID)
|
||||
storedMusicID := strconv.Itoa(constants.StoredMusicProviderID)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
render func() ([]byte, error)
|
||||
}{
|
||||
{"AccountFullToXML", func() ([]byte, error) { return AccountFullToXML(ds, account) }},
|
||||
{"AccountSourcesToXML", func() ([]byte, error) { return AccountSourcesToXML(ds, account) }},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
data, err := tc.render()
|
||||
if err != nil {
|
||||
t.Fatalf("%s failed: %v", tc.name, err)
|
||||
}
|
||||
|
||||
var sources []models.FullResponseSource
|
||||
|
||||
switch tc.name {
|
||||
case "AccountFullToXML":
|
||||
var resp models.AccountFullResponse
|
||||
if uerr := xml.Unmarshal(data, &resp); uerr != nil {
|
||||
t.Fatalf("%s: whole-document unmarshal failed: %v\n%s", tc.name, uerr, data)
|
||||
}
|
||||
sources = resp.Sources
|
||||
case "AccountSourcesToXML":
|
||||
var resp models.AccountSourcesResponse
|
||||
if uerr := xml.Unmarshal(data, &resp); uerr != nil {
|
||||
t.Fatalf("%s: whole-document unmarshal failed: %v\n%s", tc.name, uerr, data)
|
||||
}
|
||||
sources = resp.Sources
|
||||
}
|
||||
|
||||
// Defaults minus AUX (filtered out of /full and /sources on purpose,
|
||||
// see the getAccountSources comment) plus our one extra STORED_MUSIC entry.
|
||||
wantCount := len(ds.GetInitialSources()) - 1 + 1
|
||||
if len(sources) != wantCount {
|
||||
t.Fatalf("%s: got %d sources, want %d:\n%s", tc.name, len(sources), wantCount, data)
|
||||
}
|
||||
|
||||
var tuneinSource, storedMusicSource *models.FullResponseSource
|
||||
for i := range sources {
|
||||
switch sources[i].SourceProviderID {
|
||||
case tunein:
|
||||
tuneinSource = &sources[i]
|
||||
case storedMusicID:
|
||||
storedMusicSource = &sources[i]
|
||||
}
|
||||
}
|
||||
|
||||
if tuneinSource == nil {
|
||||
t.Fatalf("%s: TUNEIN source missing from rendered document:\n%s", tc.name, data)
|
||||
}
|
||||
if storedMusicSource == nil {
|
||||
t.Fatalf("%s: STORED_MUSIC source missing, or its sourceproviderid did not resolve to %q:\n%s", tc.name, storedMusicID, data)
|
||||
}
|
||||
if storedMusicSource.ID == tuneinSource.ID {
|
||||
t.Errorf("%s: STORED_MUSIC source id %q collides with a default source id", tc.name, storedMusicSource.ID)
|
||||
}
|
||||
|
||||
tuneinFragment := extractSourceFragment(t, string(data), tuneinSource.ID)
|
||||
storedFragment := extractSourceFragment(t, string(data), storedMusicSource.ID)
|
||||
|
||||
wantShape := xmlElementNames(tuneinFragment)
|
||||
gotShape := xmlElementNames(storedFragment)
|
||||
|
||||
if len(wantShape) != len(gotShape) {
|
||||
t.Fatalf("%s: element count differs from a known-good default source:\n default (TUNEIN): %v\n STORED_MUSIC: %v", tc.name, wantShape, gotShape)
|
||||
}
|
||||
|
||||
for i := range wantShape {
|
||||
if wantShape[i] != gotShape[i] {
|
||||
t.Errorf("%s: element %d differs: default (TUNEIN) %q, STORED_MUSIC %q", tc.name, i, wantShape[i], gotShape[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,21 @@ func (m *Manager) ResetBoseURLs(deviceIP, serviceURL string) (string, error) {
|
||||
return m.setBoseURLsViaTelnet(deviceIP, serviceURL, serviceURL+"/update")
|
||||
}
|
||||
|
||||
// DefaultTelnetCommandDelay is the pause between successive commands in
|
||||
// EnableSSHViaTelnetFullConfig's sequence. Originally set based on #515
|
||||
// comment 5228449448 (same six commands, back-to-back left sshd down after
|
||||
// reboot but succeeded with ~7s gaps). That inter-command-delay theory was
|
||||
// RETRACTED by the same reporter after a controlled A/B on three variants
|
||||
// (issue comment 5231931569): back-to-back and 5s-gapped runs produced
|
||||
// identical results (all writes applied, confirmed via verified reboots),
|
||||
// so the delay itself does not appear to be the mechanism — the likely real
|
||||
// gate was the account-pairing precondition (see EnsureMargeAccountPaired),
|
||||
// fixed independently. The flag is kept at a small non-zero default (3s)
|
||||
// as a low-cost hedge for firmware variants nobody has A/B-tested yet
|
||||
// (only lisa/mojo/spotty/ginger/taigan are confirmed); 0 sends everything
|
||||
// back-to-back.
|
||||
const DefaultTelnetCommandDelay = 3 * time.Second
|
||||
|
||||
// EnableSSHViaTelnetFullConfig is the #515 variant of EnableSSHViaTelnet for
|
||||
// devices where the single-envswitch injection is accepted and persisted but
|
||||
// sshd never starts (ST Portable, CineMate 520; see also memory note #471). It
|
||||
@@ -43,13 +58,15 @@ func (m *Manager) ResetBoseURLs(deviceIP, serviceURL string) (string, error) {
|
||||
// writes all four `sys configuration` URL keys with the remote_services
|
||||
// injection on margeServerUrl (the runtime layer, not just the envswitch
|
||||
// persistence layer), mirrors the injection into `envswitch boseurls set`, and
|
||||
// verifies with getpdo. The caller should reboot afterwards (the injection
|
||||
// fires on the speaker's next full config re-parse at boot) and then
|
||||
// WaitForSSHPort.
|
||||
// verifies with getpdo. The caller should pause commandDelay again, reboot
|
||||
// (the injection fires on the speaker's next full config re-parse at boot),
|
||||
// and then WaitForSSHPort.
|
||||
//
|
||||
// serviceURL is the AfterTouch service base the speaker should point at
|
||||
// (e.g. https://192.0.2.10:8443). It must not contain a double quote.
|
||||
func (m *Manager) EnableSSHViaTelnetFullConfig(deviceIP, serviceURL string) (string, error) {
|
||||
// commandDelay is the pause between each command (see
|
||||
// DefaultTelnetCommandDelay); 0 sends them back-to-back.
|
||||
func (m *Manager) EnableSSHViaTelnetFullConfig(deviceIP, serviceURL string, commandDelay time.Duration) (string, error) {
|
||||
u := defaultTelnetURLs(serviceURL)
|
||||
margeInjected := serviceURL + remoteServicesInjection
|
||||
|
||||
@@ -65,16 +82,18 @@ func (m *Manager) EnableSSHViaTelnetFullConfig(deviceIP, serviceURL string) (str
|
||||
`envswitch boseurls set "` + margeInjected + `" "` + u.SwUpdate + `"`,
|
||||
}
|
||||
|
||||
return m.runTelnetInjection(deviceIP, []string{serviceURL, u.SwUpdate}, cmds)
|
||||
return m.runTelnetInjection(deviceIP, []string{serviceURL, u.SwUpdate}, cmds, commandDelay)
|
||||
}
|
||||
|
||||
// runTelnetInjection opens the port-17000 shell, runs an ordered list of
|
||||
// commands (aborting on the first transport error or "command not found"
|
||||
// rejection), then logs a getpdo verification. forbidQuote values are checked
|
||||
// for an embedded double quote, which would break the command parsing.
|
||||
// Verification is best-effort (logged, never fatal) to match enable-ssh's
|
||||
// forgiving philosophy and tolerate the aftertouch.invalid placeholder.
|
||||
func (m *Manager) runTelnetInjection(deviceIP string, forbidQuote, cmds []string) (string, error) {
|
||||
// rejection), pausing commandDelay after each one (see
|
||||
// DefaultTelnetCommandDelay), then logs a getpdo verification. forbidQuote
|
||||
// values are checked for an embedded double quote, which would break the
|
||||
// command parsing. Verification is best-effort (logged, never fatal) to
|
||||
// match enable-ssh's forgiving philosophy and tolerate the
|
||||
// aftertouch.invalid placeholder.
|
||||
func (m *Manager) runTelnetInjection(deviceIP string, forbidQuote, cmds []string, commandDelay time.Duration) (string, error) {
|
||||
if m.NewTelnet == nil {
|
||||
return "", errors.New("telnet not configured: Manager.NewTelnet is nil")
|
||||
}
|
||||
@@ -109,10 +128,14 @@ func (m *Manager) runTelnetInjection(deviceIP string, forbidQuote, cmds []string
|
||||
if isCommandNotFound(resp) {
|
||||
return logs.String(), fmt.Errorf("device rejected %q (firmware does not expose this command)", cmd)
|
||||
}
|
||||
|
||||
if commandDelay > 0 {
|
||||
time.Sleep(commandDelay)
|
||||
}
|
||||
}
|
||||
|
||||
if verify, err := t.SendCommand("getpdo CurrentSystemConfiguration"); err == nil {
|
||||
fmt.Fprintf(&logs, "→ getpdo CurrentSystemConfiguration\n%s\n", strings.TrimRight(verify, "\r\n"))
|
||||
fmt.Fprintf(&logs, "→ getpdo CurrentSystemConfiguration (runtime layer only — confirms the writes were accepted, not that they'll survive a reboot)\n%s\n", strings.TrimRight(verify, "\r\n"))
|
||||
}
|
||||
|
||||
return logs.String(), nil
|
||||
@@ -160,6 +183,47 @@ func (m *Manager) setBoseURLsViaTelnet(deviceIP, marge, swUpdate string) (string
|
||||
return logs.String(), nil
|
||||
}
|
||||
|
||||
// setAllBoseURLsViaTelnet writes all four boseurls (bmx, stats, marge,
|
||||
// swUpdate) to the runtime layer via `sys configuration ...`, then commits
|
||||
// them with `envswitch boseurls set`, over the port-17000 shell. Unlike
|
||||
// setBoseURLsViaTelnet (which only issues the envswitch commit, used by the
|
||||
// #471 SSH-bootstrap/reset flows that need that specific two-argument
|
||||
// injection), this mirrors telnetURLs.Commands()'s full sequence so the
|
||||
// envswitch commit captures fresh values for all four fields, not just two.
|
||||
func (m *Manager) setAllBoseURLsViaTelnet(deviceIP string, urls telnetURLs) (string, error) {
|
||||
if m.NewTelnet == nil {
|
||||
return "", errors.New("telnet 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: %w", deviceIP, err)
|
||||
}
|
||||
|
||||
defer func() { _ = t.Close() }()
|
||||
|
||||
if banner, _ := t.Probe(); 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)
|
||||
}
|
||||
}
|
||||
|
||||
return logs.String(), nil
|
||||
}
|
||||
|
||||
// fwScript is the speaker's persistent iptables script; appending here makes a
|
||||
// rule survive reboot (it is re-applied on boot).
|
||||
const fwScript = "/etc/init.d/Firewalls/update_iptables"
|
||||
|
||||
@@ -3,6 +3,7 @@ package setup
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEnableSSHViaTelnet_BuildsInjectedCommand(t *testing.T) {
|
||||
@@ -44,7 +45,7 @@ func TestEnableSSHViaTelnetFullConfig_BuildsInjectedSequence(t *testing.T) {
|
||||
f := &fakeTelnet{responses: resp}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
if _, err := m.EnableSSHViaTelnetFullConfig("192.0.2.10", svc); err != nil {
|
||||
if _, err := m.EnableSSHViaTelnetFullConfig("192.0.2.10", svc, 0); err != nil {
|
||||
t.Fatalf("EnableSSHViaTelnetFullConfig: %v", err)
|
||||
}
|
||||
|
||||
@@ -59,6 +60,78 @@ func TestEnableSSHViaTelnetFullConfig_BuildsInjectedSequence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// fullConfigResponses builds the {command: "OK"} map for
|
||||
// EnableSSHViaTelnetFullConfig's fixed 6-step sequence (5 commands + the
|
||||
// getpdo verification) against svc, matching
|
||||
// TestEnableSSHViaTelnetFullConfig_BuildsInjectedSequence's command list.
|
||||
func fullConfigResponses(svc string) map[string]string {
|
||||
injected := svc + `;touch /tmp/remote_services;/etc/init.d/sshd start`
|
||||
|
||||
cmds := []string{
|
||||
`sys configuration bmxRegistryUrl "` + svc + `/bmx/registry/v1/services"`,
|
||||
`sys configuration statsServerUrl "` + svc + `"`,
|
||||
`sys configuration margeServerUrl "` + injected + `"`,
|
||||
`sys configuration swUpdateUrl "` + svc + `/updates/soundtouch"`,
|
||||
`envswitch boseurls set "` + injected + `" "` + svc + `/updates/soundtouch"`,
|
||||
`getpdo CurrentSystemConfiguration`,
|
||||
}
|
||||
|
||||
resp := make(map[string]string, len(cmds))
|
||||
for _, c := range cmds {
|
||||
resp[c] = "OK\n"
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// TestEnableSSHViaTelnetFullConfig_PausesBetweenCommands is the regression
|
||||
// test for #515 comment 5228449448: the same commands sent back-to-back
|
||||
// left sshd down on a real device, but succeeded sent one at a time with
|
||||
// gaps. Uses a small real duration rather than a fake clock/injectable
|
||||
// sleeper — simplest thing that actually proves time.Sleep is in the loop,
|
||||
// and small enough (5 gaps x 5ms) not to slow the suite down.
|
||||
func TestEnableSSHViaTelnetFullConfig_PausesBetweenCommands(t *testing.T) {
|
||||
const svc = "https://192.0.2.10:8443"
|
||||
const delay = 5 * time.Millisecond
|
||||
|
||||
f := &fakeTelnet{responses: fullConfigResponses(svc)}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
if _, err := m.EnableSSHViaTelnetFullConfig("192.0.2.10", svc, delay); err != nil {
|
||||
t.Fatalf("EnableSSHViaTelnetFullConfig: %v", err)
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
// 5 real commands = 5 gaps (see runTelnetInjection: delay after each
|
||||
// command in the loop, including before the getpdo verification).
|
||||
wantMin := 5 * delay
|
||||
|
||||
if elapsed < wantMin {
|
||||
t.Errorf("elapsed %v, want at least %v (delay not applied between commands)", elapsed, wantMin)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnableSSHViaTelnetFullConfig_ZeroDelayIsInstant verifies 0 keeps the
|
||||
// old back-to-back behavior — no accidental minimum sleep.
|
||||
func TestEnableSSHViaTelnetFullConfig_ZeroDelayIsInstant(t *testing.T) {
|
||||
const svc = "https://192.0.2.10:8443"
|
||||
|
||||
f := &fakeTelnet{responses: fullConfigResponses(svc)}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
if _, err := m.EnableSSHViaTelnetFullConfig("192.0.2.10", svc, 0); err != nil {
|
||||
t.Fatalf("EnableSSHViaTelnetFullConfig: %v", err)
|
||||
}
|
||||
|
||||
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
|
||||
t.Errorf("elapsed %v with a 0 delay, expected near-instant", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetBoseURLs_BuildsCleanCommand(t *testing.T) {
|
||||
const svc = "https://192.0.2.10:8443"
|
||||
|
||||
@@ -84,6 +157,59 @@ func TestSetBoseURLs_RejectsDoubleQuote(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetAllBoseURLsViaTelnet_WritesAllFourBeforeEnvswitch is the regression
|
||||
// test for the stale statsServerUrl/bmxRegistryUrl bug reported in #621: the
|
||||
// XML migration's telnet resync used to commit `envswitch boseurls set` with
|
||||
// only marge/swUpdate as arguments, silently freezing whatever stats/bmx
|
||||
// happened to still be in the runtime layer at that moment. This asserts all
|
||||
// four `sys configuration` writes land before the single `envswitch` commit,
|
||||
// matching telnetURLs.Commands()'s known-good sequence.
|
||||
func TestSetAllBoseURLsViaTelnet_WritesAllFourBeforeEnvswitch(t *testing.T) {
|
||||
const targetURL = "http://localhost:8000"
|
||||
|
||||
urls := telnetURLs{
|
||||
Marge: targetURL,
|
||||
Stats: targetURL,
|
||||
SwUpdate: targetURL + "/updates/soundtouch",
|
||||
BmxRegistry: targetURL + "/bmx/registry/v1/services",
|
||||
}
|
||||
|
||||
want := urls.Commands()
|
||||
|
||||
resp := make(map[string]string, len(want))
|
||||
for _, c := range want {
|
||||
resp[c] = "OK\n"
|
||||
}
|
||||
|
||||
f := &fakeTelnet{responses: resp}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
if _, err := m.setAllBoseURLsViaTelnet("192.0.2.10", urls); err != nil {
|
||||
t.Fatalf("setAllBoseURLsViaTelnet: %v", err)
|
||||
}
|
||||
|
||||
if len(f.commands) != len(want) {
|
||||
t.Fatalf("sent %d commands %q\n want %d %q", len(f.commands), f.commands, len(want), want)
|
||||
}
|
||||
|
||||
for i, c := range want {
|
||||
if f.commands[i] != c {
|
||||
t.Errorf("command %d = %q\n want %q", i, f.commands[i], c)
|
||||
}
|
||||
}
|
||||
|
||||
envswitchIdx := len(want) - 1
|
||||
for i, c := range f.commands[:envswitchIdx] {
|
||||
if !strings.HasPrefix(c, "sys configuration ") {
|
||||
t.Errorf("command %d = %q, want a `sys configuration ...` runtime write before the envswitch commit", i, c)
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(f.commands[envswitchIdx], "envswitch boseurls set ") {
|
||||
t.Errorf("last command = %q, want the envswitch commit last", f.commands[envswitchIdx])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClose17000_RunsFirewallSteps(t *testing.T) {
|
||||
var ran []string
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// InitPlan describes everything required to take a factory-reset (or
|
||||
@@ -250,8 +252,8 @@ func (m *Manager) runURLRewrite(plan InitPlan, emit func(StepKind, string, StepS
|
||||
// ID, or validating a user-supplied value.
|
||||
func (m *Manager) resolveAccountID(plan InitPlan, info *DeviceInfoXML, emit func(StepKind, string, StepStatus, error)) (InitPlan, error) {
|
||||
if plan.AccountID != "" {
|
||||
if !IsValidAccountID(plan.AccountID) {
|
||||
invalidErr := fmt.Errorf("invalid AccountID %q: must be exactly 7 digits", plan.AccountID)
|
||||
if !datastore.IsSafeIdentifier(plan.AccountID) {
|
||||
invalidErr := fmt.Errorf("invalid AccountID %q: must be a non-empty, path-safe identifier", plan.AccountID)
|
||||
emit(StepGenerateAccountID, "validate account ID", StatusFailed, invalidErr)
|
||||
|
||||
return plan, invalidErr
|
||||
@@ -260,7 +262,7 @@ func (m *Manager) resolveAccountID(plan InitPlan, info *DeviceInfoXML, emit func
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
if info.MargeAccountUUID != "" && IsValidAccountID(info.MargeAccountUUID) {
|
||||
if info.MargeAccountUUID != "" && datastore.IsSafeIdentifier(info.MargeAccountUUID) {
|
||||
plan.AccountID = info.MargeAccountUUID
|
||||
emit(StepGenerateAccountID, "reuse existing margeAccountUUID="+plan.AccountID, StatusOK, nil)
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// fakeSession is a StateMachine that records the order of
|
||||
@@ -195,11 +197,13 @@ func TestExecuteInitPlan_ReusesExistingAccountUUID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecuteInitPlan_GeneratesAccountWhenDeviceUUIDInvalid(t *testing.T) {
|
||||
// Devices that report a non-7-digit UUID (e.g. a stale local value) must
|
||||
// not be reused — we treat them as factory-reset for ID purposes.
|
||||
// Devices that report an unsafe/malformed UUID (e.g. containing a path
|
||||
// separator) must not be reused — we treat them as factory-reset for ID
|
||||
// purposes. A merely non-numeric UUID (e.g. "stick@local", #634) IS
|
||||
// reused now; see resolveAccountID/datastore.IsSafeIdentifier.
|
||||
info := &fakeInfoResponder{
|
||||
deviceID: "AABBCCDDEEFF",
|
||||
paired: "not-7-digits",
|
||||
paired: "not/valid",
|
||||
postInitPaired: "", // we'll learn the generated ID from the result
|
||||
}
|
||||
sess := &fakeSession{}
|
||||
@@ -220,11 +224,11 @@ func TestExecuteInitPlan_GeneratesAccountWhenDeviceUUIDInvalid(t *testing.T) {
|
||||
t.Fatalf("ExecuteInitPlan: %v", err)
|
||||
}
|
||||
|
||||
if !IsValidAccountID(got.AccountID) {
|
||||
t.Errorf("got.AccountID = %q, want a valid 7-digit ID", got.AccountID)
|
||||
if !datastore.IsSafeIdentifier(got.AccountID) {
|
||||
t.Errorf("got.AccountID = %q, want a valid generated ID", got.AccountID)
|
||||
}
|
||||
|
||||
if got.AccountID == "not-7-digits" {
|
||||
if got.AccountID == "not/valid" {
|
||||
t.Error("orchestrator should not reuse an invalid UUID")
|
||||
}
|
||||
}
|
||||
@@ -236,7 +240,7 @@ func TestExecuteInitPlan_RejectsInvalidSuppliedAccountID(t *testing.T) {
|
||||
|
||||
plan := InitPlan{
|
||||
DeviceIP: "192.0.2.10",
|
||||
AccountID: "abc",
|
||||
AccountID: "abc/def",
|
||||
SkipURLRewrite: true,
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ func TestIssue234_FactoryResetSpeakerSyncsReducedSources(t *testing.T) {
|
||||
// SyncDeviceData derives accountID/deviceID from /info; with
|
||||
// an empty margeAccountUUID the account falls through to
|
||||
// "default".
|
||||
if err := m.SyncDeviceData(deviceIP); err != nil {
|
||||
if _, err := m.SyncDeviceData(deviceIP, false); err != nil {
|
||||
t.Fatalf("SyncDeviceData: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
@@ -11,6 +12,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// PairAccountTimeouts bounds every step of the pairing call so a wedged
|
||||
@@ -44,8 +47,8 @@ func (m *Manager) PairAccount(deviceIP, accountID string, t TelnetClient) (PairA
|
||||
logs strings.Builder
|
||||
)
|
||||
|
||||
if !IsValidAccountID(accountID) {
|
||||
return result, "", fmt.Errorf("invalid account ID %q: must be exactly 7 digits", accountID)
|
||||
if !datastore.IsSafeIdentifier(accountID) {
|
||||
return result, "", fmt.Errorf("invalid account ID %q: must be a non-empty, path-safe identifier", accountID)
|
||||
}
|
||||
|
||||
supported, supportedErr := m.probeSetMargeAccount(deviceIP)
|
||||
@@ -84,6 +87,9 @@ func (m *Manager) PairAccount(deviceIP, accountID string, t TelnetClient) (PairA
|
||||
|
||||
result.TelnetAttempted = true
|
||||
|
||||
// Safe to concatenate: datastore.IsSafeIdentifier (checked above) rejects
|
||||
// any whitespace or control characters, so accountID can't smuggle extra
|
||||
// tokens into this single-line telnet command.
|
||||
cmd := "envswitch accountid set " + accountID
|
||||
|
||||
resp, err := t.SendCommand(cmd)
|
||||
@@ -108,6 +114,45 @@ func (m *Manager) PairAccount(deviceIP, accountID string, t TelnetClient) (PairA
|
||||
return result, logs.String(), nil
|
||||
}
|
||||
|
||||
// EnsureMargeAccountPaired reads the device's /info and, if margeAccountUUID
|
||||
// is empty (a genuinely unpaired, factory-reset device), pairs it via
|
||||
// PairAccount using wantAccountID if given, otherwise a freshly generated ID.
|
||||
// See #515 comment 5230833551: on an unpaired device, margeServerUrl is
|
||||
// reportedly never polled at all, so the boseurls SSH-enable injection has no
|
||||
// read cycle to fire on regardless of command delay — pairing first gives it
|
||||
// one. accountID is empty when GetLiveDeviceInfo itself fails; otherwise it
|
||||
// is either the device's existing margeAccountUUID (alreadyPaired=true) or
|
||||
// the account ID just paired with.
|
||||
func (m *Manager) EnsureMargeAccountPaired(deviceIP, wantAccountID string, t TelnetClient) (accountID string, alreadyPaired bool, logs string, err error) {
|
||||
info, infoErr := m.GetLiveDeviceInfo(deviceIP)
|
||||
if infoErr != nil {
|
||||
return "", false, "", fmt.Errorf("read /info: %w", infoErr)
|
||||
}
|
||||
|
||||
if info.MargeAccountUUID != "" {
|
||||
return info.MargeAccountUUID, true, "", nil
|
||||
}
|
||||
|
||||
target := wantAccountID
|
||||
if target == "" {
|
||||
generated, genErr := GenerateAccountID(nil)
|
||||
if genErr != nil {
|
||||
return "", false, "", fmt.Errorf("generate account id: %w", genErr)
|
||||
}
|
||||
|
||||
target = generated
|
||||
} else if !datastore.IsSafeIdentifier(target) {
|
||||
return "", false, "", fmt.Errorf("invalid account id %q: must be a non-empty, path-safe identifier", target)
|
||||
}
|
||||
|
||||
_, pairLogs, pairErr := m.PairAccount(deviceIP, target, t)
|
||||
if pairErr != nil {
|
||||
return target, false, pairLogs, pairErr
|
||||
}
|
||||
|
||||
return target, false, pairLogs, nil
|
||||
}
|
||||
|
||||
// probeSetMargeAccount fetches /supportedURLs and reports whether
|
||||
// /setMargeAccount is in the listing.
|
||||
func (m *Manager) probeSetMargeAccount(deviceIP string) (bool, error) {
|
||||
@@ -157,9 +202,18 @@ func (m *Manager) probeSetMargeAccount(deviceIP string) (bool, error) {
|
||||
func (m *Manager) postSetMargeAccount(deviceIP, accountID string) error {
|
||||
url := buildDeviceURL(deviceIP, "/setMargeAccount")
|
||||
|
||||
// accountID is XML-escaped rather than interpolated raw:
|
||||
// datastore.IsSafeIdentifier already excludes '<', '>', '&', '\'', '"'
|
||||
// (see #634), but escaping here too means this stays well-formed even
|
||||
// if that gate is ever bypassed.
|
||||
var escapedAccountID bytes.Buffer
|
||||
if err := xml.EscapeText(&escapedAccountID, []byte(accountID)); err != nil {
|
||||
return fmt.Errorf("escape account ID: %w", err)
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(
|
||||
`<PairDeviceWithAccount><accountId>%s</accountId><userAuthToken>aftertouch</userAuthToken></PairDeviceWithAccount>`,
|
||||
accountID,
|
||||
escapedAccountID.String(),
|
||||
)
|
||||
|
||||
client := &http.Client{
|
||||
@@ -186,6 +240,82 @@ func (m *Manager) postSetMargeAccount(deviceIP, accountID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfigurationStatus values reported by GET /soundTouchConfigurationStatus.
|
||||
// See issue #615: a speaker can be reachable, named, and already
|
||||
// account-paired yet still report SOUNDTOUCH_NOT_CONFIGURED, which leaves
|
||||
// the firmware nagging the owner to install the Bose app. Only a full pass
|
||||
// through the WebSocket setup state machine (ExecuteInitPlan) clears it.
|
||||
const (
|
||||
ConfigurationStatusConfigured = "SOUNDTOUCH_CONFIGURED"
|
||||
ConfigurationStatusNotConfigured = "SOUNDTOUCH_NOT_CONFIGURED"
|
||||
)
|
||||
|
||||
// ReadConfigurationStatus fetches /soundTouchConfigurationStatus and returns
|
||||
// its raw status attribute (e.g. "SOUNDTOUCH_CONFIGURED").
|
||||
func (m *Manager) ReadConfigurationStatus(deviceIP string) (string, error) {
|
||||
url := buildDeviceURL(deviceIP, "/soundTouchConfigurationStatus")
|
||||
|
||||
client := &http.Client{Timeout: supportedURLsTimeout}
|
||||
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("GET %s: %w", url, err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("GET %s returned %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s: %w", url, err)
|
||||
}
|
||||
|
||||
var doc struct {
|
||||
Status string `xml:"status,attr"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(body, &doc); err != nil {
|
||||
return "", fmt.Errorf("parse %s: %w", url, err)
|
||||
}
|
||||
|
||||
return doc.Status, nil
|
||||
}
|
||||
|
||||
// PreflightInitPlan reports whether ExecuteInitPlan should be run against
|
||||
// deviceIP, gated on the two conditions from issue #615: /setMargeAccount
|
||||
// must be listed in /supportedURLs, and the device's current
|
||||
// /soundTouchConfigurationStatus must be exactly SOUNDTOUCH_NOT_CONFIGURED.
|
||||
// needed=false with a nil error means "already configured, nothing to do."
|
||||
// Any other outcome (unsupported route, unrecognised status value) is
|
||||
// treated as unknown and returned as an error rather than guessed at.
|
||||
func (m *Manager) PreflightInitPlan(deviceIP string) (needed bool, status string, err error) {
|
||||
supported, probeErr := m.probeSetMargeAccount(deviceIP)
|
||||
if probeErr != nil {
|
||||
return false, "", fmt.Errorf("supportedURLs probe: %w", probeErr)
|
||||
}
|
||||
|
||||
if !supported {
|
||||
return false, "", errors.New("/setMargeAccount is not listed in /supportedURLs — device does not support this pairing path")
|
||||
}
|
||||
|
||||
status, err = m.ReadConfigurationStatus(deviceIP)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("read /soundTouchConfigurationStatus: %w", err)
|
||||
}
|
||||
|
||||
switch status {
|
||||
case ConfigurationStatusConfigured:
|
||||
return false, status, nil
|
||||
case ConfigurationStatusNotConfigured:
|
||||
return true, status, nil
|
||||
default:
|
||||
return false, status, fmt.Errorf("unexpected /soundTouchConfigurationStatus value %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -197,23 +327,6 @@ func buildDeviceURL(deviceIP, path string) string {
|
||||
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) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package setup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -9,18 +10,22 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// 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
|
||||
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
|
||||
margeAccountUUID string // served by /info; empty means "unpaired"
|
||||
configurationStatus string // served by /soundTouchConfigurationStatus; empty = route not served (404)
|
||||
}
|
||||
|
||||
func newFakeDevice(t *testing.T) *fakeDevice {
|
||||
@@ -55,6 +60,21 @@ func newFakeDevice(t *testing.T) *fakeDevice {
|
||||
w.WriteHeader(d.postStatus)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/info", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprintf(w, `<info deviceID="AABBCCDDEE0A"><margeAccountUUID>%s</margeAccountUUID></info>`, d.margeAccountUUID)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/soundTouchConfigurationStatus", func(w http.ResponseWriter, _ *http.Request) {
|
||||
if d.configurationStatus == "" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprintf(w, `<SoundTouchConfigurationStatus status="%s" />`, d.configurationStatus)
|
||||
})
|
||||
|
||||
d.srv = httptest.NewServer(mux)
|
||||
|
||||
u := d.srv.URL[len("http://"):]
|
||||
@@ -254,36 +274,220 @@ func TestPairAccount_TelnetTransportErrorReturned(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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},
|
||||
func TestEnsureMargeAccountPaired_AlreadyPairedSkipsPairing(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.margeAccountUUID = "1234567"
|
||||
|
||||
f := &fakeTelnet{}
|
||||
|
||||
m := NewManager("", nil, nil)
|
||||
|
||||
accountID, alreadyPaired, _, err := m.EnsureMargeAccountPaired(d.addr, "", f)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureMargeAccountPaired: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := IsValidAccountID(tc.in); got != tc.want {
|
||||
t.Errorf("IsValidAccountID(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
if !alreadyPaired {
|
||||
t.Error("alreadyPaired should be true")
|
||||
}
|
||||
|
||||
if accountID != "1234567" {
|
||||
t.Errorf("accountID = %q, want the existing margeAccountUUID", accountID)
|
||||
}
|
||||
|
||||
if len(f.commands) != 0 || d.gotPostBody != "" {
|
||||
t.Error("pairing should not have been attempted for an already-paired device")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureMargeAccountPaired_UnpairedGeneratesAndPairs(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.margeAccountUUID = ""
|
||||
|
||||
m := NewManager("", nil, nil)
|
||||
|
||||
accountID, alreadyPaired, _, err := m.EnsureMargeAccountPaired(d.addr, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureMargeAccountPaired: %v", err)
|
||||
}
|
||||
|
||||
if alreadyPaired {
|
||||
t.Error("alreadyPaired should be false for an unpaired device")
|
||||
}
|
||||
|
||||
if !datastore.IsSafeIdentifier(accountID) {
|
||||
t.Errorf("accountID %q is not a valid generated ID", accountID)
|
||||
}
|
||||
|
||||
if !strings.Contains(d.gotPostBody, "<accountId>"+accountID+"</accountId>") {
|
||||
t.Errorf("device received %q, want it to be paired with the generated %q", d.gotPostBody, accountID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureMargeAccountPaired_UnpairedUsesWantAccountID(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.margeAccountUUID = ""
|
||||
|
||||
m := NewManager("", nil, nil)
|
||||
|
||||
accountID, alreadyPaired, _, err := m.EnsureMargeAccountPaired(d.addr, "7654321", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureMargeAccountPaired: %v", err)
|
||||
}
|
||||
|
||||
if alreadyPaired {
|
||||
t.Error("alreadyPaired should be false for an unpaired device")
|
||||
}
|
||||
|
||||
if accountID != "7654321" {
|
||||
t.Errorf("accountID = %q, want the requested 7654321", accountID)
|
||||
}
|
||||
|
||||
if !strings.Contains(d.gotPostBody, "<accountId>7654321</accountId>") {
|
||||
t.Errorf("device received %q, want the requested account id", d.gotPostBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureMargeAccountPaired_RejectsInvalidWantAccountID(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.margeAccountUUID = ""
|
||||
|
||||
m := NewManager("", nil, nil)
|
||||
|
||||
_, _, _, err := m.EnsureMargeAccountPaired(d.addr, "not/valid", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an invalid --account value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureMargeAccountPaired_PropagatesPairingFailure(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.margeAccountUUID = ""
|
||||
d.supportsSetMarge = false
|
||||
|
||||
m := NewManager("", nil, nil)
|
||||
|
||||
_, _, _, err := m.EnsureMargeAccountPaired(d.addr, "1234567", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when HTTP pairing is unsupported and no telnet client is given")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadConfigurationStatus_ReturnsRawStatus(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.configurationStatus = ConfigurationStatusConfigured
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
status, err := m.ReadConfigurationStatus(d.addr)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadConfigurationStatus: %v", err)
|
||||
}
|
||||
|
||||
if status != ConfigurationStatusConfigured {
|
||||
t.Errorf("status = %q, want %q", status, ConfigurationStatusConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadConfigurationStatus_ErrorsWhenRouteUnsupported(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.configurationStatus = ""
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
if _, err := m.ReadConfigurationStatus(d.addr); err == nil {
|
||||
t.Fatal("expected an error when the route is unsupported (404)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightInitPlan_NotConfiguredNeedsRepair(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.configurationStatus = ConfigurationStatusNotConfigured
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
needed, status, err := m.PreflightInitPlan(d.addr)
|
||||
if err != nil {
|
||||
t.Fatalf("PreflightInitPlan: %v", err)
|
||||
}
|
||||
|
||||
if !needed {
|
||||
t.Error("needed should be true for SOUNDTOUCH_NOT_CONFIGURED")
|
||||
}
|
||||
|
||||
if status != ConfigurationStatusNotConfigured {
|
||||
t.Errorf("status = %q, want %q", status, ConfigurationStatusNotConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightInitPlan_AlreadyConfiguredIsNoOp(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.configurationStatus = ConfigurationStatusConfigured
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
needed, status, err := m.PreflightInitPlan(d.addr)
|
||||
if err != nil {
|
||||
t.Fatalf("PreflightInitPlan: %v", err)
|
||||
}
|
||||
|
||||
if needed {
|
||||
t.Error("needed should be false for SOUNDTOUCH_CONFIGURED")
|
||||
}
|
||||
|
||||
if status != ConfigurationStatusConfigured {
|
||||
t.Errorf("status = %q, want %q", status, ConfigurationStatusConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightInitPlan_UnsupportedSetMargeAccountFailsClosed(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.supportsSetMarge = false
|
||||
d.configurationStatus = ConfigurationStatusNotConfigured
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
needed, _, err := m.PreflightInitPlan(d.addr)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when /setMargeAccount is not listed in /supportedURLs")
|
||||
}
|
||||
|
||||
if needed {
|
||||
t.Error("needed should be false when preflight fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightInitPlan_UnrecognisedStatusFailsClosed(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.configurationStatus = "SOMETHING_UNEXPECTED"
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
needed, status, err := m.PreflightInitPlan(d.addr)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an unrecognised status value")
|
||||
}
|
||||
|
||||
if needed {
|
||||
t.Error("needed should be false when the status is unrecognised")
|
||||
}
|
||||
|
||||
if status != "SOMETHING_UNEXPECTED" {
|
||||
t.Errorf("status = %q, want the raw unrecognised value returned alongside the error", status)
|
||||
}
|
||||
}
|
||||
|
||||
// Account-ID format validation is now solely datastore.IsSafeIdentifier's
|
||||
// responsibility (see datastore.TestIsSafeIdentifier); setup no longer has
|
||||
// its own account-ID validator to test.
|
||||
|
||||
func TestGenerateAccountID_AvoidsCollisions(t *testing.T) {
|
||||
id, err := GenerateAccountID(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccountID(nil): %v", err)
|
||||
}
|
||||
|
||||
if !IsValidAccountID(id) {
|
||||
if !datastore.IsSafeIdentifier(id) {
|
||||
t.Errorf("generated ID %q is not valid", id)
|
||||
}
|
||||
|
||||
|
||||
+285
-59
@@ -124,9 +124,17 @@ type MigrationSummary struct {
|
||||
}
|
||||
|
||||
// SSHClient defines the interface for SSH operations.
|
||||
//
|
||||
// Connect/Close are optional: Run/UploadContent both work standalone
|
||||
// (dialing their own one-off connection each time, as they always have).
|
||||
// Call Connect first when making several calls in a row — e.g.
|
||||
// RevertMigration's ~17 commands — so they reuse one connection instead of
|
||||
// dialing fresh every time; defer Close to release it afterward.
|
||||
type SSHClient interface {
|
||||
Run(command string) (string, error)
|
||||
UploadContent(content []byte, remotePath string) error
|
||||
Connect() error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// TelnetClient defines the interface for the device's port-17000 diagnostic
|
||||
@@ -1081,32 +1089,46 @@ func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options ma
|
||||
}
|
||||
}
|
||||
|
||||
logs += m.resyncBoseURLsAfterXML(deviceIP, cfg.MargeServerUrl, cfg.SwUpdateUrl)
|
||||
logs += m.resyncBoseURLsAfterXML(deviceIP, telnetURLs{
|
||||
Marge: cfg.MargeServerUrl,
|
||||
Stats: cfg.StatsServerUrl,
|
||||
SwUpdate: cfg.SwUpdateUrl,
|
||||
BmxRegistry: cfg.BmxRegistryUrl,
|
||||
})
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
// resyncBoseURLsAfterXML re-applies the boseurls over telnet so the runtime
|
||||
// URL layer matches the XML just written by migrateViaXML.
|
||||
// resyncBoseURLsAfterXML re-applies all four boseurls over telnet so the
|
||||
// runtime URL layer matches the XML just written by migrateViaXML.
|
||||
//
|
||||
// The XML migration only updates the persisted SoundTouchSdkPrivateCfg.xml; it
|
||||
// does not touch the runtime/persistence layer that `getpdo
|
||||
// CurrentSystemConfiguration` reports. When SSH was bootstrapped via #471
|
||||
// (`enable-ssh`), that layer still points at the placeholder boseurls
|
||||
// (https://aftertouch.invalid), so the preflight cross-check keeps warning that
|
||||
// margeServerUrl/swUpdateUrl differ between transports until a reboot.
|
||||
// Re-applying the real boseurls over telnet :17000 reconciles it immediately.
|
||||
// the URLs differ between transports until a reboot.
|
||||
//
|
||||
// All four fields are re-applied, not just marge/swUpdate: the closing
|
||||
// `envswitch boseurls set` commit persists whatever is currently in the
|
||||
// runtime layer at the moment it runs, not only its own two arguments (see
|
||||
// docs/content/docs/analysis/TELNET-COMMAND-REFERENCE.md). Committing while
|
||||
// stats/bmx are still stale in the runtime layer freezes those stale values
|
||||
// into the persistence layer permanently — a later reboot loads that frozen
|
||||
// persistence layer, not the XML file, so nothing short of a factory reset
|
||||
// clears it again. Re-applying the real boseurls over telnet :17000
|
||||
// reconciles all four immediately.
|
||||
//
|
||||
// Best-effort: telnet may be unavailable (no port 17000, or it was closed via
|
||||
// --close-17000), in which case a reboot still reconciles the layers, so this
|
||||
// only returns a note and never fails the migration. Returns the log lines to
|
||||
// append.
|
||||
func (m *Manager) resyncBoseURLsAfterXML(deviceIP, marge, swUpdate string) string {
|
||||
func (m *Manager) resyncBoseURLsAfterXML(deviceIP string, urls telnetURLs) string {
|
||||
if m.NewTelnet == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
rlogs, rerr := m.setBoseURLsViaTelnet(deviceIP, marge, swUpdate)
|
||||
rlogs, rerr := m.setAllBoseURLsViaTelnet(deviceIP, urls)
|
||||
if rerr != nil {
|
||||
return fmt.Sprintf("Note: could not re-sync boseurls over telnet (%v); a device reboot will reconcile the runtime layer.\n", rerr)
|
||||
}
|
||||
@@ -1869,6 +1891,18 @@ func (m *Manager) patchUdhcpcScript(client SSHClient, targetScript, hookMarker s
|
||||
// RevertMigration reverts the speaker to its original Bose cloud configuration.
|
||||
func (m *Manager) RevertMigration(deviceIP string) (string, error) {
|
||||
client := m.NewSSH(deviceIP)
|
||||
|
||||
// This function alone makes ~17 client.Run/UploadContent calls across
|
||||
// its sub-steps below. Dialing a fresh SSH connection per call (the
|
||||
// default when Connect isn't used) was confirmed on real hardware to
|
||||
// overwhelm a resource-constrained speaker; Connect+defer Close keeps
|
||||
// it to one connection for the whole revert instead.
|
||||
if err := client.Connect(); err != nil {
|
||||
return "", fmt.Errorf("failed to connect for revert: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
rwCmd := "(rw || mount -o remount,rw /)"
|
||||
|
||||
var logs string
|
||||
@@ -2601,12 +2635,51 @@ func (m *Manager) resolveIP(host string, client SSHClient) (string, error) {
|
||||
ErrResolvedFromServiceOnly, host, resolved)
|
||||
}
|
||||
|
||||
// SyncDeviceData fetches presets, recents and sources from the device and saves them to the datastore.
|
||||
func (m *Manager) SyncDeviceData(deviceIP string) error {
|
||||
// SyncResourceDiff describes what a Data Sync would change for one
|
||||
// datastore resource (presets or recents): what's currently stored versus
|
||||
// what the speaker's own live :8090 API returned just now.
|
||||
type SyncResourceDiff struct {
|
||||
Resource string `json:"resource"`
|
||||
CurrentCount int `json:"currentCount"`
|
||||
IncomingCount int `json:"incomingCount"`
|
||||
Removed []string `json:"removed,omitempty"`
|
||||
Destructive bool `json:"destructive"`
|
||||
}
|
||||
|
||||
// SyncResult is the outcome of a SyncDeviceData call: whether it actually
|
||||
// wrote anything, and the per-resource diff that led to that decision.
|
||||
type SyncResult struct {
|
||||
Applied bool `json:"applied"`
|
||||
Destructive bool `json:"destructive"`
|
||||
Diffs []SyncResourceDiff `json:"diffs"`
|
||||
// SourcesCount is the number of configured sources saved for this
|
||||
// device, or -1 if the sources fetch failed. Sources are synced
|
||||
// unconditionally (see syncSources) — there's no diff/confirm gate for
|
||||
// them — so this is a plain count rather than a SyncResourceDiff.
|
||||
SourcesCount int `json:"sourcesCount"`
|
||||
}
|
||||
|
||||
// SyncDeviceData fetches presets, recents and sources from the device and
|
||||
// saves them to the datastore.
|
||||
//
|
||||
// Presets and recents are fetched live from the speaker's own :8090 API and
|
||||
// would previously overwrite the datastore unconditionally — including with
|
||||
// an empty or shrunk list if the speaker's own local cache happened to be
|
||||
// stale or incomplete at that exact moment (e.g. right after a burst of
|
||||
// preset writes, or shortly after a reboot before the speaker has resynced
|
||||
// with Marge). That's a real, confirmed mechanism for #614's "Sync wipes my
|
||||
// presets" reports. Now: if applying would shrink either list relative to
|
||||
// what's already stored, SyncDeviceData does NOT write — it reports the
|
||||
// diff instead — unless confirmed is true. There is no cached "preview"
|
||||
// state: every call (confirmed or not) re-fetches live from the speaker at
|
||||
// that moment, so confirming re-checks reality rather than replaying a
|
||||
// possibly-stale earlier snapshot. Sources are left unconditional, as
|
||||
// before — a source-list change is comparatively low-risk and self-healing.
|
||||
func (m *Manager) SyncDeviceData(deviceIP string, confirmed bool) (SyncResult, error) {
|
||||
// 1. Fetch info to get Serial Number (account identifier)
|
||||
info, err := m.GetLiveDeviceInfo(deviceIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get device info: %w", err)
|
||||
return SyncResult{}, fmt.Errorf("failed to get device info: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Starting sync for device at %s: Name='%s', DeviceID='%s', SerialNumber='%s'",
|
||||
@@ -2618,7 +2691,7 @@ func (m *Manager) SyncDeviceData(deviceIP string) error {
|
||||
deviceID := info.DeviceID
|
||||
if deviceID == "" {
|
||||
log.Printf("No deviceID found in /info response for device '%s' at %s", sanitizeLog(info.Name), sanitizeLog(deviceIP))
|
||||
return fmt.Errorf("no deviceID found in /info response for device at %s - cannot sync without canonical device identifier", deviceIP)
|
||||
return SyncResult{}, fmt.Errorf("no deviceID found in /info response for device at %s - cannot sync without canonical device identifier", deviceIP)
|
||||
}
|
||||
|
||||
log.Printf("Using deviceID '%s' for sync operations (MAC address from /info)", sanitizeLog(deviceID))
|
||||
@@ -2642,14 +2715,42 @@ func (m *Manager) SyncDeviceData(deviceIP string) error {
|
||||
accountID = "default"
|
||||
}
|
||||
|
||||
// 2. Fetch Presets from :8090
|
||||
m.syncPresets(deviceIP, accountID, deviceID)
|
||||
// 2. Diff presets and recents against a fresh live fetch, before writing
|
||||
// anything.
|
||||
presetDiff, incomingPresets, presetErr := m.presetSyncDiff(deviceIP, accountID, deviceID)
|
||||
if presetErr != nil {
|
||||
log.Printf("[SYNC_ERR] Failed to fetch presets for %s: %v", sanitizeLog(deviceIP), presetErr)
|
||||
}
|
||||
|
||||
// 3. Fetch Recents from :8090
|
||||
m.syncRecents(deviceIP, accountID, deviceID)
|
||||
recentDiff, incomingRecents, recentErr := m.recentSyncDiff(deviceIP, accountID, deviceID)
|
||||
if recentErr != nil {
|
||||
log.Printf("[SYNC_ERR] Failed to fetch recents for %s: %v", sanitizeLog(deviceIP), recentErr)
|
||||
}
|
||||
|
||||
result := SyncResult{
|
||||
Diffs: []SyncResourceDiff{presetDiff, recentDiff},
|
||||
Destructive: presetDiff.Destructive || recentDiff.Destructive,
|
||||
}
|
||||
|
||||
if result.Destructive && !confirmed {
|
||||
log.Printf("[SYNC] Sync for %s would shrink stored data (presets %d->%d, recents %d->%d) — awaiting confirmation, not writing anything",
|
||||
sanitizeLog(deviceIP), presetDiff.CurrentCount, presetDiff.IncomingCount, recentDiff.CurrentCount, recentDiff.IncomingCount)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 3. Apply presets/recents (skip whichever one failed to fetch, leaving
|
||||
// the existing stored data untouched rather than wiping it).
|
||||
if presetErr == nil {
|
||||
_ = m.DataStore.SavePresets(accountID, deviceID, incomingPresets)
|
||||
}
|
||||
|
||||
if recentErr == nil {
|
||||
_ = m.DataStore.SaveRecents(accountID, deviceID, incomingRecents)
|
||||
}
|
||||
|
||||
// 4. Fetch Sources
|
||||
m.syncSources(deviceIP, accountID, deviceID)
|
||||
result.SourcesCount = m.syncSources(deviceIP, accountID, deviceID)
|
||||
|
||||
// 5. Nudge the device to re-render its source list. After a factory
|
||||
// reset (issue #234) the speaker's /sources only lists the always-on
|
||||
@@ -2663,28 +2764,113 @@ func (m *Manager) SyncDeviceData(deviceIP string) error {
|
||||
// 6. Create off-device backup of system configuration
|
||||
_ = m.BackupConfigOffDevice(deviceIP)
|
||||
|
||||
return nil
|
||||
result.Applied = true
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) syncPresets(deviceIP, accountID, deviceID string) {
|
||||
// presetSyncDiff fetches the live preset list from the speaker and compares
|
||||
// it against what's currently stored, without writing anything.
|
||||
func (m *Manager) presetSyncDiff(deviceIP, accountID, deviceID string) (SyncResourceDiff, []models.ServicePreset, error) {
|
||||
current, _ := m.DataStore.GetPresets(accountID, deviceID)
|
||||
|
||||
incoming, err := m.fetchLivePresets(deviceIP)
|
||||
if err != nil {
|
||||
return SyncResourceDiff{Resource: "presets", CurrentCount: len(current), IncomingCount: len(current)}, nil, err
|
||||
}
|
||||
|
||||
return diffPresets(current, incoming), incoming, nil
|
||||
}
|
||||
|
||||
// recentSyncDiff fetches the live recents list from the speaker and
|
||||
// compares it against what's currently stored, without writing anything.
|
||||
func (m *Manager) recentSyncDiff(deviceIP, accountID, deviceID string) (SyncResourceDiff, []models.ServiceRecent, error) {
|
||||
current, _ := m.DataStore.GetRecents(accountID, deviceID)
|
||||
|
||||
incoming, err := m.fetchLiveRecents(deviceIP)
|
||||
if err != nil {
|
||||
return SyncResourceDiff{Resource: "recents", CurrentCount: len(current), IncomingCount: len(current)}, nil, err
|
||||
}
|
||||
|
||||
return diffRecents(current, incoming), incoming, nil
|
||||
}
|
||||
|
||||
// diffPresets compares a stored preset list against a freshly-fetched one.
|
||||
// Removed lists the names of presets present in current but absent (by
|
||||
// button/slot ID) from incoming — this is what tells an operator "Sync
|
||||
// would remove preset 6: Ici Roussillon" instead of just a bare count.
|
||||
func diffPresets(current, incoming []models.ServicePreset) SyncResourceDiff {
|
||||
incomingIDs := make(map[string]bool, len(incoming))
|
||||
for i := range incoming {
|
||||
if incoming[i].ID != "" {
|
||||
incomingIDs[incoming[i].ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
var removed []string
|
||||
|
||||
for i := range current {
|
||||
if current[i].ID != "" && current[i].Name != "" && !incomingIDs[current[i].ID] {
|
||||
removed = append(removed, current[i].Name)
|
||||
}
|
||||
}
|
||||
|
||||
return SyncResourceDiff{
|
||||
Resource: "presets",
|
||||
CurrentCount: len(current),
|
||||
IncomingCount: len(incoming),
|
||||
Removed: removed,
|
||||
Destructive: len(incoming) < len(current),
|
||||
}
|
||||
}
|
||||
|
||||
// diffRecents compares a stored recents list against a freshly-fetched one.
|
||||
// Recents have no stable per-entry ID the way presets do (they're an
|
||||
// ordered, time-sorted, size-capped list), so entries are matched by
|
||||
// content Location instead.
|
||||
func diffRecents(current, incoming []models.ServiceRecent) SyncResourceDiff {
|
||||
incomingLocations := make(map[string]bool, len(incoming))
|
||||
for i := range incoming {
|
||||
if incoming[i].Location != "" {
|
||||
incomingLocations[incoming[i].Location] = true
|
||||
}
|
||||
}
|
||||
|
||||
var removed []string
|
||||
|
||||
for i := range current {
|
||||
if current[i].Location != "" && current[i].Name != "" && !incomingLocations[current[i].Location] {
|
||||
removed = append(removed, current[i].Name)
|
||||
}
|
||||
}
|
||||
|
||||
return SyncResourceDiff{
|
||||
Resource: "recents",
|
||||
CurrentCount: len(current),
|
||||
IncomingCount: len(incoming),
|
||||
Removed: removed,
|
||||
Destructive: len(incoming) < len(current),
|
||||
}
|
||||
}
|
||||
|
||||
// fetchLivePresets fetches the current preset list straight from the
|
||||
// speaker's own local :8090 API. It does not touch the datastore.
|
||||
func (m *Manager) fetchLivePresets(deviceIP string) ([]models.ServicePreset, error) {
|
||||
presetsURL := fmt.Sprintf("http://%s:8090/presets", deviceIP)
|
||||
if _, _, splitErr := net.SplitHostPort(deviceIP); splitErr == nil {
|
||||
presetsURL = fmt.Sprintf("http://%s/presets", deviceIP)
|
||||
}
|
||||
|
||||
log.Printf("[SYNC] Syncing presets for %s", sanitizeLog(deviceIP))
|
||||
|
||||
resp, err := m.HTTPGet(presetsURL)
|
||||
if err != nil {
|
||||
log.Printf("[SYNC_ERR] Failed to fetch presets for %s: %v", sanitizeLog(deviceIP), err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var ps models.Presets
|
||||
if decodeErr := xml.NewDecoder(resp.Body).Decode(&ps); decodeErr != nil {
|
||||
return
|
||||
return nil, decodeErr
|
||||
}
|
||||
|
||||
var servicePresets []models.ServicePreset
|
||||
@@ -2728,10 +2914,28 @@ func (m *Manager) syncPresets(deviceIP, accountID, deviceID string) {
|
||||
})
|
||||
}
|
||||
|
||||
_ = m.DataStore.SavePresets(accountID, deviceID, servicePresets)
|
||||
return servicePresets, nil
|
||||
}
|
||||
|
||||
func (m *Manager) syncRecents(deviceIP, accountID, deviceID string) {
|
||||
// syncPresets fetches the live preset list and unconditionally persists it.
|
||||
// Used directly by tests exercising the raw fetch+save behaviour; the
|
||||
// button-driven path goes through SyncDeviceData's diff/confirm guard
|
||||
// instead.
|
||||
func (m *Manager) syncPresets(deviceIP, accountID, deviceID string) {
|
||||
log.Printf("[SYNC] Syncing presets for %s", sanitizeLog(deviceIP))
|
||||
|
||||
presets, err := m.fetchLivePresets(deviceIP)
|
||||
if err != nil {
|
||||
log.Printf("[SYNC_ERR] Failed to fetch presets for %s: %v", sanitizeLog(deviceIP), err)
|
||||
return
|
||||
}
|
||||
|
||||
_ = m.DataStore.SavePresets(accountID, deviceID, presets)
|
||||
}
|
||||
|
||||
// fetchLiveRecents fetches the current recents list straight from the
|
||||
// speaker's own local :8090 API. It does not touch the datastore.
|
||||
func (m *Manager) fetchLiveRecents(deviceIP string) ([]models.ServiceRecent, error) {
|
||||
recentsURL := fmt.Sprintf("http://%s:8090/recents", deviceIP)
|
||||
if _, _, splitErr := net.SplitHostPort(deviceIP); splitErr == nil {
|
||||
recentsURL = fmt.Sprintf("http://%s/recents", deviceIP)
|
||||
@@ -2739,14 +2943,14 @@ func (m *Manager) syncRecents(deviceIP, accountID, deviceID string) {
|
||||
|
||||
resp, err := m.HTTPGet(recentsURL)
|
||||
if err != nil {
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var rr models.RecentsResponse
|
||||
if decodeErr := xml.NewDecoder(resp.Body).Decode(&rr); decodeErr != nil {
|
||||
return
|
||||
return nil, decodeErr
|
||||
}
|
||||
|
||||
var serviceRecents []models.ServiceRecent
|
||||
@@ -2773,10 +2977,28 @@ func (m *Manager) syncRecents(deviceIP, accountID, deviceID string) {
|
||||
})
|
||||
}
|
||||
|
||||
_ = m.DataStore.SaveRecents(accountID, deviceID, serviceRecents)
|
||||
return serviceRecents, nil
|
||||
}
|
||||
|
||||
func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
|
||||
// syncRecents fetches the live recents list and unconditionally persists
|
||||
// it. Used directly by tests exercising the raw fetch+save behaviour; the
|
||||
// button-driven path goes through SyncDeviceData's diff/confirm guard
|
||||
// instead.
|
||||
func (m *Manager) syncRecents(deviceIP, accountID, deviceID string) {
|
||||
recents, err := m.fetchLiveRecents(deviceIP)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = m.DataStore.SaveRecents(accountID, deviceID, recents)
|
||||
}
|
||||
|
||||
// syncSources fetches the device's configured sources (via SSH first, then
|
||||
// falling back to :8090/sources) and persists them. It returns the number
|
||||
// of sources actually saved, or -1 if neither path produced anything to
|
||||
// save (so the caller/UI can distinguish "synced zero sources" from "sync
|
||||
// didn't run").
|
||||
func (m *Manager) syncSources(deviceIP, accountID, deviceID string) int {
|
||||
client := m.NewSSH(deviceIP)
|
||||
|
||||
sourcesXML, err := client.Run("cat /mnt/nv/BoseApp-Persistence/1/Sources.xml")
|
||||
@@ -2801,7 +3023,7 @@ func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
|
||||
|
||||
_ = m.DataStore.SaveConfiguredSources(accountID, deviceID, srs.Sources)
|
||||
|
||||
return
|
||||
return len(srs.Sources)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2813,46 +3035,50 @@ func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
|
||||
|
||||
resp, err := m.HTTPGet(sourcesURL)
|
||||
if err != nil {
|
||||
return
|
||||
return -1
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var srs models.Sources
|
||||
if decodeErr := xml.NewDecoder(resp.Body).Decode(&srs); decodeErr == nil {
|
||||
var configuredSources []models.ConfiguredSource
|
||||
if decodeErr := xml.NewDecoder(resp.Body).Decode(&srs); decodeErr != nil {
|
||||
return -1
|
||||
}
|
||||
|
||||
for _, s := range srs.SourceItem {
|
||||
cs := models.ConfiguredSource{
|
||||
DisplayName: s.DisplayName,
|
||||
Secret: "",
|
||||
SecretType: "",
|
||||
}
|
||||
if s.Status == "READY" {
|
||||
cs.SecretType = "token"
|
||||
}
|
||||
var configuredSources []models.ConfiguredSource
|
||||
|
||||
if s.Source == constants.ProviderSpotify {
|
||||
cs.SecretType = "token_version_3"
|
||||
}
|
||||
|
||||
cs.SourceKey.Type = s.Source
|
||||
cs.SourceKey.Account = s.SourceAccount
|
||||
// Also set legacy fields for now
|
||||
cs.SourceKeyType = s.Source
|
||||
cs.SourceKeyAccount = s.SourceAccount
|
||||
|
||||
configuredSources = append(configuredSources, cs)
|
||||
for _, s := range srs.SourceItem {
|
||||
cs := models.ConfiguredSource{
|
||||
DisplayName: s.DisplayName,
|
||||
Secret: "",
|
||||
SecretType: "",
|
||||
}
|
||||
if s.Status == "READY" {
|
||||
cs.SecretType = "token"
|
||||
}
|
||||
|
||||
// Drop device-local/transient sources without a resolvable
|
||||
// sourceproviderid (e.g. STORED_MUSIC_MEDIA_RENDERER, UPNP).
|
||||
// Persisting them causes /full to emit an empty <sourceproviderid>
|
||||
// which the speaker rejects as INVALID_SOURCE (#334).
|
||||
configuredSources = filterServableSources(configuredSources, deviceID)
|
||||
if s.Source == constants.ProviderSpotify {
|
||||
cs.SecretType = "token_version_3"
|
||||
}
|
||||
|
||||
_ = m.DataStore.SaveConfiguredSources(accountID, deviceID, configuredSources)
|
||||
cs.SourceKey.Type = s.Source
|
||||
cs.SourceKey.Account = s.SourceAccount
|
||||
// Also set legacy fields for now
|
||||
cs.SourceKeyType = s.Source
|
||||
cs.SourceKeyAccount = s.SourceAccount
|
||||
|
||||
configuredSources = append(configuredSources, cs)
|
||||
}
|
||||
|
||||
// Drop device-local/transient sources without a resolvable
|
||||
// sourceproviderid (e.g. STORED_MUSIC_MEDIA_RENDERER, UPNP).
|
||||
// Persisting them causes /full to emit an empty <sourceproviderid>
|
||||
// which the speaker rejects as INVALID_SOURCE (#334).
|
||||
configuredSources = filterServableSources(configuredSources, deviceID)
|
||||
|
||||
_ = m.DataStore.SaveConfiguredSources(accountID, deviceID, configuredSources)
|
||||
|
||||
return len(configuredSources)
|
||||
}
|
||||
|
||||
// filterServableSources returns a copy of srcs containing only sources that
|
||||
|
||||
@@ -132,6 +132,12 @@ func (m *mockSSH) UploadContent(content []byte, remotePath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Connect/Close are no-ops here — the mock has no real connection to
|
||||
// reuse, and every test call already goes through Run/UploadContent above
|
||||
// regardless of whether Connect was called first.
|
||||
func (m *mockSSH) Connect() error { return nil }
|
||||
func (m *mockSSH) Close() error { return nil }
|
||||
|
||||
func TestMigrateViaHosts(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "setup-test")
|
||||
if err != nil {
|
||||
@@ -2096,25 +2102,41 @@ func TestMigrateViaXML_ReappliesBoseURLsOverTelnet(t *testing.T) {
|
||||
return &mockSSH{runFunc: func(string) (string, error) { return "", nil }}
|
||||
}
|
||||
|
||||
ft := &fakeTelnet{banner: "->", responses: map[string]string{}}
|
||||
wantCmds := telnetURLs{
|
||||
Marge: target,
|
||||
Stats: target,
|
||||
SwUpdate: target + "/updates/soundtouch",
|
||||
BmxRegistry: target + "/bmx/registry/v1/services",
|
||||
}.Commands()
|
||||
|
||||
resp := make(map[string]string, len(wantCmds))
|
||||
for _, c := range wantCmds {
|
||||
resp[c] = "OK\n"
|
||||
}
|
||||
|
||||
ft := &fakeTelnet{banner: "->", responses: resp}
|
||||
m.NewTelnet = func(string) TelnetClient { return ft }
|
||||
|
||||
if _, err := m.MigrateSpeaker("192.0.2.10", target, "", nil, MigrationMethodXML); err != nil {
|
||||
t.Fatalf("MigrateSpeaker: %v", err)
|
||||
}
|
||||
|
||||
want := `envswitch boseurls set "` + target + `" "` + target + `/updates/soundtouch"`
|
||||
|
||||
var found bool
|
||||
for _, c := range ft.commands {
|
||||
if c == want {
|
||||
found = true
|
||||
break
|
||||
// All four `sys configuration` writes must land before the envswitch
|
||||
// commit — see enable_ssh.go's setAllBoseURLsViaTelnet — otherwise the
|
||||
// commit freezes whatever stale value was still in the runtime layer for
|
||||
// any field not passed to it (the #621 statsServerUrl/bmxRegistryUrl bug).
|
||||
for _, want := range wantCmds {
|
||||
var found bool
|
||||
for _, c := range ft.commands {
|
||||
if c == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("expected boseurls re-apply %q after XML migration; sent: %v", want, ft.commands)
|
||||
if !found {
|
||||
t.Errorf("expected boseurls re-apply command %q after XML migration; sent: %v", want, ft.commands)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// TestSyncDeviceData_DestructiveSyncRequiresConfirmation is a regression
|
||||
// test for #614's 2026-08-23 finding: SyncDeviceData used to overwrite the
|
||||
// datastore unconditionally with whatever the speaker's live :8090 API
|
||||
// returned, even if that snapshot had fewer presets than what was already
|
||||
// stored — e.g. because the speaker's own local cache was stale or
|
||||
// incomplete at that exact moment. This is a real, confirmed mechanism for
|
||||
// "Sync wipes my presets" reports.
|
||||
//
|
||||
// A device already has 3 stored presets. The mock speaker's live /presets
|
||||
// only reports 1. The first (unconfirmed) sync must NOT write anything and
|
||||
// must report the shrink; a confirmed retry must apply it.
|
||||
func TestSyncDeviceData_DestructiveSyncRequiresConfirmation(t *testing.T) {
|
||||
const (
|
||||
accountID = "1234567"
|
||||
deviceID = "AABBCCDDEEFF"
|
||||
)
|
||||
|
||||
mockDevice := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/info":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="%s">
|
||||
<name>Test Device</name>
|
||||
<type>SoundTouch 20</type>
|
||||
<margeAccountUUID>%s</margeAccountUUID>
|
||||
</info>`, deviceID, accountID)
|
||||
case "/presets":
|
||||
// Only one preset survived on the speaker's own live cache —
|
||||
// the datastore already has three (seeded below).
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets>
|
||||
<preset id="1">
|
||||
<ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="/custom/v1/playback/station1" isPresetable="true">
|
||||
<itemName>Station 1</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
</presets>`)
|
||||
case "/recents":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?><recents></recents>`)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer mockDevice.Close()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "sync-destructive-guard-*")
|
||||
if err != nil {
|
||||
t.Fatalf("tempdir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
seeded := []models.ServicePreset{
|
||||
{ID: "1", ButtonNumber: "1", ServiceContentItem: models.ServiceContentItem{Name: "Station 1"}},
|
||||
{ID: "2", ButtonNumber: "2", ServiceContentItem: models.ServiceContentItem{Name: "Station 2"}},
|
||||
{ID: "3", ButtonNumber: "3", ServiceContentItem: models.ServiceContentItem{Name: "Station 3"}},
|
||||
}
|
||||
if err := ds.SavePresets(accountID, deviceID, seeded); err != nil {
|
||||
t.Fatalf("seed SavePresets: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager("http://localhost:8000", ds, nil)
|
||||
deviceIP := mockDevice.Listener.Addr().String()
|
||||
|
||||
// Unconfirmed: must refuse to write and report the shrink.
|
||||
result, err := m.SyncDeviceData(deviceIP, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncDeviceData(confirmed=false): %v", err)
|
||||
}
|
||||
|
||||
if result.Applied {
|
||||
t.Fatal("expected unconfirmed destructive sync to NOT apply")
|
||||
}
|
||||
|
||||
if !result.Destructive {
|
||||
t.Fatal("expected result.Destructive=true for a 3->1 preset shrink")
|
||||
}
|
||||
|
||||
var presetDiff *SyncResourceDiff
|
||||
for i := range result.Diffs {
|
||||
if result.Diffs[i].Resource == "presets" {
|
||||
presetDiff = &result.Diffs[i]
|
||||
}
|
||||
}
|
||||
|
||||
if presetDiff == nil {
|
||||
t.Fatal("expected a presets diff in the result")
|
||||
}
|
||||
|
||||
if presetDiff.CurrentCount != 3 || presetDiff.IncomingCount != 1 {
|
||||
t.Errorf("expected presets diff 3->1, got %d->%d", presetDiff.CurrentCount, presetDiff.IncomingCount)
|
||||
}
|
||||
|
||||
if len(presetDiff.Removed) != 2 {
|
||||
t.Errorf("expected 2 removed preset names (slots 2 and 3), got %v", presetDiff.Removed)
|
||||
}
|
||||
|
||||
presetsAfterRefusal, err := ds.GetPresets(accountID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPresets after refused sync: %v", err)
|
||||
}
|
||||
|
||||
if len(presetsAfterRefusal) != 3 {
|
||||
t.Fatalf("expected the original 3 presets to survive an unconfirmed destructive sync, got %d", len(presetsAfterRefusal))
|
||||
}
|
||||
|
||||
// Confirmed: must re-check fresh state and apply.
|
||||
result, err = m.SyncDeviceData(deviceIP, true)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncDeviceData(confirmed=true): %v", err)
|
||||
}
|
||||
|
||||
if !result.Applied {
|
||||
t.Fatal("expected confirmed destructive sync to apply")
|
||||
}
|
||||
|
||||
presetsAfterConfirm, err := ds.GetPresets(accountID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPresets after confirmed sync: %v", err)
|
||||
}
|
||||
|
||||
if len(presetsAfterConfirm) != 1 {
|
||||
t.Fatalf("expected confirmed sync to shrink to 1 preset, got %d", len(presetsAfterConfirm))
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func TestSyncDeviceData_UsesDeviceID(t *testing.T) {
|
||||
manager := NewManager("http://localhost:8000", ds, cm)
|
||||
|
||||
// Test SyncDeviceData
|
||||
err := manager.SyncDeviceData(serverHost)
|
||||
_, err := manager.SyncDeviceData(serverHost, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncDeviceData failed: %v", err)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func TestSyncDeviceData_NoDeviceID_ShouldFail(t *testing.T) {
|
||||
manager := NewManager("http://localhost:8000", ds, cm)
|
||||
|
||||
// Test SyncDeviceData - should fail
|
||||
err := manager.SyncDeviceData(serverHost)
|
||||
_, err := manager.SyncDeviceData(serverHost, false)
|
||||
if err == nil {
|
||||
t.Fatal("SyncDeviceData should have failed when deviceID is empty")
|
||||
}
|
||||
@@ -221,7 +221,7 @@ func TestSyncDeviceData_FallbackToExistingDeviceMapping(t *testing.T) {
|
||||
manager := NewManager("http://localhost:8000", ds, cm)
|
||||
|
||||
// Sync should work and use MAC address
|
||||
err := manager.SyncDeviceData(serverHost)
|
||||
_, err := manager.SyncDeviceData(serverHost, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncDeviceData failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ func TestSyncSources_Format(t *testing.T) {
|
||||
deviceIP := mockDevice.Listener.Addr().String()
|
||||
accountID := "1234567"
|
||||
deviceID := "001122334455"
|
||||
err = m.SyncDeviceData(deviceIP)
|
||||
_, err = m.SyncDeviceData(deviceIP, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncDeviceData failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -130,13 +130,13 @@ func (m *Manager) migrateViaTelnet(deviceIP, targetURL string, urls telnetURLs)
|
||||
return logs.String(), fmt.Errorf("verification command failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&logs, "→ getpdo CurrentSystemConfiguration\n%s\n", strings.TrimRight(verify, "\r\n"))
|
||||
fmt.Fprintf(&logs, "→ getpdo CurrentSystemConfiguration (runtime layer only — confirms the writes were accepted, not that they'll survive a reboot)\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")
|
||||
logs.WriteString("Telnet writes accepted (runtime layer). Reboot the device so the envswitch-persisted layer takes over.\n")
|
||||
|
||||
return logs.String(), nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user