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: |
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -457,7 +457,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.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 @@
|
||||
# Build stage
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.5-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.6-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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+255
-10
@@ -52,10 +52,12 @@ func setupCommand() *cli.Command {
|
||||
setupRemoteServicesCmd(),
|
||||
setupInstallCACmd(),
|
||||
setupMigrateCmd(),
|
||||
setupRevertCmd(),
|
||||
setupRebootCmd(),
|
||||
setupVerifyCmd(),
|
||||
setupPlanCmd(),
|
||||
setupPairCmd(),
|
||||
setupSyncCmd(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -517,8 +519,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 +548,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 +583,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 +602,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 +660,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: 7-digit account ID to pair " +
|
||||
"with (empty = generate 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 +711,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 +1015,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 +1135,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 +1150,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 +1180,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 +1509,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",
|
||||
@@ -1847,6 +2081,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 +2105,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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+522
-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
|
||||
@@ -1249,6 +1471,17 @@ func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, d
|
||||
return err
|
||||
}
|
||||
|
||||
// Opt-in (#622): hand-edit settings.json's auto_resume_on_source_disconnect
|
||||
// to enable. Read fresh per drop so toggling it applies without a restart.
|
||||
webApp.AutoResumeOnSourceDisconnect = func() bool {
|
||||
settings, err := ds.GetSettings()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return settings.AutoResumeOnSourceDisconnect
|
||||
}
|
||||
|
||||
// Keep the UI registry live as the service discovers or devices are added.
|
||||
server.SetDevicesChangedHook(func() {
|
||||
webApp.SeedExtraDevices()
|
||||
@@ -1290,8 +1523,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 +1814,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 +1838,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 +1859,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 +1890,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 +1910,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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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.26.6-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.26.6-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.26.6-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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.26.6
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.118.0
|
||||
require github.com/gesellix/bose-soundtouch v0.123.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module preset-management-example
|
||||
|
||||
go 1.26.5
|
||||
go 1.26.6
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.118.0
|
||||
require github.com/gesellix/bose-soundtouch v0.123.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/gesellix/bose-soundtouch
|
||||
|
||||
go 1.26.5
|
||||
go 1.26.6
|
||||
|
||||
require (
|
||||
filippo.io/age v1.3.1
|
||||
@@ -15,8 +15,9 @@ require (
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.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,9 @@ 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/image v0.45.0 // indirect
|
||||
golang.org/x/sync v0.22.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
|
||||
golang.org/x/tools v0.49.0 // indirect
|
||||
)
|
||||
|
||||
@@ -63,18 +63,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 +86,11 @@ 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/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
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"`
|
||||
|
||||
@@ -2594,6 +2594,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"`
|
||||
@@ -2649,6 +2651,19 @@ type Settings struct {
|
||||
// individual format tokens.
|
||||
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
|
||||
|
||||
// AutoResumeOnSourceDisconnect, when true, re-issues a device's last
|
||||
// playing content item if now_playing drops into an error source right
|
||||
// after a healthy one, instead of leaving the speaker silent until a
|
||||
// user manually re-selects it. See #622: some TuneIn streams disconnect
|
||||
// the speaker's own audio pipeline (errorUpdate 1041
|
||||
// SOURCE_DISCONNECTED) on their own, mid-playback, with the SoundTouch
|
||||
// WebSocket control channel staying healthy throughout; the observed
|
||||
// fix is exactly what pressing the preset again does. Opt-in (default
|
||||
// false): this automatically re-triggers content selection without a
|
||||
// user action, which not every operator wants. Hand-edit settings.json
|
||||
// to enable — no admin UI control yet, matching TuneInStreamFormats.
|
||||
AutoResumeOnSourceDisconnect bool `json:"auto_resume_on_source_disconnect,omitempty"`
|
||||
|
||||
// DefaultLanding selects what the root path "/" serves to a browser:
|
||||
// "chooser" (or empty) — the neutral landing page that links to the
|
||||
// player and the admin/setup console;
|
||||
@@ -2657,6 +2672,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 +2735,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 +2810,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) {
|
||||
|
||||
@@ -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,6 +9,7 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -189,6 +190,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 {
|
||||
@@ -671,33 +673,84 @@ func (s *Server) addSystemFiles(tw *tar.Writer) {
|
||||
// diagSettings is a copy of datastore.Settings with secrets zeroed out so the
|
||||
// struct can be marshalled into the archive without exposing credentials.
|
||||
type diagSettings struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
HTTPSServerURL string `json:"https_server_url,omitempty"`
|
||||
HTTPSServerURLOverride string `json:"https_server_url_override,omitempty"`
|
||||
RedactLogs bool `json:"redact_logs"`
|
||||
LogBodies bool `json:"log_bodies"`
|
||||
RecordInteractions bool `json:"record_interactions"`
|
||||
DiscoveryInterval string `json:"discovery_interval,omitempty"`
|
||||
DiscoveryEnabled bool `json:"discovery_enabled"`
|
||||
DNSEnabled bool `json:"dns_enabled"`
|
||||
DNSUpstream []string `json:"dns_upstream,omitempty"`
|
||||
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
|
||||
InternalPaths []string `json:"internal_paths,omitempty"`
|
||||
Shortcuts map[string]int `json:"shortcuts,omitempty"`
|
||||
SpotifyClientID string `json:"spotify_client_id,omitempty"`
|
||||
SpotifyClientSecret string `json:"spotify_client_secret,omitempty"`
|
||||
SpotifyRedirectURI string `json:"spotify_redirect_uri,omitempty"`
|
||||
AmazonClientID string `json:"amazon_client_id,omitempty"`
|
||||
AmazonClientSecret string `json:"amazon_client_secret,omitempty"`
|
||||
AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"`
|
||||
TrustForwardedHeaders bool `json:"trust_forwarded_headers,omitempty"`
|
||||
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
|
||||
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
|
||||
ServerURL string `json:"server_url"`
|
||||
HTTPSServerURL string `json:"https_server_url,omitempty"`
|
||||
HTTPSServerURLOverride string `json:"https_server_url_override,omitempty"`
|
||||
RedactLogs bool `json:"redact_logs"`
|
||||
LogBodies bool `json:"log_bodies"`
|
||||
RecordInteractions bool `json:"record_interactions"`
|
||||
DiscoveryInterval string `json:"discovery_interval,omitempty"`
|
||||
DiscoveryEnabled bool `json:"discovery_enabled"`
|
||||
DNSEnabled bool `json:"dns_enabled"`
|
||||
DNSUpstream []string `json:"dns_upstream,omitempty"`
|
||||
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
|
||||
InternalPaths []string `json:"internal_paths,omitempty"`
|
||||
Shortcuts map[string]int `json:"shortcuts,omitempty"`
|
||||
SpotifyClientID string `json:"spotify_client_id,omitempty"`
|
||||
SpotifyClientSecret string `json:"spotify_client_secret,omitempty"`
|
||||
SpotifyRedirectURI string `json:"spotify_redirect_uri,omitempty"`
|
||||
AmazonClientID string `json:"amazon_client_id,omitempty"`
|
||||
AmazonClientSecret string `json:"amazon_client_secret,omitempty"`
|
||||
AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"`
|
||||
TrustForwardedHeaders bool `json:"trust_forwarded_headers,omitempty"`
|
||||
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
|
||||
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
|
||||
AutoResumeOnSourceDisconnect bool `json:"auto_resume_on_source_disconnect,omitempty"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -721,28 +774,29 @@ func (s *Server) addSettingsJSON(tw *tar.Writer) {
|
||||
_, effectiveHTTPSURL := s.GetSettings()
|
||||
|
||||
ds := diagSettings{
|
||||
ServerURL: st.ServerURL,
|
||||
HTTPSServerURL: effectiveHTTPSURL,
|
||||
HTTPSServerURLOverride: st.HTTPServerURL,
|
||||
RedactLogs: st.RedactLogs,
|
||||
LogBodies: st.LogBodies,
|
||||
RecordInteractions: st.RecordInteractions,
|
||||
DiscoveryInterval: st.DiscoveryInterval,
|
||||
DiscoveryEnabled: st.DiscoveryEnabled,
|
||||
DNSEnabled: st.DNSEnabled,
|
||||
DNSUpstream: st.DNSUpstream,
|
||||
DNSBindAddr: st.DNSBindAddr,
|
||||
InternalPaths: st.InternalPaths,
|
||||
Shortcuts: st.Shortcuts,
|
||||
SpotifyClientID: st.SpotifyClientID,
|
||||
SpotifyClientSecret: redact(st.SpotifyClientSecret),
|
||||
SpotifyRedirectURI: st.SpotifyRedirectURI,
|
||||
AmazonClientID: st.AmazonClientID,
|
||||
AmazonClientSecret: redact(st.AmazonClientSecret),
|
||||
AmazonRedirectURI: st.AmazonRedirectURI,
|
||||
TrustForwardedHeaders: st.TrustForwardedHeaders,
|
||||
TrustedProxyCIDRs: st.TrustedProxyCIDRs,
|
||||
TuneInStreamFormats: st.TuneInStreamFormats,
|
||||
ServerURL: st.ServerURL,
|
||||
HTTPSServerURL: effectiveHTTPSURL,
|
||||
HTTPSServerURLOverride: st.HTTPServerURL,
|
||||
RedactLogs: st.RedactLogs,
|
||||
LogBodies: st.LogBodies,
|
||||
RecordInteractions: st.RecordInteractions,
|
||||
DiscoveryInterval: st.DiscoveryInterval,
|
||||
DiscoveryEnabled: st.DiscoveryEnabled,
|
||||
DNSEnabled: st.DNSEnabled,
|
||||
DNSUpstream: st.DNSUpstream,
|
||||
DNSBindAddr: st.DNSBindAddr,
|
||||
InternalPaths: st.InternalPaths,
|
||||
Shortcuts: st.Shortcuts,
|
||||
SpotifyClientID: st.SpotifyClientID,
|
||||
SpotifyClientSecret: redact(st.SpotifyClientSecret),
|
||||
SpotifyRedirectURI: st.SpotifyRedirectURI,
|
||||
AmazonClientID: st.AmazonClientID,
|
||||
AmazonClientSecret: redact(st.AmazonClientSecret),
|
||||
AmazonRedirectURI: st.AmazonRedirectURI,
|
||||
TrustForwardedHeaders: st.TrustForwardedHeaders,
|
||||
TrustedProxyCIDRs: st.TrustedProxyCIDRs,
|
||||
TuneInStreamFormats: st.TuneInStreamFormats,
|
||||
AutoResumeOnSourceDisconnect: st.AutoResumeOnSourceDisconnect,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(ds, "", " ")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
"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 {
|
||||
@@ -1271,6 +1406,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 +1436,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,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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -830,6 +842,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 +899,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);
|
||||
}
|
||||
@@ -901,7 +958,7 @@ 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 = `
|
||||
@@ -1797,6 +1854,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
|
||||
@@ -2038,7 +2096,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 +2150,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 +2572,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 +2787,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 +2869,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 +2885,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 +2920,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 +2946,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 +2966,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 +3840,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 +3925,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 +4059,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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -108,6 +108,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 !IsValidAccountID(target) {
|
||||
return "", false, "", fmt.Errorf("invalid account id %q: must be exactly 7 digits", 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) {
|
||||
@@ -186,6 +225,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.
|
||||
|
||||
@@ -2,6 +2,7 @@ package setup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -15,12 +16,14 @@ import (
|
||||
// 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 +58,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,6 +272,209 @@ func TestPairAccount_TelnetTransportErrorReturned(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 !IsValidAccountID(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-7-digits", 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidAccountID(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ func TestMigrateViaTelnet_HappyPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.Contains(logs, "succeeded") {
|
||||
if !strings.Contains(logs, "accepted") {
|
||||
t.Errorf("logs missing success marker:\n%s", logs)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package soundtouchweb
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
)
|
||||
|
||||
// autoResumeBackoff is the delay before re-issuing a dropped content item,
|
||||
// giving a transient upstream hiccup a moment to clear before retrying.
|
||||
const autoResumeBackoff = 2 * time.Second
|
||||
|
||||
// autoResumeState tracks what ConnectDeviceWebSocket needs to decide whether
|
||||
// a now_playing transition should trigger an auto-resume. Split out from the
|
||||
// WebSocket goroutine so the decision can be unit tested without a live
|
||||
// connection.
|
||||
//
|
||||
// resumeAttempts only labels log lines — it is never used to cap retries.
|
||||
// A resume is gated on wasError being false (see observe), which already
|
||||
// means at most one attempt ever fires per drop: if the attempt fails and
|
||||
// the source stays in error, every following event has wasError=true and
|
||||
// nothing fires again until a genuine recovery is observed. A station that
|
||||
// keeps recovering and re-dropping (the reported #622 pattern — a TuneIn
|
||||
// stream disconnecting the speaker on a fixed cycle, indefinitely, while
|
||||
// otherwise healthy) is exactly the case this should keep resuming forever.
|
||||
type autoResumeState struct {
|
||||
lastGoodContentItem *models.ContentItem
|
||||
resumeAttempts int
|
||||
}
|
||||
|
||||
// observe updates the state for a new now_playing event and reports whether
|
||||
// the caller should fire an auto-resume for item, plus a label for the log
|
||||
// line. prevSource is the source seen on the previous event.
|
||||
//
|
||||
// #622: some TuneIn stations disconnect the speaker's audio pipeline on
|
||||
// their own (errorUpdate 1041 SOURCE_DISCONNECTED, observed ~5m35s into
|
||||
// playback on one reporter's setup) even though the SoundTouch WebSocket
|
||||
// control channel stays healthy throughout. The firmware does not recover
|
||||
// on its own, so a fresh transition into an error source right after a
|
||||
// healthy one — the speaker dropping a source it didn't choose to leave, as
|
||||
// opposed to the user picking a new one — re-issues the last content item,
|
||||
// exactly what pressing the physical preset button again does.
|
||||
func (s *autoResumeState) observe(prevSource string, np *models.NowPlaying) (item *models.ContentItem, attempt int, shouldResume bool) {
|
||||
wasError := isErrorSource(prevSource)
|
||||
nowError := isErrorSource(np.Source)
|
||||
|
||||
if !nowError {
|
||||
if np.ContentItem != nil {
|
||||
s.lastGoodContentItem = np.ContentItem
|
||||
}
|
||||
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
if wasError || s.lastGoodContentItem == nil {
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
s.resumeAttempts++
|
||||
|
||||
return s.lastGoodContentItem, s.resumeAttempts, true
|
||||
}
|
||||
|
||||
// autoResumePlayback re-selects item on conn's device after autoResumeBackoff.
|
||||
// It runs in its own goroutine (never on the WebSocket read loop) so a slow
|
||||
// or hanging /select call can't stall processing of further device events.
|
||||
func autoResumePlayback(conn *webtypes.DeviceConnection, deviceID string, item *models.ContentItem, attempt int) {
|
||||
autoResumePlaybackAfter(conn, deviceID, item, attempt, autoResumeBackoff)
|
||||
}
|
||||
|
||||
// autoResumePlaybackAfter is autoResumePlayback with an injectable delay so
|
||||
// tests don't have to wait out the real backoff.
|
||||
func autoResumePlaybackAfter(conn *webtypes.DeviceConnection, deviceID string, item *models.ContentItem, attempt int, delay time.Duration) {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-conn.Done():
|
||||
return
|
||||
}
|
||||
|
||||
if conn.Client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := conn.Client.SelectContentItem(item); err != nil {
|
||||
log.Printf("[play] device=%q auto-resume attempt %d failed: %v",
|
||||
sanitizeLog(deviceID), attempt, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[play] device=%q auto-resume attempt %d re-selected source=%q location=%q",
|
||||
sanitizeLog(deviceID), attempt, sanitizeLog(item.Source), sanitizeLog(item.Location))
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package soundtouchweb
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
)
|
||||
|
||||
func tuneInNowPlaying(source string) *models.NowPlaying {
|
||||
return &models.NowPlaying{
|
||||
Source: source,
|
||||
ContentItem: &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: "stationurl",
|
||||
Location: "/v1/playback/station/s119025",
|
||||
ItemName: "Arabella Lovesongs",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoResumeState_HealthyRemembersContentItemAndDoesNotResume(t *testing.T) {
|
||||
s := &autoResumeState{}
|
||||
|
||||
item, attempt, shouldResume := s.observe("", tuneInNowPlaying("TUNEIN"))
|
||||
if shouldResume {
|
||||
t.Fatalf("shouldResume = true on a healthy source, want false")
|
||||
}
|
||||
|
||||
if item != nil || attempt != 0 {
|
||||
t.Errorf("item/attempt = %v/%d, want nil/0", item, attempt)
|
||||
}
|
||||
|
||||
if s.lastGoodContentItem == nil {
|
||||
t.Fatal("lastGoodContentItem was not recorded from a healthy now_playing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoResumeState_FreshErrorAfterHealthyTriggersResume(t *testing.T) {
|
||||
s := &autoResumeState{}
|
||||
|
||||
// Prime with a healthy TUNEIN event, matching the WS handler calling
|
||||
// observe once per event with the source seen on the previous call.
|
||||
s.observe("", tuneInNowPlaying("TUNEIN"))
|
||||
|
||||
item, attempt, shouldResume := s.observe("TUNEIN", tuneInNowPlaying("INVALID_SOURCE"))
|
||||
if !shouldResume {
|
||||
t.Fatal("shouldResume = false on a fresh error transition, want true")
|
||||
}
|
||||
|
||||
if attempt != 1 {
|
||||
t.Errorf("attempt = %d, want 1", attempt)
|
||||
}
|
||||
|
||||
if item == nil || item.Location != "/v1/playback/station/s119025" {
|
||||
t.Errorf("item = %+v, want the last healthy ContentItem", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoResumeState_DoesNotResumeWithoutAPriorGoodContentItem(t *testing.T) {
|
||||
s := &autoResumeState{}
|
||||
|
||||
// No healthy event was ever observed, so there's nothing to restore.
|
||||
_, _, shouldResume := s.observe("", tuneInNowPlaying("INVALID_SOURCE"))
|
||||
if shouldResume {
|
||||
t.Fatal("shouldResume = true with no prior good ContentItem, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoResumeState_DoesNotResumeOnRepeatedErrorEvents(t *testing.T) {
|
||||
s := &autoResumeState{}
|
||||
|
||||
s.observe("", tuneInNowPlaying("TUNEIN"))
|
||||
s.observe("TUNEIN", tuneInNowPlaying("INVALID_SOURCE")) // first resume, attempt 1
|
||||
|
||||
// A second consecutive error event (wasError=true this time) must not
|
||||
// fire another resume — one attempt per drop, not per event.
|
||||
_, _, shouldResume := s.observe("INVALID_SOURCE", tuneInNowPlaying("INVALID_SOURCE"))
|
||||
if shouldResume {
|
||||
t.Fatal("shouldResume = true on a repeated error event, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoResumeState_KeepsResumingIndefinitelyAcrossRepeatedDrops(t *testing.T) {
|
||||
s := &autoResumeState{}
|
||||
|
||||
s.observe("", tuneInNowPlaying("TUNEIN"))
|
||||
|
||||
// The reported #622 pattern: the same station drops and (once resumed)
|
||||
// recovers repeatedly, indefinitely, on a fixed cycle. Each fresh drop
|
||||
// after a genuine recovery must keep resuming — there is no cap.
|
||||
const cycles = 20
|
||||
|
||||
for i := 1; i <= cycles; i++ {
|
||||
_, attempt, shouldResume := s.observe("TUNEIN", tuneInNowPlaying("INVALID_SOURCE"))
|
||||
if !shouldResume {
|
||||
t.Fatalf("cycle %d: shouldResume = false, want true", i)
|
||||
}
|
||||
|
||||
if attempt != i {
|
||||
t.Errorf("cycle %d: attempt label = %d, want %d", i, attempt, i)
|
||||
}
|
||||
|
||||
s.observe("INVALID_SOURCE", tuneInNowPlaying("TUNEIN")) // the resume worked
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoResumeState_StopsRetryingAfterAFailedResume(t *testing.T) {
|
||||
s := &autoResumeState{}
|
||||
|
||||
s.observe("", tuneInNowPlaying("TUNEIN"))
|
||||
|
||||
_, _, shouldResume := s.observe("TUNEIN", tuneInNowPlaying("INVALID_SOURCE"))
|
||||
if !shouldResume {
|
||||
t.Fatal("shouldResume = false on the first drop, want true")
|
||||
}
|
||||
|
||||
// The resume attempt itself failed (or the station is genuinely gone):
|
||||
// the speaker keeps reporting the same error source on further events.
|
||||
// wasError is now true, so nothing should fire again without a genuine
|
||||
// recovery in between — this is what keeps a truly dead station from
|
||||
// being retried forever.
|
||||
for i := 0; i < 5; i++ {
|
||||
_, _, shouldResume := s.observe("INVALID_SOURCE", tuneInNowPlaying("INVALID_SOURCE"))
|
||||
if shouldResume {
|
||||
t.Fatalf("iteration %d: shouldResume = true on a persisting error, want false", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoResumePlaybackAfter_ReselectsContentItem(t *testing.T) {
|
||||
speaker, captured := setupSpeakerMock(t, nil)
|
||||
defer speaker.Close()
|
||||
|
||||
c := client.NewClient(&client.Config{Host: speaker.URL})
|
||||
conn := webtypes.NewDeviceConnection(c, &models.DeviceInfo{DeviceID: "DEVICEID01"})
|
||||
|
||||
item := &models.ContentItem{Source: "TUNEIN", Type: "stationurl", Location: "/v1/playback/station/s119025", ItemName: "Arabella Lovesongs"}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
autoResumePlaybackAfter(conn, "DEVICEID01", item, 1, 0)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("autoResumePlaybackAfter did not return in time")
|
||||
}
|
||||
|
||||
body, ok := captured["/select"]
|
||||
if !ok {
|
||||
t.Fatalf("no /select request captured; requests: %v", captured)
|
||||
}
|
||||
|
||||
if !strings.Contains(body, `source="TUNEIN"`) || !strings.Contains(body, "/v1/playback/station/s119025") {
|
||||
t.Errorf("/select body = %q, want it to carry the TUNEIN content item", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoResumePlaybackAfter_StopsWhenConnectionClosed(t *testing.T) {
|
||||
speaker, captured := setupSpeakerMock(t, nil)
|
||||
defer speaker.Close()
|
||||
|
||||
c := client.NewClient(&client.Config{Host: speaker.URL})
|
||||
conn := webtypes.NewDeviceConnection(c, &models.DeviceInfo{DeviceID: "DEVICEID01"})
|
||||
conn.Close()
|
||||
|
||||
item := &models.ContentItem{Source: "TUNEIN", Type: "stationurl", Location: "/v1/playback/station/s119025"}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
autoResumePlaybackAfter(conn, "DEVICEID01", item, 1, time.Hour)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("autoResumePlaybackAfter did not return promptly after conn.Close()")
|
||||
}
|
||||
|
||||
if _, ok := captured["/select"]; ok {
|
||||
t.Error("/select was called after the connection was closed, want no request")
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,15 @@ type WebApp struct {
|
||||
// removal only prunes the in-memory registry).
|
||||
RemoveDeviceHook func(deviceID string) error
|
||||
|
||||
// AutoResumeOnSourceDisconnect, when set and returning true, makes
|
||||
// ConnectDeviceWebSocket re-issue a device's last playing content item
|
||||
// after an unsolicited drop into an error source (#622). Opt-in: the
|
||||
// embedded build wires it to Settings.AutoResumeOnSourceDisconnect
|
||||
// (settings.json, default false); standalone soundtouch-player leaves it
|
||||
// nil, which disables the behaviour. Read once per drop rather than
|
||||
// cached, so toggling the setting takes effect without a restart.
|
||||
AutoResumeOnSourceDisconnect func() bool
|
||||
|
||||
discoveryStatus atomic.Value // stores *webtypes.DiscoveryStatus
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
@@ -55,10 +57,17 @@ func normalizeUDN(s string) string {
|
||||
}
|
||||
|
||||
// HandleDiscoverLibraryServers performs a LAN-wide SSDP sweep for DLNA media
|
||||
// servers and returns them as a JSON array. An optional ?timeout= query
|
||||
// parameter (in seconds, integer) overrides the default 5-second budget.
|
||||
// servers, plus a query to every paired speaker's own /listMediaServers, and
|
||||
// returns the merged set as a JSON array. An optional ?timeout= query
|
||||
// parameter (in seconds, integer) overrides the default 5-second SSDP budget.
|
||||
// This handler is global (not device-scoped) and lives under
|
||||
// /api/control/providers/library/servers.
|
||||
//
|
||||
// The two sources see different networks: our SSDP sweep runs from the
|
||||
// AfterTouch service host, while each speaker's /listMediaServers reflects
|
||||
// what that speaker sees on its own LAN segment. They can disagree when the
|
||||
// service isn't co-located with the speaker (different subnet/VLAN), so a
|
||||
// server invisible to one path may still be visible via the other.
|
||||
func (app *WebApp) HandleDiscoverLibraryServers(w http.ResponseWriter, r *http.Request) {
|
||||
timeout := 5 * time.Second
|
||||
|
||||
@@ -74,15 +83,44 @@ func (app *WebApp) HandleDiscoverLibraryServers(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]libraryServer, 0, len(servers))
|
||||
byUDN := make(map[string]libraryServer, len(servers))
|
||||
order := make([]string, 0, len(servers))
|
||||
|
||||
for _, s := range servers {
|
||||
out = append(out, libraryServer{
|
||||
UDN: normalizeUDN(s.UDN),
|
||||
udn := normalizeUDN(s.UDN)
|
||||
byUDN[udn] = libraryServer{
|
||||
UDN: udn,
|
||||
Name: s.FriendlyName,
|
||||
Manufacturer: s.Manufacturer,
|
||||
Model: s.ModelName,
|
||||
CDSControlURL: s.CDSControlURL,
|
||||
})
|
||||
}
|
||||
order = append(order, udn)
|
||||
}
|
||||
|
||||
deviceFound := app.discoverDeviceMediaServers()
|
||||
for i := range deviceFound {
|
||||
found := &deviceFound[i]
|
||||
|
||||
udn := normalizeUDN(found.ID)
|
||||
if _, exists := byUDN[udn]; exists {
|
||||
// Already found via SSDP, which carries CDSControlURL; keep that
|
||||
// entry since registration itself only needs the UDN and name.
|
||||
continue
|
||||
}
|
||||
|
||||
byUDN[udn] = libraryServer{
|
||||
UDN: udn,
|
||||
Name: found.FriendlyName,
|
||||
Manufacturer: found.Manufacturer,
|
||||
Model: found.ModelName,
|
||||
}
|
||||
order = append(order, udn)
|
||||
}
|
||||
|
||||
out := make([]libraryServer, 0, len(order))
|
||||
for _, udn := range order {
|
||||
out = append(out, byUDN[udn])
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -92,6 +130,71 @@ func (app *WebApp) HandleDiscoverLibraryServers(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
}
|
||||
|
||||
// discoverDeviceMediaServers queries every paired speaker's own
|
||||
// /listMediaServers endpoint concurrently and returns the union of what they
|
||||
// report, deduplicated by UDN. A speaker that is offline, times out, or runs
|
||||
// firmware without the endpoint is skipped silently: this is a best-effort
|
||||
// second discovery path, and one unreachable speaker must not fail or delay
|
||||
// the overall response.
|
||||
func (app *WebApp) discoverDeviceMediaServers() []models.MediaServerInfo {
|
||||
devices := app.DeviceSnapshot()
|
||||
|
||||
type result struct {
|
||||
servers []models.MediaServerInfo
|
||||
}
|
||||
|
||||
results := make(chan result, len(devices))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, entry := range devices {
|
||||
deviceClient := entry.Device.Client
|
||||
if deviceClient == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
|
||||
go func(c *client.Client) {
|
||||
defer wg.Done()
|
||||
|
||||
resp, err := c.ListMediaServers()
|
||||
if err != nil || resp == nil {
|
||||
results <- result{}
|
||||
return
|
||||
}
|
||||
|
||||
results <- result{servers: resp.MediaServers}
|
||||
}(deviceClient)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(results)
|
||||
}()
|
||||
|
||||
seen := make(map[string]bool)
|
||||
|
||||
out := make([]models.MediaServerInfo, 0, len(devices))
|
||||
|
||||
for r := range results {
|
||||
for i := range r.servers {
|
||||
s := &r.servers[i]
|
||||
|
||||
udn := normalizeUDN(s.ID)
|
||||
if udn == "" || seen[udn] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[udn] = true
|
||||
|
||||
out = append(out, *s)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// HandleDeviceLibraryServers returns the STORED_MUSIC sources currently
|
||||
// registered on a specific speaker. Each source corresponds to one DLNA
|
||||
// server that has been paired with that device.
|
||||
|
||||
@@ -733,3 +733,118 @@ func TestHandleAddLibraryServer_AlreadyRegistered(t *testing.T) {
|
||||
t.Errorf("expected success=true when error contains 1024, got error=%s", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- discoverDeviceMediaServers (speaker-side /listMediaServers) --------
|
||||
|
||||
// cannedListMediaServersResponse is a minimal /listMediaServers XML response
|
||||
// with one server, used to exercise the speaker-side discovery merge path
|
||||
// that complements our own SSDP sweep.
|
||||
const cannedListMediaServersResponse = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<ListMediaServersResponse>
|
||||
<media_server id="uuid:fa095ecc-uuid" ip="198.51.100.10" manufacturer="AVM" model_name="FRITZ!Mediaserver" friendly_name="FRITZ!Mediaserver"/>
|
||||
</ListMediaServersResponse>`
|
||||
|
||||
// newMediaServerTestDevice registers a device backed by speakerURL under id,
|
||||
// mirroring newLibraryTestApp's setup but for tests that need more than one
|
||||
// device on the same WebApp.
|
||||
func newMediaServerTestDevice(app *WebApp, id, speakerURL string) {
|
||||
c := client.NewClient(&client.Config{Host: speakerURL})
|
||||
info := &models.DeviceInfo{DeviceID: id}
|
||||
app.AddDevice(id, webtypes.NewDeviceConnection(c, info))
|
||||
}
|
||||
|
||||
// TestDiscoverDeviceMediaServers_QueriesEveryPairedSpeaker verifies that
|
||||
// discoverDeviceMediaServers calls /listMediaServers on every paired device
|
||||
// and returns the union of what they report. This is the path that lets
|
||||
// discovery see a server the AfterTouch service's own SSDP sweep might miss
|
||||
// because it isn't co-located with the speaker's LAN segment.
|
||||
func TestDiscoverDeviceMediaServers_QueriesEveryPairedSpeaker(t *testing.T) {
|
||||
speakerA, _ := setupSpeakerMock(t, map[string]string{
|
||||
"/listMediaServers": cannedListMediaServersResponse,
|
||||
})
|
||||
defer speakerA.Close()
|
||||
|
||||
speakerB, _ := setupSpeakerMock(t, map[string]string{
|
||||
"/listMediaServers": `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<ListMediaServersResponse>
|
||||
<media_server id="uuid:other-udn" ip="198.51.100.20" manufacturer="Synology" model_name="DS220" friendly_name="NAS"/>
|
||||
</ListMediaServersResponse>`,
|
||||
})
|
||||
defer speakerB.Close()
|
||||
|
||||
app := NewWebApp()
|
||||
newMediaServerTestDevice(app, "dev-a", speakerA.URL)
|
||||
newMediaServerTestDevice(app, "dev-b", speakerB.URL)
|
||||
|
||||
got := app.discoverDeviceMediaServers()
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 servers across both speakers, got %d: %+v", len(got), got)
|
||||
}
|
||||
|
||||
udns := map[string]bool{}
|
||||
for _, s := range got {
|
||||
udns[normalizeUDN(s.ID)] = true
|
||||
}
|
||||
|
||||
if !udns["fa095ecc-uuid"] || !udns["other-udn"] {
|
||||
t.Errorf("expected both UDNs present, got %+v", udns)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverDeviceMediaServers_DedupesAcrossSpeakers verifies that the same
|
||||
// server reported by two speakers (e.g. two boxes on the same LAN both seeing
|
||||
// one NAS) is returned only once, keyed by normalized UDN, even when the two
|
||||
// speakers report the UDN in different forms (with/without "uuid:" prefix).
|
||||
func TestDiscoverDeviceMediaServers_DedupesAcrossSpeakers(t *testing.T) {
|
||||
speakerA, _ := setupSpeakerMock(t, map[string]string{
|
||||
"/listMediaServers": cannedListMediaServersResponse,
|
||||
})
|
||||
defer speakerA.Close()
|
||||
|
||||
speakerB, _ := setupSpeakerMock(t, map[string]string{
|
||||
"/listMediaServers": `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<ListMediaServersResponse>
|
||||
<media_server id="fa095ecc-uuid" ip="198.51.100.10" manufacturer="AVM" model_name="FRITZ!Mediaserver" friendly_name="FRITZ!Mediaserver"/>
|
||||
</ListMediaServersResponse>`,
|
||||
})
|
||||
defer speakerB.Close()
|
||||
|
||||
app := NewWebApp()
|
||||
newMediaServerTestDevice(app, "dev-a", speakerA.URL)
|
||||
newMediaServerTestDevice(app, "dev-b", speakerB.URL)
|
||||
|
||||
got := app.discoverDeviceMediaServers()
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 deduplicated server, got %d: %+v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverDeviceMediaServers_UnreachableSpeakerSkippedSilently verifies
|
||||
// that a speaker whose /listMediaServers call fails (offline, old firmware
|
||||
// without the endpoint) does not prevent results from other, reachable
|
||||
// speakers, and does not error the overall call.
|
||||
func TestDiscoverDeviceMediaServers_UnreachableSpeakerSkippedSilently(t *testing.T) {
|
||||
speakerA, _ := setupSpeakerMock(t, map[string]string{
|
||||
"/listMediaServers": cannedListMediaServersResponse,
|
||||
})
|
||||
defer speakerA.Close()
|
||||
|
||||
speakerB, _ := setupSpeakerMock(t, nil)
|
||||
speakerB.Close() // closed before use: every request to it fails outright.
|
||||
|
||||
app := NewWebApp()
|
||||
newMediaServerTestDevice(app, "dev-a", speakerA.URL)
|
||||
newMediaServerTestDevice(app, "dev-b", speakerB.URL)
|
||||
|
||||
got := app.discoverDeviceMediaServers()
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 server from the reachable speaker, got %d: %+v", len(got), got)
|
||||
}
|
||||
|
||||
if normalizeUDN(got[0].ID) != "fa095ecc-uuid" {
|
||||
t.Errorf("expected the reachable speaker's server, got %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -911,3 +911,34 @@ img { display: block; max-width: 100%; }
|
||||
animation: fade-in .2s ease;
|
||||
}
|
||||
@keyframes fade-in { from { opacity: 0; transform: translateX(-50%) translateY(8px); } }
|
||||
|
||||
/* ── Announcements ───────────────────────────────────────────────────────── */
|
||||
.announcements-banner {
|
||||
padding: .5rem 1rem 0;
|
||||
}
|
||||
.announcement {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: .75rem;
|
||||
padding: .6rem .9rem;
|
||||
margin-bottom: .5rem;
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-size: .85rem;
|
||||
}
|
||||
.announcement-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.announcement-dismiss:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { RadioBrowser } from './components/RadioBrowser.js';
|
||||
import { Library } from './components/Library.js';
|
||||
import { PlayURL } from './components/PlayURL.js';
|
||||
import { TTS } from './components/TTS.js';
|
||||
import { Announcements } from './components/Announcements.js';
|
||||
import { api } from './api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
@@ -233,6 +234,8 @@ function App() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<${Announcements} />
|
||||
|
||||
<main class="main-content">
|
||||
${page === 'devices' ? html`
|
||||
<${DeviceList}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
// Announcements renders dismissible banners for the player (target=app),
|
||||
// mirroring the admin UI's banner (pkg/service/handlers/web/js/script.js,
|
||||
// fetchAnnouncements/dismissAnnouncement) against the same backend endpoints
|
||||
// added for #419 — see _/i419/design-admin-area-auth-gate.md. Dismissal is
|
||||
// recorded server-side (not a client-only localStorage flag), so it stays
|
||||
// dismissed across sessions/devices.
|
||||
export function Announcements() {
|
||||
const [announcements, setAnnouncements] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/announcements?target=app')
|
||||
.then(res => res.ok ? res.json() : { announcements: [] })
|
||||
.then(data => setAnnouncements(data.announcements || []))
|
||||
.catch(err => console.error('Failed to fetch announcements:', err));
|
||||
}, []);
|
||||
|
||||
async function dismiss(id) {
|
||||
try {
|
||||
await fetch(`/api/announcements/${encodeURIComponent(id)}/dismiss`, { method: 'POST' });
|
||||
} catch (err) {
|
||||
console.error('Failed to dismiss announcement:', err);
|
||||
}
|
||||
setAnnouncements(prev => prev.filter(a => a.id !== id));
|
||||
}
|
||||
|
||||
if (announcements.length === 0) return null;
|
||||
|
||||
return html`
|
||||
<div class="announcements-banner">
|
||||
${announcements.map(a => html`
|
||||
<div class="announcement announcement-${a.level || 'info'}" key=${a.id}>
|
||||
<span>
|
||||
${a.message}
|
||||
${a.link_url ? html` <a href=${a.link_url} target="_blank" rel="noopener">${a.link_text || a.link_url}</a>` : null}
|
||||
</span>
|
||||
<button class="announcement-dismiss" onClick=${() => dismiss(a.id)} title="Dismiss">×</button>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -166,6 +166,12 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
|
||||
// error source is logged once per transition into it, not on every event.
|
||||
var prevSource string
|
||||
|
||||
// resumeState survives both the speaker's own WebSocket reconnects and
|
||||
// this loop's outer reconnects (declared once, outside the loop) so an
|
||||
// auto-resume can fire regardless of which layer last re-established
|
||||
// the connection.
|
||||
resumeState := &autoResumeState{}
|
||||
|
||||
for {
|
||||
// Stop if the device was removed from the registry (conn.Close()).
|
||||
select {
|
||||
@@ -189,6 +195,11 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
|
||||
logNowPlayingError(deviceID, np.Source, np.SourceAccount)
|
||||
}
|
||||
|
||||
if item, attempt, shouldResume := resumeState.observe(prevSource, np); shouldResume &&
|
||||
app.AutoResumeOnSourceDisconnect != nil && app.AutoResumeOnSourceDisconnect() {
|
||||
go autoResumePlayback(conn, deviceID, item, attempt)
|
||||
}
|
||||
|
||||
prevSource = np.Source
|
||||
|
||||
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// Package updatecheck implements the opt-in periodic check against a
|
||||
// GitHub repo's latest release (#591,
|
||||
// _/i591/design-update-check.md). Deliberately generic (repo and current
|
||||
// version are constructor arguments, not hardcoded) and decoupled from
|
||||
// handlers.Server/main.go globals, so other binaries could construct their
|
||||
// own Checker later without a rewrite — see the design doc's answer to
|
||||
// open question 2 (CLI-only users).
|
||||
package updatecheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
const defaultTimeout = 5 * time.Second
|
||||
|
||||
const defaultBaseURL = "https://api.github.com"
|
||||
|
||||
// Result is the outcome of the most recent check.
|
||||
type Result struct {
|
||||
Available bool `json:"available"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
LatestVersion string `json:"latest_version,omitempty"`
|
||||
ReleaseURL string `json:"release_url,omitempty"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
}
|
||||
|
||||
// Checker checks a GitHub repo's latest release against the running
|
||||
// version. Safe for concurrent use.
|
||||
type Checker struct {
|
||||
mu sync.RWMutex
|
||||
repo string // "owner/repo"
|
||||
currentVersion string
|
||||
httpClient *http.Client
|
||||
ds *datastore.DataStore
|
||||
baseURL string
|
||||
last Result
|
||||
}
|
||||
|
||||
// NewChecker constructs a Checker for repo (e.g. "gesellix/Bose-SoundTouch")
|
||||
// against currentVersion, seeding its last-known result from ds's persisted
|
||||
// UpdateCheckState if present (so a restart doesn't lose "already knew
|
||||
// about vX.Y.Z" until the next tick). ds may be nil (state just won't
|
||||
// persist across restarts).
|
||||
func NewChecker(ds *datastore.DataStore, repo, currentVersion string) *Checker {
|
||||
c := &Checker{
|
||||
repo: repo,
|
||||
currentVersion: currentVersion,
|
||||
httpClient: &http.Client{Timeout: defaultTimeout},
|
||||
ds: ds,
|
||||
baseURL: defaultBaseURL,
|
||||
last: Result{CurrentVersion: currentVersion},
|
||||
}
|
||||
|
||||
if ds == nil {
|
||||
return c
|
||||
}
|
||||
|
||||
state, err := ds.GetUpdateCheckState()
|
||||
if err != nil || state.LastSeenVersion == "" {
|
||||
return c
|
||||
}
|
||||
|
||||
c.last.LatestVersion = state.LastSeenVersion
|
||||
c.last.ReleaseURL = state.LastReleaseURL
|
||||
|
||||
if ts, parseErr := time.Parse(time.RFC3339, state.LastCheckedAt); parseErr == nil {
|
||||
c.last.CheckedAt = ts
|
||||
}
|
||||
|
||||
if normalizedCurrent, ok := normalizeVersion(currentVersion); ok {
|
||||
if normalizedLatest, ok2 := normalizeVersion(state.LastSeenVersion); ok2 {
|
||||
c.last.Available = semver.Compare(normalizedLatest, normalizedCurrent) > 0
|
||||
}
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// SetBaseURL overrides the GitHub API base URL. Test-only — not exposed via
|
||||
// config, since there's exactly one GitHub to check against in production.
|
||||
func (c *Checker) SetBaseURL(url string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.baseURL = url
|
||||
}
|
||||
|
||||
// SetTimeout overrides the HTTP client timeout (production always uses
|
||||
// defaultTimeout). Test-only, to exercise timeout handling without a
|
||||
// multi-second test.
|
||||
func (c *Checker) SetTimeout(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.httpClient.Timeout = d
|
||||
}
|
||||
|
||||
// LastResult returns the outcome of the most recent check (or the
|
||||
// persisted-state-seeded value if CheckNow hasn't run yet this process).
|
||||
func (c *Checker) LastResult() Result {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
return c.last
|
||||
}
|
||||
|
||||
// CheckNow performs one check against the GitHub API, updates LastResult,
|
||||
// and persists the outcome (if ds is non-nil and a latest version was
|
||||
// found). Returns an error only on a genuine fetch/parse failure — a
|
||||
// current version that can't be meaningfully compared (dev/(devel)/dirty
|
||||
// builds) is not an error, it's a no-op "skip the check" result, per the
|
||||
// design doc's answer on non-release builds.
|
||||
func (c *Checker) CheckNow(ctx context.Context) (Result, error) {
|
||||
result := Result{CurrentVersion: c.currentVersion, CheckedAt: time.Now()}
|
||||
|
||||
normalizedCurrent, ok := normalizeVersion(c.currentVersion)
|
||||
if !ok {
|
||||
c.setLast(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
release, err := c.fetchLatestRelease(ctx)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
if !release.Prerelease {
|
||||
result.LatestVersion = release.TagName
|
||||
result.ReleaseURL = release.HTMLURL
|
||||
|
||||
if normalizedLatest, ok := normalizeVersion(release.TagName); ok {
|
||||
result.Available = semver.Compare(normalizedLatest, normalizedCurrent) > 0
|
||||
}
|
||||
}
|
||||
|
||||
c.setLast(result)
|
||||
c.persist(result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Checker) setLast(result Result) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.last = result
|
||||
}
|
||||
|
||||
// persist saves the outcome, but only when a latest version was actually
|
||||
// found — a transient fetch failure (already excluded, CheckNow returns
|
||||
// before calling this) or a defensive prerelease-only response must not
|
||||
// overwrite previously-known-good state with emptiness.
|
||||
func (c *Checker) persist(result Result) {
|
||||
if c.ds == nil || result.LatestVersion == "" {
|
||||
return
|
||||
}
|
||||
|
||||
_ = c.ds.SaveUpdateCheckState(datastore.UpdateCheckState{
|
||||
LastCheckedAt: result.CheckedAt.UTC().Format(time.RFC3339),
|
||||
LastSeenVersion: result.LatestVersion,
|
||||
LastReleaseURL: result.ReleaseURL,
|
||||
})
|
||||
}
|
||||
|
||||
type githubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
func (c *Checker) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
|
||||
c.mu.RLock()
|
||||
baseURL := c.baseURL
|
||||
c.mu.RUnlock()
|
||||
|
||||
url := fmt.Sprintf("%s/repos/%s/releases/latest", baseURL, c.repo)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return githubRelease{}, err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "AfterTouch-update-check")
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return githubRelease{}, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return githubRelease{}, fmt.Errorf("github releases API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return githubRelease{}, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
var release githubRelease
|
||||
if err := json.Unmarshal(body, &release); err != nil {
|
||||
return githubRelease{}, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
|
||||
return release, nil
|
||||
}
|
||||
|
||||
// normalizeVersion reports whether v can be meaningfully compared as
|
||||
// semver, and its normalized ("v"-prefixed) form if so. Deliberately
|
||||
// treats dev/(devel)/dirty builds as unparseable rather than guessing —
|
||||
// see the design doc.
|
||||
func normalizeVersion(v string) (string, bool) {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" || v == "dev" || v == "(devel)" || strings.Contains(v, "dirty") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(v, "v") {
|
||||
v = "v" + v
|
||||
}
|
||||
|
||||
if !semver.IsValid(v) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return v, true
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package updatecheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestNormalizeVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
wantOK bool
|
||||
wantOut string
|
||||
}{
|
||||
{"v1.2.3", true, "v1.2.3"},
|
||||
{"1.2.3", true, "v1.2.3"}, // missing "v" prefix gets added
|
||||
{"dev", false, ""},
|
||||
{"(devel)", false, ""},
|
||||
{"v0.120.1-0.20260808211626-abcdef123456+dirty", false, ""},
|
||||
{"", false, ""},
|
||||
{"not-a-version", false, ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
out, ok := normalizeVersion(tc.in)
|
||||
if ok != tc.wantOK {
|
||||
t.Errorf("normalizeVersion(%q): ok = %v, want %v", tc.in, ok, tc.wantOK)
|
||||
}
|
||||
if ok && out != tc.wantOut {
|
||||
t.Errorf("normalizeVersion(%q) = %q, want %q", tc.in, out, tc.wantOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T, status int, body string) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if ua := r.Header.Get("User-Agent"); ua == "" {
|
||||
t.Error("expected a User-Agent header to be set")
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
}
|
||||
|
||||
func newTestDataStore(t *testing.T) *datastore.DataStore {
|
||||
t.Helper()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "updatecheck-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(tempDir) })
|
||||
|
||||
return datastore.NewDataStore(tempDir)
|
||||
}
|
||||
|
||||
func TestCheckNow_NewerVersionAvailable(t *testing.T) {
|
||||
server := newTestServer(t, http.StatusOK, `{"tag_name":"v1.1.0","prerelease":false,"html_url":"https://example.invalid/v1.1.0"}`)
|
||||
defer server.Close()
|
||||
|
||||
ds := newTestDataStore(t)
|
||||
c := NewChecker(ds, "owner/repo", "v1.0.0")
|
||||
c.SetBaseURL(server.URL)
|
||||
|
||||
result, err := c.CheckNow(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("CheckNow failed: %v", err)
|
||||
}
|
||||
|
||||
if !result.Available {
|
||||
t.Error("Expected Available=true for a newer release")
|
||||
}
|
||||
if result.LatestVersion != "v1.1.0" {
|
||||
t.Errorf("Expected LatestVersion v1.1.0, got %q", result.LatestVersion)
|
||||
}
|
||||
if result.ReleaseURL != "https://example.invalid/v1.1.0" {
|
||||
t.Errorf("Expected ReleaseURL to be set, got %q", result.ReleaseURL)
|
||||
}
|
||||
|
||||
if got := c.LastResult(); got != result {
|
||||
t.Errorf("LastResult() = %+v, want %+v", got, result)
|
||||
}
|
||||
|
||||
persisted, err := ds.GetUpdateCheckState()
|
||||
if err != nil {
|
||||
t.Fatalf("GetUpdateCheckState failed: %v", err)
|
||||
}
|
||||
if persisted.LastSeenVersion != "v1.1.0" {
|
||||
t.Errorf("Expected persisted LastSeenVersion v1.1.0, got %q", persisted.LastSeenVersion)
|
||||
}
|
||||
if persisted.LastReleaseURL != "https://example.invalid/v1.1.0" {
|
||||
t.Errorf("Expected persisted LastReleaseURL to be set, got %q", persisted.LastReleaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNow_CurrentVersionIsUpToDate(t *testing.T) {
|
||||
server := newTestServer(t, http.StatusOK, `{"tag_name":"v1.0.0","prerelease":false,"html_url":"https://example.invalid/v1.0.0"}`)
|
||||
defer server.Close()
|
||||
|
||||
c := NewChecker(newTestDataStore(t), "owner/repo", "v1.0.0")
|
||||
c.SetBaseURL(server.URL)
|
||||
|
||||
result, err := c.CheckNow(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("CheckNow failed: %v", err)
|
||||
}
|
||||
|
||||
if result.Available {
|
||||
t.Error("Expected Available=false when already on the latest version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNow_OlderReleaseThanCurrent(t *testing.T) {
|
||||
// e.g. a beta/main build ahead of the last tagged release.
|
||||
server := newTestServer(t, http.StatusOK, `{"tag_name":"v0.9.0","prerelease":false}`)
|
||||
defer server.Close()
|
||||
|
||||
c := NewChecker(newTestDataStore(t), "owner/repo", "v1.0.0")
|
||||
c.SetBaseURL(server.URL)
|
||||
|
||||
result, err := c.CheckNow(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("CheckNow failed: %v", err)
|
||||
}
|
||||
|
||||
if result.Available {
|
||||
t.Error("Expected Available=false when the release is older than current")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNow_PrereleaseExcluded(t *testing.T) {
|
||||
server := newTestServer(t, http.StatusOK, `{"tag_name":"v2.0.0","prerelease":true,"html_url":"https://example.invalid/v2.0.0"}`)
|
||||
defer server.Close()
|
||||
|
||||
c := NewChecker(newTestDataStore(t), "owner/repo", "v1.0.0")
|
||||
c.SetBaseURL(server.URL)
|
||||
|
||||
result, err := c.CheckNow(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("CheckNow failed: %v", err)
|
||||
}
|
||||
|
||||
if result.Available {
|
||||
t.Error("Expected Available=false for a prerelease, even though it's semver-newer")
|
||||
}
|
||||
if result.LatestVersion != "" {
|
||||
t.Errorf("Expected no LatestVersion recorded for a prerelease-only response, got %q", result.LatestVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNow_DirtyCurrentVersionSkipsWithoutError(t *testing.T) {
|
||||
// Server would answer, but must never be called for a non-release build.
|
||||
called := false
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"tag_name":"v9.9.9","prerelease":false}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewChecker(newTestDataStore(t), "owner/repo", "v1.0.0-0.20260101000000-abcdef+dirty")
|
||||
c.SetBaseURL(server.URL)
|
||||
|
||||
result, err := c.CheckNow(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error for a dirty current version, got: %v", err)
|
||||
}
|
||||
if result.Available {
|
||||
t.Error("Expected Available=false, dirty builds must skip the comparison")
|
||||
}
|
||||
if called {
|
||||
t.Error("Expected no HTTP call for a dirty current version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNow_TimeoutReturnsError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"tag_name":"v1.1.0","prerelease":false}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewChecker(newTestDataStore(t), "owner/repo", "v1.0.0")
|
||||
c.SetBaseURL(server.URL)
|
||||
c.SetTimeout(20 * time.Millisecond)
|
||||
|
||||
if _, err := c.CheckNow(context.Background()); err == nil {
|
||||
t.Error("Expected a timeout error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNow_MalformedJSONReturnsError(t *testing.T) {
|
||||
server := newTestServer(t, http.StatusOK, `not json`)
|
||||
defer server.Close()
|
||||
|
||||
c := NewChecker(newTestDataStore(t), "owner/repo", "v1.0.0")
|
||||
c.SetBaseURL(server.URL)
|
||||
|
||||
if _, err := c.CheckNow(context.Background()); err == nil {
|
||||
t.Error("Expected an error for a malformed JSON response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNow_NonOKStatusReturnsError(t *testing.T) {
|
||||
server := newTestServer(t, http.StatusInternalServerError, `oops`)
|
||||
defer server.Close()
|
||||
|
||||
c := NewChecker(newTestDataStore(t), "owner/repo", "v1.0.0")
|
||||
c.SetBaseURL(server.URL)
|
||||
|
||||
if _, err := c.CheckNow(context.Background()); err == nil {
|
||||
t.Error("Expected an error for a non-200 response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNow_FailureDoesNotOverwritePersistedState(t *testing.T) {
|
||||
ds := newTestDataStore(t)
|
||||
if err := ds.SaveUpdateCheckState(datastore.UpdateCheckState{
|
||||
LastCheckedAt: "2026-08-01T00:00:00Z",
|
||||
LastSeenVersion: "v1.1.0",
|
||||
}); err != nil {
|
||||
t.Fatalf("Failed to seed state: %v", err)
|
||||
}
|
||||
|
||||
server := newTestServer(t, http.StatusInternalServerError, `oops`)
|
||||
defer server.Close()
|
||||
|
||||
c := NewChecker(ds, "owner/repo", "v1.0.0")
|
||||
c.SetBaseURL(server.URL)
|
||||
|
||||
if _, err := c.CheckNow(context.Background()); err == nil {
|
||||
t.Fatal("Expected an error from the failing server")
|
||||
}
|
||||
|
||||
persisted, err := ds.GetUpdateCheckState()
|
||||
if err != nil {
|
||||
t.Fatalf("GetUpdateCheckState failed: %v", err)
|
||||
}
|
||||
if persisted.LastSeenVersion != "v1.1.0" {
|
||||
t.Errorf("Expected previously-persisted state to survive a failed check, got %+v", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewChecker_SeedsFromPersistedState verifies a restarted process picks
|
||||
// up "already knew about vX.Y.Z" from a previous run without needing to
|
||||
// call CheckNow first.
|
||||
func TestNewChecker_SeedsFromPersistedState(t *testing.T) {
|
||||
ds := newTestDataStore(t)
|
||||
checkedAt := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
if err := ds.SaveUpdateCheckState(datastore.UpdateCheckState{
|
||||
LastCheckedAt: checkedAt.Format(time.RFC3339),
|
||||
LastSeenVersion: "v1.1.0",
|
||||
LastReleaseURL: "https://example.invalid/v1.1.0",
|
||||
}); err != nil {
|
||||
t.Fatalf("Failed to seed state: %v", err)
|
||||
}
|
||||
|
||||
c := NewChecker(ds, "owner/repo", "v1.0.0")
|
||||
result := c.LastResult()
|
||||
|
||||
if !result.Available {
|
||||
t.Error("Expected a seeded Checker to report Available=true")
|
||||
}
|
||||
if result.LatestVersion != "v1.1.0" {
|
||||
t.Errorf("Expected seeded LatestVersion v1.1.0, got %q", result.LatestVersion)
|
||||
}
|
||||
// Regression check: the announcement's link needs this populated
|
||||
// immediately after a restart, not just after the next live check.
|
||||
if result.ReleaseURL != "https://example.invalid/v1.1.0" {
|
||||
t.Errorf("Expected seeded ReleaseURL to be set, got %q", result.ReleaseURL)
|
||||
}
|
||||
if !result.CheckedAt.Equal(checkedAt) {
|
||||
t.Errorf("Expected seeded CheckedAt %v, got %v", checkedAt, result.CheckedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewChecker_NilDataStoreIsSafe(t *testing.T) {
|
||||
c := NewChecker(nil, "owner/repo", "v1.0.0")
|
||||
|
||||
result := c.LastResult()
|
||||
if result.Available {
|
||||
t.Error("Expected a fresh Checker with no datastore to report Available=false")
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity check that the JSON tags on Result round-trip as expected — a
|
||||
// contract worth pinning if this ever gets exposed via an HTTP handler.
|
||||
func TestResult_JSONShape(t *testing.T) {
|
||||
r := Result{Available: true, CurrentVersion: "v1.0.0", LatestVersion: "v1.1.0", ReleaseURL: "https://example.invalid"}
|
||||
|
||||
data, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
for _, key := range []string{"available", "current_version", "latest_version", "release_url", "checked_at"} {
|
||||
if _, ok := got[key]; !ok {
|
||||
t.Errorf("Expected JSON key %q in marshaled Result", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
-12
@@ -14,6 +14,15 @@ import (
|
||||
type Client struct {
|
||||
Host string
|
||||
User string
|
||||
|
||||
// conn is non-nil once Connect has been called, and is then reused by
|
||||
// Run/UploadContent until Close. Left nil, each Run/UploadContent call
|
||||
// dials its own one-off connection as before — Connect is opt-in for
|
||||
// callers making several calls in a row (e.g. RevertMigration's ~17
|
||||
// commands), where dialing fresh every time is both slow and, on a
|
||||
// resource-constrained speaker, has been observed to overwhelm the
|
||||
// device (#614 self-test, 2026-08-16).
|
||||
conn *ssh.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new SSH client for the given host. The default user is "root".
|
||||
@@ -66,21 +75,71 @@ func (c *Client) getConfig() *ssh.ClientConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// Connect opens a persistent SSH connection reused by subsequent
|
||||
// Run/UploadContent calls, instead of each dialing its own. Call Close when
|
||||
// done with it. Idempotent — calling Connect again while already connected
|
||||
// is a no-op. Skip this for a single (or a rare few) command — dialing
|
||||
// once and reusing it is only worth the extra Close bookkeeping when
|
||||
// several calls follow in quick succession.
|
||||
func (c *Client) Connect() error {
|
||||
if c.conn != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
conn, err := ssh.Dial("tcp", c.Host+":22", c.getConfig())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to dial: %w", err)
|
||||
}
|
||||
|
||||
c.conn = conn
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the persistent connection opened by Connect, if any. Safe
|
||||
// to call even when Connect was never called (e.g. every Run/UploadContent
|
||||
// call so far used its own one-off connection).
|
||||
func (c *Client) Close() error {
|
||||
if c.conn == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := c.conn.Close()
|
||||
c.conn = nil
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// dial returns the persistent connection from Connect if one is open,
|
||||
// otherwise dials a fresh one-off connection for the caller to close via
|
||||
// the returned closeFunc (a no-op when reusing the persistent connection —
|
||||
// that one is only closed by an explicit Close call).
|
||||
func (c *Client) dial() (conn *ssh.Client, closeFunc func(), err error) {
|
||||
if c.conn != nil {
|
||||
return c.conn, func() {}, nil
|
||||
}
|
||||
|
||||
conn, err = ssh.Dial("tcp", c.Host+":22", c.getConfig())
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to dial: %w", err)
|
||||
}
|
||||
|
||||
return conn, func() { _ = conn.Close() }, nil
|
||||
}
|
||||
|
||||
// Run executes a command on the remote host and returns the combined stdout and stderr.
|
||||
//
|
||||
// command MUST be a hardcoded shell literal or constructed entirely from
|
||||
// internal, service-controlled values — never from user-supplied HTTP input.
|
||||
func (c *Client) Run(command string) (string, error) {
|
||||
config := c.getConfig()
|
||||
|
||||
client, err := ssh.Dial("tcp", c.Host+":22", config)
|
||||
conn, closeConn, err := c.dial()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to dial: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
defer func() { _ = client.Close() }()
|
||||
defer closeConn()
|
||||
|
||||
session, err := client.NewSession()
|
||||
session, err := conn.NewSession()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
@@ -133,16 +192,14 @@ func (c *Client) ReadDir(remotePath string) (map[string][]byte, error) {
|
||||
|
||||
// UploadContent uploads the given content to a file on the remote host using stdin piping.
|
||||
func (c *Client) UploadContent(content []byte, remotePath string) error {
|
||||
config := c.getConfig()
|
||||
|
||||
client, err := ssh.Dial("tcp", c.Host+":22", config)
|
||||
conn, closeConn, err := c.dial()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to dial: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = client.Close() }()
|
||||
defer closeConn()
|
||||
|
||||
session, err := client.NewSession()
|
||||
session, err := conn.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
@@ -44,3 +44,33 @@ func TestRun_DialFailure(t *testing.T) {
|
||||
t.Errorf("Expected 'failed to dial' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClose_NoOpWithoutConnect(t *testing.T) {
|
||||
client := NewClient("127.0.0.1")
|
||||
|
||||
if err := client.Close(); err != nil {
|
||||
t.Errorf("Close on a never-connected client should be a no-op, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnect_DialFailureLeavesConnNil(t *testing.T) {
|
||||
client := NewClient("127.0.0.1:0")
|
||||
|
||||
err := client.Connect()
|
||||
if err == nil {
|
||||
t.Fatal("Expected dial failure, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "failed to dial") {
|
||||
t.Errorf("Expected 'failed to dial' error, got: %v", err)
|
||||
}
|
||||
|
||||
if client.conn != nil {
|
||||
t.Error("Connect should leave conn nil after a dial failure, so Run/UploadContent still fall back to their own one-off dial")
|
||||
}
|
||||
|
||||
// Close after a failed Connect should still be a harmless no-op.
|
||||
if err := client.Close(); err != nil {
|
||||
t.Errorf("Close after a failed Connect should be a no-op, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wraps markdown-link-check with a status-code-aware pass/fail policy that
|
||||
# the tool itself doesn't support: a 404 is a real dead link (fails the
|
||||
# build), but most other 4xx responses (429 rate-limited, 401/403
|
||||
# anti-bot/access-restricted, ...) just mean the checker couldn't get a
|
||||
# clean answer from a site we don't control — those are downgraded to a
|
||||
# GitHub Actions warning annotation instead of failing CI. Anything else
|
||||
# (5xx, timeouts, DNS failures) still fails the build, same as before.
|
||||
#
|
||||
# Usage: scripts/check-doc-links.sh [root-dir]
|
||||
# Requires markdown-link-check on PATH and .github/markdown-link-check.json.
|
||||
set -euo pipefail
|
||||
|
||||
root="${1:-.}"
|
||||
config=".github/markdown-link-check.json"
|
||||
had_error=0
|
||||
|
||||
while IFS= read -r -d '' file; do
|
||||
echo "Checking links in $file"
|
||||
|
||||
set +e
|
||||
output=$(markdown-link-check -q -v -c "$config" "$file" 2>&1)
|
||||
set -e
|
||||
|
||||
echo "$output"
|
||||
|
||||
# markdown-link-check's -v mode logs each dead link more than once
|
||||
# (inline with the raw error detail, then again in its summary); de-dupe
|
||||
# on (url, code) so each broken link gets one annotation. A plain
|
||||
# delimited string, not an associative array (bash 3.2, macOS's
|
||||
# default /bin/bash, predates those), with a sentinel on both sides of
|
||||
# each key so substring matches can't collide.
|
||||
seen="|"
|
||||
|
||||
while IFS= read -r line; do
|
||||
[ -z "$line" ] && continue
|
||||
|
||||
url=$(sed -E 's/^[[:space:]]*\[✖\] (.*) → Status: .*/\1/' <<<"$line")
|
||||
code=$(sed -E 's/.*Status: ([0-9]+).*/\1/' <<<"$line")
|
||||
|
||||
key="${url}#${code}|"
|
||||
case "$seen" in
|
||||
*"|${key}"*) continue ;;
|
||||
esac
|
||||
seen="${seen}${key}"
|
||||
|
||||
if [[ "$code" =~ ^[0-9]+$ ]] && [ "$code" != "404" ] && [ "$code" -ge 400 ] && [ "$code" -lt 500 ]; then
|
||||
echo "::warning file=${file}::Link check got HTTP ${code} (treated as non-fatal, see scripts/check-doc-links.sh): ${url}"
|
||||
else
|
||||
echo "::error file=${file}::Dead link (${code:-no response}): ${url}"
|
||||
had_error=1
|
||||
fi
|
||||
done < <(grep -F '[✖]' <<<"$output" || true)
|
||||
done < <(find "$root" -name "*.md" -not -path "./tests/*" -not -path "./node_modules/*" -print0)
|
||||
|
||||
exit "$had_error"
|
||||
@@ -56,7 +56,71 @@ After the installation check if you can access AfterTouch from your local device
|
||||
|
||||
### If `http://<IP_ADDRESS_OF_SPEAKER>:8000` fails: SSH port forwarding
|
||||
|
||||
Some firmware images only bind the AfterTouch HTTP port to loopback (see issue #196). The workaround is an SSH tunnel — your machine talks to its own local `:8000`, the SSH connection forwards to the speaker's `:8000` on loopback.
|
||||
On some device models AfterTouch's port is reachable from other machines on
|
||||
your LAN out of the box. On others (see issue #196) it isn't, and (unlike
|
||||
the phrasing this README used to have) that's not AfterTouch or its
|
||||
firewall configuration choosing to bind loopback-only. AfterTouch itself
|
||||
binds `0.0.0.0` (all interfaces) correctly, confirmed by inspecting the
|
||||
running device directly, and there's no firewall rule (`iptables`,
|
||||
`nftables`, or otherwise) blocking it either.
|
||||
|
||||
**Current knowledge (2026-08-16), confirmed on real hardware via a
|
||||
decrypted firmware backup plus simultaneous packet captures on both the
|
||||
speaker and a client machine:** some SoundTouch models built around a
|
||||
"combo" WiFi/Bluetooth co-processor (used for AirPlay) route LAN traffic
|
||||
through that co-processor before it reaches the main application
|
||||
processor where AfterTouch actually runs. That co-processor only relays a
|
||||
fixed set of the device's own original service ports (the same ones the
|
||||
stock SoundTouch app and companion services always used), a list that,
|
||||
as far as we can tell, is compiled into the co-processor's own firmware.
|
||||
AfterTouch's ports were never part of that original design, so they never
|
||||
got included. This isn't a bug in AfterTouch, a router/firewall setting,
|
||||
or WiFi client isolation; all three were separately ruled out.
|
||||
|
||||
**The installer works around this automatically.** On an affected speaker
|
||||
it redirects one of the ports the co-processor *does* relay to AfterTouch,
|
||||
so the UI is reachable from the LAN without any tunnel:
|
||||
|
||||
```
|
||||
http://<IP_ADDRESS_OF_SPEAKER>:17008
|
||||
```
|
||||
|
||||
Port `17008` is Bose's software-update listener; that cloud service no
|
||||
longer exists, so taking over its inbound traffic costs nothing. Only
|
||||
traffic from other machines is affected; anything running on the speaker
|
||||
still reaches AfterTouch on `:8000` as before. Change or disable this with
|
||||
`AFTERTOUCH_LAN_PORT` (`auto` / `none` / a port number) in
|
||||
`/opt/aftertouch/aftertouch.conf`, or pass it at install time:
|
||||
|
||||
```bash
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | AFTERTOUCH_LAN_PORT=none sh
|
||||
```
|
||||
|
||||
**`aftertouch.conf` isn't limited to `AFTERTOUCH_LAN_PORT`.** The init
|
||||
script exports every assignment in this file into the daemon's own
|
||||
environment, so any env var `soundtouch-service` reads (see the
|
||||
[configuration table](../../docs/content/docs/guides/SOUNDTOUCH-SERVICE.md#configuration-options))
|
||||
can be set the same way — for example, to change the admin credentials:
|
||||
|
||||
```
|
||||
MGMT_USERNAME=admin
|
||||
MGMT_PASSWORD=change-me
|
||||
```
|
||||
|
||||
Edit `/opt/aftertouch/aftertouch.conf` over SSH, then
|
||||
`/etc/init.d/aftertouch restart` to apply. `DEPLOYMENT_MODE=on-device` is
|
||||
already set by the init script itself — it never needs to be added here.
|
||||
The auto-export behavior described here needs a build including the fix
|
||||
for issue #546; older installs (before `aftertouch.conf` even existed, or
|
||||
between then and that fix) need to reinstall/update first.
|
||||
|
||||
Which models need this, and how to report one that isn't listed yet, is
|
||||
tracked in
|
||||
[MODEL-SUPPORT-MATRIX.md](../../docs/content/docs/reference/MODEL-SUPPORT-MATRIX.md).
|
||||
The SSH tunnel below still works, and remains the better route 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.
|
||||
|
||||
**Open a fresh terminal on your own machine** (Linux/macOS/Windows — NOT another shell inside the speaker's SSH session — see issue #250 for the trap that catches everyone here) and run:
|
||||
|
||||
@@ -94,15 +158,17 @@ Run the installer again with the version you want to install. The script backs u
|
||||
**Install (or upgrade to) a specific version** — three equivalent ways:
|
||||
|
||||
```bash
|
||||
# 1. Environment variable (works when piping into sh)
|
||||
VERSION=0.111.3 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
# 1. Environment variable — goes on `sh`, not `curl`: in a pipe, each
|
||||
# command is a separate process, so `VERSION=X curl ... | sh` silently
|
||||
# does NOT set it for `sh` (the one that actually reads $VERSION).
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | VERSION=0.123.0 sh
|
||||
|
||||
# 2. Command-line flag (pass args after `sh -s --`)
|
||||
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
|
||||
|
||||
# 3. Download first, then run with a flag
|
||||
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
|
||||
```
|
||||
|
||||
Running **without** a version override installs the latest release: the script
|
||||
|
||||
@@ -15,6 +15,7 @@ DESC="Bose AfterTouch service"
|
||||
DAEMON="/opt/aftertouch/aftertouch-service"
|
||||
PIDFILE="/var/run/$NAME.pid"
|
||||
DATADIR="/opt/aftertouch/data"
|
||||
CONFFILE="/opt/aftertouch/aftertouch.conf"
|
||||
SCRIPTNAME="/etc/init.d/$NAME"
|
||||
USER="root"
|
||||
LOG_TAG="aftertouch"
|
||||
@@ -24,6 +25,37 @@ LOG_TAG="aftertouch"
|
||||
export PATH="/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin"
|
||||
|
||||
|
||||
# Optional settings written by install.sh (AFTERTOUCH_LAN_PORT, SERVICE_PORT),
|
||||
# or added by hand for anything the daemon reads from its environment
|
||||
# (SERVER_URL, MGMT_USERNAME, MGMT_PASSWORD, DEPLOYMENT_MODE, ...). `set -a`
|
||||
# auto-exports every assignment while the file is sourced, so any such
|
||||
# variable actually reaches the daemon -- it's forked from this same shell's
|
||||
# environment further down via `--startas "/bin/sh" -- -c "... \"$DAEMON\" ..."`.
|
||||
# Sourced before the defaults below so it can override either.
|
||||
if [ -r "$CONFFILE" ]; then
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
. "$CONFFILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Port the daemon binds locally. Kept in one variable because it appears in
|
||||
# the daemon arguments, the readiness poll and `status` -- three places that
|
||||
# used to hardcode 8000 independently, so changing one silently broke the
|
||||
# other two.
|
||||
SERVICE_PORT="${SERVICE_PORT:-8000}"
|
||||
|
||||
# LAN entry port: a port number, "auto" (default), or "none".
|
||||
LAN_PORT_MODE="${AFTERTOUCH_LAN_PORT:-auto}"
|
||||
|
||||
# This script only ever runs on the speaker itself, so the deployment mode is
|
||||
# not a guess -- default it here (overridable via aftertouch.conf, though that
|
||||
# should never be needed). Exported so soundtouch-service picks it up via
|
||||
# DEPLOYMENT_MODE without needing a --deployment-mode flag threaded through
|
||||
# the daemon invocation below.
|
||||
export DEPLOYMENT_MODE="${DEPLOYMENT_MODE:-on-device}"
|
||||
|
||||
|
||||
# Sanity check executable
|
||||
test -x "$DAEMON" || {
|
||||
echo "ERROR: Cannot execute $DAEMON (check path and permissions)." >&2
|
||||
@@ -31,6 +63,101 @@ test -x "$DAEMON" || {
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LAN entry-port redirect
|
||||
#
|
||||
# On chassis built around a BCO ("SMSC") Wi-Fi/Bluetooth co-processor,
|
||||
# inbound LAN traffic only reaches this Linux SoC for a fixed set of Bose's
|
||||
# own service ports, which appears to be compiled into the co-processor's
|
||||
# firmware. AfterTouch's :8000 is not on that list, so a LAN client's SYN
|
||||
# never arrives here at all -- confirmed on an ST20 (`spotty`), where
|
||||
# `tcpdump -i eth0` on the speaker saw zero packets for :8000 while Bose's
|
||||
# own :8090/:8091/:17000 answered normally from the same client. The usual
|
||||
# suspects were all ruled out: the service does bind 0.0.0.0 correctly, the
|
||||
# speaker's iptables is empty, and SSH over the same path works.
|
||||
#
|
||||
# Workaround: NAT one of the relayed Bose ports to ours. The default, 17008,
|
||||
# is Bose's SoftwareUpdate listener -- its cloud is gone, so taking over its
|
||||
# inbound traffic costs nothing real. Only external traffic is matched
|
||||
# (`! -i lo`), so anything running on the speaker still reaches both the real
|
||||
# service on loopback and AfterTouch on :8000 as before.
|
||||
#
|
||||
# Credit: the STR / SoundTouch Reborn project (github.com/JRpersonal/streborn)
|
||||
# documented and shipped this REDIRECT technique first, using the same entry
|
||||
# port for the same reason.
|
||||
#
|
||||
# Which models need this is tracked in
|
||||
# docs/content/docs/reference/MODEL-SUPPORT-MATRIX.md.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Resolve LAN_PORT_MODE into $LAN_PORT. Returns non-zero when no redirect
|
||||
# should be installed.
|
||||
lan_redirect_port() {
|
||||
case "$LAN_PORT_MODE" in
|
||||
none|off|disabled|0)
|
||||
return 1
|
||||
;;
|
||||
auto|"")
|
||||
# Only auto-enable where direct LAN access is known not to work.
|
||||
# has-bco is Bose's own helper: [ "$(cat /proc/module_type)" = scm ]
|
||||
has-bco >/dev/null 2>&1 || return 1
|
||||
LAN_PORT=17008
|
||||
;;
|
||||
*[!0-9]*)
|
||||
echo "WARNING: ignoring AFTERTOUCH_LAN_PORT='$LAN_PORT_MODE'; expected a port number, 'auto' or 'none'." >&2
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
LAN_PORT="$LAN_PORT_MODE"
|
||||
;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
# Remove every PREROUTING rule pointing at our service port, whatever entry
|
||||
# port it used, so changing AFTERTOUCH_LAN_PORT cannot orphan the old rule.
|
||||
lan_redirect_purge() {
|
||||
iptables -t nat -S PREROUTING 2>/dev/null \
|
||||
| grep -- "--to-ports $SERVICE_PORT" \
|
||||
| sed 's/^-A /-D /' \
|
||||
| while read -r rule; do
|
||||
# shellcheck disable=SC2086
|
||||
iptables -t nat $rule 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
|
||||
lan_redirect_apply() {
|
||||
lan_redirect_port || return 0
|
||||
|
||||
if ! iptables -t nat -L PREROUTING -n >/dev/null 2>&1; then
|
||||
echo "WARNING: this kernel has no iptables nat table; :$LAN_PORT was not" >&2
|
||||
echo " redirected. Reach AfterTouch over an SSH tunnel instead." >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
lan_redirect_purge
|
||||
|
||||
# Safety net for a kernel whose iptables lacks -S (purge would no-op):
|
||||
# without this, every restart would stack another duplicate rule.
|
||||
if iptables -t nat -C PREROUTING ! -i lo -p tcp --dport "$LAN_PORT" \
|
||||
-j REDIRECT --to-ports "$SERVICE_PORT" 2>/dev/null; then
|
||||
echo "LAN access already active on port $LAN_PORT."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if iptables -t nat -I PREROUTING 1 ! -i lo -p tcp --dport "$LAN_PORT" \
|
||||
-j REDIRECT --to-ports "$SERVICE_PORT" 2>/dev/null; then
|
||||
echo "LAN access: port $LAN_PORT now reaches AfterTouch on :$SERVICE_PORT."
|
||||
else
|
||||
echo "WARNING: could not install the :$LAN_PORT -> :$SERVICE_PORT redirect." >&2
|
||||
fi
|
||||
}
|
||||
|
||||
lan_redirect_remove() {
|
||||
lan_redirect_purge
|
||||
}
|
||||
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
echo "Starting $DESC..."
|
||||
@@ -42,37 +169,82 @@ case "$1" in
|
||||
|
||||
mkdir -p "$DATADIR"
|
||||
|
||||
# Pipe stdout + stderr through `logger -t $LOG_TAG` so the
|
||||
# Route stdout + stderr through `logger -t $LOG_TAG` so the
|
||||
# daemon's output lands in busybox syslog (bounded ring buffer,
|
||||
# never grows on disk). Users diagnose with:
|
||||
#
|
||||
# logread | grep aftertouch | tail -20
|
||||
# logread -f | grep aftertouch # live tail
|
||||
#
|
||||
# `exec` on the daemon replaces /bin/sh so --make-pidfile records
|
||||
# the daemon's own PID (not the shell wrapper). The `logger`
|
||||
# process sits on the read end of the pipe and exits cleanly
|
||||
# when the daemon dies and closes its end.
|
||||
# This used to be a `--startas "/bin/sh" -- -c "exec $DAEMON | logger"`
|
||||
# pipeline, on the theory that `exec` replaces /bin/sh so --make-pidfile
|
||||
# records the daemon's own PID. That's wrong for a *piped* command:
|
||||
# POSIX requires each side of a pipe to run in its own forked process,
|
||||
# so the top-level /bin/sh forks two children (one execs into the
|
||||
# daemon, one becomes logger) and stays alive itself, blocked in
|
||||
# wait() -- --make-pidfile recorded *that* wrapper's PID, not the
|
||||
# daemon's. `stop` then killed the wrapper, which doesn't forward
|
||||
# SIGTERM to its children, orphaning the real daemon (reparented to
|
||||
# init) to keep running -- and keep holding :8000 -- forever, silently
|
||||
# surviving every later stop/start/restart.
|
||||
#
|
||||
# A first fix attempt dropped the wrapper shell entirely in favor of
|
||||
# `--exec "$DAEMON"` directly, with a plain shell-level `>FIFO`
|
||||
# redirection on the start-stop-daemon invocation. That broke logging
|
||||
# instead: this busybox's `--background` resets the backgrounded
|
||||
# child's own stdio, ignoring the outer redirection, so the daemon's
|
||||
# output never reached the FIFO -- confirmed on hardware (`logger`
|
||||
# exited immediately with nothing to read, `logread` showed nothing
|
||||
# new).
|
||||
#
|
||||
# This version keeps a wrapper shell -- its *own* FIFO redirection,
|
||||
# set up by its own script logic rather than inherited from outside,
|
||||
# isn't affected by whatever --background did to its stdio -- but has
|
||||
# the wrapper record the daemon's real PID itself instead of trusting
|
||||
# --make-pidfile. $! after a single, non-piped backgrounded command is
|
||||
# portably that command's own PID; --make-pidfile can only ever see
|
||||
# whatever process start-stop-daemon directly forked (the wrapper),
|
||||
# never a PID from inside it.
|
||||
LOGFIFO="/tmp/$NAME.fifo"
|
||||
rm -f "$LOGFIFO"
|
||||
mkfifo "$LOGFIFO"
|
||||
|
||||
# --pidfile (without --make-pidfile, since the wrapper writes it itself
|
||||
# once it knows the daemon's real PID) makes start-stop-daemon's own
|
||||
# "already running?" check keyed on *our* pidfile, not on "/bin/sh"
|
||||
# identity. Without this, --startas "/bin/sh" is itself the match
|
||||
# criterion -- and since the wrapper stays alive for the daemon's whole
|
||||
# lifetime (blocked in its own `wait`), and `stop` only confirms the
|
||||
# *daemon* PID died (not that the wrapper has finished tearing down),
|
||||
# a `restart` firing `start` right after `stop` can catch the previous
|
||||
# wrapper still mid-teardown. start-stop-daemon then silently refuses
|
||||
# ("/bin/sh is already running", swallowed by --quiet) while the
|
||||
# script burns its full 120s timeout waiting for a daemon that was
|
||||
# never launched. Confirmed on hardware: a bare
|
||||
# `start-stop-daemon --startas "/bin/sh" -- -c "echo hi"` was refused
|
||||
# with exactly that message while a prior wrapper was still alive.
|
||||
start-stop-daemon --start \
|
||||
--quiet \
|
||||
--pidfile "$PIDFILE" \
|
||||
--background \
|
||||
--make-pidfile \
|
||||
--chuid "$USER" \
|
||||
--startas "/bin/sh" \
|
||||
-- -c "exec \"$DAEMON\" --data-dir '$DATADIR' --record-interactions=false --discovery-interval=60m 2>&1 | logger -t $LOG_TAG"
|
||||
-- -c "logger -t $LOG_TAG <'$LOGFIFO' & \"$DAEMON\" --data-dir '$DATADIR' --port '$SERVICE_PORT' --record-interactions=false --discovery-interval=60m >'$LOGFIFO' 2>&1 & echo \$! >'$PIDFILE'; wait"
|
||||
|
||||
tries=0
|
||||
max_tries=60
|
||||
while [ $tries -lt $max_tries ]; do
|
||||
if curl -fsS http://localhost:8000 >/dev/null 2>&1; then
|
||||
if curl -fsS "http://localhost:$SERVICE_PORT" >/dev/null 2>&1; then
|
||||
# Only once the service actually answers is it worth pointing LAN
|
||||
# traffic at it.
|
||||
lan_redirect_apply
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
tries=$((tries + 1))
|
||||
done
|
||||
|
||||
echo "ERROR: daemon started but http://localhost:8000 never responded within $((max_tries * 2))s." >&2
|
||||
echo "ERROR: daemon started but http://localhost:$SERVICE_PORT never responded within $((max_tries * 2))s." >&2
|
||||
echo " Inspect the daemon's syslog output:" >&2
|
||||
echo " logread | grep $LOG_TAG | tail -20" >&2
|
||||
exit 1
|
||||
@@ -80,6 +252,9 @@ case "$1" in
|
||||
|
||||
stop)
|
||||
echo "Stopping $DESC..."
|
||||
# Drop the LAN redirect first: leaving it in place while nothing listens
|
||||
# would silently blackhole the entry port.
|
||||
lan_redirect_remove
|
||||
if [ -f "$PIDFILE" ]; then
|
||||
PID=$(cat "$PIDFILE")
|
||||
start-stop-daemon --stop \
|
||||
@@ -120,11 +295,19 @@ case "$1" in
|
||||
# (Gustour's ST30: status said running, curl said
|
||||
# connection-refused). Distinguish the two states here so
|
||||
# status isn't a false-positive.
|
||||
if curl -fsS --max-time 3 http://localhost:8000 >/dev/null 2>&1; then
|
||||
echo "$NAME is running (PID $PID, http://localhost:8000 responding)."
|
||||
if curl -fsS --max-time 3 "http://localhost:$SERVICE_PORT" >/dev/null 2>&1; then
|
||||
echo "$NAME is running (PID $PID, http://localhost:$SERVICE_PORT responding)."
|
||||
if lan_redirect_port; then
|
||||
if iptables -t nat -C PREROUTING ! -i lo -p tcp --dport "$LAN_PORT" \
|
||||
-j REDIRECT --to-ports "$SERVICE_PORT" 2>/dev/null; then
|
||||
echo "LAN access: reachable from other machines on port $LAN_PORT."
|
||||
else
|
||||
echo "LAN access: redirect for port $LAN_PORT is NOT installed." >&2
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
else
|
||||
echo "$NAME PID $PID is alive but http://localhost:8000 is not responding." >&2
|
||||
echo "$NAME PID $PID is alive but http://localhost:$SERVICE_PORT is not responding." >&2
|
||||
echo "Recent log:" >&2
|
||||
logread 2>/dev/null | grep "$LOG_TAG" | tail -10 >&2
|
||||
exit 3
|
||||
|
||||
@@ -5,14 +5,16 @@ set -eo pipefail
|
||||
# curl -sSL .../install.sh | sh
|
||||
# resolves and installs the latest release automatically (see below).
|
||||
#
|
||||
# Pin a specific version via environment variable or the --version/-v flag:
|
||||
# VERSION=0.111.3 curl -sSL .../install.sh | sh
|
||||
# curl -sSL .../install.sh | sh -s -- --version 0.111.3
|
||||
# Pin a specific version via environment variable or the --version/-v flag.
|
||||
# The env var goes on `sh`, not `curl`: in a pipe, each command is its own
|
||||
# process, so `VERSION=X curl ... | sh` silently does NOT set it for `sh`.
|
||||
# curl -sSL .../install.sh | VERSION=0.123.0 sh
|
||||
# curl -sSL .../install.sh | sh -s -- --version 0.123.0
|
||||
VERSION=${VERSION:-}
|
||||
|
||||
# Parse optional command-line arguments so the script can be invoked as:
|
||||
# install.sh --version 0.111.3
|
||||
# install.sh -v 0.111.3
|
||||
# install.sh --version 0.123.0
|
||||
# install.sh -v 0.123.0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version|-v)
|
||||
@@ -29,7 +31,7 @@ GH_REPO=${GH_REPO:-gesellix/Bose-SoundTouch}
|
||||
|
||||
# Used only when the latest-release lookup fails (offline / rate-limited /
|
||||
# a curl without -w support).
|
||||
FALLBACK_VERSION=${FALLBACK_VERSION:-0.111.3}
|
||||
FALLBACK_VERSION=${FALLBACK_VERSION:-0.123.0}
|
||||
|
||||
# Resolve the latest release when no explicit version was provided, by
|
||||
# following the stable redirect https://github.com/<repo>/releases/latest
|
||||
@@ -85,6 +87,24 @@ if [ "$INSTALL_DIR" != "/opt/aftertouch" ]; then
|
||||
ln -sf "$INSTALL_DIR" /opt/aftertouch
|
||||
fi
|
||||
|
||||
# Prune any *.backup/*.old/*.new artefacts left behind by an earlier install
|
||||
# attempt, before doing anything else that needs disk space. /mnt/nv is small
|
||||
# (tens of MB), and if a previous run died between creating its backup and
|
||||
# reaching the GC step below (e.g. "no space left on device" during the
|
||||
# download that follows), that backup would otherwise never get cleaned up --
|
||||
# and low free space is exactly what makes the next attempt likely to die the
|
||||
# same way. Pruning up front makes cleanup idempotent regardless of where a
|
||||
# prior run was interrupted.
|
||||
echo "Disk usage before pre-install GC:"; df -h "$INSTALL_DIR"
|
||||
for f in "$INSTALL_DIR/aftertouch-service".*.backup \
|
||||
"$INSTALL_DIR/aftertouch-service".*.old \
|
||||
"$INSTALL_DIR/aftertouch-service.new"; do
|
||||
[ -f "$f" ] || continue
|
||||
rm -f "$f"
|
||||
echo "Removed stale artefact: $f"
|
||||
done
|
||||
echo "Disk usage after pre-install GC:"; df -h "$INSTALL_DIR"
|
||||
|
||||
curl \
|
||||
-sSL \
|
||||
-o "$UPDATE_TMP_DIR/binary" \
|
||||
@@ -110,10 +130,11 @@ mv "$UPDATE_TMP_DIR/binary" "$INSTALL_DIR/aftertouch-service"
|
||||
chmod +x "$INSTALL_DIR/aftertouch-service"
|
||||
|
||||
# Keep only the backup we just created; prune all older *.backup, *.old, and
|
||||
# *.new artefacts left by earlier installs. /mnt/nv is small (tens of MB),
|
||||
# so accumulation quickly causes "no space left on device" during downloads.
|
||||
# *.new artefacts left by earlier installs. This is a second, defensive pass:
|
||||
# it only matters if something wrote a stray artefact between the pre-install
|
||||
# GC above and here (e.g. a concurrent install run).
|
||||
if [ -n "$BACKUP_FILE" ]; then
|
||||
echo "Disk usage before GC:"; df -h "$INSTALL_DIR"
|
||||
echo "Disk usage before post-install GC:"; df -h "$INSTALL_DIR"
|
||||
for f in "$INSTALL_DIR/aftertouch-service".*.backup \
|
||||
"$INSTALL_DIR/aftertouch-service".*.old \
|
||||
"$INSTALL_DIR/aftertouch-service.new"; do
|
||||
@@ -122,7 +143,36 @@ if [ -n "$BACKUP_FILE" ]; then
|
||||
rm -f "$f"
|
||||
echo "Removed stale artefact: $f"
|
||||
done
|
||||
echo "Disk usage after GC:"; df -h "$INSTALL_DIR"
|
||||
echo "Disk usage after post-install GC:"; df -h "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# Settings file sourced by the init script. Written before the service is
|
||||
# (re)started so the very first start already sees it.
|
||||
#
|
||||
# An existing file is left alone on upgrade -- it may carry the operator's own
|
||||
# choices -- unless AFTERTOUCH_LAN_PORT was passed to this script explicitly.
|
||||
CONF_FILE="$INSTALL_DIR/aftertouch.conf"
|
||||
if [ -n "${AFTERTOUCH_LAN_PORT:-}" ] || [ ! -f "$CONF_FILE" ]; then
|
||||
cat > "$CONF_FILE" <<CONFEOF
|
||||
# AfterTouch on-device settings. Sourced by /etc/init.d/aftertouch, which
|
||||
# exports every assignment here into the daemon's own environment -- so any
|
||||
# env var soundtouch-service reads (see docs: guides/SOUNDTOUCH-SERVICE.md,
|
||||
# "Configuration Options") can be set by adding a line below and running
|
||||
# \`/etc/init.d/aftertouch restart\`, e.g.:
|
||||
# MGMT_USERNAME=admin
|
||||
# MGMT_PASSWORD=change-me
|
||||
#
|
||||
# AFTERTOUCH_LAN_PORT: how AfterTouch is reached from other machines.
|
||||
# auto (default) redirect a spare Bose port to AfterTouch, but only on
|
||||
# speakers whose Wi-Fi co-processor refuses to pass :8000 through.
|
||||
# none never redirect; use an SSH tunnel instead.
|
||||
# <port> always redirect this inbound port to AfterTouch.
|
||||
# See docs: reference/MODEL-SUPPORT-MATRIX.md
|
||||
AFTERTOUCH_LAN_PORT=${AFTERTOUCH_LAN_PORT:-auto}
|
||||
CONFEOF
|
||||
echo "Wrote settings to $CONF_FILE (AFTERTOUCH_LAN_PORT=${AFTERTOUCH_LAN_PORT:-auto})"
|
||||
else
|
||||
echo "Keeping existing settings in $CONF_FILE"
|
||||
fi
|
||||
|
||||
echo "Creating init script..."
|
||||
@@ -136,8 +186,18 @@ mv "$UPDATE_TMP_DIR/init-script" /etc/init.d/aftertouch
|
||||
chmod +x /etc/init.d/aftertouch
|
||||
update-rc.d aftertouch defaults
|
||||
|
||||
echo "Installation complete. Running initial startup..."
|
||||
/etc/init.d/aftertouch start
|
||||
echo "Installation complete. (Re)starting the service..."
|
||||
# Use `restart`, not `start`: if AfterTouch is already running (the normal
|
||||
# case for an in-place upgrade or downgrade), `start` calls start-stop-daemon
|
||||
# with a pidfile that still points at a live PID. start-stop-daemon then
|
||||
# refuses to launch a second instance and exits non-zero -- but this script
|
||||
# has no `set -e` here and never checked that exit status, so the old
|
||||
# process kept running untouched while the new binary sat unused on disk.
|
||||
# The post-install curl check below couldn't catch it either, since the old
|
||||
# process kept answering on :8000 throughout. `restart` stops the old
|
||||
# process first (a no-op if nothing was running yet, e.g. on a fresh
|
||||
# install), guaranteeing the newly-installed binary is the one that starts.
|
||||
/etc/init.d/aftertouch restart
|
||||
|
||||
/etc/init.d/aftertouch status
|
||||
|
||||
@@ -150,10 +210,35 @@ echo "Installation complete. Running initial startup..."
|
||||
# daemon's stdout/stderr through `logger -t aftertouch`, so panics
|
||||
# land in busybox syslog and `logread` reads them out.
|
||||
if curl -fsS --max-time 10 http://localhost:8000 >/dev/null 2>&1; then
|
||||
# We are running ON the speaker, so print the address people actually need
|
||||
# rather than a <your-device-ip> placeholder they have to resolve themselves.
|
||||
LAN_IP=$(ip -4 addr show scope global 2>/dev/null \
|
||||
| awk '/inet /{sub(/\/.*/,"",$2); print $2; exit}')
|
||||
[ -n "$LAN_IP" ] || LAN_IP="<your-device-ip>"
|
||||
|
||||
# If the init script installed a LAN entry-port redirect, that port -- not
|
||||
# 8000 -- is the one reachable from other machines.
|
||||
LAN_PORT=$(iptables -t nat -S PREROUTING 2>/dev/null \
|
||||
| grep -- '-j REDIRECT' \
|
||||
| sed -n 's/.*--dport \([0-9][0-9]*\).*--to-ports 8000.*/\1/p' \
|
||||
| head -1)
|
||||
|
||||
echo ""
|
||||
echo "Installation complete. AfterTouch $VERSION is now running on your device."
|
||||
echo "Connect to http://<your-device-ip>:8000 from another machine on the LAN."
|
||||
echo "If the device doesn't expose :8000 directly, port-forward via SSH:"
|
||||
echo " ssh -L 8000:localhost:8000 root@<IP_ADDRESS_OF_SPEAKER>"
|
||||
echo ""
|
||||
if [ -n "$LAN_PORT" ]; then
|
||||
echo " Open http://$LAN_IP:$LAN_PORT from any machine on your network."
|
||||
echo ""
|
||||
echo " (This speaker's Wi-Fi co-processor does not pass port 8000 through to"
|
||||
echo " AfterTouch, so port $LAN_PORT is redirected to it instead. Set"
|
||||
echo " AFTERTOUCH_LAN_PORT in $CONF_FILE to change or disable this.)"
|
||||
else
|
||||
echo " Open http://$LAN_IP:8000 from any machine on your network."
|
||||
fi
|
||||
echo ""
|
||||
echo "If that doesn't load, reach it through an SSH tunnel instead:"
|
||||
echo " ssh -oHostKeyAlgorithms=+ssh-rsa -L 8000:localhost:8000 root@$LAN_IP"
|
||||
echo "then open http://localhost:8000"
|
||||
else
|
||||
echo "WARNING: the init script reports AfterTouch as running, but" >&2
|
||||
echo " http://localhost:8000 isn't responding. The daemon may have" >&2
|
||||
|
||||
@@ -5,6 +5,18 @@
|
||||
set -eu
|
||||
|
||||
/etc/init.d/aftertouch stop || true
|
||||
|
||||
# `stop` normally removes the LAN entry-port redirect. Repeat it directly in
|
||||
# case the init script was already gone or failed, so no rule is left behind
|
||||
# pointing at a service that no longer exists.
|
||||
iptables -t nat -S PREROUTING 2>/dev/null \
|
||||
| grep -- '--to-ports 8000' \
|
||||
| sed 's/^-A /-D /' \
|
||||
| while read -r rule; do
|
||||
# shellcheck disable=SC2086
|
||||
iptables -t nat $rule 2>/dev/null || true
|
||||
done
|
||||
|
||||
rm -f /etc/init.d/aftertouch
|
||||
update-rc.d -f aftertouch remove
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ Both install the **latest release** by default (resolved from GitHub's
|
||||
specific release:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh v0.111.3
|
||||
sudo bash install-player.sh v0.111.3
|
||||
sudo bash install.sh v0.123.0
|
||||
sudo bash install-player.sh v0.123.0
|
||||
```
|
||||
|
||||
## Removal
|
||||
|
||||
@@ -10,7 +10,7 @@ set -euo pipefail
|
||||
# Examples (override defaults via env vars):
|
||||
#
|
||||
# sudo \
|
||||
# VERSION=v0.111.3 \
|
||||
# VERSION=v0.123.0 \
|
||||
# HTTP_PORT=8081 \
|
||||
# bash install-player.sh
|
||||
#
|
||||
@@ -21,7 +21,7 @@ set -euo pipefail
|
||||
# bash install-player.sh
|
||||
#
|
||||
# Or with a version argument to perform an update:
|
||||
# sudo bash install-player.sh v0.111.3
|
||||
# sudo bash install-player.sh v0.123.0
|
||||
#
|
||||
# Notes:
|
||||
# - This script downloads a release binary for your CPU (auto-detects armv7/arm64/amd64).
|
||||
@@ -41,7 +41,7 @@ if [[ -n "$VERSION" && ! "$VERSION" =~ ^v ]]; then
|
||||
fi
|
||||
GH_REPO="${GH_REPO:-gesellix/Bose-SoundTouch}"
|
||||
# Used only when the latest-release lookup fails (offline / rate-limited).
|
||||
FALLBACK_VERSION="${FALLBACK_VERSION:-v0.111.3}"
|
||||
FALLBACK_VERSION="${FALLBACK_VERSION:-v0.123.0}"
|
||||
SERVICE_NAME="${SERVICE_NAME:-soundtouch-player}"
|
||||
BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-player}"
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ set -euo pipefail
|
||||
# Examples (override defaults via env vars):
|
||||
#
|
||||
# sudo \
|
||||
# VERSION=v0.111.3 \
|
||||
# VERSION=v0.123.0 \
|
||||
# HOSTNAME_FQDN=soundtouch.local \
|
||||
# HTTP_PORT=80 \
|
||||
# HTTPS_PORT=443 \
|
||||
@@ -18,7 +18,7 @@ set -euo pipefail
|
||||
# bash install.sh
|
||||
#
|
||||
# Or with a version argument to perform an update:
|
||||
# sudo bash install.sh v0.111.3
|
||||
# sudo bash install.sh v0.123.0
|
||||
#
|
||||
# Notes:
|
||||
# - This script downloads a release binary for your CPU (auto-detects armv7/arm64/amd64).
|
||||
@@ -37,7 +37,7 @@ if [[ -n "$VERSION" && ! "$VERSION" =~ ^v ]]; then
|
||||
fi
|
||||
GH_REPO="${GH_REPO:-gesellix/Bose-SoundTouch}"
|
||||
# Used only when the latest-release lookup fails (offline / rate-limited).
|
||||
FALLBACK_VERSION="${FALLBACK_VERSION:-v0.111.3}"
|
||||
FALLBACK_VERSION="${FALLBACK_VERSION:-v0.123.0}"
|
||||
SERVICE_NAME="${SERVICE_NAME:-soundtouch-service}"
|
||||
BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-service}"
|
||||
|
||||
@@ -122,7 +122,7 @@ detect_arch_asset() {
|
||||
download_url_for() {
|
||||
local asset="$1"
|
||||
# Release asset pattern used by you earlier:
|
||||
# soundtouch-service-v0.111.3-linux-armv7
|
||||
# soundtouch-service-v0.123.0-linux-armv7
|
||||
echo "https://github.com/gesellix/Bose-SoundTouch/releases/download/${VERSION}/soundtouch-service-${VERSION}-${asset}"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user