Compare commits

...
118 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Sonnet 5 44830790b8 test(player): adopt httptest.NewTestServer (Go 1.27) in the new discovery test
NewTestServer registers its own t.Cleanup(Close) instead of needing a
manual defer, and fails the test on a handler panic instead of just
logging it. It defaults to an in-memory transport reachable only via
Server.Client(), which wouldn't work here since our production
client.NewClient dials a real address rather than using that client --
calling Start() instead of Client() opts back into a real loopback
listener, identical to the old NewServer, confirmed by reading the
actual go1.27.0 source (server.go's Start implementation).

This is a proactive adoption of a new stdlib idiom, not one of the
review findings from the previous commit; it doesn't change the
goroutine-drain fix from that commit, which is a separate concern
Close()'s "wait for outstanding requests" guarantee doesn't fully
cover (a goroutine that hasn't started its request yet at Close() time
isn't "outstanding").

Verified: 10x -count re-run under -race, full suite + lint clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:00:11 +02:00
Tobias GesellchenandClaude Sonnet 5 df62073ab0 fix(player): address code review findings on #644
Five findings from reviewing gesellix/Bose-SoundTouch#644
(Retry configured player devices during discovery):

1. Source-labeling used exact string equality
   (device.DiscoveryMethod == "Configuration"), which breaks once a
   configured host is also found via mDNS/UPnP in the same sweep:
   mergeDeviceData concatenates methods into e.g.
   "Configuration+mDNS/Bonjour", so the check silently failed and the
   device got labeled "discovered" instead of "manual". Extracted the
   decision into classifySource() and switched to a substring match.
   Added TestClassifySource, which fails against the old exact-equality
   logic on exactly the composite-string cases (verified) and would
   have caught this before merge -- the PR's own test disables
   mDNS/UPnP, so it never exercised this path.

2. Manually configured devices no longer registered immediately at
   startup -- they now wait for the full mDNS/UPnP sweep (up to the
   10s discovery timeout) to complete, since the PR removed the
   synchronous registration loop and relies entirely on
   PreferredDevices. Restored the immediate loop alongside (not
   instead of) folding manualHosts into PreferredDevices, so a
   currently-online configured device registers immediately as
   before, while an offline one still gets retried on every
   subsequent discovery pass -- the actual value this PR adds.

3. The new PreferredDevices-seeding loop didn't dedupe against hosts
   already loaded from PREFERRED_DEVICES, so setting both for the same
   host produced duplicate entries. Currently harmless (absorbed by
   AddDeviceByHost's fast path) but fragile. Added dedup by host.

4. NewDiscoveryService's doc comment didn't mention the new
   configuredHosts parameter or its retry-on-every-sweep behavior.
   Documented.

5. The new test's second DiscoverDevices call spawns a one-shot
   status-update goroutine and a 30s-ticker poll loop with no
   guaranteed drain before the deferred server.Close(), risking
   benign but real -race/CI flakiness. Added a bounded settle delay
   after RemoveDevice.

Verified: full build/vet/race test suite/lint clean; the new
TestClassifySource fails against the pre-fix logic and passes with
it; TestDiscoverDevicesRetriesConfiguredHosts re-run 20x under -race
with no flakiness.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:00:11 +02:00
Lukáš Lipinský 7015e04556 fix(player): retry configured devices during discovery 2026-08-23 22:00:11 +02:00
Tobias GesellchenandClaude Sonnet 5 d3c5ad2d8e style(install): align the environment variables table columns
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:50:27 +02:00
Tobias GesellchenandClaude Sonnet 5 ef8bfdc74b docs(install): add an environment variables reference table
The nine env vars install.sh reads were only ever mentioned inline,
scattered across the file, or not documented at all
(AFTERTOUCH_FORCE_NO_BACKUP, GH_REPO, BINARY_URL, INIT_SCRIPT_URL,
FALLBACK_VERSION, AFTERTOUCH_LAN_PORT). Collect them into one table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:50:27 +02:00
Tobias GesellchenandClaude Sonnet 5 fdc08d2745 docs(install): replace stale "we're unsure how to update" note
The Space Limitation section still described update safety as an open
problem ("we are currently working on this"). Replace it with what the
installer now actually does: gzip-compressed backups, a preflight
disk-space check with an interactive confirm-or-abort before skipping
the backup, and a hard abort before downloading anything if there
isn't even room for the update itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:50:27 +02:00
Tobias GesellchenandClaude Sonnet 5 ec340847f2 fix(install): preflight disk-space check before replacing the live binary
The gzip fix in the previous commit only helps once a backup is being
made; it doesn't address the actual moment that broke on real hardware:
the cross-device mv/copy of the new binary into place ran out of space
mid-write, leaving a truncated, non-executable binary as the live one.
UBIFS is a log-structured flash filesystem, so space "freed" by
overwriting the old binary isn't guaranteed reusable in time for the new
one to land -- this happened on a device with 15.7MB available against
a ~14.8MB binary.

Add a preflight check before downloading anything: fetch the new
binary's real size via a HEAD request (adapts automatically as binaries
grow, instead of a threshold that goes stale every release) and compare
against available space plus a flat 5MB safety margin.

- Comfortably enough room for old + new + a compressed backup: proceed
  exactly as before, silently.
- Enough for old + new but not enough extra for a backup: warn
  interactively and require explicit confirmation before proceeding
  without one. Reads from /dev/tty since the script is normally piped
  via `curl | sh` (stdin is consumed by the script itself). Defaults to
  the safe choice (abort) on empty input, matching the [y/N] prompt.
  AFTERTOUCH_FORCE_NO_BACKUP=yes overrides for non-interactive/scripted
  use.
- Not enough room even for the replace itself: abort before starting
  the download, rather than attempting a doomed download/replace that
  could leave a truncated live binary.
- No TTY available and the operator didn't set the override: abort
  rather than silently guessing.
- HEAD request fails for any reason: skip the check with a warning
  rather than blocking the install on it.

Verified: all five decision branches (plenty of room, warn+decline,
warn+confirm, warn+forced-override, hard abort) produce the correct
result under both dash and a real BusyBox v1.38.0 container, including
the gzip/gunzip streaming backup and glob-based pruning from the
previous commit. The HEAD-request size lookup was separately verified
against a live GitHub release URL with real curl -- catching and fixing
a bug where naively taking the first "content-length:" header grabbed
the 302 redirect's (0), not the actual asset's, size. Not yet re-tested
end-to-end on real hardware.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:50:27 +02:00
Tobias GesellchenandClaude Sonnet 5 e8f1b53992 fix(install): gzip the rollback backup to reduce on-device disk pressure
Binaries are tens of MB and only growing (Go 1.27 alone added ~655KB
to soundtouch-service via its own new stdlib defaults, unrelated to
this project's code), while the on-device install target (/mnt/nv) is
only tens of MB total. A user already hit "no space left on device"
attempting an update on real hardware.

Stream the pre-update backup straight through gzip instead of cp-then-
gzip: at that point in the script the old binary is still live and the
newly-downloaded one is already sitting in the temp dir, so writing an
intermediate uncompressed backup copy would briefly need three full
binary-sized copies on disk at once. Streaming avoids ever creating
that intermediate copy. Falls back to a plain uncompressed backup if
gzip is unavailable or the stream fails partway, matching prior
behavior exactly.

Both GC loops (pre- and post-install) now also prune stale
*.backup.gz artefacts, and the README's documented rollback command
covers both the compressed and (fallback) uncompressed cases.

Verified locally (not yet on real hardware): streaming path produces
no uncompressed intermediate, the gzip-unavailable fallback still
produces a plain backup, and the documented gunzip+chmod rollback
restores a byte-identical, executable binary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:50:27 +02:00
Tobias GesellchenandClaude Sonnet 5 424631b93a build(lint): point golangci-lint install at the v2 module path
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
silently resolves to the latest v1.x release (v1.64.8) -- Go's semantic
import versioning treats v2+ as a completely separate module path
(.../v2/cmd/golangci-lint), so the unsuffixed path's @latest can never
see v2 releases. That mismatched v1 binary can't even load this
repo's v2-format .golangci.yml, and separately doesn't understand the
go1.27.0 toolchain declared in go.mod.

Fix the install hint in `make lint`'s not-found message to use the /v2
path, and refresh the now-current version noted in .golangci.yml's
header comment (installed locally as v2.13.1, built with go1.27.0).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:50:33 +02:00
Tobias GesellchenandClaude Sonnet 5 97c28b5516 test(router): update route-name snapshot for Go 1.27, drop redundant special case
Go 1.27 changed how runtime.FuncForPC reports the symbol for
HandleWeb()'s returned closure: it now correctly attributes it to its
defining function (handlers.(*Server).HandleWeb) instead of leaking the
inlining call site's enclosing function name (setupRouter) the way
older Go versions did. The registered route itself is unchanged -- this
is purely a difference in the introspected debug name.

The test's cleanup logic had a dedicated special case for stripping a
leading "setupRouter" prefix, added to work around exactly that
inlining artifact. Verified empirically (temporarily instrumented with
the raw runtime.FuncForPC output, then diffed the full 299-route table
with the special case removed) that the general prefix-stripping loop
already produces an identical result for the remaining legitimate
cases (closures actually defined inline in setupRouter, e.g.
/favicon.ico) -- so the dedicated case was already redundant before
this Go bump and can be dropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:50:33 +02:00
Tobias Gesellchen fb69ce29e0 Bump Golang to 1.27.0 2026-08-23 14:50:33 +02:00
Tobias GesellchenandClaude Sonnet 5 18e6c32220 fix(web): show a sources count in Sync results, render as a list
syncSources never reported how many sources it actually saved, so the
Admin UI's success message always said the meaningless "sources:
synced" regardless of outcome. syncSources now returns the count saved
(-1 if the fetch failed), threaded through SyncResult.SourcesCount.

Also replaces the single run-on results string (which visually mashed
presets/recents/sources together with no separator) with a real <ul>
list, one <li> per resource, matching the presets/recents diff lines.
Built via DOM APIs rather than innerHTML string concatenation, since
preset/recent names ultimately come from user-editable station names
on the speaker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:19:40 +02:00
Tobias GesellchenandClaude Sonnet 5 7fa70d725a fix(web): surface Sync's destructive-confirm gate in the admin UI
startSync() used to POST once and, on any 2xx, render a hardcoded
"Presets: OK / Recents: OK / Sources: OK" regardless of what the
response actually said -- exactly why a silent partial data loss (see
the previous commit) would have looked like success to the user.

Now: on a 409 (destructive) response, build a specific confirm message
from the diff (e.g. "presets: 6 -> 5: Ici Roussillon") and gate via
window.confirm(), matching the existing QuickFix confirm UX; on
confirm, retry with ?confirmed=true. On success, render the actual
per-resource counts from the response body instead of a canned string.

Adds an HTTP-level regression test
(TestHandleInitialSync_DestructiveSyncReturns409ThenAppliesWhenConfirmed)
covering the same refuse-then-confirm flow through the real handler and
router, complementing the lower-level setup package test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:19:40 +02:00
Tobias GesellchenandClaude Sonnet 5 2bac4fb208 fix(setup): require confirmation before Sync would shrink stored data
SyncDeviceData's syncPresets/syncRecents unconditionally overwrote the
datastore with whatever the speaker's live :8090 API returned at that
instant, with no check against what's already stored. If the speaker's
own local cache was stale or incomplete at that moment (e.g. right
after a burst of preset writes, or shortly after a reboot before the
speaker resyncs with Marge), Sync would silently persist that bad
snapshot over good data. A reporter's fresh #614 repro showed the
account's /full response dropping from 6 to 5 presets right after a
Sync click, consistent with this mechanism.

SyncDeviceData now diffs a fresh live fetch against what's stored
before writing anything; if applying would shrink either list, it
returns the diff (via the new SyncResourceDiff/SyncResult types)
without writing unless the caller passes confirmed=true.
HandleInitialSync surfaces this as a 409 with the diff JSON; every call
(confirmed or not) re-fetches live from the speaker, so a confirmed
retry re-checks reality rather than replaying a stale snapshot. Sources
sync is left unconditional, as before -- lower risk in practice and
out of scope for this fix.

fetchLivePresets/fetchLiveRecents are extracted pure-fetch helpers;
syncPresets/syncRecents keep their unconditional-apply behavior (used
directly by existing tests) since the button-driven path now goes
through the diff/confirm guard instead.

Adds TestSyncDeviceData_DestructiveSyncRequiresConfirmation covering
both the refusal and the confirmed-retry path.

Frontend wiring (script.js's startSync + real per-resource result
rendering, replacing the current hardcoded "OK" text) is a follow-up
commit on this branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:19:40 +02:00
Tobias GesellchenandClaude Sonnet 5 d4f4b4fb80 style(marge): fix wsl_v5 lint finding in AddRecent's MutateRecents call
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:08:24 +02:00
Tobias GesellchenandClaude Sonnet 5 9eed1e11c5 fix(marge): route preset/recent/source read-modify-write through Mutate*
Converts the remaining GetX-then-SaveX call sites (UpdatePreset,
RemovePreset, AddRecent's recent + learned-source persistence, AddSource)
to the new datastore.Mutate{Presets,Recents,ConfiguredSources} helpers,
closing the lost-update race for good on the actual write path the
speaker hits on every preset/recent store.

Adds a regression test that fires 6 concurrent UpdatePreset calls (same
shape as #614's rapid-fire repro) and asserts none are lost. Verified it
reliably fails against the pre-fix code (consistently drops presets
across repeated runs) and passes reliably with the fix, including under
-race.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:08:24 +02:00
Tobias GesellchenandClaude Sonnet 5 28de254f77 fix(datastore): add lock-spanning Mutate helpers for presets/recents/sources
GetX-then-SaveX call sites did an unguarded read-modify-write: two
concurrent callers could each read the same starting list, mutate
different entries, and the second writer's Save clobber the first's
update. This is exactly what dropped a preset during #614's rapid-fire
preset-programming repro (overlapping PUT .../preset/N requests).

Add MutatePresets/MutateRecents/MutateConfiguredSources, each holding a
single write lock across the whole read-mutate-write cycle, and switch
resolvePresetSource's auto-add-canonical-source path (the same race,
for sources) to use the new MutateConfiguredSources.

Part of the #614 follow-up; more call sites (UpdatePreset's own preset
write, recents, other sources writers) still need converting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:08:24 +02:00
dependabot[bot]andlnx01 8c0f8b0592 docker(deps): bump golang from 1.26.6-alpine to 1.27.0-alpine (#637)
Bumps golang from 1.26.6-alpine to 1.27.0-alpine.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=golang&package-manager=docker&previous-version=1.26.6-alpine&new-version=1.27.0-alpine)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-23 13:56:50 +02:00
dependabot[bot]andlnx01 85fa9ede2f ci(deps): bump docker/setup-buildx-action from 4.2.0 to 4.3.0 in the setup-actions group (#640)
Bumps the setup-actions group with 1 update:
[docker/setup-buildx-action](https://github.com/docker/setup-buildx-action).

Updates `docker/setup-buildx-action` from 4.2.0 to 4.3.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/setup-buildx-action/releases">docker/setup-buildx-action's
releases</a>.</em></p>
<blockquote>
<h2>v4.3.0</h2>
<ul>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.92.0 to 0.95.0 in
<a
href="https://redirect.github.com/docker/setup-buildx-action/pull/595">docker/setup-buildx-action#595</a></li>
<li>Bump brace-expansion from 1.1.13 to 1.1.18 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/600">docker/setup-buildx-action#600</a></li>
<li>Bump js-yaml from 5.2.0 to 5.3.0 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/585">docker/setup-buildx-action#585</a></li>
<li>Bump postcss from 8.5.10 to 8.5.25 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/598">docker/setup-buildx-action#598</a></li>
<li>Bump undici from 6.27.0 to 6.28.0 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/601">docker/setup-buildx-action#601</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/setup-buildx-action/compare/v4.2.0...v4.3.0">https://github.com/docker/setup-buildx-action/compare/v4.2.0...v4.3.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/37fe631027851001ddb9b187196cc803df7f5f0e"><code>37fe631</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/595">#595</a>
from docker/dependabot/npm_and_yarn/docker/actions-to...</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/b5c4f91922681cc7c58d15ab7838986951f09d19"><code>b5c4f91</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/3e93b637c6430ba8fa896fad44d3aa6821899d63"><code>3e93b63</code></a>
build(deps): bump <code>@​docker/actions-toolkit</code> from 0.92.0 to
0.95.0</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/e527031b32c86649307d5d492506855f90470604"><code>e527031</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/600">#600</a>
from docker/dependabot/npm_and_yarn/brace-expansion-1...</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/c68814b33cb66f1f7538e546190d410ae557a640"><code>c68814b</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/3f891b01bd5012a434f582800366972569aa1886"><code>3f891b0</code></a>
build(deps): bump brace-expansion from 1.1.13 to 1.1.18</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/787db26fcde8ddcabd49a81472318028f7113962"><code>787db26</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/585">#585</a>
from docker/dependabot/npm_and_yarn/js-yaml-5.2.1</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/f7793687c711790ca336bd4934f1b1bf5f778e17"><code>f779368</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/7d5e60413489a33d28077e11d71c668580cfaf8d"><code>7d5e604</code></a>
build(deps): bump js-yaml from 5.2.0 to 5.3.0</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/292c2fb3837a12d3ac2d1e47bbc5c00712bad939"><code>292c2fb</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/590">#590</a>
from docker/dependabot/github_actions/actions/setup-n...</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/setup-buildx-action/compare/bb05f3f5519dd87d3ba754cc423b652a5edd6d2c...37fe631027851001ddb9b187196cc803df7f5f0e">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=docker/setup-buildx-action&package-manager=github_actions&previous-version=4.2.0&new-version=4.3.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-23 13:51:28 +02:00
dependabot[bot]andlnx01 b6ce6e79ac deps(deps): bump github.com/go-chi/chi/v5 from 5.3.1 to 5.3.2 (#639)
Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from
5.3.1 to 5.3.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/go-chi/chi/releases">github.com/go-chi/chi/v5's
releases</a>.</em></p>
<blockquote>
<h2>v5.3.2</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(middleware): add text/markdown, text/csv, text/vtt to default
compressible types by <a
href="https://github.com/VojtechVitek"><code>@​VojtechVitek</code></a>
in <a
href="https://redirect.github.com/go-chi/chi/pull/1151">go-chi/chi#1151</a></li>
<li>docs: deployment recipe for
middleware.ClientIPFromXFFTrustedProxies() by <a
href="https://github.com/VojtechVitek"><code>@​VojtechVitek</code></a>
in <a
href="https://redirect.github.com/go-chi/chi/pull/1111">go-chi/chi#1111</a></li>
<li>fix: don't drop handlers that collide with a Mount()/Route() pattern
by <a
href="https://github.com/VojtechVitek"><code>@​VojtechVitek</code></a>
in <a
href="https://redirect.github.com/go-chi/chi/pull/1148">go-chi/chi#1148</a></li>
<li>Don't duplicate methods in Allow: header for 405 responses by <a
href="https://github.com/flimzy"><code>@​flimzy</code></a> in <a
href="https://redirect.github.com/go-chi/chi/pull/1029">go-chi/chi#1029</a></li>
<li>fix(middleware): reject catch-all compress wildcards by <a
href="https://github.com/VojtechVitek"><code>@​VojtechVitek</code></a>
in <a
href="https://redirect.github.com/go-chi/chi/pull/1156">go-chi/chi#1156</a>
<ul>
<li><code>middleware.NewCompressor(level, &quot;/*&quot;)</code> never
worked and silently compressed nothing. Instead of turning it into a
compress-everything catch-all (as proposed in <a
href="https://redirect.github.com/go-chi/chi/issues/868">go-chi/chi#868</a>
and <a
href="https://redirect.github.com/go-chi/chi/pull/1121">go-chi/chi#1121</a>),
we decided to reject both &quot;/<em>&quot; and &quot;</em>/*&quot; at
construction and panic. Compressing every response wastes CPU on
already-compressed types (zip, jpeg, png), which is why the middleware
keeps a curated default list. Users should pass explicit content
types.</li>
</ul>
</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/go-chi/chi/compare/v5.3.1...v5.3.2">https://github.com/go-chi/chi/compare/v5.3.1...v5.3.2</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/go-chi/chi/commit/38939062c5df4d3e8814aad1a488983112627ced"><code>3893906</code></a>
fix(middleware): reject catch-all compress wildcards &quot;/<em>&quot;
and &quot;</em>/*&quot; (<a
href="https://redirect.github.com/go-chi/chi/issues/1156">#1156</a>)</li>
<li><a
href="https://github.com/go-chi/chi/commit/9b6ddcddb96aa14648702e7eaabef38e14e65157"><code>9b6ddcd</code></a>
Don't duplicate methods in Allow: header for 405 responses (<a
href="https://redirect.github.com/go-chi/chi/issues/1029">#1029</a>)</li>
<li><a
href="https://github.com/go-chi/chi/commit/29164f023bf9319e74d5961a21a712653bb98c83"><code>29164f0</code></a>
fix: don't drop handlers that collide with a Mount()/Route() pattern (<a
href="https://redirect.github.com/go-chi/chi/issues/1148">#1148</a>)</li>
<li><a
href="https://github.com/go-chi/chi/commit/bc02284e9db220c644912320fe1db6bc9b4a087c"><code>bc02284</code></a>
docs: deployment recipe + verify checklist for
ClientIPFromXFFTrustedProxies ...</li>
<li><a
href="https://github.com/go-chi/chi/commit/60ecea54191a4cad3d5a96568708dad996509b17"><code>60ecea5</code></a>
feat(middleware): add text/markdown, text/csv, text/vtt to default
compressib...</li>
<li>See full diff in <a
href="https://github.com/go-chi/chi/compare/v5.3.1...v5.3.2">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-23 13:51:03 +02:00
dependabot[bot]andlnx01 7a6b5866fe deps(deps): bump github.com/miekg/dns from 1.1.72 to 1.1.73 (#638)
Bumps [github.com/miekg/dns](https://github.com/miekg/dns) from 1.1.72
to 1.1.73.
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/miekg/dns/commit/d854399da1ee385b432e8b07f79e53bbfc1ab1b0"><code>d854399</code></a>
Release 1.1.73</li>
<li><a
href="https://github.com/miekg/dns/commit/aed10f489b2a2507477a39b70ef8f22c2c71db75"><code>aed10f4</code></a>
go.mod: add tool directive to replace tools.go</li>
<li><a
href="https://github.com/miekg/dns/commit/76c682a2649fa559ca5a94a6e727714959c2cabe"><code>76c682a</code></a>
Fix gogen diff</li>
<li><a
href="https://github.com/miekg/dns/commit/000bd62913f2dd478fdbd452387777be1c423b47"><code>000bd62</code></a>
Bump the all group with 4 updates (<a
href="https://redirect.github.com/miekg/dns/issues/1726">#1726</a>)</li>
<li><a
href="https://github.com/miekg/dns/commit/24ce5ef354706374797e4aa197977e45a272a24b"><code>24ce5ef</code></a>
Bump the all group with 4 updates (<a
href="https://redirect.github.com/miekg/dns/issues/1725">#1725</a>)</li>
<li><a
href="https://github.com/miekg/dns/commit/fa041eedc7a8991bb4bc515b95bda793f5776b29"><code>fa041ee</code></a>
Bump the all group with 3 updates (<a
href="https://redirect.github.com/miekg/dns/issues/1723">#1723</a>)</li>
<li><a
href="https://github.com/miekg/dns/commit/3124152ebe810d79ce60e09c8aba7b356ff698b0"><code>3124152</code></a>
MD5: remove keytag calculation (<a
href="https://redirect.github.com/miekg/dns/issues/1724">#1724</a>)</li>
<li><a
href="https://github.com/miekg/dns/commit/d1539a788a12830620381c4cc6617762994f3fa1"><code>d1539a7</code></a>
Bump the all group with 4 updates (<a
href="https://redirect.github.com/miekg/dns/issues/1713">#1713</a>)</li>
<li><a
href="https://github.com/miekg/dns/commit/cd053176d80a0143a56f61f5e5d06bdd94a610e9"><code>cd05317</code></a>
Shorter v2 announcement</li>
<li><a
href="https://github.com/miekg/dns/commit/ce76cb6c9b5f3b75ff44996597994ae6f13eae28"><code>ce76cb6</code></a>
Bump the all group with 3 updates (<a
href="https://redirect.github.com/miekg/dns/issues/1703">#1703</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/miekg/dns/compare/v1.1.72...v1.1.73">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/miekg/dns&package-manager=go_modules&previous-version=1.1.72&new-version=1.1.73)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-23 13:39:54 +02:00
Tobias GesellchenandClaude Sonnet 5 245032e005 fix(setup): accept non-numeric account IDs reported by third-party pairing tools (#634)
Speakers paired via non-AfterTouch tooling (e.g. the USB-stick SSH-enable
method) can report a margeAccountUUID that isn't Bose's own 7-digit
numeric format, such as "stick@local". Discovery persisted this value
unvalidated, and the datastore's identifier check rejected it outright,
so the device was silently never saved.

Widens datastore.IsSafeIdentifier to accept any identifier that's safe
as a path component, XML value, and telnet-command token (still
excluding whitespace, control characters, and HTML/XML/shell
metacharacters), and makes it the single account-ID validator,
replacing setup's separate, stricter 7-digit-only IsValidAccountID.

Also closes related gaps found while widening the validator:
- postSetMargeAccount now XML-escapes the account ID instead of raw
  string interpolation.
- SaveAccountInfo/HandleMargeCreateAccount now validate the account ID
  the same way SaveDeviceInfo already did.
- handlers_export.go URL-escapes account/device IDs before building
  outbound diagnostic-fetch URLs.
- pkg/service/health gained the sanitizeLog helper every other package
  already has, applied to log lines carrying speaker-reported values.
- The admin web UI (script.js) renders account/device IDs via DOM APIs
  instead of innerHTML/inline event-handler string interpolation,
  closing a stored-XSS path, and a duplicate escape helper was
  consolidated into one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 11:31:08 +02:00
Tobias Gesellchen 790a20d49b fix(datastore): log previously-silent empty-preset/recent reads (#614)
readPresetsLocked's os.IsNotExist branch and GetRecents' equivalent
branch silently returned an empty result with no log line at all,
unlike their sibling 0-byte/malformed-XML branches which already log.
When a reporter's speaker got served an empty preset list at reboot
despite an intact on-disk Presets.xml, there was no durable record of
it anywhere except a live capture at the exact moment.

Also log the per-device preset count going into every /full response
in CreateAccountDevice, distinguishing a disk read that came back
empty from one where source-mapping silently dropped presets
afterward.

Diagnostic only, no behavior change - the actual trigger for the
empty response is still open.
2026-08-21 08:59:36 +02:00
Tobias GesellchenandClaude Sonnet 5 21043d542a feat(release): add real per-platform download links to release notes
Release notes previously pointed at the flat, alphabetical Assets
list, forcing readers to hunt for their platform's soundtouch-service
or soundtouch-cli build. Generate direct per-platform links (with
inline checksum links, one row per OS/arch) from the deterministic
asset naming convention, and wire it into both release paths: the
auto-generated notes (create_release) and the hand-authored notes a
maintainer publishes via the GitHub web UI (update_release, which now
replaces the Downloads footer line in place). The footer-replace logic
always goes through the same strip-then-append path so re-running the
job for the same tag stays byte-for-byte idempotent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 21:52:56 +02:00
dependabot[bot] 27bb738751 ci(deps): bump the codeql-action group with 3 updates
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

Updates `github/codeql-action/analyze` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

Updates `github/codeql-action/upload-sarif` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-18 19:55:50 +02:00
Tobias Gesellchen e57708ea11 fix(setup): gate setup pair --mode=full on configuration status (#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 (reported for ST30 Series II/III in #615). Only a full
pass through the WebSocket setup state machine clears it, but running
that unconditionally risks re-running the bracket on speakers that
don't need or support it.

Add Manager.PreflightInitPlan: checks /supportedURLs for
/setMargeAccount, then requires /soundTouchConfigurationStatus to read
exactly SOUNDTOUCH_NOT_CONFIGURED before ExecuteInitPlan runs.
Already-configured devices are a no-op; an unsupported route or an
unrecognised status value aborts instead of guessing.
2026-08-17 21:29:39 +02:00
Tobias GesellchenandClaude Sonnet 5 d873d88b4f fix(admin-ui): clarify CA/TLS and HTTPS test are optional for HTTP plans
The default Suggested Plan (both XML-over-SSH and Telnet) migrates the
speaker over plain HTTP and never touches CA/TLS at all, but the CA/TLS
precondition always showed a red not-installed marker and the HTTPS
Connection Test panel was always rendered, regardless of whether the
current Target URL actually needs HTTPS. Both read as mandatory steps
even when nothing needed doing.

CA/TLS and HTTPS only matter when the Target URL is https:// or the
Customize form's DNS-interception method is chosen (that one always
targets https://*.bose.com).

- caVerdict() now takes whether the Target URL is HTTPS: shows a
  neutral marker with a "not needed" note for HTTP targets, keeps the
  red marker with a sharper "required" note for HTTPS targets.
- The HTTPS Connection Test panel gets a small note under its heading
  ("Optional for your current plan (HTTP)" / "Required ... (HTTPS)"),
  computed from the same check. Stays visible either way so someone can
  still run it if they want.

Frontend-only — showSummary already had the Target URL in scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 20:58:51 +02:00
Tobias GesellchenandClaude Sonnet 5 57c0895063 fix(admin-ui): make Migration tab action buttons consistently reachable
Follow-up on #621: the Reboot Speaker button (plus Revert to Defaults,
Enable SSH, Disable SSH) was reachable only after expanding the
collapsed "Customize this migration" section and scrolling past three
fieldsets and the XML/telnet diff panes. Meanwhile every other real
action elsewhere in the admin UI (Save Settings, Apply Suggested Plan,
Start Sync, ...) is visible by default.

- Move Revert to Defaults and Reboot Speaker into an always-visible
  "Speaker controls" row directly under the Migration State card.
- Move Enable/Disable SSH into the Preconditions table, inline with the
  SSH (remote_services) status row, sized like the existing "Trust CA
  Now" button next to the CA/TLS row. script.js now only rewrites the
  inner status span on re-render (matching the CA/TLS pattern) so the
  buttons survive summary refreshes.
- Add shared .btn-primary/.btn-danger CSS classes so button color
  consistently means the same thing everywhere (primary = confirm,
  danger = destructive) instead of ad-hoc inline colors; applied to
  Save Settings, Apply Suggested/Custom Plan, Enable/Disable SSH,
  Revert to Defaults, and Trust CA Now. Removed decorative gray from
  Reboot Speaker and the connection/DNS test buttons.
- Replace the "Cancel" button (which only hid the whole summary panel,
  not any of the actions it sat beside) with a "✕ Hide" control next
  to the "Migration Summary for <device>" heading, alongside a new
  "↻ Reload" shortcut for refreshSummary().
- Remove the now-unneeded force-open-the-details hack in migrate()
  since Reboot no longer lives inside any collapsed container.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 20:58:51 +02:00
Tobias GesellchenandClaude Sonnet 5 fb8eab27c3 docs(readme): remove obsolete Go Report Card badge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 20:58:38 +02:00
Tobias GesellchenandClaude Sonnet 5 2218a28179 docs: add ST30 III reset note and post-update version-check guidance
Two doc notes from dunha's #621 follow-up: the factory-reset button
combo is confirmed identical on the SoundTouch 30 Series III, and
checking the reported version right after an on-device update can
still show stale info until the speaker (or an open Admin UI tab) is
rebooted, even though the new binary is already running.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 19:57:02 +02:00
Tobias GesellchenandClaude Sonnet 5 9e56c4f3f4 fix(setup,admin-ui,install): three bugs from #621 follow-up feedback
- setup: resync all four boseurls (not just marge/swUpdate) over telnet
  after an SSH-XML migration. `envswitch boseurls set` persists whatever
  is currently in the runtime layer, so leaving stats/bmx untouched froze
  their stale pre-migration values into the persistence layer permanently
  -- surviving reboot and previously requiring a factory reset to clear.
- admin-ui: Migrate tab's Target Domain edits now propagate into the four
  service URL fields (tracked via a dataset.autofilled flag so real manual
  edits still aren't clobbered), closing the gap where changing Target
  Domain to a new value left the four fields pointed at a stale default.
- install.sh: prune stale binary backups before the download too, not
  only after a successful install, so a backup left by a previously
  aborted (out-of-space) run gets cleaned up instead of compounding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 19:57:02 +02:00
dependabot[bot] ba45d997cf deps(deps): bump the golang group with 3 updates
Bumps the golang group with 3 updates: [golang.org/x/mod](https://github.com/golang/mod), [golang.org/x/net](https://github.com/golang/net) and [golang.org/x/tools](https://github.com/golang/tools).


Updates `golang.org/x/mod` from 0.39.0 to 0.40.0
- [Commits](https://github.com/golang/mod/compare/v0.39.0...v0.40.0)

Updates `golang.org/x/net` from 0.57.0 to 0.58.0
- [Commits](https://github.com/golang/net/compare/v0.57.0...v0.58.0)

Updates `golang.org/x/tools` from 0.48.0 to 0.49.0
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.48.0...v0.49.0)

---
updated-dependencies:
- dependency-name: golang.org/x/mod
  dependency-version: 0.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/net
  dependency-version: 0.58.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/tools
  dependency-version: 0.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 19:44:44 +02:00
Tobias GesellchenandClaude Sonnet 5 eec57cbc10 fix(admin-ui): Migrate tab blocked on-device localhost by default
validateURL() unconditionally rejected the hostnames "localhost" and
"127.0.0.1" for the four Migrate-tab plan URL fields, with no awareness
of deployment mode. Since the Suggested Plan's URLs are derived from the
page's own configured Target URL, a fresh on-device install (whose
server_url is now correctly http://localhost:8000, since #546) loaded
the Migrate tab with "Apply Suggested Plan" and "Pre-flight" disabled
by default, before the user touched anything -- directly contradicting
the on-device docs' "Migrate -> accept the suggested plan -> apply"
instructions.

Found while investigating why a #614 reporter used the non-standard
"localhost.localdomain" as a workaround, and why a #621 reporter got
stuck with "Migration Status: Migrated (URL mismatch)" trying to follow
the (correct) on-device localhost guidance.

Fix: a loopback URL is only flagged when it doesn't match the plan's
own Target URL origin. A field that's exactly what the service itself
is already configured to answer as (the on-device case) is accepted;
a stray "localhost" typed into one field while Target URL is a real LAN
address (the external-host mistake the check exists to catch) is still
flagged, since the origins differ.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 20:41:40 +02:00
Tobias GesellchenandClaude Sonnet 5 3e730c983f docs(troubleshooting): Settings tab alone never updates an already-migrated speaker
Confirmed via code review: the Settings-save handler only updates the
service's own serverURL/settings.json, never contacts a device, and
neither migration method (telnet or XML/SSH) leaves anything behind that
would make a speaker later re-fetch a new address on its own. Both write
once, at migrate time.

Adds a Troubleshooting entry for this, and a cross-reference from the
Migration Guide's Step 2 (Target Domain) pointing at it, plus a step-
number fix (Migrate is Step 5, not Step 4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 20:23:13 +02:00
Tobias GesellchenandClaude Sonnet 5 81b915bfca fix(on-device): stop leaking the speaker's own hostname into BMX/TLS URLs
On an on-device install, soundtouch-service defaulted its server URL to
os.Hostname() when --server-url wasn't set. Since the service runs on the
speaker's own Linux, that returns the speaker's internal variant codename
(e.g. "spotty", "mojo") -- never resolvable, not even by the speaker itself
-- breaking TuneIn/BMX playback with CURL ErrorCode 6 (issue #546).

Add a --deployment-mode/DEPLOYMENT_MODE flag (on-device, private-network,
public-network) so the fallback is chosen deliberately instead of guessed:
on-device defaults to localhost, public-network refuses to start rather
than guess a public address, and the previous hostname-guessing behavior
is kept for private-network/unset installs, now with a startup warning.

The on-device init script sets DEPLOYMENT_MODE=on-device automatically and
now auto-exports aftertouch.conf into the daemon's environment generally,
which also unblocks discussion #610 (setting MGMT_USERNAME/MGMT_PASSWORD
on-device) without any further code change.

Verified end-to-end on real ST20 hardware: service now resolves
http://localhost:8000, a re-migrate updates the speaker's own runtime
config to match, and TuneIn playback works again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 20:12:44 +02:00
Tobias GesellchenandClaude Opus 5 de6172de17 docs: fix two broken links to the model support matrix
The site sets disablePathToLower, so published URLs keep the source
filename's case, but the README link was lowercased and 404'd.

The walkthrough link was wrong in a second way: it used ../../reference/
with no extension, while cross-document links from guides/ resolve as
../reference/NAME.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 16:01:34 +02:00
Tobias GesellchenandClaude Opus 5 b8427b0bbe feat(on-device): reach AfterTouch from the LAN without an SSH tunnel
On-device installs were only reachable through an SSH tunnel, and the
docs blamed it on the service binding loopback-only. That was wrong.

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 firmware. AfterTouch's :8000 was never
part of that design, so connections never arrive at the SoC at all.
Confirmed on an ST20: a port sweep from a LAN client showed Bose's
:82/:8080/:8090/:8091/:8200/:17000 all answering while :8000 failed,
and tcpdump on the speaker's own eth0 recorded zero packets for it.
Ruled out along the way: iptables (empty), nft/ebtables (absent), the
router, Wi-Fi isolation, and the binding itself (0.0.0.0 is correct).

The init script now redirects one of the relayed ports to AfterTouch,
so http://<speaker-ip>:17008 works with no tunnel. 17008 is Bose's
software-update listener, whose cloud no longer exists. Only external
traffic is matched, so anything on the speaker still reaches :8000 as
before. Auto-enabled only where has-bco reports the co-processor, and
configurable via AFTERTOUCH_LAN_PORT (auto/none/port) in
aftertouch.conf. The rule is re-applied on every start and removed on
stop and uninstall, so it needs no watchdog; unlike prior art it is not
pinned to the LAN IP, so it also survives DHCP changes.

Credit for the REDIRECT technique goes to the STR / SoundTouch Reborn
project, which documented and shipped it first.

Also de-hardcodes the service port, which was baked independently into
the daemon args, the readiness poll and status, and makes install.sh
print the speaker's real address instead of a <your-device-ip>
placeholder it never filled in.

Adds a model support matrix, since the repo had no per-model
compatibility record and this behaviour is entirely chassis-dependent.
Only the verified ST20 row is filled in; everything else is marked
unknown rather than inferred.

Verified on hardware: auto-detection, idempotency across restarts,
teardown and restore, persistence across a full reboot, and LAN access
returning the service's health JSON.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:54:00 +02:00
Tobias GesellchenandClaude Sonnet 5 1396bb32dc docs(troubleshooting): mark the setup revert dial-storm entry as fixed
The SSH connection-reuse fix (a4c0539) landed after this entry was
originally written describing the bug as open. Update the entry to
reflect the fix, confirmed on the same real hardware that surfaced
the original failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 15:54:00 +02:00
Tobias GesellchenandClaude Sonnet 5 4a1e722069 fix(on-device-install): daemon restart never replaced the running process
Three stacked bugs, found and confirmed on real hardware while
downgrading a speaker: the new binary landed on disk correctly, but
the running service kept reporting the old version indefinitely.

- install.sh called `/etc/init.d/aftertouch start`, not `restart`,
  after installing. start-stop-daemon silently refuses to launch a
  second instance when one is already running, and the init script
  never checked its exit status, so the old process was never
  replaced.

- The init script started the daemon through a `sh -c "exec ... |
  logger"` pipeline, on the assumption that `exec` lets --make-pidfile
  record the daemon's own PID. POSIX forks each side of a pipe into
  its own process, so the wrapper shell (not the daemon) was the one
  actually tracked. `stop` killed the wrapper, which doesn't forward
  SIGTERM to its children, orphaning the real daemon to keep running
  and keep holding :8000 forever.

- Once the wrapper correctly tracked the daemon's own PID, a further
  race surfaced: start-stop-daemon's own "already running?" check
  matched on generic `/bin/sh` identity, so a `restart`'s `start`
  phase could catch the previous wrapper still mid-teardown and
  silently refuse to launch a new one (masked by --quiet, looking
  like a 120s hang).

Fixed by calling `restart` instead of `start` in install.sh, and by
having the wrapper shell record the daemon's real PID itself (via $!)
while keying start-stop-daemon's own check on that same pidfile
instead of process identity.

Verified on hardware: three consecutive restart cycles, each fast,
each with the pidfile matching the live daemon PID and daemon output
flowing through syslog again via logread.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 15:54:00 +02:00
Tobias Gesellchen a8f4469eb0 fix(docs): VERSION= env var must go on sh, not curl, in the install pipe
Confirmed on real hardware: `VERSION=0.123.0 curl -sSL .../install.sh | sh`
silently does NOT pin the version, despite the docs claiming it "works
with pipe-to-sh". Shell variable-assignment prefixes only apply to the one
command they're attached to; in a pipe each command is its own process, so
the env var was set for `curl` (which never reads it) and never reached
`sh` (which does). A real attempt to pin v0.123.0 for a #614 pure
reproduction silently installed "latest" instead.

Verified the fix with a minimal repro (VERSION=X cat file | sh vs.
cat file | VERSION=X sh) before changing anything.

Moves the VERSION= prefix onto sh -- the last command in the pipe, the one
that actually reads it -- in ON-DEVICE-INSTALL-WALKTHROUGH.md (both
occurrences), scripts/on-device-install/README.md, and
scripts/on-device-install/install.sh's own header comment.

RASPBERRY-PI.md/EXTERNAL-HOST-WALKTHROUGH.md's `sudo VERSION=x ... bash
install.sh` pattern is unaffected -- that's a direct invocation, not a
pipe, so the env var already reaches the right process there.
2026-08-16 15:54:00 +02:00
Tobias Gesellchen 2b0172c21b fix(ssh): reuse one SSH connection across RevertMigration's ~17 calls
Confirmed on real hardware (192.168.178.28): RevertMigration's full call
graph (revertXMLConfig/revertHosts/revertResolvConf/revertAftertouchHook/
removeRcLocalHooks/revertCACert) makes 17 separate client.Run() calls, and
pkg/ssh.Client.Run/UploadContent each dialed a brand-new SSH connection
per call with no reuse. Hitting a resource-constrained speaker with 17
rapid reconnects overwhelmed it -- confirmed via a follow-up plain SSH
command timing out at the TCP level, and the speaker going visibly
unresponsive.

Gives pkg/ssh.Client an opt-in persistent connection: Connect() dials
once and caches it, Close() releases it, and a shared dial() helper makes
Run/UploadContent reuse the cached connection when one's open, falling
back to today's per-call dial otherwise. RevertMigration now calls
Connect() once and defer Close(), collapsing 17 connections into 1. The
other ~21 m.NewSSH() call sites in pkg/service/setup never call Connect,
so their behavior is completely unchanged -- this only touches the one
function that was actually causing real-world problems.

SSHClient interface gained Connect()/Close(); both test mocks
(pkg/service/setup/setup_test.go, pkg/service/handlers/handlers_setup_test.go)
got no-op stubs. Added TestClose_NoOpWithoutConnect and
TestConnect_DialFailureLeavesConnNil in pkg/ssh/ssh_test.go -- these don't
prove connection reuse against a real server (Client.Run hardcodes :22,
no configurable port for a test listener), so that specific behavior is
verified by code review (a single `if c.conn != nil` branch) plus the
real-hardware confirmation above, not an automated integration test.

Also fixes the web UI's "Revert to Defaults" button, which calls the same
RevertMigration code path.
2026-08-16 15:54:00 +02:00
Tobias Gesellchen 96a62a847d feat(cli,docs): setup migrate URL overrides + real-hardware recovery notes
Prompted by recovering a real speaker (192.168.178.28) that had gone
stressed/unresponsive: it had previously been through `setup enable-ssh`
without --service-url (leaving margeServerUrl persisted as the
aftertouch.invalid placeholder, firmware retry-looping a failing DNS/curl
lookup against it, #546-shaped), compounded by a burst of SSH connections
from a `setup revert` attempt (see the dial-storm finding, tracked
separately, not fixed here).

Added --marge-url/--stats-url/--sw-update-url/--bmx-url override flags to
`setup migrate`. No new backend logic: telnetURLsFromOptions
(pkg/service/setup/telnet_migration.go) already supported per-field
overrides end-to-end, shared with the XML method's applyURLOverrides --
the CLI just never exposed it. Lets a speaker's URLs be pointed anywhere
(back to AfterTouch, or back to the genuine original Bose cloud) via a
single telnet connection, no SSH and no .original backup required --
confirmed recovering the real speaker above (migration committed, a
previously-timing-out plain SSH command returned instantly afterward).

Documented in TROUBLESHOOTING.md: the enable-ssh placeholder-persistence
gotcha (now with the escape hatch above) and the setup revert dial-storm
risk as a known, not-yet-fixed issue with a workaround (prefer this
lighter telnet-only migrate over repeated revert attempts). Documented the
new flags in CLI-REFERENCE.md's setup migrate section.
2026-08-16 15:54:00 +02:00
Tobias Gesellchen 76dc390b2a feat(cli,docs): setup sync/revert commands + on-device install doc gaps
Prompted by writing a #614 self-test guide (on-device install walkthrough)
and by helping fully revert a real speaker a factory reset didn't fully
clean up.

New soundtouch-cli commands (cmd/soundtouch-cli/cmd_setup.go):
- `setup sync` — wraps POST /api/setup/sync/{deviceId}, the same operation
  as the web UI's Devices -> Sync Data button. Read-only towards the
  speaker (presets/recents/sources into the datastore); never writes back.
- `setup revert` — wraps setup.Manager.RevertMigration, the same operation
  as the web UI's "Revert to Defaults" button. Restores
  SoundTouchSdkPrivateCfg.xml/hosts/resolv.conf from their .original
  backups and strips the AfterTouch CA cert from the trust bundle. No
  --service-url needed; pure SSH against the speaker. Deliberately leaves
  SSH persistence and account pairing untouched, matching the web UI
  button (use `setup remote-services --remove` / `account unpair` for
  those).

Both are thin wrappers with no new business logic, matching the existing
migrate/pair/reboot pattern. Tests added for setup sync's HTTP plumbing
(auth-retry, device-scoped URL, error propagation); no CLI-level test for
setup revert, consistent with reboot/migrate/pair also having none --
RevertMigration itself is already tested in pkg/service/setup/setup_test.go.

Documentation gaps closed:
- ON-DEVICE-INSTALL-WALKTHROUGH.md never showed the Migrate step at all --
  jumped from install/reboot straight to the pairing QuickFix as if the
  speaker were already pointed at itself. Added an explicit Migrate step
  (web-UI and CLI paths), a CLI alternative for the pairing QuickFix, a
  no-USB-stick `enable-ssh` (#471) alternative to the physical stick
  procedure, and a "testing a pre-release build" section for cross-
  compiling and manually swapping an unreleased binary (soundtouch-cli
  deploy step included, mirroring the already-covered soundtouch-service
  swap).
- MIGRATION-GUIDE.md's "never use localhost" Target Domain warning had no
  on-device exception, even though loopback is exactly correct there since
  the speaker and the service are the same machine. Added the callout, and
  the same enable-ssh alternative to its SSH-enablement step.
- DEVICE-INITIAL-SETUP.md's AP-mode Wi-Fi provisioning commands were
  macOS-only (networksetup, dns-sd) with no Linux/Windows equivalents,
  unlike the rest of the docs. Added nmcli/netsh wlan alongside.
- CLI-REFERENCE.md's entire `setup <subcommand>` group was undocumented
  (--help was the only reference) -- wrote a full "Setup & Migration"
  section covering all 16 subcommands, and added the also-undocumented
  `account unpair` to the existing Music Service Account Management
  section.
2026-08-16 15:54:00 +02:00
Tobias Gesellchen ba43b9ac16 docs/scripts: bump stale 0.111.x example versions to current release
Repo-wide sweep of example version strings still pinned around 0.111.2/
0.111.3 (four releases behind) across install-script comments, README
walkthroughs, the FALLBACK_VERSION defaults in on-device-install and
raspberry-pi install scripts, and the bug-report issue template's version
placeholder. Bumped to 0.123.0, the current release.

Left untouched: RFC-5737 example IPs and Go test fixtures that happened to
match the same version-number pattern, dated blog posts, and the Hugo
theme's own unrelated version pin.
2026-08-16 15:54:00 +02:00
dependabot[bot] 9244c00cff deps(deps): bump the golang group with 4 updates
Bumps the golang group with 4 updates: [golang.org/x/crypto](https://github.com/golang/crypto), [golang.org/x/mod](https://github.com/golang/mod), [golang.org/x/image](https://github.com/golang/image) and [golang.org/x/text](https://github.com/golang/text).


Updates `golang.org/x/crypto` from 0.54.0 to 0.55.0
- [Commits](https://github.com/golang/crypto/compare/v0.54.0...v0.55.0)

Updates `golang.org/x/mod` from 0.38.0 to 0.39.0
- [Commits](https://github.com/golang/mod/compare/v0.38.0...v0.39.0)

Updates `golang.org/x/image` from 0.44.0 to 0.45.0
- [Commits](https://github.com/golang/image/compare/v0.44.0...v0.45.0)

Updates `golang.org/x/text` from 0.40.0 to 0.41.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.40.0...v0.41.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/mod
  dependency-version: 0.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/image
  dependency-version: 0.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/text
  dependency-version: 0.41.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-15 14:46:29 +02:00
Tobias Gesellchen b0b8b9a475 feat(health): offer a sourcesUpdated pull alongside the presets push
Adds a second QuickFix to the speaker_presets_count warning, reusing
the existing postSourcesUpdated fix (checks_refresh_sources.go). It
nudges the speaker to re-fetch /full, which is confirmed (both from
marge.AccountFullToXML and a genuine captured Bose-cloud response) to
carry presets alongside sources.

Whether firmware actually re-applies /full's preset section back onto
its own local table is unconfirmed — issue253_regression_test.go
already flags that exact link as untested. So this is offered as a
free, non-destructive thing to try first, with the guaranteed
restore_presets_to_speaker push as the fallback. Gives both directions
(pull-style nudge, direct push) rather than only the one.

Refs #614
2026-08-15 14:39:49 +02:00
Tobias Gesellchen 0f452357e2 feat(health): add "Restore presets to speaker" QuickFix
When the speaker shows 0 preset slots while the service's Presets.xml
has entries (the #614 pattern), replays each stored preset onto the
speaker via :8090/storePreset (client.StorePreset), one slot at a
time. Doesn't require a reboot and doesn't need the content playing
first, unlike a physical preset-button save.

Sync only ever reads from the speaker; this is the missing write
direction, and lets a reporter try recovering presets without
re-entering all 6 by hand.

Refs #614
2026-08-15 14:39:49 +02:00
Tobias Gesellchen bcb819dccd fix(health): correct preset-count guidance, drop wrong citation
The speaker_presets_count check told users a power-cycle "usually
re-syncs" missing presets. #614 shows a power-cycle is itself one of
the two reported triggers for the speaker wiping its own presets, so
that advice was actively harmful for this failure mode.

Also fixes the comment's citation: it claimed this was a known pattern
from discussion #295 and #235, but neither actually discusses preset
loss (#295 is a cloud-hosting question, #235 a closed Spotify
preset-save bug). That reference was wrong from the original commit
(7d46ae2); #614 is the first confirmed instance.

Refs #614
2026-08-15 14:39:49 +02:00
Tobias Gesellchen 888348ac55 chore: bump Go to 1.26.6, refresh example go.mod pins
Bumps the go directive to 1.26.6 across the main module and both
standalone example modules (preset-management, navigation-station-demo),
plus the builder image in Dockerfile and the three mock-service images
in docker-compose.ci.yml.

Also refreshes the examples' require github.com/gesellix/bose-soundtouch
pin from the stale v0.118.0 to the current v0.123.0 release tag (the
replace directive means they build against local source regardless,
but the pin should still track reality). go mod tidy run in all three
modules; no other dependency changes.
2026-08-15 14:35:10 +02:00
dependabot[bot] 998054e993 ci(deps): bump the codeql-action group with 3 updates
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.4 to 4.37.6
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/f205ea1c3313d32999d8d6a48b4f6530d4437b38...5595ccaf912efad79be6eef63a5619ff05969be3)

Updates `github/codeql-action/analyze` from 4.37.4 to 4.37.6
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/f205ea1c3313d32999d8d6a48b4f6530d4437b38...5595ccaf912efad79be6eef63a5619ff05969be3)

Updates `github/codeql-action/upload-sarif` from 4.37.4 to 4.37.6
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/f205ea1c3313d32999d8d6a48b4f6530d4437b38...5595ccaf912efad79be6eef63a5619ff05969be3)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 12:09:20 +02:00
Tobias Gesellchen 68c2f6400e docs(cli): document the update-check command in CLI-REFERENCE.md
#591 added `soundtouch-cli update-check` / `soundtouch-backup
update-check` (PR #611) but the CLI command reference never got the
matching entry.
2026-08-11 09:40:38 +02:00
Tobias Gesellchen 97caea112f feat(cli): add on-demand update-check command to soundtouch-cli and soundtouch-backup
Answers #591's open question 2: CLI-only users get no update notice
from soundtouch-service's periodic background check. Both binaries
gain a soundtouch-cli/soundtouch-backup update-check command that
does a single, on-demand GitHub Releases check via the existing
pkg/service/updatecheck package. Running the command is itself the
opt-in, so unlike the service there's no config flag or persisted
state.

pkg/service/updatecheck.Checker was already designed decoupled from
handlers.Server/main.go specifically so other binaries could import
it directly; this is that follow-through.
2026-08-11 09:27:54 +02:00
Tobias Gesellchen 4b245d264e chore 2026-08-10 23:17:36 +02:00
Tobias Gesellchen df5eb1af01 fix(admin): add matching syntax help to the Discovery Interval field
The update-check interval field just got an info-toggle explaining Go
duration syntax; Discovery Interval takes the exact same syntax and
had no such help, which would read as inconsistent on the same
Settings page. Pre-existing gap, unrelated to #591 itself, but small
enough to fix alongside it while the pattern is fresh.
2026-08-10 23:17:36 +02:00
Tobias Gesellchen b138892c2a fix(admin): add syntax help for the update-check interval field
Reuses the existing info-toggle/info-details pattern (already used for
the HTTPS override, TLS extra hosts, and DNS upstream fields) rather
than inventing a new affordance, so users aren't left guessing at Go's
duration syntax when typing a custom interval.
2026-08-10 23:17:36 +02:00
Tobias Gesellchen 9f61d00b2d feat(admin): live Settings-page toggle for the opt-in update check
Follow-up to #591: UpdateCheckEnabled/UpdateCheckInterval are now
persisted, live-reloaded Settings fields (mirroring the discovery
enabled/interval pattern), editable from the admin Settings page
without a restart. The env var/CLI flag remains the seed value for a
fresh install with no settings.json yet.

The background goroutine now always runs and polls the live settings
every minute (updateCheckPollTick), instead of being started only if
enabled at process launch, so flipping the toggle takes effect within
a minute rather than requiring a restart.
2026-08-10 23:17:36 +02:00
Tobias Gesellchen e6cd031ea4 fix(player): render literal x instead of HTML entity for dismiss button
htm/Preact template literals insert text as a DOM text node rather than
parsing it as HTML, so the &times; entity was never decoded and showed
up literally in the player UI's announcement banner. The admin UI's
equivalent button is unaffected because it's built as an HTML string
inserted via innerHTML, where the browser does decode entities.

Fixes the player-UI regression noted in #591.
2026-08-10 22:31:44 +02:00
Tobias GesellchenandClaude Sonnet 5 f899dbaa89 test(marge): guard source XML shape; feat(library): merge speaker-side media server discovery
Comparing against JRpersonal/streborn#587 surfaced two gaps: no test
pinned that a newly added source type renders the same element shape
as a known-good default (the firmware rejects the whole account
document if one source entry omits an expected element), and our DLNA
discovery only swept SSDP from the service host, missing servers only
visible from a paired speaker's own LAN segment.

Adds TestSourceXMLShapeConsistencyAcrossTypes in pkg/service/marge,
and has HandleDiscoverLibraryServers merge results from each paired
speaker's own /listMediaServers alongside the existing SSDP sweep,
deduped by UDN, with unreachable speakers skipped silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 22:27:13 +02:00
Tobias GesellchenandClaude Sonnet 5 401a546482 docs+fix(telnet): label getpdo output as runtime-layer-only, not proof of persistence
Addresses recommendations 4 and 7 from #515 comment 5231931569: a
green-looking getpdo readback only confirms the sys configuration
writes were accepted, not that they'll survive a reboot (that's what
the envswitch-persisted layer decides). Labels the getpdo line in both
migrateViaTelnet and runTelnetInjection's CLI/log output accordingly,
softens migrateViaTelnet's "succeeded" wording to "accepted", and adds
the same one-line caveat to TELNET-MIGRATION-METHOD.md #2.3 (previously
only in TELNET-COMMAND-REFERENCE.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 20:46:51 +02:00
Tobias GesellchenandClaude Sonnet 5 b7a2a7bdb1 docs(telnet): document per-port reboot readiness for enable-ssh retries
Confirmed on hardware (#471 comments 5231997551, 5232046477): after a
reboot, HTTP :8090 and the diagnostic telnet :17000 shell become ready
at very different times, up to ~92s. Our own enable-ssh retry guidance
tells users to power-cycle and re-run immediately, which can hit the
device mid-boot and surface as a raw connection-refused error. Adds a
troubleshooting entry plus the underlying measurement in
TELNET-COMMAND-REFERENCE.md; no code change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 20:46:51 +02:00
Tobias GesellchenandClaude Sonnet 5 d36cd75d26 docs(telnet): correct envswitch/getpdo claims from #515/#471 measurements
Community hardware testing (bitranox, JRpersonal) on 2026-08-09 retracted
the earlier "inter-command delay is necessary" theory and established that
envswitch boseurls set commits the whole runtime layer (not just its two
arguments), has no read form, and doesn't ack with "OK". Corrects
TELNET-MIGRATION-METHOD.md and TELNET-COMMAND-REFERENCE.md accordingly,
retracts the stale "confirmed necessary" command-delay claim in
enable_ssh.go/cmd_setup.go, and lowers DefaultTelnetCommandDelay 5s -> 3s
as a smaller hedge now that the delay itself is known not to be the
mechanism.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 20:46:51 +02:00
Tobias GesellchenandClaude Sonnet 5 27da179082 fix(models): surface DeviceError's name attribute, not just its message
ErrorsResponse.Error() only returned the <error> element's text body,
dropping the name attribute entirely. Some speaker error responses
have a Message that just restates Value as text (e.g. a bare "1047"
for SOURCE_ALREADY_REMOVED), so callers only ever saw the useless
numeric string. Found while live-debugging a Deezer account
add/remove cycle on real hardware, where the raw XML
(<error value="1047" name="SOURCE_ALREADY_REMOVED">1047</error>)
carried real information only in the name attribute.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 19:47:49 +02:00
Tobias GesellchenandClaude Sonnet 5 aae1673451 fix(service): trim whitespace from TuneIn path/query params
Guards against whitespace-only stationID/podcastID/encodedName path
segments and tightens the existing empty-string checks on the search
q/cursor query params. Spotted while reviewing stalkerquatre-oss's
fork diff for TuneIn handling improvements; their s0/Radio fallback
defaults were skipped as unprecedented invented values that would
mask malformed requests instead of erroring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 19:47:49 +02:00
Tobias GesellchenandClaude Sonnet 5 3cfbd05d99 fix(cli): bump default enable-ssh --full-config command delay to 5s
Follow-up to the 3s default from earlier in #515: the reporter agreed
5s is a better trade-off (issue comment 5230881285) — more headroom
than the original guess, still comfortably under the ~7s gap their
manual A/B test used.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 11:55:54 +02:00
Tobias GesellchenandClaude Sonnet 5 3b7ba8bd2f feat(cli): auto-pair unpaired devices before the enable-ssh injection
#515 comment 5230833551: on a genuinely unpaired (factory-reset) device,
margeServerUrl is reportedly never polled at all, so the boseurls
SSH-enable injection has no read cycle to fire on regardless of any
command delay. enable-ssh now checks /info first and, if
margeAccountUUID is empty, pairs the device via the existing
PairAccount helper (HTTP /setMargeAccount, telnet fallback) before
running the injection.

Adds setup.Manager.EnsureMargeAccountPaired plus --no-auto-pair (skip
entirely) and --account (use a specific 7-digit ID instead of a
generated one, e.g. to match one already in the datastore) flags on
enable-ssh. Pairing failure is a warning, not fatal, since the claim
is unconfirmed on this specific hardware and existing working flows
must not regress.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 11:55:54 +02:00
Tobias Gesellchen 73c0dfe88b ci(docs): treat non-404 4xx link-check failures as warnings, not errors
markdown-link-check has no concept of "warning" vs "error" — it's a
binary alive/dead per link, so a transient 429 from a rate-limiting site
we don't control (recently: blogspot.com, izndgroup.com) fails the whole
CI job exactly like a genuine dead link, with no way to tell them apart
from the job's exit code.

New scripts/check-doc-links.sh wraps the tool per file, parses its
"[✖] <url> → Status: <code>" output, and re-decides pass/fail per link:
404 still fails the build (a real dead link, worth fixing), other 4xx
(429, 401, 403, ...) become a GitHub Actions ::warning:: annotation
instead, and anything else (5xx, timeouts, DNS failures) still fails the
build same as before. De-dupes markdown-link-check's own doubled -v
output. Written for bash 3.2 (macOS's default /bin/bash) so it's testable
locally, not just on the ubuntu-latest runner.

Existing retry config in .github/markdown-link-check.json (retryOn429,
3 retries, 30s backoff) is untouched; this only changes what happens once
retries are exhausted.

Verified locally: 6 synthetic scenarios (404/429/500/mixed/clean/
duplicate-line) via a stubbed markdown-link-check, plus a real run
against the docs tree with the actual tool.
2026-08-09 11:29:49 +02:00
Tobias Gesellchen 69a21cdda7 feat(cli): configurable pause between enable-ssh --full-config commands
Per #515 comment 5228449448: on a real Lifestyle console, the same 6
commands (5 sys configuration/envswitch + reboot) sent back-to-back left
sshd down after reboot, but succeeded sent one at a time with ~7s gaps —
same commands, same order, same device, minutes apart. Sending fast may
not let the device fully process one command before the next arrives.

Adds --command-delay (setup.DefaultTelnetCommandDelay, 3s), threaded
through EnableSSHViaTelnetFullConfig/runTelnetInjection (pause after each
of the 5 commands) and runEnableSSHInjection (one more pause before the
reboot). 0 restores the old back-to-back behavior. The reporter didn't
try to find the true minimum, just confirmed ~7s works and speculated
"a second or two may well be enough" — 3s is a middle ground, tunable via
the flag if a specific device needs more.

Also prints an approximate total for the injection phase up front (6
steps x delay, ~18s at the default) so the command doesn't look hung —
separate from the existing --wait message for sshd coming up after
reboot, which can take much longer.

Refs #515
2026-08-09 11:14:03 +02:00
Tobias Gesellchen 06ad41412f fix(cli): stop ssh-check telling users telnet SSH-enable is impossible
setup ssh-check's failure message claimed "those commands were removed"
on FW 27.x and jumped straight to the USB-stick fallback — flatly
contradicted by setup enable-ssh, which exists specifically to bootstrap
SSH over telnet via the port-17000 envswitch trick (#471), and by
TELNET-COMMAND-REFERENCE.md's own notes that the injection is
field-confirmed on several FW 27.x models. Success is model/build-
dependent, not universally impossible — some devices (ST Portable, some
CineMate 520 units) need --full-config instead of the default injection.

Reordered the message to point at `setup enable-ssh` (with the
--full-config caveat) first, USB stick as the fallback if that doesn't
work on a given device — this was the exact point where a user hitting a
closed port 22 would previously be told to go find a USB stick without
ever learning the telnet route exists.

Refs #598
2026-08-09 11:14:03 +02:00
Tobias Gesellchen 69010f766e docs: document Lifestyle/console POWER-key behavior and input isolation
On Bose Lifestyle/CineMate consoles, the SoundTouch module is one input
among several, and #160 already established the input can't be switched
from the SoundTouch side. This adds a troubleshooting section covering:

- source="LOCAL" in /now_playing with LOCAL absent from /sources means
  the console is on a different input, not that the content is invalid.
- POST /key POWER is not a harmless "stop playback" on these devices like
  it is on a plain speaker — it puts the console into standby, and on
  waking it returns to the console's OWN input, not back to SoundTouch.
  A test loop that uses POWER between trials silently drops off
  SoundTouch after the first trial, so every later station reports
  INVALID_SOURCE regardless of whether it would actually play fine.

Also adds a cross-reference caveat to the `key power` row in
TELNET-COMMAND-REFERENCE.md, whose existing "no observable effect on FW
27.x" note is speaker-only phrasing that doesn't hold for these consoles.

Refs #597
2026-08-09 11:14:03 +02:00
Tobias Gesellchen 59a881e667 feat(announcements): support a proper link, not a raw URL in the message text
Follow-up to #591, prompted by the update-check notice showing a raw
https:// URL as plain text instead of a clickable link. Made it general
rather than a one-off fix, since future announcements may also want to
link to docs.

Added Announcement.LinkText/LinkURL (+ LinkURLFunc, the dynamic
counterpart, for the update-check entry's per-release URL) alongside the
existing Message/MessageFunc pair. Both frontends render it as a real
<a> element now: the admin UI (innerHTML) escapes Message/LinkText/LinkURL
via the existing escapeHtml() before composing the markup — previously
Message went into innerHTML unescaped, which this incidentally hardens;
the player (Preact/htm) templates an actual <a> rather than interpolating
a string, since Preact escapes string children by default and a raw
<a href=...> string would otherwise render as literal text, not a link.

Rephrased the #419 admin-gate announcement to use the new field too (was
a plain "See issue #419 for details." text mention).

Bug found while wiring this up: UpdateCheckState never persisted the
release URL, only the version — so after a restart, the announcement
would show a correct message but a broken/empty link until the next live
check completed (which can be up to a full interval away, since a fresh
check is skipped when the persisted last-check is still recent). Fixed by
adding UpdateCheckState.LastReleaseURL and threading it through
Checker.persist/NewChecker's seeding path, with a test
(TestNewChecker_SeedsFromPersistedState) that would have caught it.

Also fixed two gocritic rangeValCopy findings in
handlers_announcements.go (switched to index-based iteration) surfaced by
the Announcement struct growing with the new fields.

Refs #591
2026-08-09 10:18:19 +02:00
Tobias Gesellchen f33a306c47 chore: suppress math/rand Semgrep finding on two non-security use sites
Addresses the Semgrep finding on PR #599
(go.lang.security.audit.crypto.math_random.math-random-used) on the
update-check jitter delay, and applies the same treatment to the #419
activity-log filename suffix, which has the same non-security shape but
predates this PR's diff so it wasn't flagged.

Neither value is ever compared, kept secret, or otherwise security-
sensitive (a sleep duration and a filename-uniqueness suffix), so
crypto/rand would only add error-handling overhead for no real benefit.
Suppressed with the same // nosemgrep: <rule-id> pattern already used in
the mock-amazon/mock-spotify/mock-tunein servers, mirroring the existing
//nolint:gosec on the same lines.

Refs #591
2026-08-09 01:22:03 +02:00
Tobias Gesellchen afaa00e483 feat(version-info): expose update-check state; docs
Fifth and final piece of #591's initial implementation. Extends
/api/setup/version with update_available/latest_version/
latest_release_url (nil-safe via Server.UpdateCheckResult, defaults to
Available: false when the check was never enabled). Response switched from
map[string]string to map[string]interface{} to carry the new bool field;
updated the one existing test that decoded into the old stricter type.

Documents UPDATE_CHECK_ENABLED/UPDATE_CHECK_INTERVAL in the Configuration
Options reference table, explicit that this is the only network call
AfterTouch makes beyond speaker/provider traffic when enabled, and that it
defaults off.

This closes out the initial #591 implementation per the design doc
(_/i591/design-update-check.md): UpdateCheckState persistence, the
updatecheck.Checker package, background goroutine wiring with jitter/
backoff, reusing #419's Announcements mechanism instead of a second notice
UI, and this version-info exposure. `make check` passes end to end
(including the Docker HTTP integration suite).

Refs #591
2026-08-09 01:22:03 +02:00
Tobias Gesellchen 0c343f85c7 feat(announcements): reuse #419's mechanism for the update-check notice
Fourth piece of #591 — the "minimal and future-proof at once" move from
the design doc: no new notice UI, just one new entry in the #419
announcements list, which is already rendered in both the admin UI and the
player and already has per-ID dismissal.

Added Announcement.MessageFunc/DismissKeyFunc (nil = use the static
Message/ID, as before, so the existing #419 entry is unaffected) since
this entry's text names a specific version and its dismissal must be
per-version — dismissing the notice for v1.2.0 must not suppress a later
notice for v1.3.0. HandleListAnnouncements/HandleDismissAnnouncement now
compute the effective key through Announcement.dismissKey(s) rather than
reading the static ID field directly.

Refs #591
2026-08-09 01:22:03 +02:00
Tobias Gesellchen 1248a0bd1c feat(update-check): wire the Checker into the service, opt-in via env flags
Third piece of #591. --update-check-enabled/--update-check-interval
(UPDATE_CHECK_ENABLED/UPDATE_CHECK_INTERVAL), default off/24h, following
the same local main.go flag pattern as discovery-enabled — not pkg/config,
which soundtouch-service doesn't import at all (correction to the issue's
proposed location, see the design doc).

Background goroutine modeled on startDeviceDiscovery: startup jitter
(0-5min), skips the immediate check if the persisted last-check is still
fresh, backs off retries to no sooner than 1h after a failure, logs once
per newly-detected version. The decision logic (shouldCheckImmediately,
shouldSkipDueToBackoff, logUpdateIfNewlyAvailable) is split into pure,
directly-testable functions rather than living inline in the goroutine.

Server gets a SetUpdateChecker/UpdateCheckResult pair (nil-safe) so the
next two pieces (announcement, /api/setup/version) can read the current
state without importing updatecheck's construction details.

Manually verified against a running instance: enabled via flags, no panic,
service stays responsive (jitter means the actual first check can take up
to 5 minutes to fire, so this only confirms the wiring, not a live
GitHub response — that's covered by the previous commit's httptest-backed
unit tests).

Refs #591
2026-08-09 01:22:03 +02:00
Tobias Gesellchen c71f0623fb feat(updatecheck): add Checker package for GitHub release comparison
Second piece of #591. Standalone package (pkg/service/updatecheck):
GitHub releases API client, golang.org/x/mod/semver comparison (promoted
from indirect to direct dependency), persisted state via datastore's
UpdateCheckState. Dev/(devel)/dirty current versions skip the comparison
entirely rather than guessing; prereleases are excluded even though
GitHub's /releases/latest endpoint shouldn't return one anyway (defensive).

Deliberately decoupled from handlers.Server/main.go: repo and current
version are constructor arguments, not hardcoded, so a future CLI-side
check could reuse this as a plain import rather than a rewrite (open
question 2 in the design doc).

Not wired into the service yet — nothing calls NewChecker/CheckNow outside
tests.

Refs #591
2026-08-09 01:22:03 +02:00
Tobias Gesellchen 500f7850be feat(datastore): add UpdateCheckState persistence
First piece of #591 (opt-in periodic update check). A small persisted
state (last_checked_at, last_seen_version) under update-check.json,
mirroring Settings' Get/Save shape — separate from Settings itself since
this is runtime state, not operator-editable config.

Not wired to anything yet.

Refs #591
2026-08-09 01:22:03 +02:00
Tobias Gesellchen 29463fac98 feat(admin): show the resolved data directory in Settings
Prompted by not being able to tell where a locally-run instance's data dir
actually was without inspecting the running process (ps/lsof). Adds
data_dir to /api/setup/version's response, resolved to an absolute path so
it's unambiguous regardless of whether --data-dir/DATA_DIR was relative or
left at the default.

Shown as a read-only line at the top of the Settings tab, not the always-
visible footer — the footer is prime real estate seen on every tab/every
page load, and this is a rarely-needed piece of diagnostic info that
belongs alongside the rest of System Settings instead.

Also filled in a test-helper gap: the pkg/service/handlers package's
internal test router (main_test.go) never registered /setup/version at
all, unlike the real production router — added it so the new test (and any
future one exercising this endpoint) can actually run.

Unrelated to #419, but found while verifying that work against a running
instance.
2026-08-08 23:49:57 +02:00
Tobias Gesellchen bdcbd29e3b fix(admin): add the actual Settings control for admin_area_auth
The backend (field, validation, guard rail, live-reload middleware) has
worked since the first #419 commit, and the announcement banner correctly
told people "you can opt in now in Settings" — but there was never
actually a control in Settings to do that with. Caught by manual testing
against a running instance: the banner rendered fine, proving chunks 1-6
worked, but Settings had nothing to act on.

Adds a select (mirroring default_landing's pattern) with the tri-state
choices spelled out in plain language, wired into updateSettings()/
fetchSettings() alongside the other fields.

Verified end-to-end against a running instance: setting it to "enabled"
(with non-default credentials) persists, and immediately gates /admin
(401 without credentials, 200 with) without a restart.

Refs #419
2026-08-08 23:49:57 +02:00
Tobias Gesellchen d2ba3d757d feat(player): render announcement banners in soundtouch-player too
The design's three-area target model (chooser/app/admin) and the backend
(HandleListAnnouncements' target=app filtering) already supported this, but
nothing in soundtouch-player's frontend called it — chunk 5 only wired the
admin UI. New Announcements Preact component (static/js/components/), mounted
in App() above the main content so it's visible across every page, styled
with the app's existing CSS variables (dark-mode-aware, unlike the admin
UI's hardcoded inline colors). Currently renders nothing, since no
announcement in the list targets "app" yet (only the admin-gate notice,
targeting "admin") — this is just closing the parity gap so a future
app-targeted announcement has somewhere to show up.

Verified end-to-end against a running instance: the component is served
under /app/static/js/components/, app.js references it, and
/api/announcements?target=app responds correctly (empty today).

Refs #419
2026-08-08 23:49:57 +02:00
Tobias Gesellchen 50509ef729 feat(export): bundle the local activity log into diagnostic exports; docs
Seventh and final piece of #419's initial rollout. Adds addActivityLog to
buildDiagnosticArchive, walking stats/activity/ and bundling every event
file verbatim (same idea as the per-device XML bundling, mirroring
addSettingsJSON's placement). Without this, the privacy guarantee discussed
during design ("local-only, but included in an explicit diagnostic export")
would have been aspirational rather than true — caught before documenting
it as fact.

Documents the activity log in DIAGNOSTIC-EXPORT.md, anchored to the
existing "all data stays on your network" language in
SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md.

This closes out the initial #419 implementation: AdminAreaAuth setting +
guard rail, BasicAuthAdmin gate, activity log + dismissal cache,
announcements + dismiss endpoint, admin UI banner, health check nudge, and
now diagnostic-export coverage + docs. Still opt-in only (AdminAreaAuth
defaults to unset) — flipping the default is a separate, later change per
the design doc's rollout plan.

Refs #419
2026-08-08 23:49:57 +02:00
Tobias Gesellchen 4bbcd4d178 feat(health): add admin_area_auth_available check
Sixth piece of #419. Visibility-only nudge, same spirit as
mgmt_default_credentials: surfaces on the Health tab that the admin-area
gate exists and is unset, for operators who dismissed the announcement
banner or never saw it on an older release. Does not gate anything.

Refs #419
2026-08-08 23:49:57 +02:00
Tobias Gesellchen 232adeb23f feat(admin): render dismissible announcement banners in the admin UI
Fifth piece of #419: wires up the announcements endpoint added in the
previous commit. A banner container sits outside the tab-content divs
(index.html) so it stays visible across all tabs, not just one. Fetched
once on page load alongside settings/version/devices; dismissing calls the
server-side dismiss endpoint (not a client-only localStorage flag, so it
stays dismissed across sessions/devices) and removes it from the DOM
immediately.

Manually verified end-to-end against a running instance: the banner
container renders, the admin-gate notice appears by default, dismissing it
removes it from subsequent /api/announcements responses. No Go test
coverage — this is frontend-only wiring of already-tested endpoints.

Refs #419
2026-08-08 23:49:57 +02:00
Tobias Gesellchen 8a4e4191a9 feat(admin): add announcements list + dismiss endpoint
Fourth piece of #419: a small in-code (not admin-authored) announcement
list, target-scoped ("app"/"admin", "chooser" reserved but not wired since
the landing page has no JS yet) and filterable by live server state via
ShowWhile. First entry: the admin-area-gate heads-up, shown on the admin
target while AdminAreaAuth is unset.

GET /api/announcements?target=... and POST /api/announcements/{id}/dismiss
are deliberately NOT behind BasicAuthAdmin — the whole point of the gate
notice is to reach operators who haven't set up credentials yet, the exact
audience an admin-only endpoint would exclude. The dismiss endpoint
validates id against the known announcement list before it reaches
RecordActivity, since this is the one call site where an id comes from an
HTTP request rather than a compile-time constant.

Updated the router snapshot (testdata/router_routes.txt) for the two new
routes.

Not wired into any UI yet — nothing calls these endpoints.

Refs #419
2026-08-08 23:49:57 +02:00
Tobias Gesellchen d930886f07 feat(admin): gate /admin + /api/setup behind BasicAuthAdmin when enabled
Third piece of #419. BasicAuthAdmin() mirrors BasicAuthMgmt but reads the
live AdminAreaAuth mode and credentials on every request instead of
capturing them once at router-setup time, so toggling the Settings-UI
switch takes effect immediately.

Split mountSetupAPI into mountSetupAPIShared (ca.crt, tts/speak, tts/config
— used directly by soundtouch-cli and soundtouch-player, must stay reachable
regardless of the gate) and mountSetupAPIAdmin (everything else). Wired the
gate around /admin and both mountSetupAPIAdmin mounts (/setup, /api/setup).
Stockholm's optional legacy setup wizard is intentionally left out of scope.

Also fixes two lint issues introduced in the prior commit (unchecked
json.Marshal in tests, HandleUpdateSettings over the cyclomatic complexity
threshold) since `make lint` wasn't run before that commit landed.

Refs #419
2026-08-08 23:49:57 +02:00
Tobias Gesellchen 5d12e7fac9 feat(admin): add local activity log + in-memory dismissal cache
Second piece of #419: a generic, local-only, append-only activity log
(datastore.RecordActivity/GetActivityRecords, one file per event under
stats/activity/<kind>/, same shape as SaveUsageStats) meant to back the
upcoming announcement-banner dismissals and be reusable for other admin-UI
action kinds later.

The read path never touches disk: a scoped startup scan folds prior
dismissals into an in-memory map once, RecordDismissal updates it
write-through. Same id can recur with a new timestamp (re-shown, dismissed
again) — it's a log, not a keyed store.

Not wired to anything user-facing yet — no announcements exist to dismiss.

Refs #419
2026-08-08 23:49:57 +02:00
Tobias Gesellchen 9090fad563 feat(admin): add tri-state AdminAreaAuth setting with default-creds guard rail
First piece of the #419 admin-area gate: a persisted, live-reloadable
tri-state setting ("" unset / "enabled" / "disabled") so a later release
can flip the default from open to gated without breaking an explicit
opt-out. Rejects enabling while MGMT_USERNAME/MGMT_PASSWORD are still the
published default, since that would give a false sense of security.

No behavior change yet — nothing reads this field to actually gate
anything. That's the next chunk.

Refs #419
2026-08-08 23:49:57 +02:00
Tobias Gesellchen bee0d25747 fix(admin): soften missing-account.json placeholder notice
The placeholder state is expected and harmless (every code path already
treats it safely, and it self-heals once language/provider settings are
saved), but the old wording read like a file-corruption error and leaked
the internal account.json filename to users. Reword it to explain the
actual (benign) state instead.

Refs #360
2026-08-08 21:31:20 +02:00
github-actions[bot] 811ce67972 chore: sync static dependencies with package.json 2026-08-06 11:40:59 +02:00
dependabot[bot] abd20f8c22 deps(deps): bump preact from 10.29.7 to 10.29.8
Bumps [preact](https://github.com/preactjs/preact) from 10.29.7 to 10.29.8.
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.29.7...10.29.8)

---
updated-dependencies:
- dependency-name: preact
  dependency-version: 10.29.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-06 11:40:59 +02:00
dependabot[bot] 5f64e4a185 ci(deps): bump docker/login-action from 4.5.2 to 4.6.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.5.2 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/371161bbe7024a29a25c5e19bfcbc0804fe9ad2c...dbcb813823bdd20940b903addbd779551569679f)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-06 08:19:28 +02:00
dependabot[bot] 028ad57ba8 ci(deps): bump the codeql-action group with 3 updates
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.3 to 4.37.4
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...f205ea1c3313d32999d8d6a48b4f6530d4437b38)

Updates `github/codeql-action/analyze` from 4.37.3 to 4.37.4
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...f205ea1c3313d32999d8d6a48b4f6530d4437b38)

Updates `github/codeql-action/upload-sarif` from 4.37.3 to 4.37.4
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...f205ea1c3313d32999d8d6a48b4f6530d4437b38)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-06 08:19:04 +02:00
Tobias GesellchenandClaude Sonnet 5 0489dd39cb fix(admin): stop Save Settings from dropping fields the UI doesn't manage
HandleUpdateSettings and HandleUpdateLoggingSettings each built a fresh
datastore.Settings{} from scratch before saving, so any field not covered
by that handler's own DTO (e.g. hand-edited trust_forwarded_headers /
trusted_proxy_cidrs) was silently reset to its zero value on every save.
Load the persisted settings first and overlay only the fields each
handler actually owns, matching the pattern already used elsewhere
(addMargeHostToTLSFix).

Fixes #589

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 19:05:49 +02:00
dependabot[bot]andlnx01 44e364d33f ci(deps): bump docker/login-action from 4.4.0 to 4.5.2 (#588)
Bumps [docker/login-action](https://github.com/docker/login-action) from
4.4.0 to 4.5.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/login-action/releases">docker/login-action's
releases</a>.</em></p>
<blockquote>
<h2>v4.5.2</h2>
<ul>
<li>Surface Docker Hub OIDC error responses by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/login-action/pull/1058">docker/login-action#1058</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/login-action/compare/v4.5.1...v4.5.2">https://github.com/docker/login-action/compare/v4.5.1...v4.5.2</a></p>
<h2>v4.5.1</h2>
<ul>
<li>Support <code>dhi.io</code> as Docker Hub OIDC registry by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/login-action/pull/1054">docker/login-action#1054</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/login-action/compare/v4.5.0...v4.5.1">https://github.com/docker/login-action/compare/v4.5.0...v4.5.1</a></p>
<h2>v4.5.0</h2>
<ul>
<li><a href="https://github.com/docker/login-action#docker-hub">Docker
Hub OIDC</a> login support by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/login-action/pull/1048">docker/login-action#1048</a></li>
<li>Bump <code>@​aws-sdk/client-ecr</code> and
<code>@​aws-sdk/client-ecr-public</code> to 3.1091.0 in <a
href="https://redirect.github.com/docker/login-action/pull/1037">docker/login-action#1037</a></li>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.92.0 to 0.94.0 in
<a
href="https://redirect.github.com/docker/login-action/pull/1044">docker/login-action#1044</a>
<a
href="https://redirect.github.com/docker/login-action/pull/1050">docker/login-action#1050</a></li>
<li>Bump brace-expansion from 1.1.13 to 1.1.16 in <a
href="https://redirect.github.com/docker/login-action/pull/1046">docker/login-action#1046</a></li>
<li>Bump js-yaml from 5.2.0 to 5.2.1 in <a
href="https://redirect.github.com/docker/login-action/pull/1038">docker/login-action#1038</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/login-action/compare/v4.4.0...v4.5.0">https://github.com/docker/login-action/compare/v4.4.0...v4.5.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/login-action/commit/371161bbe7024a29a25c5e19bfcbc0804fe9ad2c"><code>371161b</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1058">#1058</a>
from crazy-max/fix-dockerhub-oidc-error-handling</li>
<li><a
href="https://github.com/docker/login-action/commit/5dc73df38ebcfa6f96479901e253d172c3e35849"><code>5dc73df</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/login-action/commit/2aa1edee0b06c23880529064a4f7d7d3d2f9bc87"><code>2aa1ede</code></a>
surface Docker Hub OIDC error responses</li>
<li><a
href="https://github.com/docker/login-action/commit/abd2ef45e78c5afb21d64d4ca52ee8550d9572c7"><code>abd2ef4</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1055">#1055</a>
from crazy-max/test-registry-auth-oidc</li>
<li><a
href="https://github.com/docker/login-action/commit/d49d3a9839fef51322fa44989a44fdc43fccfc22"><code>d49d3a9</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1054">#1054</a>
from crazy-max/oidc-missing-dhi</li>
<li><a
href="https://github.com/docker/login-action/commit/b58b17c30b4db92a4ed049b213cae512b12e460b"><code>b58b17c</code></a>
test: cover Docker Hub OIDC with registry-auth</li>
<li><a
href="https://github.com/docker/login-action/commit/be646c21cec26cea303e29290d5f6ba6fde8e606"><code>be646c2</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/login-action/commit/d77c059cb9956cedaa427dc022d89f39acba678f"><code>d77c059</code></a>
support dhi.io as Docker Hub OIDC registry</li>
<li><a
href="https://github.com/docker/login-action/commit/06fb636fac595d6fb4b28a5dfcb21a6f5091859c"><code>06fb636</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1037">#1037</a>
from docker/dependabot/npm_and_yarn/aws-sdk-dependen...</li>
<li><a
href="https://github.com/docker/login-action/commit/a8bc9539118a762b0e5788b53a50907977cc1b8d"><code>a8bc953</code></a>
[dependabot skip] chore: update generated content</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/login-action/compare/af1e73f918a031802d376d3c8bbc3fe56130a9b0...371161bbe7024a29a25c5e19bfcbc0804fe9ad2c">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=docker/login-action&package-manager=github_actions&previous-version=4.4.0&new-version=4.5.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 18:08:16 +02:00
dependabot[bot]andlnx01 1721f46f9f ci(deps): bump the codeql-action group with 3 updates (#587)
Bumps the codeql-action group with 3 updates:
[github/codeql-action/init](https://github.com/github/codeql-action),
[github/codeql-action/analyze](https://github.com/github/codeql-action)
and
[github/codeql-action/upload-sarif](https://github.com/github/codeql-action).

Updates `github/codeql-action/init` from 4.37.1 to 4.37.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/init's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.3</h2>
<p>No user facing changes.</p>
<h2>v4.37.2</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/init's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
</ul>
<h2>4.37.3 - 22 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.2 - 21 Jul 2026</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<h2>4.36.1 - 02 Jun 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.0 - 22 May 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.19.4. <a
href="https://redirect.github.com/github/codeql-action/pull/3894">#3894</a></li>
<li>Add support for SHA-256 Git object IDs. <a
href="https://redirect.github.com/github/codeql-action/pull/3893">#3893</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.5">2.25.5</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3926">#3926</a></li>
</ul>
<h2>4.35.5 - 15 May 2026</h2>
<ul>
<li>We have improved how the JavaScript bundles for the CodeQL Action
are generated to avoid duplication across bundles and reduce the size of
the repository by around 70%. This should have no effect on the runtime
behaviour of the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3899">#3899</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81"><code>e4fba86</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4031">#4031</a>
from github/update-v4.37.3-72f6a9da0</li>
<li><a
href="https://github.com/github/codeql-action/commit/fb50ab5d62a274adf3ef3e22cfe750ae87a0ede7"><code>fb50ab5</code></a>
Update changelog for v4.37.3</li>
<li><a
href="https://github.com/github/codeql-action/commit/72f6a9da0def52d9193d6a758f0378b65091f8d1"><code>72f6a9d</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4030">#4030</a>
from github/mbg/fix/no-proxy</li>
<li><a
href="https://github.com/github/codeql-action/commit/3b5ee58597653d9cc6785f3f1277f796d81f3646"><code>3b5ee58</code></a>
Use default <code>request</code> options instead of
<code>undefined</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/bfb6be4b5ecd3650f02f530571453e8c64ef0778"><code>bfb6be4</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4028">#4028</a>
from github/mergeback/v4.37.2-to-main-e0647621</li>
<li><a
href="https://github.com/github/codeql-action/commit/526ab84f9858816d9cf5f7b9df4dd5e2235f0eba"><code>526ab84</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/d6217b9b8c14166e4851db94c11155d03bd13c07"><code>d6217b9</code></a>
Update changelog and version after v4.37.2</li>
<li><a
href="https://github.com/github/codeql-action/commit/e0647621c2984b5ed2f768cb892365bf2a616ad1"><code>e064762</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4027">#4027</a>
from github/update-v4.37.2-385bcdc5a</li>
<li><a
href="https://github.com/github/codeql-action/commit/e0faed839190caa67a5cd42f1cc16246028ca3df"><code>e0faed8</code></a>
Add a couple of change notes</li>
<li><a
href="https://github.com/github/codeql-action/commit/73aad0eaa9df172668665a150d17b8bc5a650c20"><code>73aad0e</code></a>
Update changelog for v4.37.2</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/7188fc363630916deb702c7fdcf4e481b751f97a...e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/analyze` from 4.37.1 to 4.37.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/analyze's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.3</h2>
<p>No user facing changes.</p>
<h2>v4.37.2</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/analyze's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
</ul>
<h2>4.37.3 - 22 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.2 - 21 Jul 2026</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<h2>4.36.1 - 02 Jun 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.0 - 22 May 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.19.4. <a
href="https://redirect.github.com/github/codeql-action/pull/3894">#3894</a></li>
<li>Add support for SHA-256 Git object IDs. <a
href="https://redirect.github.com/github/codeql-action/pull/3893">#3893</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.5">2.25.5</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3926">#3926</a></li>
</ul>
<h2>4.35.5 - 15 May 2026</h2>
<ul>
<li>We have improved how the JavaScript bundles for the CodeQL Action
are generated to avoid duplication across bundles and reduce the size of
the repository by around 70%. This should have no effect on the runtime
behaviour of the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3899">#3899</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81"><code>e4fba86</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4031">#4031</a>
from github/update-v4.37.3-72f6a9da0</li>
<li><a
href="https://github.com/github/codeql-action/commit/fb50ab5d62a274adf3ef3e22cfe750ae87a0ede7"><code>fb50ab5</code></a>
Update changelog for v4.37.3</li>
<li><a
href="https://github.com/github/codeql-action/commit/72f6a9da0def52d9193d6a758f0378b65091f8d1"><code>72f6a9d</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4030">#4030</a>
from github/mbg/fix/no-proxy</li>
<li><a
href="https://github.com/github/codeql-action/commit/3b5ee58597653d9cc6785f3f1277f796d81f3646"><code>3b5ee58</code></a>
Use default <code>request</code> options instead of
<code>undefined</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/bfb6be4b5ecd3650f02f530571453e8c64ef0778"><code>bfb6be4</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4028">#4028</a>
from github/mergeback/v4.37.2-to-main-e0647621</li>
<li><a
href="https://github.com/github/codeql-action/commit/526ab84f9858816d9cf5f7b9df4dd5e2235f0eba"><code>526ab84</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/d6217b9b8c14166e4851db94c11155d03bd13c07"><code>d6217b9</code></a>
Update changelog and version after v4.37.2</li>
<li><a
href="https://github.com/github/codeql-action/commit/e0647621c2984b5ed2f768cb892365bf2a616ad1"><code>e064762</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4027">#4027</a>
from github/update-v4.37.2-385bcdc5a</li>
<li><a
href="https://github.com/github/codeql-action/commit/e0faed839190caa67a5cd42f1cc16246028ca3df"><code>e0faed8</code></a>
Add a couple of change notes</li>
<li><a
href="https://github.com/github/codeql-action/commit/73aad0eaa9df172668665a150d17b8bc5a650c20"><code>73aad0e</code></a>
Update changelog for v4.37.2</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/7188fc363630916deb702c7fdcf4e481b751f97a...e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/upload-sarif` from 4.37.1 to 4.37.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/upload-sarif's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.3</h2>
<p>No user facing changes.</p>
<h2>v4.37.2</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/upload-sarif's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
</ul>
<h2>4.37.3 - 22 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.2 - 21 Jul 2026</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<h2>4.36.1 - 02 Jun 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.0 - 22 May 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.19.4. <a
href="https://redirect.github.com/github/codeql-action/pull/3894">#3894</a></li>
<li>Add support for SHA-256 Git object IDs. <a
href="https://redirect.github.com/github/codeql-action/pull/3893">#3893</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.5">2.25.5</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3926">#3926</a></li>
</ul>
<h2>4.35.5 - 15 May 2026</h2>
<ul>
<li>We have improved how the JavaScript bundles for the CodeQL Action
are generated to avoid duplication across bundles and reduce the size of
the repository by around 70%. This should have no effect on the runtime
behaviour of the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3899">#3899</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81"><code>e4fba86</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4031">#4031</a>
from github/update-v4.37.3-72f6a9da0</li>
<li><a
href="https://github.com/github/codeql-action/commit/fb50ab5d62a274adf3ef3e22cfe750ae87a0ede7"><code>fb50ab5</code></a>
Update changelog for v4.37.3</li>
<li><a
href="https://github.com/github/codeql-action/commit/72f6a9da0def52d9193d6a758f0378b65091f8d1"><code>72f6a9d</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4030">#4030</a>
from github/mbg/fix/no-proxy</li>
<li><a
href="https://github.com/github/codeql-action/commit/3b5ee58597653d9cc6785f3f1277f796d81f3646"><code>3b5ee58</code></a>
Use default <code>request</code> options instead of
<code>undefined</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/bfb6be4b5ecd3650f02f530571453e8c64ef0778"><code>bfb6be4</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4028">#4028</a>
from github/mergeback/v4.37.2-to-main-e0647621</li>
<li><a
href="https://github.com/github/codeql-action/commit/526ab84f9858816d9cf5f7b9df4dd5e2235f0eba"><code>526ab84</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/d6217b9b8c14166e4851db94c11155d03bd13c07"><code>d6217b9</code></a>
Update changelog and version after v4.37.2</li>
<li><a
href="https://github.com/github/codeql-action/commit/e0647621c2984b5ed2f768cb892365bf2a616ad1"><code>e064762</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4027">#4027</a>
from github/update-v4.37.2-385bcdc5a</li>
<li><a
href="https://github.com/github/codeql-action/commit/e0faed839190caa67a5cd42f1cc16246028ca3df"><code>e0faed8</code></a>
Add a couple of change notes</li>
<li><a
href="https://github.com/github/codeql-action/commit/73aad0eaa9df172668665a150d17b8bc5a650c20"><code>73aad0e</code></a>
Update changelog for v4.37.2</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/7188fc363630916deb702c7fdcf4e481b751f97a...e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 18:07:39 +02:00
Tobias GesellchenandClaude Sonnet 5 31ba2bd9cd docs: document and consolidate Management API credential defaults (#586)
## Summary
- Documents the default Management API credentials (`admin` /
`change_me!`) right where the Local Account tab is introduced
(`MUSIC-SERVICES.md`) — surfaced by #269, where the reporter was stuck
at an unexplained login prompt.
- Consolidates: the defaults now live in one place
(`SOUNDTOUCH-SERVICE.md`'s existing Configuration Options table), with
the other five mentions across the docs linking to it instead of
restating the value independently.
- Fixes two factual errors found while consolidating
(`SELF-HOSTING.md`): it claimed there's no login by default (wrong —
Basic Auth is always on with a published default) and that it protects
the Settings tab (wrong — `/api/setup/*` isn't behind Basic Auth at
all).

Refs #269.

## Test plan
- [x] Docs-only change; links verified against existing cross-file
anchor conventions used elsewhere in the repo

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 21:42:44 +02:00
Tobias GesellchenandClaude Opus 4.8 4a76805df4 feat(health): flag speakers whose runtime bmxRegistryUrl is still on the Bose cloud (#577)
## What

Adds a new `runtime_bmx_url_stale` health check to `soundtouch-service`.
For each reachable speaker it reads the **runtime** `bmxRegistryUrl`
(from the on-device `SoundTouchSdkPrivateCfg.xml` via SSH, or `getpdo
CurrentSystemConfiguration` over telnet) and warns when it still points
at the shut-down Bose cloud, offering a copy-paste re-migrate command.

## Why

Radio source types (TUNEIN / RADIO_BROWSER / LOCAL_INTERNET_RADIO) are
delivered to the speaker through the BMX registry. A speaker whose
runtime `bmxRegistryUrl` still names the Bose cloud can never mount
them, even though the service's own `/sources` listing is correct. The
existing `sources_xml_diff` check reports the *symptom* ("missing 3
source types"); this check reports the *per-device cause*, so an
operator sees exactly which speakers still need re-migrating.

This is the recurring "radio missing after migration" theme (relates to
#549, #547, #546, #493). In the #549 diagnostic, 6 of 9 speakers had
never actually been migrated (all four runtime URLs still on
`content.api.bose.io` / `streaming.bose.com`) while the service itself
looked healthy; this check would have surfaced that per device
immediately.

## False-positive guard

Under a DNS-based migration (AfterTouch acting as the speaker's DNS
server) a cloud URL is legitimate: the redirect happens at the DNS
layer, not by rewriting the on-device URL. So the check stays silent
while the service's own DNS interception is running (`GetDNSRunning`).
The router-DNS variant (the LAN's DNS points at AfterTouch without our
DNS server running) cannot be detected here, so it is called out as a
known exception in the finding text rather than suppressed.

## Changes

- `pkg/service/health/checks_runtime_bmx_url.go` (+ unit test): the
check, following the injected-closure pattern of `checks_marge_url.go`.
`assessRuntimeBmxURL` is the pure, testable core; `isBoseCloudHost` does
a domain-suffix match on the Bose cloud domains.
- `pkg/service/handlers/handlers_export.go`: a lightweight
`readSpeakerBmxRegistryURL(ip)` reader (SSH then telnet), reusing the
existing export imports. The diagnostic export path itself is unchanged.
- `pkg/service/handlers/server.go`: registers the check, wiring
`GetDNSRunning` as the guard.

The health package stays free of the SSH / `setup` imports (the reader
lives in the handlers layer), matching the existing dependency boundary.

## Testing

- `go test ./pkg/service/health/` green (new tests: cloud URL warns;
AfterTouch URL and empty URL do not; `isBoseCloudHost` matrix).
- `go vet` and `golangci-lint` clean on both packages; `go build
./cmd/soundtouch-service/` succeeds.

Not tied to a single issue to close; it complements the #549 / #547 /
#546 / #493 cluster as a diagnostic aid.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 21:11:10 +02:00
Tobias GesellchenandClaude Sonnet 5 bb9bce440e fix(admin): surface silent Management API auth failures, add link-state health checks (#585)
## Summary
- #269's stuck Spotify presets traced to the account link never
completing, invisible because the admin UI silently swallowed 401s from
`/api/mgmt/*` instead of prompting for a retry. The browser's own Basic
Auth caching already works correctly here (verified live); the bug was
purely missing feedback.
- Adds two Health-tab checks: Spotify configured but no account linked,
and Management API credentials still at the published default.

Refs #269, #419.

## Test plan
- [x] `make check` (fmt, vet, unit tests) clean
- [x] `make lint` clean
- [x] Live-tested end to end with a headless Chrome (chromedp) against a
local build: confirmed the new failure-path messages render correctly
for `fetchSpotifyStatus`, `fetchAccountList`, and `linkSpotify`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 20:24:57 +02:00
dependabot[bot]andlnx01 a0f8b86922 ci(deps): bump the codeql-action group with 3 updates (#581)
Bumps the codeql-action group with 3 updates:
[github/codeql-action/init](https://github.com/github/codeql-action),
[github/codeql-action/analyze](https://github.com/github/codeql-action)
and
[github/codeql-action/upload-sarif](https://github.com/github/codeql-action).

Updates `github/codeql-action/init` from 4.37.0 to 4.37.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/init's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.1</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/init's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<h2>4.36.1 - 02 Jun 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.0 - 22 May 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.19.4. <a
href="https://redirect.github.com/github/codeql-action/pull/3894">#3894</a></li>
<li>Add support for SHA-256 Git object IDs. <a
href="https://redirect.github.com/github/codeql-action/pull/3893">#3893</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.5">2.25.5</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3926">#3926</a></li>
</ul>
<h2>4.35.5 - 15 May 2026</h2>
<ul>
<li>We have improved how the JavaScript bundles for the CodeQL Action
are generated to avoid duplication across bundles and reduce the size of
the repository by around 70%. This should have no effect on the runtime
behaviour of the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3899">#3899</a></li>
<li>For performance and accuracy reasons, <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> will now only be enabled on a pull request when
diff-informed analysis is also enabled for that run. If diff-informed
analysis is unavailable (for example, because the PR diff ranges could
not be computed), the action will fall back to a full analysis. <a
href="https://redirect.github.com/github/codeql-action/pull/3791">#3791</a></li>
<li>If multiple inputs are provided for the GitHub-internal
<code>analysis-kinds</code> input, only <code>code-scanning</code> will
be enabled. The <code>analysis-kinds</code> input is experimental, for
GitHub-internal use only, and may change without notice at any time. <a
href="https://redirect.github.com/github/codeql-action/pull/3892">#3892</a></li>
<li>Added an experimental change which, when running a Code Scanning
analysis for a PR with <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> enabled, prefers CodeQL CLI versions that have
a cached overlay-base database for the configured languages. This speeds
up analysis for a repository when there is not yet a cached overlay-base
database for the latest CLI version. We expect to roll this change out
to everyone in May. <a
href="https://redirect.github.com/github/codeql-action/pull/3880">#3880</a></li>
</ul>
<h2>4.35.4 - 07 May 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.4">2.25.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3881">#3881</a></li>
</ul>
<h2>4.35.3 - 01 May 2026</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/7188fc363630916deb702c7fdcf4e481b751f97a"><code>7188fc3</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4020">#4020</a>
from github/update-v4.37.1-9e7c07009</li>
<li><a
href="https://github.com/github/codeql-action/commit/c8b5f69be686908c3dfd844428137d56fe80c936"><code>c8b5f69</code></a>
Update changelog for v4.37.1</li>
<li><a
href="https://github.com/github/codeql-action/commit/9e7c070092090e89e8b3d62f977d4456e0732cd7"><code>9e7c070</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4014">#4014</a>
from github/mbg/explicit-remote-prefix</li>
<li><a
href="https://github.com/github/codeql-action/commit/3492b7e9ab96e28b1d8b971345d30e929c6f8fee"><code>3492b7e</code></a>
Change <code>REMOTE_PATH_PREFIX</code> to <code>remote=</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/3654baa924bc6456db54002581cb7c1c877548c4"><code>3654baa</code></a>
Merge remote-tracking branch 'origin/main' into
mbg/explicit-remote-prefix</li>
<li><a
href="https://github.com/github/codeql-action/commit/2d682ac05f1b3588aaff3814826bede39b9ba6bb"><code>2d682ac</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4017">#4017</a>
from github/dependabot/github_actions/dot-github/wor...</li>
<li><a
href="https://github.com/github/codeql-action/commit/23f6a50753a88efd9b7ae8687b29f6bdb65f6250"><code>23f6a50</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4009">#4009</a>
from github/mbg/action-state/additions</li>
<li><a
href="https://github.com/github/codeql-action/commit/1ee3c75d1988ab8621f01ebb165115c38d56df91"><code>1ee3c75</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4018">#4018</a>
from github/dependabot/github_actions/dot-github/wor...</li>
<li><a
href="https://github.com/github/codeql-action/commit/e053684dc500899b0b5520edc8549ac0f1ed730b"><code>e053684</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4015">#4015</a>
from github/dependabot/npm_and_yarn/npm-minor-fd2e83...</li>
<li><a
href="https://github.com/github/codeql-action/commit/6803c5671d2f87a83ed96e151c441b1cb3bdc66a"><code>6803c56</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4019">#4019</a>
from github/update-bundle/codeql-bundle-v2.26.1</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...7188fc363630916deb702c7fdcf4e481b751f97a">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/analyze` from 4.37.0 to 4.37.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/analyze's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.1</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/analyze's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<h2>4.36.1 - 02 Jun 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.0 - 22 May 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.19.4. <a
href="https://redirect.github.com/github/codeql-action/pull/3894">#3894</a></li>
<li>Add support for SHA-256 Git object IDs. <a
href="https://redirect.github.com/github/codeql-action/pull/3893">#3893</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.5">2.25.5</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3926">#3926</a></li>
</ul>
<h2>4.35.5 - 15 May 2026</h2>
<ul>
<li>We have improved how the JavaScript bundles for the CodeQL Action
are generated to avoid duplication across bundles and reduce the size of
the repository by around 70%. This should have no effect on the runtime
behaviour of the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3899">#3899</a></li>
<li>For performance and accuracy reasons, <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> will now only be enabled on a pull request when
diff-informed analysis is also enabled for that run. If diff-informed
analysis is unavailable (for example, because the PR diff ranges could
not be computed), the action will fall back to a full analysis. <a
href="https://redirect.github.com/github/codeql-action/pull/3791">#3791</a></li>
<li>If multiple inputs are provided for the GitHub-internal
<code>analysis-kinds</code> input, only <code>code-scanning</code> will
be enabled. The <code>analysis-kinds</code> input is experimental, for
GitHub-internal use only, and may change without notice at any time. <a
href="https://redirect.github.com/github/codeql-action/pull/3892">#3892</a></li>
<li>Added an experimental change which, when running a Code Scanning
analysis for a PR with <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> enabled, prefers CodeQL CLI versions that have
a cached overlay-base database for the configured languages. This speeds
up analysis for a repository when there is not yet a cached overlay-base
database for the latest CLI version. We expect to roll this change out
to everyone in May. <a
href="https://redirect.github.com/github/codeql-action/pull/3880">#3880</a></li>
</ul>
<h2>4.35.4 - 07 May 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.4">2.25.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3881">#3881</a></li>
</ul>
<h2>4.35.3 - 01 May 2026</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/7188fc363630916deb702c7fdcf4e481b751f97a"><code>7188fc3</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4020">#4020</a>
from github/update-v4.37.1-9e7c07009</li>
<li><a
href="https://github.com/github/codeql-action/commit/c8b5f69be686908c3dfd844428137d56fe80c936"><code>c8b5f69</code></a>
Update changelog for v4.37.1</li>
<li><a
href="https://github.com/github/codeql-action/commit/9e7c070092090e89e8b3d62f977d4456e0732cd7"><code>9e7c070</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4014">#4014</a>
from github/mbg/explicit-remote-prefix</li>
<li><a
href="https://github.com/github/codeql-action/commit/3492b7e9ab96e28b1d8b971345d30e929c6f8fee"><code>3492b7e</code></a>
Change <code>REMOTE_PATH_PREFIX</code> to <code>remote=</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/3654baa924bc6456db54002581cb7c1c877548c4"><code>3654baa</code></a>
Merge remote-tracking branch 'origin/main' into
mbg/explicit-remote-prefix</li>
<li><a
href="https://github.com/github/codeql-action/commit/2d682ac05f1b3588aaff3814826bede39b9ba6bb"><code>2d682ac</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4017">#4017</a>
from github/dependabot/github_actions/dot-github/wor...</li>
<li><a
href="https://github.com/github/codeql-action/commit/23f6a50753a88efd9b7ae8687b29f6bdb65f6250"><code>23f6a50</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4009">#4009</a>
from github/mbg/action-state/additions</li>
<li><a
href="https://github.com/github/codeql-action/commit/1ee3c75d1988ab8621f01ebb165115c38d56df91"><code>1ee3c75</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4018">#4018</a>
from github/dependabot/github_actions/dot-github/wor...</li>
<li><a
href="https://github.com/github/codeql-action/commit/e053684dc500899b0b5520edc8549ac0f1ed730b"><code>e053684</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4015">#4015</a>
from github/dependabot/npm_and_yarn/npm-minor-fd2e83...</li>
<li><a
href="https://github.com/github/codeql-action/commit/6803c5671d2f87a83ed96e151c441b1cb3bdc66a"><code>6803c56</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4019">#4019</a>
from github/update-bundle/codeql-bundle-v2.26.1</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...7188fc363630916deb702c7fdcf4e481b751f97a">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/upload-sarif` from 4.37.0 to 4.37.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/upload-sarif's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.1</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/upload-sarif's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<h2>4.36.1 - 02 Jun 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.0 - 22 May 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.19.4. <a
href="https://redirect.github.com/github/codeql-action/pull/3894">#3894</a></li>
<li>Add support for SHA-256 Git object IDs. <a
href="https://redirect.github.com/github/codeql-action/pull/3893">#3893</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.5">2.25.5</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3926">#3926</a></li>
</ul>
<h2>4.35.5 - 15 May 2026</h2>
<ul>
<li>We have improved how the JavaScript bundles for the CodeQL Action
are generated to avoid duplication across bundles and reduce the size of
the repository by around 70%. This should have no effect on the runtime
behaviour of the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3899">#3899</a></li>
<li>For performance and accuracy reasons, <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> will now only be enabled on a pull request when
diff-informed analysis is also enabled for that run. If diff-informed
analysis is unavailable (for example, because the PR diff ranges could
not be computed), the action will fall back to a full analysis. <a
href="https://redirect.github.com/github/codeql-action/pull/3791">#3791</a></li>
<li>If multiple inputs are provided for the GitHub-internal
<code>analysis-kinds</code> input, only <code>code-scanning</code> will
be enabled. The <code>analysis-kinds</code> input is experimental, for
GitHub-internal use only, and may change without notice at any time. <a
href="https://redirect.github.com/github/codeql-action/pull/3892">#3892</a></li>
<li>Added an experimental change which, when running a Code Scanning
analysis for a PR with <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> enabled, prefers CodeQL CLI versions that have
a cached overlay-base database for the configured languages. This speeds
up analysis for a repository when there is not yet a cached overlay-base
database for the latest CLI version. We expect to roll this change out
to everyone in May. <a
href="https://redirect.github.com/github/codeql-action/pull/3880">#3880</a></li>
</ul>
<h2>4.35.4 - 07 May 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.4">2.25.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3881">#3881</a></li>
</ul>
<h2>4.35.3 - 01 May 2026</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/7188fc363630916deb702c7fdcf4e481b751f97a"><code>7188fc3</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4020">#4020</a>
from github/update-v4.37.1-9e7c07009</li>
<li><a
href="https://github.com/github/codeql-action/commit/c8b5f69be686908c3dfd844428137d56fe80c936"><code>c8b5f69</code></a>
Update changelog for v4.37.1</li>
<li><a
href="https://github.com/github/codeql-action/commit/9e7c070092090e89e8b3d62f977d4456e0732cd7"><code>9e7c070</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4014">#4014</a>
from github/mbg/explicit-remote-prefix</li>
<li><a
href="https://github.com/github/codeql-action/commit/3492b7e9ab96e28b1d8b971345d30e929c6f8fee"><code>3492b7e</code></a>
Change <code>REMOTE_PATH_PREFIX</code> to <code>remote=</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/3654baa924bc6456db54002581cb7c1c877548c4"><code>3654baa</code></a>
Merge remote-tracking branch 'origin/main' into
mbg/explicit-remote-prefix</li>
<li><a
href="https://github.com/github/codeql-action/commit/2d682ac05f1b3588aaff3814826bede39b9ba6bb"><code>2d682ac</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4017">#4017</a>
from github/dependabot/github_actions/dot-github/wor...</li>
<li><a
href="https://github.com/github/codeql-action/commit/23f6a50753a88efd9b7ae8687b29f6bdb65f6250"><code>23f6a50</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4009">#4009</a>
from github/mbg/action-state/additions</li>
<li><a
href="https://github.com/github/codeql-action/commit/1ee3c75d1988ab8621f01ebb165115c38d56df91"><code>1ee3c75</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4018">#4018</a>
from github/dependabot/github_actions/dot-github/wor...</li>
<li><a
href="https://github.com/github/codeql-action/commit/e053684dc500899b0b5520edc8549ac0f1ed730b"><code>e053684</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4015">#4015</a>
from github/dependabot/npm_and_yarn/npm-minor-fd2e83...</li>
<li><a
href="https://github.com/github/codeql-action/commit/6803c5671d2f87a83ed96e151c441b1cb3bdc66a"><code>6803c56</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4019">#4019</a>
from github/update-bundle/codeql-bundle-v2.26.1</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...7188fc363630916deb702c7fdcf4e481b751f97a">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-23 13:41:28 +02:00
dependabot[bot]andlnx01 e47805e6a9 ci(deps): bump the actions-core group with 2 updates (#582)
Bumps the actions-core group with 2 updates:
[actions/checkout](https://github.com/actions/checkout) and
[actions/setup-go](https://github.com/actions/setup-go).

Updates `actions/checkout` from 7.0.0 to 7.0.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/checkout/releases">actions/checkout's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.1</h2>
<h2>What's Changed</h2>
<ul>
<li>skip running unsafe pr check if input is default by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2518">actions/checkout#2518</a></li>
<li>trim only ascii whitespace for branch by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2521">actions/checkout#2521</a></li>
<li>escape values passed to --unset by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2530">actions/checkout#2530</a></li>
<li>Various dependency updates</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/checkout/compare/v7...v7.0.1">https://github.com/actions/checkout/compare/v7...v7.0.1</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/actions/checkout/blob/main/CHANGELOG.md">actions/checkout's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<h2>v7.0.1</h2>
<ul>
<li>Skip running unsafe pr check if input is default by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2518">actions/checkout#2518</a></li>
<li>Trim only ascii whitespace for branch by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2521">actions/checkout#2521</a></li>
<li>Escape values passed to --unset by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2530">actions/checkout#2530</a></li>
<li>Various dependency updates</li>
</ul>
<h2>v7.0.0</h2>
<ul>
<li>Block checking out fork PR for pull_request_target and workflow_run
by <a href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2454">actions/checkout#2454</a></li>
<li>Various dependency updates</li>
</ul>
<h2>v6.0.3</h2>
<ul>
<li>Fix checkout init for SHA-256 repositories by <a
href="https://github.com/yaananth"><code>@​yaananth</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2439">actions/checkout#2439</a></li>
<li>fix: expand merge commit SHA regex and add SHA-256 test cases by <a
href="https://github.com/yaananth"><code>@​yaananth</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2414">actions/checkout#2414</a></li>
</ul>
<h2>v6.0.2</h2>
<ul>
<li>Fix tag handling: preserve annotations and explicit fetch-tags by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2356">actions/checkout#2356</a></li>
</ul>
<h2>v6.0.1</h2>
<ul>
<li>Add worktree support for persist-credentials includeIf by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2327">actions/checkout#2327</a></li>
</ul>
<h2>v6.0.0</h2>
<ul>
<li>Persist creds to a separate file by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2286">actions/checkout#2286</a></li>
<li>Update README to include Node.js 24 support details and requirements
by <a href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/2248">actions/checkout#2248</a></li>
</ul>
<h2>v5.0.1</h2>
<ul>
<li>Port v6 cleanup to v5 by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2301">actions/checkout#2301</a></li>
</ul>
<h2>v5.0.0</h2>
<ul>
<li>Update actions checkout to use node 24 by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2226">actions/checkout#2226</a></li>
</ul>
<h2>v4.3.1</h2>
<ul>
<li>Port v6 cleanup to v4 by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2305">actions/checkout#2305</a></li>
</ul>
<h2>v4.3.0</h2>
<ul>
<li>docs: update README.md by <a
href="https://github.com/motss"><code>@​motss</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1971">actions/checkout#1971</a></li>
<li>Add internal repos for checking out multiple repositories by <a
href="https://github.com/mouismail"><code>@​mouismail</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1977">actions/checkout#1977</a></li>
<li>Documentation update - add recommended permissions to Readme by <a
href="https://github.com/benwells"><code>@​benwells</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2043">actions/checkout#2043</a></li>
<li>Adjust positioning of user email note and permissions heading by <a
href="https://github.com/joshmgross"><code>@​joshmgross</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2044">actions/checkout#2044</a></li>
<li>Update README.md by <a
href="https://github.com/nebuk89"><code>@​nebuk89</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2194">actions/checkout#2194</a></li>
<li>Update CODEOWNERS for actions by <a
href="https://github.com/TingluoHuang"><code>@​TingluoHuang</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/2224">actions/checkout#2224</a></li>
<li>Update package dependencies by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2236">actions/checkout#2236</a></li>
</ul>
<h2>v4.2.2</h2>
<ul>
<li><code>url-helper.ts</code> now leverages well-known environment
variables by <a href="https://github.com/jww3"><code>@​jww3</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/1941">actions/checkout#1941</a></li>
<li>Expand unit test coverage for <code>isGhes</code> by <a
href="https://github.com/jww3"><code>@​jww3</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1946">actions/checkout#1946</a></li>
</ul>
<h2>v4.2.1</h2>
<ul>
<li>Check out other refs/* by commit if provided, fall back to ref by <a
href="https://github.com/orhantoy"><code>@​orhantoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1924">actions/checkout#1924</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/checkout/commit/3d3c42e5aac5ba805825da76410c181273ba90b1"><code>3d3c42e</code></a>
prep v7.0.1 release (<a
href="https://redirect.github.com/actions/checkout/issues/2531">#2531</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/28802689a136bfcdb721715abd713740beecbe07"><code>2880268</code></a>
escape values passed to --unset (<a
href="https://redirect.github.com/actions/checkout/issues/2530">#2530</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/12cd2235efa0937479335606d7c3ac9f6c0973b1"><code>12cd223</code></a>
trim only ascii whitespace for branch (<a
href="https://redirect.github.com/actions/checkout/issues/2521">#2521</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/62661c4e71a304b2823ed026347b8d34c3eac541"><code>62661c4</code></a>
skip running unsafe pr check if input is default (<a
href="https://redirect.github.com/actions/checkout/issues/2518">#2518</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/e8d4307400f9427dba7cb98e488d6ab85f1cec5f"><code>e8d4307</code></a>
Bump the minor-actions-dependencies group with 2 updates (<a
href="https://redirect.github.com/actions/checkout/issues/2499">#2499</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/631c942040754b6e095e929c1677c07e10ed4f87"><code>631c942</code></a>
eslint 9 (<a
href="https://redirect.github.com/actions/checkout/issues/2474">#2474</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/4f1f4aec02e41874fa0262ea8ff5172d7978ad1e"><code>4f1f4ae</code></a>
Bump actions/upload-artifact from 4 to 7 (<a
href="https://redirect.github.com/actions/checkout/issues/2476">#2476</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/ba097532fb203f7e88c9c3c0b899b49469908a92"><code>ba09753</code></a>
Bump actions/checkout from 6 to 7 (<a
href="https://redirect.github.com/actions/checkout/issues/2488">#2488</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/b9e0990d219a03df7633c93f6f005a8fecbcab22"><code>b9e0990</code></a>
Bump docker/login-action from 3.3.0 to 4.2.0 (<a
href="https://redirect.github.com/actions/checkout/issues/2479">#2479</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/e8cb398be4a550817e382abf69e4c12c76fce1f2"><code>e8cb398</code></a>
Bump docker/build-push-action from 6.5.0 to 7.2.0 (<a
href="https://redirect.github.com/actions/checkout/issues/2478">#2478</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/actions/checkout/compare/9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0...3d3c42e5aac5ba805825da76410c181273ba90b1">compare
view</a></li>
</ul>
</details>
<br />

Updates `actions/setup-go` from 6.5.0 to 7.0.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/setup-go/releases">actions/setup-go's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Migrate to ESM and upgrade dependencies by <a
href="https://github.com/priyagupta108"><code>@​priyagupta108</code></a>
in <a
href="https://redirect.github.com/actions/setup-go/pull/763">actions/setup-go#763</a></li>
<li>chore(deps): bump <code>@​actions/cache</code> to 6.2.0 by <a
href="https://github.com/philip-gai"><code>@​philip-gai</code></a> in <a
href="https://redirect.github.com/actions/setup-go/pull/771">actions/setup-go#771</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/philip-gai"><code>@​philip-gai</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/setup-go/pull/771">actions/setup-go#771</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-go/compare/v6...v7.0.0">https://github.com/actions/setup-go/compare/v6...v7.0.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/setup-go/commit/b7ad1dad31e06c5925ef5d2fc7ad053ef454303e"><code>b7ad1da</code></a>
chore(deps): bump <code>@​actions/cache</code> to 6.2.0 (<a
href="https://redirect.github.com/actions/setup-go/issues/771">#771</a>)</li>
<li><a
href="https://github.com/actions/setup-go/commit/0778a10ce47b5d450cf60fb94fafad4330008a35"><code>0778a10</code></a>
Migrate to ESM and upgrade dependencies (<a
href="https://redirect.github.com/actions/setup-go/issues/763">#763</a>)</li>
<li>See full diff in <a
href="https://github.com/actions/setup-go/compare/924ae3a1cded613372ab5595356fb5720e22ba16...b7ad1dad31e06c5925ef5d2fc7ad053ef454303e">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-23 13:40:54 +02:00
dependabot[bot]andlnx01 ee5f351828 deps(deps): bump github.com/chromedp/chromedp from 0.15.1 to 0.16.0 (#579)
Bumps
[github.com/chromedp/chromedp](https://github.com/chromedp/chromedp)
from 0.15.1 to 0.16.0.
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/chromedp/chromedp/commit/7963c203ed5458147d27dc39a5c06d2b12e81664"><code>7963c20</code></a>
Running modernize</li>
<li><a
href="https://github.com/chromedp/chromedp/commit/9178eba2a70c7691c6d710fd3383542503d4aa38"><code>9178eba</code></a>
Updating to latest cdproto, running go fix</li>
<li>See full diff in <a
href="https://github.com/chromedp/chromedp/compare/v0.15.1...v0.16.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/chromedp/chromedp&package-manager=go_modules&previous-version=0.15.1&new-version=0.16.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 12:40:04 +02:00
Tobias GesellchenandClaude Opus 4.8 1270eed554 feat(player): add name/IP device sort toggle (#571) (#576)
Relates to #571.

## What

Adds a **Name / IP** sort toggle to the Player device list. The choice
is persisted in `localStorage` (`aftertouch_device_sort`), following the
same preference pattern as the service-URL field in `PlayURL.js`.

## Why

The device list was previously ordered only by IP: the service datastore
keys devices by IP address and Go marshals map keys lexicographically,
so the frontend received an already-IP-ordered object and rendered it
as-is. BirdyBA (#571) asked to be able to sort by name instead.

## Changes

- `DeviceList.js`: a `sortEntries()` helper plus a `useState`-backed
toggle seeded from `localStorage`. Name mode sorts by `device.info.name`
(falling back to the IP key when a device has no name yet); IP mode
sorts the IP key **numerically** (`.2` before `.10`), which also tidies
the old lexicographic ordering.
- `css/app.css`: additive `.device-sort` / `.sort-btn` styling, reusing
the existing accent / `.active` look. No existing rules touched.

No backend change: the device name and IP are already in the payload.

## Testing

- `node --check` on `DeviceList.js` passes.
- `make build-player` succeeds (the static tree is `//go:embed`ed into
the binary).
- Manual: open the Player, toggle Name / IP, confirm the order changes
and the choice survives a page reload.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:46:53 +02:00
dependabot[bot] ca4adb3e11 ci(deps): bump actions/setup-node in the actions-core group
Bumps the actions-core group with 1 update: [actions/setup-node](https://github.com/actions/setup-node).


Updates `actions/setup-node` from 6.4.0 to 7.0.0
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e...820762786026740c76f36085b0efc47a31fe5020)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-core
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-14 22:35:43 +02:00
dependabot[bot] 66ab981695 ci(deps): bump softprops/action-gh-release from 3.0.1 to 3.0.2
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 3.0.1 to 3.0.2.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/718ea10b132b3b2eba29c1007bb80653f286566b...3d0d9888cb7fd7b750713d6e236d1fcb99157228)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-14 22:35:13 +02:00
dependabot[bot] d34e095c9f ci(deps): bump docker/metadata-action from 6.1.0 to 6.2.0
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 6.1.0 to 6.2.0.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9...dc802804100637a589fabce1cb79ff13a1411302)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 21:48:22 +02:00
dependabot[bot] 745bef5acc ci(deps): bump docker/login-action from 4.2.0 to 4.4.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.2.0 to 4.4.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/650006c6eb7dba73a995cc03b0b2d7f5ca915bee...af1e73f918a031802d376d3c8bbc3fe56130a9b0)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 21:48:07 +02:00
Tobias GesellchenandClaude Opus 4.8 00db897c24 ci: group codeql-action sub-actions in Dependabot
github/codeql-action/init, /analyze and /upload-sarif are separate
Dependabot dependencies but must run on the same version. Without a
group they update in independent PRs that merge at different times,
producing a version mismatch that fails CodeQL. Group them so all
sub-actions bump together in one PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 21:22:06 +02:00
Tobias GesellchenandClaude Opus 4.8 c20e15df21 ci: align codeql-action init/upload-sarif with analyze v4.37.0
Dependabot bumped only github/codeql-action/analyze to v4.37.0 while
init and upload-sarif stayed on v4.36.2. CodeQL requires all of its
action steps on the same version; the mismatch failed every Analyze job
with 'Loaded a configuration file for version 4.36.2, but running
version 4.37.0'. Bump init and upload-sarif to the same v4.37.0 commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 21:22:06 +02:00
dependabot[bot] 7e555d8c86 ci(deps): bump github/codeql-action/analyze from 4.36.2 to 4.37.0
Bumps [github/codeql-action/analyze](https://github.com/github/codeql-action) from 4.36.2 to 4.37.0.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/8aad20d150bbac5944a9f9d289da16a4b0d87c1e...99df26d4f13ea111d4ec1a7dddef6063f76b97e9)

---
updated-dependencies:
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.36.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 21:22:06 +02:00
Tobias GesellchenandClaude Opus 4.8 d523d96b6f test: refresh router snapshot for chi 5.3.1 QUERY method
chi 5.3.1 recognizes the HTTP QUERY method, so chi.Walk now expands the
all-methods HandleFunc registrations for the SiriusXM live-adapter routes
to include QUERY. The routes are functionally unchanged; only the walk
output grew two lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 21:16:42 +02:00
dependabot[bot] 27aa88b317 deps(deps): bump github.com/go-chi/chi/v5 from 5.3.0 to 5.3.1
Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from 5.3.0 to 5.3.1.
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.3.0...v5.3.1)

---
updated-dependencies:
- dependency-name: github.com/go-chi/chi/v5
  dependency-version: 5.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 21:16:42 +02:00
github-actions[bot] 9d17955430 chore: sync static dependencies with package.json 2026-07-09 21:12:59 +02:00
dependabot[bot] c5441639c5 deps(deps): bump preact from 10.29.3 to 10.29.7
Bumps [preact](https://github.com/preactjs/preact) from 10.29.3 to 10.29.7.
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.29.3...10.29.7)

---
updated-dependencies:
- dependency-name: preact
  dependency-version: 10.29.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 21:12:59 +02:00
Tobias GesellchenandClaude Opus 4.8 397faaaabd docker: use Go 1.26.5 release instead of 1.27rc2
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 21:05:09 +02:00
Tobias GesellchenandClaude Opus 4.8 486fb2faab deps: bump Go toolchain to 1.26.5
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 21:05:09 +02:00
dependabot[bot] a4cc6f5c96 deps(deps): bump the golang group with 9 updates
Bumps the golang group with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [golang.org/x/crypto](https://github.com/golang/crypto) | `0.53.0` | `0.54.0` |
| [golang.org/x/net](https://github.com/golang/net) | `0.56.0` | `0.57.0` |
| [golang.org/x/term](https://github.com/golang/term) | `0.44.0` | `0.45.0` |
| [golang.org/x/image](https://github.com/golang/image) | `0.43.0` | `0.44.0` |
| [golang.org/x/mod](https://github.com/golang/mod) | `0.37.0` | `0.38.0` |
| [golang.org/x/sync](https://github.com/golang/sync) | `0.21.0` | `0.22.0` |
| [golang.org/x/sys](https://github.com/golang/sys) | `0.46.0` | `0.47.0` |
| [golang.org/x/text](https://github.com/golang/text) | `0.38.0` | `0.40.0` |
| [golang.org/x/tools](https://github.com/golang/tools) | `0.47.0` | `0.48.0` |


Updates `golang.org/x/crypto` from 0.53.0 to 0.54.0
- [Commits](https://github.com/golang/crypto/compare/v0.53.0...v0.54.0)

Updates `golang.org/x/net` from 0.56.0 to 0.57.0
- [Commits](https://github.com/golang/net/compare/v0.56.0...v0.57.0)

Updates `golang.org/x/term` from 0.44.0 to 0.45.0
- [Commits](https://github.com/golang/term/compare/v0.44.0...v0.45.0)

Updates `golang.org/x/image` from 0.43.0 to 0.44.0
- [Commits](https://github.com/golang/image/compare/v0.43.0...v0.44.0)

Updates `golang.org/x/mod` from 0.37.0 to 0.38.0
- [Commits](https://github.com/golang/mod/compare/v0.37.0...v0.38.0)

Updates `golang.org/x/sync` from 0.21.0 to 0.22.0
- [Commits](https://github.com/golang/sync/compare/v0.21.0...v0.22.0)

Updates `golang.org/x/sys` from 0.46.0 to 0.47.0
- [Commits](https://github.com/golang/sys/compare/v0.46.0...v0.47.0)

Updates `golang.org/x/text` from 0.38.0 to 0.40.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.38.0...v0.40.0)

Updates `golang.org/x/tools` from 0.47.0 to 0.48.0
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.47.0...v0.48.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.54.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/net
  dependency-version: 0.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/term
  dependency-version: 0.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/image
  dependency-version: 0.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/mod
  dependency-version: 0.38.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/sync
  dependency-version: 0.22.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/sys
  dependency-version: 0.47.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/text
  dependency-version: 0.40.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/tools
  dependency-version: 0.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 20:56:40 +02:00
dependabot[bot] 0374b2a680 docker(deps): bump golang from 1.26.4-alpine to 1.27rc2-alpine
Bumps golang from 1.26.4-alpine to 1.27rc2-alpine.

---
updated-dependencies:
- dependency-name: golang
  dependency-version: 1.27rc2-alpine
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-08 19:00:01 +02:00
dependabot[bot] d944a5a794 ci(deps): bump docker/setup-buildx-action in the setup-actions group
Bumps the setup-actions group with 1 update: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action).


Updates `docker/setup-buildx-action` from 4.1.0 to 4.2.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5...bb05f3f5519dd87d3ba754cc423b652a5edd6d2c)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: setup-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-07 19:35:48 +02:00
dependabot[bot] 7dfe2dc160 ci(deps): bump docker/build-push-action from 7.2.0 to 7.3.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 7.2.0 to 7.3.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/f9f3042f7e2789586610d6e8b85c8f03e5195baf...53b7df96c91f9c12dcc8a07bcb9ccacbed38856a)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-07 19:34:51 +02:00
138 changed files with 10071 additions and 1216 deletions
+1 -1
View File
@@ -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
+7
View File
@@ -125,6 +125,13 @@ updates:
allow:
- dependency-type: "all"
groups:
# Group all codeql-action sub-actions (init/analyze/upload-sarif)
# so they bump together. They are separate dependencies to
# Dependabot but must stay on the same version, or CodeQL fails
# with "Loaded a configuration file for version X, but running Y".
codeql-action:
patterns:
- "github/codeql-action*"
# Group actions from the same organization
actions-core:
patterns:
+19 -19
View File
@@ -17,10 +17,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: "go.mod"
@@ -66,10 +66,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: "go.mod"
@@ -107,10 +107,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: "go.mod"
@@ -165,10 +165,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: "go.mod"
@@ -192,12 +192,12 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- 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: |
@@ -250,10 +250,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: "go.mod"
@@ -305,10 +305,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Set build date
id: build_date
@@ -330,7 +330,7 @@ jobs:
- name: Log in to GitHub Container Registry
if: steps.push-check.outputs.should-push == 'true'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -338,7 +338,7 @@ jobs:
- name: Extract metadata (tags, labels) for soundtouch-service
id: meta-service
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
@@ -348,7 +348,7 @@ jobs:
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
- name: Build and push soundtouch-service Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
target: soundtouch-service
@@ -364,7 +364,7 @@ jobs:
- name: Extract metadata (tags, labels) for soundtouch-player
id: meta-player
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ghcr.io/${{ github.repository }}-player
tags: |
@@ -374,7 +374,7 @@ jobs:
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
- name: Build and push soundtouch-player Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
target: soundtouch-player
+3 -3
View File
@@ -33,14 +33,14 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install libpcap (required for Go build)
if: matrix.language == 'go'
run: sudo apt-get install -y libpcap-dev
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
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@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:${{ matrix.language }}"
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Pages
id: pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
+76 -16
View File
@@ -29,7 +29,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Both triggers resolve to the same thing: the release tag. On a
# `release` event inputs.tag is empty, so this falls back to the
@@ -77,7 +77,7 @@ jobs:
fi
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: ${{ env.GO_VERSION_FILE }}
@@ -115,12 +115,12 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.validate.outputs.tag }}
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: ${{ env.GO_VERSION_FILE }}
@@ -329,7 +329,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.validate.outputs.tag }}
fetch-depth: 0
@@ -346,6 +346,14 @@ jobs:
TAG_NAME="${{ needs.validate.outputs.tag }}"
VERSION="${TAG_NAME#v}"
# Real per-platform links for the two most-used tools, generated
# from the deterministic `<binary>-<tag>-<os>-<arch>[.exe]` asset
# naming convention (see scripts/release/quick-downloads.sh),
# instead of requiring a scroll through the flat, alphabetical
# Assets list. Inline checksum link per row (à la Helm's release
# notes) instead of sending people to the combined checksums file.
QUICK_DOWNLOADS="$(scripts/release/quick-downloads.sh "$TAG_NAME" "${{ github.repository }}")"
# Short, accurate header. GitHub's auto-generated "What's Changed"
# + "Full Changelog" are appended after this (generate_release_notes).
cat > release_notes.md << EOF
@@ -353,13 +361,15 @@ jobs:
**Bose SoundTouch Toolkit.** Keep your Bose SoundTouch speakers alive after the Bose cloud shutdown. No Bose infrastructure required.
$QUICK_DOWNLOADS
## What's included
Pre-built binaries for Linux (amd64, arm64, armv7), macOS (Intel & Apple Silicon), Windows (amd64), and FreeBSD (amd64):
- **soundtouch-service**: local server that replaces the Bose cloud. Point your speaker at it and you keep full control; the built-in web UI on port 8000 handles setup.
- **soundtouch-service** (see above)
- **soundtouch-cli** (see above)
- **soundtouch-player**: standalone LAN web UI for device control: play/pause, volume, presets, live status. (Formerly \`soundtouch-web\`.)
- **soundtouch-cli**: command-line control of any device: playback, presets, sources, multiroom zones, discovery, and migration. Good for scripting and home automation.
- **soundtouch-backup**: back up your Bose cloud account and each speaker's local state. \`soundtouch-backup all\` captures everything in one step.
Not sure which file to grab? The [Downloads page](https://gesellix.github.io/Bose-SoundTouch/docs/downloads/) explains which tool you need and which \`<os>-<arch>\` build matches your computer.
@@ -388,7 +398,7 @@ jobs:
echo "release_notes_file=release_notes.md" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
tag_name: ${{ needs.validate.outputs.tag }}
name: ${{ needs.validate.outputs.tag }}
@@ -414,14 +424,64 @@ jobs:
if: github.event_name == 'release' && github.event.action == 'published'
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.validate.outputs.tag }}
- name: Download release assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-assets
path: ./release-assets
- name: Upgrade the Downloads footer with direct per-platform links
# This is the path real releases take: a maintainer hand-writes
# "Noteworthy" notes and publishes via the GitHub web UI, which
# fires this job, not create_release (workflow_dispatch only).
# _/releases/_TEMPLATE.md's convention is a trailing footer line:
# ---
# 📦 **Downloads / installation:** <downloads page URL>
# Drop that line (if present) and append the quick-downloads
# block in its place. Always goes through the same append path
# (strip block + strip footer + append), whether or not a
# footer line is still there, so re-runs stay byte-for-byte
# idempotent instead of drifting on the 2nd run.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG_NAME="${{ needs.validate.outputs.tag }}"
scripts/release/quick-downloads.sh "$TAG_NAME" "${{ github.repository }}" > quick_downloads.md
gh release view "$TAG_NAME" --json body -q .body > existing_body.md
python3 - << 'PYEOF'
import re
with open("existing_body.md") as f:
body = f.read()
with open("quick_downloads.md") as f:
block = f.read().rstrip("\n")
# Drop a block this automation inserted on a previous run.
body = re.sub(r"\n*<!-- quick-downloads:start -->.*?<!-- quick-downloads:end -->\n*", "\n", body, flags=re.DOTALL)
# Drop the hand-authored footer line (first run only) so both
# cases converge on the same append below and re-runs stay
# byte-for-byte idempotent.
footer = re.compile(r"^📦 \*\*Downloads / installation:\*\*.*\n?", re.MULTILINE)
body = footer.sub("", body, count=1)
body = body.rstrip("\n") + "\n\n" + block + "\n"
with open("combined_notes.md", "w") as f:
f.write(body)
PYEOF
gh release edit "$TAG_NAME" --notes-file combined_notes.md
- name: Upload additional assets to existing release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
tag_name: ${{ needs.validate.outputs.tag }}
files: |
@@ -442,7 +502,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.validate.outputs.tag }}
@@ -454,10 +514,10 @@ jobs:
echo "commit=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -465,7 +525,7 @@ jobs:
- name: Extract metadata (tags, labels) for soundtouch-service
id: meta-service
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
@@ -474,7 +534,7 @@ jobs:
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
- name: Build and push soundtouch-service Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
target: soundtouch-service
@@ -491,7 +551,7 @@ jobs:
- name: Extract metadata (tags, labels) for soundtouch-player
id: meta-player
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ghcr.io/${{ github.repository }}-player
tags: |
@@ -500,7 +560,7 @@ jobs:
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
- name: Build and push soundtouch-player Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
target: soundtouch-player
+6 -6
View File
@@ -19,10 +19,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: "go.mod"
@@ -46,10 +46,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: "go.mod"
@@ -78,7 +78,7 @@ jobs:
- name: Upload Semgrep SARIF results
if: always()
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
sarif_file: semgrep.sarif
continue-on-error: true
@@ -92,7 +92,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Dependency Review
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
+2 -2
View File
@@ -16,13 +16,13 @@ jobs:
if: github.actor == 'dependabot[bot]' || github.event_name == 'workflow_dispatch'
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.head_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: 'npm'
+1 -1
View File
@@ -1,5 +1,5 @@
# golangci-lint configuration for Bose SoundTouch Go Library
# Compatible with golangci-lint v2.8.0
# Compatible with golangci-lint v2.13.1
# See: https://golangci-lint.run/usage/configuration/
version: "2"
+1 -1
View File
@@ -1,5 +1,5 @@
# Build stage
FROM --platform=$BUILDPLATFORM golang:1.26.4-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.27.0-alpine AS builder
# Declare automatic platform ARGs to make them available in build stage
# See https://docs.docker.com/reference/dockerfile#automatic-platform-args-in-the-global-scope
+1 -1
View File
@@ -243,7 +243,7 @@ vet:
lint:
@echo "Running golangci-lint..."
@which golangci-lint > /dev/null || (echo "golangci-lint not found. Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest" && exit 1)
@which golangci-lint > /dev/null || (echo "golangci-lint not found. Install with: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest" && exit 1)
golangci-lint run
tidy:
+1 -1
View File
@@ -2,7 +2,6 @@
<p style="margin-top: -10px; font-style: italic; color: #666;">Bose SoundTouch Toolkit</p>
[![Go Reference](https://pkg.go.dev/badge/github.com/gesellix/bose-soundtouch.svg)](https://pkg.go.dev/github.com/gesellix/bose-soundtouch)
[![Go Report Card](https://goreportcard.com/badge/github.com/gesellix/bose-soundtouch)](https://goreportcard.com/report/github.com/gesellix/bose-soundtouch)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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/))
---
+56
View File
@@ -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)
})
}
}
+1
View File
@@ -33,6 +33,7 @@ func main() {
allCommand(),
cloudCommand(),
localCommand(),
updateCheckCommand(),
},
}
if err := app.Run(os.Args); err != nil {
+259 -13
View File
@@ -15,6 +15,7 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/urfave/cli/v2"
"golang.org/x/term"
@@ -52,10 +53,12 @@ func setupCommand() *cli.Command {
setupRemoteServicesCmd(),
setupInstallCACmd(),
setupMigrateCmd(),
setupRevertCmd(),
setupRebootCmd(),
setupVerifyCmd(),
setupPlanCmd(),
setupPairCmd(),
setupSyncCmd(),
},
}
}
@@ -517,8 +520,13 @@ func setupSSHCheckCmd() *cli.Command {
if err != nil {
PrintError(fmt.Sprintf("port 22 not reachable: %v", err))
fmt.Println()
fmt.Println("Modern SoundTouch firmware (27.x) does not let us enable SSH from")
fmt.Println("telnet — those commands were removed. To enable SSH on the speaker:")
fmt.Println("Try enabling it over telnet first — this works on many (not all) FW 27.x")
fmt.Println("speakers via the port-17000 envswitch trick (#471):")
fmt.Println(" soundtouch-cli setup enable-ssh")
fmt.Println("For stubborn devices (ST Portable, CineMate 520) where the default")
fmt.Println("injection is accepted but sshd never starts, add --full-config.")
fmt.Println()
fmt.Println("If enable-ssh doesn't work on this device, fall back to the USB-stick method:")
fmt.Println(" 1. Format a FAT32 USB stick.")
fmt.Println(" 2. Create an empty file named `remote_services` at its root.")
fmt.Println(" 3. Plug the stick into the speaker (rear USB port) while it is on.")
@@ -541,17 +549,23 @@ func setupSSHCheckCmd() *cli.Command {
// runEnableSSHInjection runs the port-17000 SSH-enable injection over telnet,
// printing the device transcript as it goes. With fullConfig it sends the
// #515 sequence (all four config URLs with the injection on margeServerUrl, not
// just envswitch) and reboots afterwards; otherwise it sends the single-
// envswitch default that fires on the speaker's next boseurls check.
func runEnableSSHInjection(m *setup.Manager, host, serviceURL string, fullConfig bool) error {
// just envswitch), pausing commandDelay between each of the 6 steps (5
// commands + reboot) — see setup.DefaultTelnetCommandDelay for why the pause
// exists — then reboots; otherwise it sends the single-envswitch default that
// fires on the speaker's next boseurls check (no pause needed, it's one
// command).
func runEnableSSHInjection(m *setup.Manager, host, serviceURL string, fullConfig bool, commandDelay time.Duration) error {
var (
logs string
err error
)
if fullConfig {
fmt.Printf("Enabling SSH on %s via telnet :17000 (full #515 sequence: all four config URLs with the injection on margeServerUrl, then reboot)...\n", host)
logs, err = m.EnableSSHViaTelnetFullConfig(host, serviceURL)
// 6 steps total (5 commands + reboot), so 6 gaps between/around them.
fmt.Printf("Enabling SSH on %s via telnet :17000 (full #515 sequence: all four config URLs with "+
"the injection on margeServerUrl, %s between each of 6 steps — about %s before the reboot fires "+
"— then reboot)...\n", host, commandDelay, 6*commandDelay)
logs, err = m.EnableSSHViaTelnetFullConfig(host, serviceURL, commandDelay)
} else {
fmt.Printf("Enabling SSH on %s via telnet :17000 (runs on the speaker's next boseurls check, up to ~60s)...\n", host)
logs, err = m.EnableSSHViaTelnet(host, serviceURL)
@@ -570,6 +584,10 @@ func runEnableSSHInjection(m *setup.Manager, host, serviceURL string, fullConfig
return nil
}
if commandDelay > 0 {
time.Sleep(commandDelay)
}
fmt.Println("Rebooting the speaker to apply the new configuration...")
rlogs, rerr := m.Reboot(host, setup.RebootMethodTelnet)
@@ -585,6 +603,41 @@ func runEnableSSHInjection(m *setup.Manager, host, serviceURL string, fullConfig
return nil
}
// ensureMargeAccountPaired checks /info and pairs an unpaired device before
// the SSH-enable injection runs — see setup.EnsureMargeAccountPaired for why.
// Pairing failure is logged as a warning, not fatal: the claim that an
// unpaired device never polls margeServerUrl is not yet confirmed on every
// device this command targets, so the injection is still worth attempting
// even if the pairing step itself couldn't be verified.
func ensureMargeAccountPaired(m *setup.Manager, deviceIP, wantAccountID string) {
var t setup.TelnetClient
if m.NewTelnet != nil {
t = m.NewTelnet(deviceIP)
if dialErr := t.Dial(); dialErr != nil {
t = nil
} else {
defer func() { _ = t.Close() }()
}
}
accountID, alreadyPaired, logs, err := m.EnsureMargeAccountPaired(deviceIP, wantAccountID, t)
if logs != "" {
fmt.Print(logs)
}
switch {
case err != nil:
PrintWarning(fmt.Sprintf("Pairing check failed (%v) — continuing anyway; the SSH-enable injection may not "+
"fire on an unpaired device (#515).", err))
case alreadyPaired:
fmt.Printf("Device already paired (margeAccountUUID=%s).\n", accountID)
default:
fmt.Printf("Device was unpaired — paired it with generated account %s so margeServerUrl gets polled (#515).\n", accountID)
}
}
func setupEnableSSHCmd() *cli.Command {
return &cli.Command{
Name: "enable-ssh",
@@ -608,6 +661,24 @@ func setupEnableSSHCmd() *cli.Command {
Usage: "For stubborn devices (ST Portable, CineMate 520) where the default single-envswitch injection is accepted but sshd never starts: " +
"replicate the #515 manual sequence — write all four sys configuration URL keys with the SSH-enable injection on margeServerUrl (not just envswitch), then reboot",
},
&cli.DurationFlag{
Name: "command-delay",
Value: setup.DefaultTelnetCommandDelay,
Usage: "Only affects --full-config: pause between each of its 6 steps (5 commands + reboot). " +
"Raise this if the default doesn't work on your device; 0 sends everything back-to-back",
},
&cli.BoolFlag{
Name: "no-auto-pair",
Usage: "Skip the automatic pairing check: by default, enable-ssh reads /info first and pairs an unpaired " +
"(factory-reset) device with an account ID, since an unpaired device reportedly never " +
"polls margeServerUrl at all (#515) — the injection would have nothing to fire on otherwise",
},
&cli.StringFlag{
Name: "account",
Usage: "Only used when the device is unpaired and --no-auto-pair is not set: account ID to pair with " +
"(empty = generate a fresh 7-digit one). Use this if you already know which account this device " +
"should end up on (e.g. to match one already in the datastore) rather than getting a random one now",
},
&cli.BoolFlag{
Name: "no-reset-urls",
Usage: "Skip restoring clean boseurls after SSH is up (leaves the injected marge URL in place)",
@@ -641,7 +712,11 @@ func setupEnableSSHCmd() *cli.Command {
serviceURL = "https://aftertouch.invalid"
}
if err := runEnableSSHInjection(m, cfg.Host, serviceURL, c.Bool("full-config")); err != nil {
if !c.Bool("no-auto-pair") {
ensureMargeAccountPaired(m, cfg.Host, c.String("account"))
}
if err := runEnableSSHInjection(m, cfg.Host, serviceURL, c.Bool("full-config"), c.Duration("command-delay")); err != nil {
return err
}
@@ -941,6 +1016,116 @@ func promptBasicAuth() (string, string, error) {
return user, string(pass), nil
}
// setupSyncCmd wraps POST /api/setup/sync/{deviceId} — the same operation
// as the web UI's Devices → Sync Data button. It only reads from the
// speaker (presets, recents, sources) into AfterTouch's datastore; it never
// writes anything back to the speaker. Useful for scripting or reproducing
// what Sync does in isolation (see issue #614: Sync's own code cannot wipe
// the speaker's preset table, since it never sends anything back).
func setupSyncCmd() *cli.Command {
return &cli.Command{
Name: "sync",
Usage: "Pull presets/recents/sources from the speaker into AfterTouch's datastore (same as the web UI's \"Sync Data\" button)",
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{Name: "service-url", Required: true, Usage: "AfterTouch base URL"},
&cli.StringFlag{Name: "auth", Usage: "Basic-auth credentials for AfterTouch as user:pass (omit to be prompted on 401)"},
},
Action: func(c *cli.Context) error {
cfg := GetClientConfig(c)
serviceURL := strings.TrimRight(c.String("service-url"), "/")
if err := validateServiceURL(serviceURL); err != nil {
PrintError(err.Error())
return err
}
client, err := CreateSoundTouchClient(cfg)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
deviceInfo, err := client.GetDeviceInfo()
if err != nil {
PrintError(fmt.Sprintf("Failed to get device info from speaker: %v", err))
return err
}
if deviceInfo.DeviceID == "" {
err := fmt.Errorf("speaker at %s did not report a DeviceID", cfg.Host)
PrintError(err.Error())
return err
}
PrintDeviceHeader(fmt.Sprintf("Syncing %s into AfterTouch", deviceInfo.DeviceID), cfg.Host, cfg.Port)
if err := postSetupSync(serviceURL, deviceInfo.DeviceID, c.String("auth")); err != nil {
PrintError(err.Error())
return err
}
PrintSuccess(fmt.Sprintf("Synced presets, recents, and sources for %s.", deviceInfo.DeviceID))
return nil
},
}
}
// postSetupSync POSTs to AfterTouch's /api/setup/sync/{deviceId}, prompting
// for basic-auth credentials on 401 (matches fetchCACert's pattern).
func postSetupSync(serviceURL, deviceID, authFlag string) error {
endpoint := fmt.Sprintf("%s/api/setup/sync/%s", serviceURL, deviceID)
doRequest := func(user, pass string) (*http.Response, error) {
req, err := http.NewRequest(http.MethodPost, endpoint, nil)
if err != nil {
return nil, err
}
if user != "" {
req.SetBasicAuth(user, pass)
}
client := &http.Client{Timeout: 30 * time.Second}
return client.Do(req)
}
user, pass := splitAuth(authFlag)
resp, err := doRequest(user, pass)
if err != nil {
return fmt.Errorf("POST %s: %w", endpoint, err)
}
if resp.StatusCode == http.StatusUnauthorized {
_ = resp.Body.Close()
fmt.Printf("%s requires basic auth.\n", endpoint)
user, pass, err = promptBasicAuth()
if err != nil {
return err
}
resp, err = doRequest(user, pass)
if err != nil {
return fmt.Errorf("POST %s (with auth): %w", endpoint, err)
}
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("POST %s returned %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
func setupMigrateCmd() *cli.Command {
return &cli.Command{
Name: "migrate",
@@ -951,6 +1136,10 @@ func setupMigrateCmd() *cli.Command {
&cli.StringFlag{Name: "method", Value: string(setup.MigrationMethodTelnet), Usage: "telnet | hosts | resolv | xml"},
&cli.StringFlag{Name: "proxy-url", Usage: "Optional upstream proxy URL (for --method=xml)"},
&cli.BoolFlag{Name: "skip-preflight", Usage: "Skip the AfterTouch settings preflight (use when AfterTouch's settings endpoint is unreachable)"},
&cli.StringFlag{Name: "marge-url", Usage: "Override margeServerUrl instead of deriving it from --service-url (e.g. to restore the original Bose cloud URL). Applies to --method=telnet and --method=xml"},
&cli.StringFlag{Name: "stats-url", Usage: "Override statsServerUrl (telnet/xml)"},
&cli.StringFlag{Name: "sw-update-url", Usage: "Override swUpdateUrl (telnet/xml)"},
&cli.StringFlag{Name: "bmx-url", Usage: "Override bmxRegistryUrl (telnet/xml)"},
},
Action: func(c *cli.Context) error {
cfg := GetClientConfig(c)
@@ -962,6 +1151,13 @@ func setupMigrateCmd() *cli.Command {
return err
}
options := map[string]string{
"marge_url": c.String("marge-url"),
"stats_url": c.String("stats-url"),
"sw_update_url": c.String("sw-update-url"),
"bmx_url": c.String("bmx-url"),
}
m := setup.NewManager(serviceURL, nil, nil)
// For DNS-redirect methods check that AfterTouch's DNS listener
@@ -985,7 +1181,7 @@ func setupMigrateCmd() *cli.Command {
fmt.Printf("Migrating %s → %s using method=%s\n", cfg.Host, serviceURL, method)
logs, err := m.MigrateSpeaker(cfg.Host, serviceURL, c.String("proxy-url"), nil, method)
logs, err := m.MigrateSpeaker(cfg.Host, serviceURL, c.String("proxy-url"), options, method)
if logs != "" {
fmt.Print(logs)
}
@@ -1314,6 +1510,45 @@ func renderMigrationSummary(deviceIP, serviceURL string, s *setup.MigrationSumma
}
}
// setupRevertCmd wraps setup.Manager.RevertMigration — the same operation
// as the web UI's "Revert to Defaults" button (Migrate tab). Restores
// SoundTouchSdkPrivateCfg.xml, /etc/hosts, and /etc/resolv.conf from their
// .original backups, removes the AfterTouch DNS-hook artifacts, and strips
// just the AfterTouch-labeled cert out of the trust bundle. No --service-url
// needed: everything it touches already lives on the speaker.
//
// Deliberately out of scope (matches the web UI button): SSH/remote_services
// persistence (use `setup remote-services --remove`) and account pairing
// (use `account unpair`) — see #614 self-test notes for the full checklist.
func setupRevertCmd() *cli.Command {
return &cli.Command{
Name: "revert",
Usage: "Undo a migration: restore SoundTouchSdkPrivateCfg.xml/hosts/resolv.conf from backups and remove the AfterTouch CA cert",
Before: RequireHost,
Action: func(c *cli.Context) error {
cfg := GetClientConfig(c)
m := setup.NewManager("", nil, nil)
fmt.Printf("Reverting migration on %s...\n", cfg.Host)
logs, err := m.RevertMigration(cfg.Host)
if logs != "" {
fmt.Print(logs)
}
if err != nil {
PrintError(err.Error())
return err
}
PrintSuccess("Migration reverted. SSH access and account pairing are untouched by this — " +
"see `setup remote-services --remove` and `account unpair` if you want those cleared too.")
return nil
},
}
}
func setupRebootCmd() *cli.Command {
return &cli.Command{
Name: "reboot",
@@ -1740,7 +1975,7 @@ func setupPairCmd() *cli.Command {
Usage: "Pair the speaker with an account via WebSocket SETUP state machine",
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{Name: "account", Usage: "7-digit account ID (empty = generate)"},
&cli.StringFlag{Name: "account", Usage: "Account ID to pair with (empty = generate a fresh 7-digit one)"},
&cli.StringFlag{Name: "mode", Value: "full", Usage: "full (state machine) or bare (setMargeAccount only — experimental)"},
&cli.StringFlag{Name: "service-url", Value: "http://aftertouch.local:8000", Usage: "AfterTouch base URL (also populates <boseServer>/<updateServer> in setMargeAccount)"},
&cli.StringFlag{Name: "name", Usage: "Speaker name to set during pairing (empty = keep current)"},
@@ -1764,8 +1999,8 @@ func setupPairCmd() *cli.Command {
fmt.Printf("Generated account id: %s\n", accountID)
}
if !setup.IsValidAccountID(accountID) {
return fmt.Errorf("invalid account id %q: must be 7 digits", accountID)
if !datastore.IsSafeIdentifier(accountID) {
return fmt.Errorf("invalid account id %q: must be a non-empty, path-safe identifier", accountID)
}
switch mode {
@@ -1847,6 +2082,17 @@ func runPairBare(c *cli.Context, deviceIP, accountID string) error {
func runPairFull(c *cli.Context, deviceIP, accountID string) error {
m := setup.NewManager(c.String("service-url"), nil, nil)
needed, status, err := m.PreflightInitPlan(deviceIP)
if err != nil {
PrintError(fmt.Sprintf("preflight: %v", err))
return err
}
if !needed {
PrintSuccess(fmt.Sprintf("Device already configured (status=%s) — nothing to do.", status))
return nil
}
plan := setup.InitPlan{
DeviceIP: deviceIP,
ServiceURL: c.String("service-url"),
@@ -1860,7 +2106,7 @@ func runPairFull(c *cli.Context, deviceIP, accountID string) error {
ctx, cancel := context.WithTimeout(c.Context, 60*time.Second)
defer cancel()
_, err := m.ExecuteInitPlan(ctx, plan, func(e setup.StepEvent) {
_, err = m.ExecuteInitPlan(ctx, plan, func(e setup.StepEvent) {
switch e.Status {
case setup.StatusOK:
fmt.Printf("[%d] %s — ok\n", e.Kind, e.Name)
+42
View File
@@ -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)"
+56
View File
@@ -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)
})
}
}
+3
View File
@@ -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)
+2 -2
View File
@@ -88,7 +88,7 @@ go build -o soundtouch-player
./soundtouch-player -port 8888
# Connect to specific device
./soundtouch-player -host 192.0.2.100
./soundtouch-player --devices 192.0.2.100
```
### Command Line Options
@@ -137,7 +137,7 @@ device datastore).
The application automatically discovers SoundTouch devices using:
- **mDNS discovery** for local network devices
- **UPnP/SSDP discovery** as fallback
- **Manual device addition** via IP address
- **Configured devices** via `--devices`, retried whenever discovery runs
### Real-time Updates
The interface maintains WebSocket connections to each device for instant updates of:
+6 -1
View File
@@ -161,7 +161,7 @@ func main() {
log.Printf("Trusting AfterTouch service CA from %s", sanitizeLog(caPath))
}
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName)
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName, manualHosts...)
// Discover devices on startup
go func() {
@@ -170,6 +170,11 @@ func main() {
webApp.BroadcastDiscoveryStatus("starting", webApp.DeviceCount())
// Register configured devices immediately rather than waiting for
// the full mDNS/UPnP sweep below (bounded by cfg.DiscoveryTimeout,
// currently 10s) to complete. manualHosts are also folded into
// discoveryService's PreferredDevices so a host that's offline
// right now still gets retried on every subsequent discovery pass.
for _, host := range manualHosts {
webApp.AddDeviceByHost(host, 8090, "manual")
}
@@ -0,0 +1,117 @@
package main
import (
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
)
// TestAdminAreaAuthGate is the wiring-level regression test for #419: it
// exercises the real production router (setupRouter), not just the
// BasicAuthAdmin middleware in isolation, to pin two things at once:
// 1. /admin and /api/setup/* (and their /setup/* legacy aliases) are open
// by default and become gated once AdminAreaAuth is "enabled".
// 2. A handful of routes deliberately stay reachable WITHOUT credentials
// regardless of the gate: ca.crt/tts/speak/tts/config because
// soundtouch-cli/soundtouch-player call them directly (the whole reason
// mountSetupAPI was split into mountSetupAPIShared/mountSetupAPIAdmin),
// and /api/announcements because it specifically needs to reach
// operators who haven't set up credentials yet.
func TestAdminAreaAuthGate(t *testing.T) {
tempDir := t.TempDir()
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
// A real setup.Manager (with an actual CA) so /setup/ca.crt genuinely
// succeeds instead of failing on a nil dependency for an unrelated
// reason, which would make the "stays reachable" assertion meaningless.
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
_ = cm.EnsureCA()
sm := setup.NewManager("http://localhost:8000", ds, cm)
server := handlers.NewServer(ds, sm, "http://localhost:8000", true, false, false)
server.SetMgmtConfig("custom-admin", "custom-password")
r := setupRouter(server, nil, nil)
ts := httptest.NewServer(r)
defer ts.Close()
adminGatedPaths := []string{
"/admin",
"/setup/settings",
"/api/setup/settings",
}
alwaysUngatedPaths := []string{
"/setup/ca.crt",
"/api/setup/ca.crt",
"/setup/tts/config",
"/api/setup/tts/config",
"/api/announcements?target=admin",
}
t.Run("open by default (AdminAreaAuth unset)", func(t *testing.T) {
for _, path := range adminGatedPaths {
status := getStatus(t, ts.URL, path, "", "")
if status == http.StatusUnauthorized {
t.Errorf("%s: expected open access by default, got 401", path)
}
}
})
server.SetAdminAreaAuth("enabled")
defer server.SetAdminAreaAuth("")
t.Run("gated paths reject without credentials once enabled", func(t *testing.T) {
for _, path := range adminGatedPaths {
status := getStatus(t, ts.URL, path, "", "")
if status != http.StatusUnauthorized {
t.Errorf("%s: expected 401 without credentials once enabled, got %d", path, status)
}
}
})
t.Run("gated paths accept correct credentials once enabled", func(t *testing.T) {
for _, path := range adminGatedPaths {
status := getStatus(t, ts.URL, path, "custom-admin", "custom-password")
if status == http.StatusUnauthorized {
t.Errorf("%s: expected access with correct credentials, got 401", path)
}
}
})
t.Run("routes intentionally left outside the gate stay reachable without credentials", func(t *testing.T) {
for _, path := range alwaysUngatedPaths {
status := getStatus(t, ts.URL, path, "", "")
if status != http.StatusOK {
t.Errorf("%s: expected 200 without credentials even with the gate enabled, got %d", path, status)
}
}
})
}
func getStatus(t *testing.T, base, path, user, pass string) int {
t.Helper()
req, err := http.NewRequest(http.MethodGet, base+path, nil)
if err != nil {
t.Fatalf("Failed to build request for %s: %v", path, err)
}
if user != "" || pass != "" {
req.SetBasicAuth(user, pass)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Request to %s failed: %v", path, err)
}
defer res.Body.Close()
return res.StatusCode
}
+511 -264
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"io"
"log"
"math/rand"
"net"
"net/http"
"net/url"
@@ -32,6 +33,7 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
"github.com/gesellix/bose-soundtouch/pkg/service/stockholm"
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/urfave/cli/v2"
@@ -222,6 +224,258 @@ func logBufferCapacityFromEnv(defaultCap int) int {
return v
}
// serviceFlags is the full flag/env-var surface for soundtouch-service.
// Extracted to a package-level var (rather than inlined in main()'s
// cli.App literal) so tests can build a real *cli.Context against the
// exact same flags loadConfig reads, instead of hand-duplicating them.
var serviceFlags = []cli.Flag{
&cli.StringFlag{
Name: "port",
Aliases: []string{"p"},
Usage: "HTTP port to bind the service to",
Value: "8000",
EnvVars: []string{"PORT"},
},
&cli.StringFlag{
Name: "bind",
Usage: "Network interface to bind to",
EnvVars: []string{"BIND_ADDR"},
},
&cli.StringFlag{
Name: "data-dir",
Usage: "Directory for persistent data",
Value: "data",
EnvVars: []string{"DATA_DIR"},
},
&cli.StringFlag{
Name: "server-url",
Aliases: []string{"s"},
Usage: "External URL of this service",
EnvVars: []string{"SERVER_URL"},
},
&cli.StringFlag{
Name: "deployment-mode",
Usage: "Where this service runs: on-device, private-network, or public-network " +
"- informs the server-url fallback when --server-url isn't set",
EnvVars: []string{"DEPLOYMENT_MODE"},
},
&cli.StringFlag{
Name: "https-port",
Usage: "HTTPS port to bind the service to",
Value: "8443",
EnvVars: []string{"HTTPS_PORT"},
},
&cli.StringFlag{
Name: "https-server-url",
Aliases: []string{"S"},
Usage: "External HTTPS URL",
EnvVars: []string{"HTTPS_SERVER_URL"},
},
&cli.BoolFlag{
Name: "redact-logs",
Usage: "Redact sensitive data in proxy logs",
Value: true,
EnvVars: []string{"REDACT_PROXY_LOGS"},
},
&cli.BoolFlag{
Name: "log-bodies",
Usage: "Log full request/response bodies",
EnvVars: []string{"LOG_PROXY_BODY"},
},
&cli.BoolFlag{
Name: "record-interactions",
Usage: "Record HTTP interactions to disk",
Value: true,
EnvVars: []string{"RECORD_INTERACTIONS"},
},
&cli.BoolFlag{
Name: "discovery-enabled",
Usage: "Enable periodic device discovery",
Value: true,
EnvVars: []string{"DISCOVERY_ENABLED"},
},
&cli.StringFlag{
Name: "discovery-interval",
Usage: "Device discovery interval",
Value: "5m",
EnvVars: []string{"DISCOVERY_INTERVAL"},
},
&cli.BoolFlag{
Name: "update-check-enabled",
Usage: "Periodically check GitHub for a newer release (opt-in; the only network call this makes beyond speaker/provider traffic)",
Value: false,
EnvVars: []string{"UPDATE_CHECK_ENABLED"},
},
&cli.StringFlag{
Name: "update-check-interval",
Usage: "Update check interval",
Value: "24h",
EnvVars: []string{"UPDATE_CHECK_INTERVAL"},
},
&cli.BoolFlag{
Name: "dns-discovery",
Usage: "Enable DNS discovery server",
EnvVars: []string{"ENABLE_DNS_DISCOVERY"},
},
&cli.StringFlag{
Name: "dns-upstream",
Usage: "Upstream DNS server(s) for non-Bose queries (comma-separated). If empty, /etc/resolv.conf is used.",
Value: "",
EnvVars: []string{"DNS_UPSTREAM"},
},
&cli.StringFlag{
Name: "dns-bind",
Usage: "Bind address for the DNS discovery server",
Value: ":53",
EnvVars: []string{"DNS_BIND_ADDR"},
},
&cli.StringFlag{
Name: "spotify-client-id",
Usage: "Spotify OAuth client ID",
EnvVars: []string{"SPOTIFY_CLIENT_ID"},
},
&cli.StringFlag{
Name: "spotify-client-secret",
Usage: "Spotify OAuth client secret",
EnvVars: []string{"SPOTIFY_CLIENT_SECRET"},
},
&cli.StringFlag{
Name: "spotify-redirect-uri",
Usage: "Spotify OAuth redirect URI (defaults to <server-url>/mgmt/spotify/callback)",
EnvVars: []string{"SPOTIFY_REDIRECT_URI"},
},
&cli.StringFlag{
Name: "spotify-token-url",
Usage: "Spotify OAuth token URL (for testing)",
EnvVars: []string{"SPOTIFY_TOKEN_URL"},
},
&cli.StringFlag{
Name: "spotify-api-base",
Usage: "Spotify API base URL (for testing)",
EnvVars: []string{"SPOTIFY_API_BASE"},
},
&cli.StringFlag{
Name: "amazon-client-id",
Usage: "Amazon LWA OAuth client ID",
EnvVars: []string{"AMAZON_CLIENT_ID"},
},
&cli.StringFlag{
Name: "amazon-client-secret",
Usage: "Amazon LWA OAuth client secret",
EnvVars: []string{"AMAZON_CLIENT_SECRET"},
},
&cli.StringFlag{
Name: "amazon-redirect-uri",
Usage: "Amazon LWA OAuth redirect URI (defaults to <server-url>/mgmt/amazon/callback)",
EnvVars: []string{"AMAZON_REDIRECT_URI"},
},
&cli.StringFlag{
Name: "amazon-token-url",
Usage: "Amazon LWA token URL (for testing)",
EnvVars: []string{"AMAZON_TOKEN_URL"},
},
&cli.StringFlag{
Name: "amazon-profile-url",
Usage: "Amazon LWA profile URL (for testing)",
EnvVars: []string{"AMAZON_PROFILE_URL"},
},
&cli.StringFlag{
Name: "tunein-opml-url",
Usage: "TuneIn OPML base URL, covering Tune.ashx/describe.ashx/navigate (for testing / local mock; defaults to opml.radiotime.com)",
EnvVars: []string{"TUNEIN_OPML_URL"},
},
&cli.StringFlag{
Name: "tunein-api-url",
Usage: "TuneIn API base URL, covering search and profile contents (for testing / local mock; defaults to api.radiotime.com)",
EnvVars: []string{"TUNEIN_API_URL"},
},
&cli.StringFlag{
Name: "tts-provider",
Usage: "Text-to-speech provider: 'translate' (Google Translate, no credentials, default) or 'google-cloud' (Google Cloud TTS, needs an API key). Empty falls back to translate; leave unset to let a value saved in the settings UI take effect",
EnvVars: []string{"TTS_PROVIDER"},
},
&cli.StringFlag{
Name: "tts-google-api-key",
Usage: "Google Cloud Text-to-Speech API key (required when --tts-provider=google-cloud)",
EnvVars: []string{"TTS_GOOGLE_API_KEY"},
},
&cli.StringFlag{
Name: "tts-google-endpoint",
Usage: "Google Cloud TTS synthesize endpoint override (for testing)",
EnvVars: []string{"TTS_GOOGLE_ENDPOINT"},
},
&cli.StringFlag{
Name: "tts-language",
Usage: "Default TTS language code. Provider-specific: 'EN'/'DE' for translate, BCP-47 like 'en-US' for google-cloud",
EnvVars: []string{"TTS_LANGUAGE"},
},
&cli.StringFlag{
Name: "tts-voice",
Usage: "Default Google Cloud TTS voice name (e.g. en-US-Neural2-C); ignored by the translate provider",
EnvVars: []string{"TTS_VOICE"},
},
&cli.StringFlag{
Name: "tts-app-key",
Usage: "Bose /speaker app_key used to play TTS notifications on speakers",
EnvVars: []string{"TTS_APP_KEY"},
},
&cli.IntFlag{
Name: "tts-volume",
Usage: "Default TTS playback volume (0-100, 0 = keep current volume)",
Value: 0,
EnvVars: []string{"TTS_VOLUME"},
},
&cli.StringFlag{
Name: "mgmt-username",
Usage: "Management API username for HTTP Basic Auth",
Value: "admin",
EnvVars: []string{"MGMT_USERNAME"},
},
&cli.StringFlag{
Name: "mgmt-password",
Usage: "Management API password for HTTP Basic Auth",
Value: "change_me!",
EnvVars: []string{"MGMT_PASSWORD"},
},
&cli.StringFlag{
Name: "base-url",
Usage: "External base URL for OAuth callbacks behind reverse proxy",
EnvVars: []string{"BASE_URL"},
},
&cli.StringSliceFlag{
Name: "internal-paths",
Usage: "Paths for internal requests (comma-separated or multiple flags)",
EnvVars: []string{"INTERNAL_PATHS"},
},
&cli.StringSliceFlag{
Name: "tls-extra-host",
Usage: "Additional DNS name or IP to include in the server TLS certificate SAN list (repeatable)",
EnvVars: []string{"TLS_EXTRA_HOST"},
},
&cli.BoolFlag{
Name: "migration-enabled",
Usage: "Enable device directory migration from serial to MAC-based structure",
Value: true,
EnvVars: []string{"MIGRATION_ENABLED"},
},
&cli.BoolFlag{
Name: "migration-dry-run",
Usage: "Log what would be migrated without actually doing it",
EnvVars: []string{"MIGRATION_DRY_RUN"},
},
&cli.StringFlag{
Name: "stockholm-dir",
Usage: "Path to the extracted Stockholm frontend directory (enables Stockholm UI when set)",
EnvVars: []string{"STOCKHOLM_DIR"},
},
&cli.StringFlag{
Name: "stockholm-base-path",
Usage: "URL prefix under which the Stockholm UI is served (e.g. /stockholm). Empty serves at root.",
Value: "/stockholm",
EnvVars: []string{"STOCKHOLM_BASE_PATH"},
},
}
func main() {
updateBuildInfo()
@@ -248,237 +502,13 @@ func main() {
Name: "Tobias Gesellchen, and the Bose-SoundTouch Contributors",
},
},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "port",
Aliases: []string{"p"},
Usage: "HTTP port to bind the service to",
Value: "8000",
EnvVars: []string{"PORT"},
},
&cli.StringFlag{
Name: "bind",
Usage: "Network interface to bind to",
EnvVars: []string{"BIND_ADDR"},
},
&cli.StringFlag{
Name: "data-dir",
Usage: "Directory for persistent data",
Value: "data",
EnvVars: []string{"DATA_DIR"},
},
&cli.StringFlag{
Name: "server-url",
Aliases: []string{"s"},
Usage: "External URL of this service",
EnvVars: []string{"SERVER_URL"},
},
&cli.StringFlag{
Name: "https-port",
Usage: "HTTPS port to bind the service to",
Value: "8443",
EnvVars: []string{"HTTPS_PORT"},
},
&cli.StringFlag{
Name: "https-server-url",
Aliases: []string{"S"},
Usage: "External HTTPS URL",
EnvVars: []string{"HTTPS_SERVER_URL"},
},
&cli.BoolFlag{
Name: "redact-logs",
Usage: "Redact sensitive data in proxy logs",
Value: true,
EnvVars: []string{"REDACT_PROXY_LOGS"},
},
&cli.BoolFlag{
Name: "log-bodies",
Usage: "Log full request/response bodies",
EnvVars: []string{"LOG_PROXY_BODY"},
},
&cli.BoolFlag{
Name: "record-interactions",
Usage: "Record HTTP interactions to disk",
Value: true,
EnvVars: []string{"RECORD_INTERACTIONS"},
},
&cli.BoolFlag{
Name: "discovery-enabled",
Usage: "Enable periodic device discovery",
Value: true,
EnvVars: []string{"DISCOVERY_ENABLED"},
},
&cli.StringFlag{
Name: "discovery-interval",
Usage: "Device discovery interval",
Value: "5m",
EnvVars: []string{"DISCOVERY_INTERVAL"},
},
&cli.BoolFlag{
Name: "dns-discovery",
Usage: "Enable DNS discovery server",
EnvVars: []string{"ENABLE_DNS_DISCOVERY"},
},
&cli.StringFlag{
Name: "dns-upstream",
Usage: "Upstream DNS server(s) for non-Bose queries (comma-separated). If empty, /etc/resolv.conf is used.",
Value: "",
EnvVars: []string{"DNS_UPSTREAM"},
},
&cli.StringFlag{
Name: "dns-bind",
Usage: "Bind address for the DNS discovery server",
Value: ":53",
EnvVars: []string{"DNS_BIND_ADDR"},
},
&cli.StringFlag{
Name: "spotify-client-id",
Usage: "Spotify OAuth client ID",
EnvVars: []string{"SPOTIFY_CLIENT_ID"},
},
&cli.StringFlag{
Name: "spotify-client-secret",
Usage: "Spotify OAuth client secret",
EnvVars: []string{"SPOTIFY_CLIENT_SECRET"},
},
&cli.StringFlag{
Name: "spotify-redirect-uri",
Usage: "Spotify OAuth redirect URI (defaults to <server-url>/mgmt/spotify/callback)",
EnvVars: []string{"SPOTIFY_REDIRECT_URI"},
},
&cli.StringFlag{
Name: "spotify-token-url",
Usage: "Spotify OAuth token URL (for testing)",
EnvVars: []string{"SPOTIFY_TOKEN_URL"},
},
&cli.StringFlag{
Name: "spotify-api-base",
Usage: "Spotify API base URL (for testing)",
EnvVars: []string{"SPOTIFY_API_BASE"},
},
&cli.StringFlag{
Name: "amazon-client-id",
Usage: "Amazon LWA OAuth client ID",
EnvVars: []string{"AMAZON_CLIENT_ID"},
},
&cli.StringFlag{
Name: "amazon-client-secret",
Usage: "Amazon LWA OAuth client secret",
EnvVars: []string{"AMAZON_CLIENT_SECRET"},
},
&cli.StringFlag{
Name: "amazon-redirect-uri",
Usage: "Amazon LWA OAuth redirect URI (defaults to <server-url>/mgmt/amazon/callback)",
EnvVars: []string{"AMAZON_REDIRECT_URI"},
},
&cli.StringFlag{
Name: "amazon-token-url",
Usage: "Amazon LWA token URL (for testing)",
EnvVars: []string{"AMAZON_TOKEN_URL"},
},
&cli.StringFlag{
Name: "amazon-profile-url",
Usage: "Amazon LWA profile URL (for testing)",
EnvVars: []string{"AMAZON_PROFILE_URL"},
},
&cli.StringFlag{
Name: "tunein-opml-url",
Usage: "TuneIn OPML base URL, covering Tune.ashx/describe.ashx/navigate (for testing / local mock; defaults to opml.radiotime.com)",
EnvVars: []string{"TUNEIN_OPML_URL"},
},
&cli.StringFlag{
Name: "tunein-api-url",
Usage: "TuneIn API base URL, covering search and profile contents (for testing / local mock; defaults to api.radiotime.com)",
EnvVars: []string{"TUNEIN_API_URL"},
},
&cli.StringFlag{
Name: "tts-provider",
Usage: "Text-to-speech provider: 'translate' (Google Translate, no credentials, default) or 'google-cloud' (Google Cloud TTS, needs an API key). Empty falls back to translate; leave unset to let a value saved in the settings UI take effect",
EnvVars: []string{"TTS_PROVIDER"},
},
&cli.StringFlag{
Name: "tts-google-api-key",
Usage: "Google Cloud Text-to-Speech API key (required when --tts-provider=google-cloud)",
EnvVars: []string{"TTS_GOOGLE_API_KEY"},
},
&cli.StringFlag{
Name: "tts-google-endpoint",
Usage: "Google Cloud TTS synthesize endpoint override (for testing)",
EnvVars: []string{"TTS_GOOGLE_ENDPOINT"},
},
&cli.StringFlag{
Name: "tts-language",
Usage: "Default TTS language code. Provider-specific: 'EN'/'DE' for translate, BCP-47 like 'en-US' for google-cloud",
EnvVars: []string{"TTS_LANGUAGE"},
},
&cli.StringFlag{
Name: "tts-voice",
Usage: "Default Google Cloud TTS voice name (e.g. en-US-Neural2-C); ignored by the translate provider",
EnvVars: []string{"TTS_VOICE"},
},
&cli.StringFlag{
Name: "tts-app-key",
Usage: "Bose /speaker app_key used to play TTS notifications on speakers",
EnvVars: []string{"TTS_APP_KEY"},
},
&cli.IntFlag{
Name: "tts-volume",
Usage: "Default TTS playback volume (0-100, 0 = keep current volume)",
Value: 0,
EnvVars: []string{"TTS_VOLUME"},
},
&cli.StringFlag{
Name: "mgmt-username",
Usage: "Management API username for HTTP Basic Auth",
Value: "admin",
EnvVars: []string{"MGMT_USERNAME"},
},
&cli.StringFlag{
Name: "mgmt-password",
Usage: "Management API password for HTTP Basic Auth",
Value: "change_me!",
EnvVars: []string{"MGMT_PASSWORD"},
},
&cli.StringFlag{
Name: "base-url",
Usage: "External base URL for OAuth callbacks behind reverse proxy",
EnvVars: []string{"BASE_URL"},
},
&cli.StringSliceFlag{
Name: "internal-paths",
Usage: "Paths for internal requests (comma-separated or multiple flags)",
EnvVars: []string{"INTERNAL_PATHS"},
},
&cli.StringSliceFlag{
Name: "tls-extra-host",
Usage: "Additional DNS name or IP to include in the server TLS certificate SAN list (repeatable)",
EnvVars: []string{"TLS_EXTRA_HOST"},
},
&cli.BoolFlag{
Name: "migration-enabled",
Usage: "Enable device directory migration from serial to MAC-based structure",
Value: true,
EnvVars: []string{"MIGRATION_ENABLED"},
},
&cli.BoolFlag{
Name: "migration-dry-run",
Usage: "Log what would be migrated without actually doing it",
EnvVars: []string{"MIGRATION_DRY_RUN"},
},
&cli.StringFlag{
Name: "stockholm-dir",
Usage: "Path to the extracted Stockholm frontend directory (enables Stockholm UI when set)",
EnvVars: []string{"STOCKHOLM_DIR"},
},
&cli.StringFlag{
Name: "stockholm-base-path",
Usage: "URL prefix under which the Stockholm UI is served (e.g. /stockholm). Empty serves at root.",
Value: "/stockholm",
EnvVars: []string{"STOCKHOLM_BASE_PATH"},
},
},
Flags: serviceFlags,
Action: func(c *cli.Context) error {
config := loadConfig(c)
config, err := loadConfig(c)
if err != nil {
return err
}
ds := initDataStore(config.dataDir)
// Detect a genuinely fresh data dir by the ABSENCE of settings.json,
@@ -503,13 +533,11 @@ func main() {
persisted = createDefaultSettings(ds, config)
}
// Recalculate domains if settings changed
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "localhost"
}
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname, config.tlsExtraHosts)
// Recalculate domains if settings changed. Reuses the same mode-aware
// fallback host loadConfig already resolved, rather than a raw
// os.Hostname() call, so an on-device install doesn't leak its
// unresolvable variant codename back in here (see issue #546).
config.domains = getDomains(config.serverURL, config.httpsServerURL, config.hostname, config.tlsExtraHosts)
cm := initCertificateManager(config.dataDir, config.hostname)
sm := setup.NewManager(config.serverURL, ds, cm)
@@ -523,12 +551,18 @@ func main() {
server.SetExpectedHosts(config.domains)
server.SetVersionInfo(version, commit, date, repoURL)
server.SetDiscoverySettings(config.discoveryInterval, config.discoveryEnabled)
server.SetUpdateCheckSettings(config.updateCheckInterval, config.updateCheckEnabled)
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
server.SetInternalPaths(persisted.InternalPaths)
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
server.SetAmazonConfig(config.amazonClientID, config.amazonClientSecret, config.amazonRedirectURI)
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
// Invalid values (e.g. a hand-edited settings.json) fall back to the
// unset default rather than failing startup.
adminAreaAuth, _ := handlers.NormalizeAdminAreaAuth(persisted.AdminAreaAuth)
server.SetAdminAreaAuth(adminAreaAuth)
initMusicServices(config, server)
initTTSService(config, server)
@@ -595,6 +629,10 @@ func main() {
startDeviceDiscovery(server)
updateChecker := updatecheck.NewChecker(ds, "gesellix/Bose-SoundTouch", version)
server.SetUpdateChecker(updateChecker)
startUpdateCheck(server, updateChecker)
var stockholmHandler *stockholm.Handler
if config.stockholmDir != "" {
@@ -698,6 +736,8 @@ type serviceConfig struct {
tlsExtraHosts []string
discoveryEnabled bool
discoveryInterval time.Duration
updateCheckEnabled bool
updateCheckInterval time.Duration
domains []string
spotifyClientID string
spotifyClientSecret string
@@ -726,7 +766,32 @@ type serviceConfig struct {
stockholmBasePath string
}
func loadConfig(c *cli.Context) serviceConfig {
// resolveFallbackHost picks the host used to guess a server URL when
// --server-url/SERVER_URL isn't set, based on where this service runs.
// On-device (running on the speaker's own Linux) is the one case where
// os.Hostname() is guaranteed useless: it returns the speaker's internal
// variant codename (e.g. "spotty", "mojo"), which nothing can resolve, not
// even the speaker itself (see issue #546). warnOnUse reports whether
// falling back to the returned host is risky enough to warrant a startup
// warning.
func resolveFallbackHost(deploymentMode string) (host string, warnOnUse bool) {
switch deploymentMode {
case "on-device":
return "localhost", false
case "public-network":
// Caller must refuse to guess a publicly reachable address.
return "", false
default: // "private-network", "", or any unrecognized value: today's behavior.
h, _ := os.Hostname()
if h == "" {
h = "localhost"
}
return strings.ToLower(h), true
}
}
func loadConfig(c *cli.Context) (serviceConfig, error) {
port := c.String("port")
bindAddr := c.String("bind")
@@ -737,16 +802,23 @@ func loadConfig(c *cli.Context) serviceConfig {
dataDir := c.String("data-dir")
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "localhost"
}
hostname = strings.ToLower(hostname)
deploymentMode := c.String("deployment-mode")
fallbackHost, warnOnFallback := resolveFallbackHost(deploymentMode)
serverURL := c.String("server-url")
if serverURL == "" {
serverURL = "http://" + hostname + ":" + port
if deploymentMode == "public-network" {
return serviceConfig{}, fmt.Errorf(
"--server-url (or SERVER_URL) is required when --deployment-mode=public-network; refusing to guess a public address")
}
serverURL = "http://" + fallbackHost + ":" + port
if warnOnFallback {
log.Printf("Warning: --server-url not set; defaulting to %s using this host's own hostname. "+
"If your SoundTouch speakers can't reach this address, set --server-url/SERVER_URL explicitly, "+
"or pass --deployment-mode=on-device if this runs on the speaker itself.", sanitizeLog(serverURL))
}
}
// Strip a trailing slash so it cannot leak into the BMX registry base or the
// margeServerUrl/bmxRegistryUrl pushed to speakers during migration.
@@ -761,14 +833,14 @@ func loadConfig(c *cli.Context) serviceConfig {
// The HTTPS URL is an override (from the flag/env); when empty it is
// derived from serverURL + https port so one setting (Target Domain)
// drives both. httpsDefaultURL is the hostname-based fallback used
// drives both. httpsDefaultURL is the same mode-aware fallback used
// before a Target Domain is configured.
httpsOverride := c.String("https-server-url")
httpsDefaultURL := "https://" + hostname + ":" + httpsPort
httpsDefaultURL := "https://" + fallbackHost + ":" + httpsPort
httpsServerURL := handlers.DeriveHTTPSURL(serverURL, httpsOverride, httpsPort, httpsDefaultURL)
tlsExtraHosts := c.StringSlice("tls-extra-host")
domains := getDomains(serverURL, httpsServerURL, hostname, tlsExtraHosts)
domains := getDomains(serverURL, httpsServerURL, fallbackHost, tlsExtraHosts)
redact := c.Bool("redact-logs")
logBody := c.Bool("log-bodies")
@@ -788,6 +860,16 @@ func loadConfig(c *cli.Context) serviceConfig {
discoveryInterval = 5 * time.Minute
}
updateCheckEnabled := c.Bool("update-check-enabled")
updateCheckIntervalStr := c.String("update-check-interval")
updateCheckInterval, err := time.ParseDuration(updateCheckIntervalStr)
if err != nil {
log.Printf("Warning: Failed to parse update check interval %s, using default 24h: %v", sanitizeLog(updateCheckIntervalStr), err)
updateCheckInterval = 24 * time.Hour
}
spotifyClientID := c.String("spotify-client-id")
spotifyClientSecret := c.String("spotify-client-secret")
spotifyRedirectURI := c.String("spotify-redirect-uri")
@@ -820,7 +902,7 @@ func loadConfig(c *cli.Context) serviceConfig {
bindAddr: bindAddr,
addr: addr,
dataDir: dataDir,
hostname: hostname,
hostname: fallbackHost,
serverURL: serverURL,
httpsServerURL: httpsServerURL,
httpsOverride: httpsOverride,
@@ -837,6 +919,8 @@ func loadConfig(c *cli.Context) serviceConfig {
tlsExtraHosts: tlsExtraHosts,
discoveryEnabled: discoveryEnabled,
discoveryInterval: discoveryInterval,
updateCheckEnabled: updateCheckEnabled,
updateCheckInterval: updateCheckInterval,
domains: domains,
spotifyClientID: spotifyClientID,
spotifyClientSecret: spotifyClientSecret,
@@ -863,7 +947,7 @@ func loadConfig(c *cli.Context) serviceConfig {
migrationDryRun: migrationDryRun,
stockholmDir: stockholmDir,
stockholmBasePath: stockholmBasePath,
}
}, nil
}
func getDomains(serverURL, httpsServerURL, hostname string, extraHosts []string) []string {
@@ -984,6 +1068,22 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
}
}
// Installs upgraded from a build that predates the Settings-page toggle
// have no update_check_* keys at all, and an absent JSON bool decodes as
// false — taking it at face value would silently switch the check off for
// everyone who had opted in via UPDATE_CHECK_ENABLED. The interval is
// always written together with the flag (createDefaultSettings and
// HandleUpdateSettings both set both, and a time.Duration never
// stringifies to ""), so a non-empty interval is the marker for
// "settings.json genuinely carries an update-check preference".
if persisted.UpdateCheckInterval != "" {
config.updateCheckEnabled = persisted.UpdateCheckEnabled
if d, durErr := time.ParseDuration(persisted.UpdateCheckInterval); durErr == nil {
config.updateCheckInterval = d
}
}
config.redact = persisted.RedactLogs
config.logBody = persisted.LogBodies
config.record = persisted.RecordInteractions
@@ -1101,10 +1201,15 @@ func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datast
RecordInteractions: config.record,
DiscoveryEnabled: config.discoveryEnabled,
DiscoveryInterval: config.discoveryInterval.String(),
DNSEnabled: config.dnsEnabled,
DNSUpstream: strings.Split(config.dnsUpstream, ","),
DNSBindAddr: config.dnsBind,
InternalPaths: config.internalPaths,
// Seed the update-check preference from the CLI/env flags so a fresh
// install's settings.json matches what the operator asked for (and so
// the Settings page shows it) instead of silently reverting to off.
UpdateCheckEnabled: config.updateCheckEnabled,
UpdateCheckInterval: config.updateCheckInterval.String(),
DNSEnabled: config.dnsEnabled,
DNSUpstream: strings.Split(config.dnsUpstream, ","),
DNSBindAddr: config.dnsBind,
InternalPaths: config.internalPaths,
Shortcuts: map[string]int{
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
"/sw.js": http.StatusNotFound,
@@ -1193,6 +1298,123 @@ func startDeviceDiscovery(server *handlers.Server) {
}()
}
// updateCheckPollTick is how often the background update-check goroutine
// re-reads the live settings. It is deliberately much shorter than the
// check interval itself: sleeping a full (possibly 24h) interval between
// reads would make flipping the Settings-page toggle on appear to do
// nothing for up to a day.
const updateCheckPollTick = time.Minute
// shouldRunUpdateCheckNow reports whether the background goroutine should
// perform a real GitHub request on this poll tick. Pure/testable — no
// sleeping, no I/O.
//
// A zero interval is treated as "don't check": with interval 0 every tick
// would look due (shouldCheckImmediately), so an enabled check would hit
// GitHub once a minute forever. HandleUpdateSettings also refuses to keep
// the check enabled with a zero interval; this is the same guard for values
// that arrive via the CLI flag or a hand-edited settings.json.
func shouldRunUpdateCheckNow(
enabled bool,
lastCheckedAt time.Time,
interval time.Duration,
lastErrorAt, now time.Time,
) bool {
return enabled &&
interval > 0 &&
shouldCheckImmediately(lastCheckedAt, interval, now) &&
!shouldSkipDueToBackoff(lastErrorAt, now)
}
// startUpdateCheck runs the opt-in periodic check against GitHub Releases in
// the background (#591, _/i591/design-update-check.md). Unlike the original
// v1 design, enabled/interval are now live-reloadable from Settings (see
// handlers.Server.SetUpdateCheckSettings) — so this goroutine always runs,
// mirroring startDeviceDiscovery's live-settings pattern, and re-reads the
// current settings every updateCheckPollTick. It only performs the actual
// GitHub request when the check is enabled and the configured interval has
// elapsed since the last check, so "always running" does not mean "always
// talking to GitHub": with the check disabled it does nothing but wake up
// once a minute and go back to sleep.
func startUpdateCheck(server *handlers.Server, checker *updatecheck.Checker) {
go func() {
time.Sleep(randomJitter(5 * time.Minute))
lastResult := checker.LastResult()
lastLoggedVersion := lastResult.LatestVersion
var lastErrorAt time.Time
for {
interval, enabled := server.GetUpdateCheckSettings()
if shouldRunUpdateCheckNow(enabled, lastResult.CheckedAt, interval, lastErrorAt, time.Now()) {
lastErrorAt, lastLoggedVersion = runUpdateCheckTick(checker, lastLoggedVersion)
lastResult = checker.LastResult()
}
time.Sleep(updateCheckPollTick)
}
}()
}
// randomJitter returns a random duration in [0, upperBound) — the startup
// delay so many installs restarting together (e.g. after a Docker image
// bump) don't all hit GitHub at once. Not a security-sensitive use of
// randomness.
func randomJitter(upperBound time.Duration) time.Duration {
if upperBound <= 0 {
return 0
}
// nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used
return time.Duration(rand.Int63n(int64(upperBound))) //nolint:gosec
}
// shouldCheckImmediately reports whether a check should run right at
// startup (after jitter) rather than waiting a full interval: true when
// there's no persisted last-check time, or it's stale (older than one
// interval). Pure/testable — no sleeping.
func shouldCheckImmediately(lastCheckedAt time.Time, interval time.Duration, now time.Time) bool {
return lastCheckedAt.IsZero() || now.Sub(lastCheckedAt) >= interval
}
// shouldSkipDueToBackoff reports whether a tick should be skipped because
// the last attempt failed less than an hour ago — so a short
// UPDATE_CHECK_INTERVAL doesn't hammer GitHub while it's erroring. A zero
// lastErrorAt means "no recent failure", never skip. Pure/testable.
func shouldSkipDueToBackoff(lastErrorAt, now time.Time) bool {
return !lastErrorAt.IsZero() && now.Sub(lastErrorAt) < time.Hour
}
// logUpdateIfNewlyAvailable logs once when result reports a version newer
// than lastLoggedVersion, and returns the version to remember as "already
// logged" — unchanged when there's nothing new, so a persistently-available
// update doesn't spam the log every tick. Pure/testable.
func logUpdateIfNewlyAvailable(result updatecheck.Result, lastLoggedVersion string) string {
if result.Available && result.LatestVersion != "" && result.LatestVersion != lastLoggedVersion {
log.Printf("[UpdateCheck] Update available: %s (current %s) — %s",
result.LatestVersion, result.CurrentVersion, result.ReleaseURL)
return result.LatestVersion
}
return lastLoggedVersion
}
// runUpdateCheckTick performs one check, logs on failure, and returns the
// updated (lastErrorAt, lastLoggedVersion) pair for the caller to carry
// into the next iteration.
func runUpdateCheckTick(checker *updatecheck.Checker, lastLoggedVersion string) (time.Time, string) {
result, err := checker.CheckNow(context.Background())
if err != nil {
log.Printf("[UpdateCheck] check failed: %v", err)
return time.Now(), lastLoggedVersion
}
return time.Time{}, logUpdateIfNewlyAvailable(result, lastLoggedVersion)
}
// newEmbeddedWebApp builds the soundtouch-player application for embedding in the
// service router: release metadata from the build vars, the service's public
// ServiceURL (used by Play URL for speaker-fetched stream URLs and shown in the
@@ -1290,8 +1512,12 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w
r.Use(server.RecordMiddleware)
r.Get("/", server.HandleRoot)
r.Get("/admin", server.HandleAdmin)
r.With(server.BasicAuthAdmin()).Get("/admin", server.HandleAdmin)
r.Get("/health", server.HandleHealth)
// Deliberately not behind BasicAuthAdmin — see HandleListAnnouncements'
// doc comment. #419.
r.Get("/api/announcements", server.HandleListAnnouncements)
r.Post("/api/announcements/{id}/dismiss", server.HandleDismissAnnouncement)
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
// The favicon lives in the embedded web/img bundle, not under
// static/media — HandleMedia would 404. HandleWeb serves from
@@ -1577,7 +1803,23 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w
// /api/setup (new canonical) from one shared registration. The Stockholm
// setup-wizard static catch-all is a frontend concern and stays under /setup
// only — /api/setup serves data only.
mountSetupAPI := func(r chi.Router) {
//
// Split in two: mountSetupAPIShared is reachable regardless of
// AdminAreaAuth — soundtouch-cli and the embedded player call these
// directly without Management API credentials (ca.crt for `setup
// install-ca`, tts/speak+tts/config for the Play URL / TTS integration
// surface). mountSetupAPIAdmin is everything else — genuinely admin-UI-only,
// gated by BasicAuthAdmin() once #419's admin-area toggle is enabled.
mountSetupAPIShared := func(r chi.Router) {
r.Get("/ca.crt", server.HandleGetCACert)
// TTS lives under /setup (LAN-trust, like the rest of the integration
// surface and Play URL), not /mgmt: the API key is already configured
// via /setup/settings, and -web/CLI reach this without mgmt credentials.
r.Post("/tts/speak", server.HandleTTSSpeak)
r.Get("/tts/config", server.HandleTTSConfig)
}
mountSetupAPIAdmin := func(r chi.Router) {
r.Get("/devices", server.HandleListDiscoveredDevices)
r.Post("/devices", server.HandleAddManualDevice)
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
@@ -1585,11 +1827,6 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
r.Get("/settings", server.HandleGetSettings)
r.Post("/settings", server.HandleUpdateSettings)
// TTS lives under /setup (LAN-trust, like the rest of the integration
// surface and Play URL), not /mgmt: the API key is already configured
// via /setup/settings, and -web/CLI reach this without mgmt credentials.
r.Post("/tts/speak", server.HandleTTSSpeak)
r.Get("/tts/config", server.HandleTTSConfig)
r.Get("/info/{deviceId}", server.HandleGetDeviceInfo)
r.Get("/summary/{deviceId}", server.HandleGetMigrationSummary)
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
@@ -1611,7 +1848,6 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
r.Post("/test-dns/{deviceId}", server.HandleTestDNSRedirection)
r.Get("/ca.crt", server.HandleGetCACert)
r.Get("/logging-settings", server.HandleGetLoggingSettings)
r.Post("/logging-settings", server.HandleUpdateLoggingSettings)
r.Get("/version", server.HandleGetVersionInfo)
@@ -1643,12 +1879,19 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w
// Stockholm wizard catch-all below is frontend, not a deprecated API path.
r.Group(func(r chi.Router) {
r.Use(server.DeprecatedRouteMiddleware)
mountSetupAPI(r)
mountSetupAPIShared(r)
})
r.Group(func(r chi.Router) {
r.Use(server.DeprecatedRouteMiddleware)
r.Use(server.BasicAuthAdmin())
mountSetupAPIAdmin(r)
})
// Serve Stockholm setup wizard pages for paths not matched by the
// management API. The Stockholm frontend has a setup/ directory that must
// be accessible at /setup/*. Frontend-only — not mirrored under /api/setup.
// Not gated by AdminAreaAuth: Stockholm is a separate, off-by-default
// (--stockholm-dir) legacy wizard, out of scope for #419.
if stockholmHandler != nil {
r.Get("/*", stockholmHandler.HandleStatic)
r.Get("/", stockholmHandler.HandleStatic)
@@ -1656,7 +1899,11 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w
})
r.Route("/api/setup", func(r chi.Router) {
mountSetupAPI(r)
mountSetupAPIShared(r)
r.Group(func(r chi.Router) {
r.Use(server.BasicAuthAdmin())
mountSetupAPIAdmin(r)
})
})
// Embedded web UI: control API under /api/control and the SPA under /app
+134
View File
@@ -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 {
+2 -5
View File
@@ -39,11 +39,8 @@ func TestPrintRoutes(t *testing.T) {
// Now we might have "soundtouch-service.setupRouter.func1"
// or "command-line-arguments.setupRouter.func1"
// or "main.setupRouter.func1"
// Let's remove the first part if it's a known varying package name
if idx := strings.Index(handlerName, "setupRouter"); idx != -1 {
handlerName = handlerName[idx:]
}
// In case it's not setupRouter but still has a package prefix
// Remove the leading package/binary-name segment(s), whatever form
// they take.
for {
dotIdx := strings.Index(handlerName, ".")
if dotIdx == -1 {
+5 -1
View File
@@ -36,6 +36,7 @@ GET /accounts/{account}/devices/{device}/recents handlers.(
GET /accounts/{account}/full handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/sources handlers.(*Server).HandleUnsupported-fm
GET /admin handlers.(*Server).HandleAdmin-fm
GET /api/announcements handlers.(*Server).HandleListAnnouncements-fm
GET /api/control/devices/ soundtouchweb.(*WebApp).HandleAPIDevices-fm
GET /api/control/devices/{id}/ soundtouchweb.(*WebApp).HandleAPIDevice-fm
GET /api/control/devices/{id}/action/{action} soundtouchweb.(*WebApp).HandleAPIControl-fm
@@ -168,7 +169,7 @@ GET /streaming/sourceproviders handlers.(
GET /updates/soundtouch handlers.(*Server).HandleMargeSoftwareUpdate-fm
GET /v1/auth handlers.(*Server).HandleSpeakerAuth-fm
GET /v1/blacklist/{deviceId} setupRouter
GET /web/* setupRouter.(*Server).HandleWeb
GET /web/* handlers.(*Server).HandleWeb
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
@@ -182,6 +183,7 @@ POST /accounts/{account}/group handlers.(
POST /accounts/{account}/group/ handlers.(*Server).HandleUnsupported-fm
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleUnsupported-fm
POST /alexa/certificate handlers.(*Server).HandleAlexaCertificate-fm
POST /api/announcements/{id}/dismiss handlers.(*Server).HandleDismissAnnouncement-fm
POST /api/control/devices/{id}/action/{action} soundtouchweb.(*WebApp).HandleAPIControl-fm
POST /api/control/devices/{id}/key/{key} soundtouchweb.(*WebApp).HandleDeviceKey-fm
POST /api/control/devices/{id}/library/play soundtouchweb.(*WebApp).HandlePlayLibrary-fm
@@ -291,5 +293,7 @@ PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handler
PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
PUT /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeUpdateDevice-fm
PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
QUERY /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
QUERY /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
TRACE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
TRACE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
+156
View File
@@ -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)
}
}
+2
View File
@@ -5,5 +5,7 @@ default/
dns/
interactions/
parity_mismatches/
stats/
patterns.json
settings.json
update-check.json
+3 -3
View File
@@ -35,7 +35,7 @@ services:
start_period: 3s
spotify-mock:
image: golang:1.26.4-alpine
image: golang:1.27.0-alpine
container_name: spotify-mock
working_dir: /app
volumes:
@@ -53,7 +53,7 @@ services:
start_period: 3s
amazon-mock:
image: golang:1.26.4-alpine
image: golang:1.27.0-alpine
container_name: amazon-mock
working_dir: /app
volumes:
@@ -71,7 +71,7 @@ services:
start_period: 3s
tunein-mock:
image: golang:1.26.4-alpine
image: golang:1.27.0-alpine
container_name: tunein-mock
working_dir: /app
volumes:
@@ -112,6 +112,14 @@ Factory-reset the same speaker again and run the full state machine — the same
This drives `setup.Manager.ExecuteInitPlan` with `SkipURLRewrite=true`, which runs:
> **Update (#615):** `--mode=full` now preflights via `Manager.PreflightInitPlan`
> before opening the WebSocket — it checks `/supportedURLs` for
> `/setMargeAccount` and requires `/soundTouchConfigurationStatus` to read
> `SOUNDTOUCH_NOT_CONFIGURED`, and no-ops on an already-configured device.
> A freshly factory-reset speaker (as in this experiment) reports
> `SOUNDTOUCH_NOT_CONFIGURED`, so the preflight passes through unchanged;
> see `docs/content/docs/reference/DEVICE-PAIRING-FLOW.md`.
```
SETUP_START
SETUP_IDENTIFY_DEVICE_ENTER
@@ -100,16 +100,16 @@ also visible on the ST 20/300/Wave captures in #221. Different from the
`sys presetkey N p` form (S4) — the `key prefix_N` shape on FW 27 is what
the device's own remote sends.
| Command | Effect | Source |
|---------------------------------|---------------------------------------------------------------------------------------|--------|
| `key prefix_1``key prefix_6` | Triggers preset 16 (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 16 (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.35.3s, ready (able to answer `getpdo` correctly) in 55.191.8s, median ~69.8s ([#471 comment 5232046477](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5232046477)). Anything automated should wait for the specific interface it's about to use, not for a different port to answer first — see the troubleshooting guide's [power-cycle retry note](../guides/TROUBLESHOOTING.md) for the user-facing version of this.
---
## Out of scope here, but worth recording
@@ -82,6 +82,16 @@ Three important details from the discussion:
silently restored on reboot — i.e. there is a parallel "envswitch" persistence
layer that wins on next boot if you don't also write to it. **We must always
issue both.**
A later, more precise measurement ([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569), confirmed on
five variants: `lisa`/`mojo`/`spotty`/`ginger`/`taigan`) explains *why*
order matters: `envswitch boseurls set` is not just a two-field setter, it
**commits whatever is currently in the runtime layer at the moment it
runs**. A `sys configuration` write only survives a reboot if `envswitch
boseurls set` runs **after** it; the same commands in reverse order lose
the `sys configuration` values silently on reboot, with every individual
command still answering normally. This is why the sequence above is
ordered all-four-`sys-configuration`-then-`envswitch`, never the reverse.
2. **margeServerUrl path is bare for `soundtouch-service`.** We mount the marge
endpoints at the **root** of port 8000, matching what the existing XML
migration writes (`Manager.migrateViaXML` in `pkg/service/setup/setup.go`
@@ -91,8 +101,15 @@ Three important details from the discussion:
routes marge under that sub-path. **For our service: bare URL. For users
redirecting to soundcork: append `/marge`** to both `margeServerUrl` and
the first argument of `envswitch boseurls set`.
3. **Each command must be sent one at a time, waiting for the device's `OK`
response** before sending the next one (`foob61451`'s explicit warning).
3. **Each command must be sent one at a time, waiting for the device's
response** before sending the next one (`foob61451`'s original warning).
Note the exception: `sys configuration` commands ack with `OK`, but
`envswitch boseurls set` does **not** — it acks with a different string
entirely (observed: `Setting Bose Server URLs to <a> and <b> ->`, no `OK`
substring; [#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)). An implementation that waits for the
literal token `OK` will time out on exactly that command. Wait for the
shell's prompt (or, as our own `pkg/telnet.Client` does, for the
connection to go idle) rather than string-matching `OK`.
### 2.2 Account pairing fallback
@@ -106,7 +123,11 @@ in-band equivalent to the HTTP `/setMargeAccount` call, useful when the
about.
- Useful read-only verification command: `getpdo CurrentSystemConfiguration`
prints the URLs after the changes have been applied so we can verify before
rebooting.
rebooting. **It only reflects the runtime (`sys configuration`) layer, not
the `envswitch`-persisted layer, so a matching `getpdo` here confirms the
writes were accepted, not that they will survive the reboot** — see the
layer-visibility caveat in
[TELNET-COMMAND-REFERENCE.md](TELNET-COMMAND-REFERENCE.md).
- `sys reboot` is the trigger that re-reads both layers.
### 2.4 What Telnet:17000 cannot do
@@ -145,6 +166,22 @@ The values are not validated by the local service, so any numeric `accountId`
will work — soundcork's runbook (#228) literally calls the token
`soundcorkdoesntcare` to make the point.
> **Booby trap, confirmed on hardware: never send an empty or truncated body
> to this endpoint.** On one firmware, a `POST /setMargeAccount` with an
> empty body returned `HTTP 200` and cleared `margeAccountUUID`, un-pairing
> an already-working speaker
> ([#471 comment 5231977172](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5231977172)).
> A later retry on the same device instead returned `400` and changed
> nothing, so the same reporter corrected the finding to
> **state-dependent, not a reliable rule you can rely on either way**
> ([#471 comment 5232232575](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5232232575)). A `400` is not proof the
> endpoint rejected a bad request (a booting device also answers a bare
> `400` with an empty body before its services are ready, per
> `JRpersonal`), and a `200` is not proof it did what you wanted. Practical
> takeaway: our own `postSetMargeAccount` always sends a well-formed XML
> body, so this doesn't affect the CLI/service — but don't probe this
> endpoint by hand against a speaker that currently works.
### 3.2 Why it's broken in practice
There are **three independent failure modes** observed:
@@ -189,10 +226,16 @@ control:
recipes).
3. **Randomize.** A "Generate" button that picks a 7-digit number and
re-rolls if it collides with an existing account in the local datastore.
- **Telnet read-back (best-effort).** `envswitch accountid get` is plausible by
symmetry with `envswitch accountid set` (#221) but is not yet confirmed
across firmwares. We will probe it during preflight; if it returns a value
we cross-check it against `:8090/info` and warn on mismatch.
- **Telnet read-back: confirmed unsupported.** `envswitch accountid get` was
originally listed as "plausible by symmetry with `envswitch accountid set`
(#221), not yet confirmed." It's now confirmed the other way: on
`lisa`/`mojo`/`spotty`, `envswitch` has **no read form at all** — both bare
`envswitch` and `envswitch boseurls` answer `Invalid Command Option`
([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)).
The persisted layer can only be written, then
observed indirectly after a reboot (e.g. via `getpdo`, mindful of the
layer-visibility caveat in
[TELNET-COMMAND-REFERENCE.md](TELNET-COMMAND-REFERENCE.md)).
This means the user is never *forced* to invent a number — the common path is
"the device already has an ID, reuse it" — and the manual/randomize controls
@@ -22,6 +22,8 @@ The encrypted `.age` file decrypts to a `.tar.gz` archive with:
Source, SourceID, location), device product code, firmware version, IP, name
- `datastore/accounts/{id}/devices/{id}/*.xml` — raw XML files verbatim from
the sender's datastore (`Presets.xml`, `Sources.xml`, `Recents.xml`, …)
- `stats/activity/{kind}/*.json` — the local admin-UI activity log (e.g.
announcement-banner dismissals), verbatim, one file per recorded event
Having both the structured JSON and the raw XML lets you compare what the
service serves via HTTP against what is actually stored on disk.
@@ -31,6 +33,24 @@ secrets, Spotify refresh tokens. The raw XML files are included as-is.
---
## Local activity log
AfterTouch records a small local activity log for admin-UI actions —
today, just announcement-banner dismissals (e.g. the admin-area-gate notice
from issue #419) — under `stats/activity/{kind}/` in the data directory.
Each event is its own plain JSON file (id, timestamp, and any detail),
readable with a text editor; there is no encoding or opaque format to
decode.
This follows the same "[all data stays on your
network](SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md)" principle as the rest of
AfterTouch: nothing here is ever transmitted automatically. The only way it
leaves the operator's network is the same as everything else in this
document — an explicitly-triggered diagnostic export, which the operator
has to click a button and choose to send.
---
## Maintainer setup (one-time)
> This section is for the project maintainer only.
@@ -566,7 +566,7 @@ func (m *MockClient) GetNowPlaying() (*models.NowPlaying, error) {
```dockerfile
# test/docker/Dockerfile
FROM golang:1.25-alpine
FROM golang:1.27.0-alpine
WORKDIR /app
COPY . .
@@ -279,6 +279,10 @@ Or set the equivalent environment variables: `AMAZON_CLIENT_ID`, `AMAZON_CLIENT_
### 3. Trigger the OAuth flow
> The commands below use the published default Management API credentials
> (`admin` / `change_me!`); substitute your own if you've changed them (see
> [Configuration Options](../guides/SOUNDTOUCH-SERVICE.md#configuration-options)).
```bash
# Get the LWA authorization URL
curl -u admin:change_me! -X POST http://localhost:8000/mgmt/amazon/init
+1 -1
View File
@@ -118,6 +118,6 @@ sequenceDiagram
## Security
- `/mgmt/spotify/callback` is intentionally outside Basic Auth to allow direct redirects from Spotify's authorization server.
- All other `/mgmt/*` endpoints require Basic Auth as configured by `--mgmt-username` and `--mgmt-password`.
- All other `/mgmt/*` endpoints require Basic Auth as configured by `--mgmt-username` and `--mgmt-password` (defaults documented in [Configuration Options](../guides/SOUNDTOUCH-SERVICE.md#configuration-options)).
- Tokens are persisted to disk as JSON with restricted file permissions (`0600`).
- The `GetAccounts` endpoint strips sensitive tokens from the response.
+11 -6
View File
@@ -17,12 +17,17 @@ then **which build** matches your computer.
AfterTouch is a small set of separate programs. Most people run one or
two of them.
| Tool | What it does | You want this if… |
|----------------------|-----------------------------------------------------------------------------------------------|----------------------------------------------------------|
| `soundtouch-service` | The local cloud replacement ("AfterTouch"). Runs always-on and takes over from the Bose cloud. | You are migrating speakers off the Bose cloud. |
| `soundtouch-player` | A browser control panel (radio browsing, device control). | You want a web UI to browse radio and control speakers. |
| `soundtouch-cli` | Command-line control and setup (status, play, presets, groups, **migration**, …). | You want to script things, or run a migration by hand. |
| `soundtouch-backup` | Backs up your Bose cloud account and each speaker's local state. | You are preparing before a shutdown / factory reset. |
| Tool | What it does | You want this if… |
|----------------------|------------------------------------------------------------------------------------------------|---------------------------------------------------------|
| `soundtouch-service` | The local cloud replacement ("AfterTouch"). Runs always-on and takes over from the Bose cloud. | You are migrating speakers off the Bose cloud. |
| `soundtouch-cli` | Command-line control and setup (status, play, presets, groups, **migration**, …). | You want to script things, or run a migration by hand. |
| `soundtouch-player` | A browser control panel (radio browsing, device control). | You want a web UI to browse radio and control speakers. |
| `soundtouch-backup` | Backs up your Bose cloud account and each speaker's local state. | You are preparing before a shutdown / factory reset. |
Most people only need **`soundtouch-service`** and **`soundtouch-cli`** — the
release notes on each [GitHub release](https://github.com/gesellix/Bose-SoundTouch/releases/latest)
link those two directly, one row per platform, so you don't have to hunt
through the flat Assets list below.
> Running a migration from the command line (for example the telnet
> re-migration in the
+292
View File
@@ -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
@@ -37,8 +37,11 @@ internet-facing, those endpoints are reachable by anyone who knows the URL.
Minimum mitigations before going live:
- Enable **HTTP Basic Auth** on the management UI (set via `MGMT_USERNAME` /
`MGMT_PASSWORD` or the `--mgmt-username` / `--mgmt-password` flags).
- **Change the Management API password**HTTP Basic Auth on the management
UI is always on, but ships with a published default
(`admin` / `change_me!`); set your own via `MGMT_USERNAME` /
`MGMT_PASSWORD` (or the `--mgmt-username` / `--mgmt-password` flags — see
[Configuration Options](SOUNDTOUCH-SERVICE.md#configuration-options)).
- Run AfterTouch **behind a reverse proxy** (Nginx, Caddy, Coolify, Traefik)
and consider blocking the `/streaming/*` paths to all but your speaker's
IP address at the proxy level if your server/firewall allows it.
+84 -84
View File
@@ -115,25 +115,25 @@ type ProductionSoundTouchService struct {
type Config struct {
// Server settings
ListenAddr string `env:"LISTEN_ADDR" default:":8080"`
// SoundTouch settings
DeviceHosts []string `env:"DEVICE_HOSTS" separator:","`
DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"30s"`
RequestTimeout time.Duration `env:"REQUEST_TIMEOUT" default:"15s"`
MaxRetries int `env:"MAX_RETRIES" default:"3"`
// Connection pool
MaxConnections int `env:"MAX_CONNECTIONS" default:"10"`
IdleTimeout time.Duration `env:"IDLE_TIMEOUT" default:"5m"`
// Monitoring
MetricsEnabled bool `env:"METRICS_ENABLED" default:"true"`
HealthCheckInterval time.Duration `env:"HEALTH_CHECK_INTERVAL" default:"30s"`
// Logging
LogLevel string `env:"LOG_LEVEL" default:"info"`
LogFormat string `env:"LOG_FORMAT" default:"json"`
// Security
EnableTLS bool `env:"ENABLE_TLS" default:"false"`
TLSCertFile string `env:"TLS_CERT_FILE"`
@@ -145,7 +145,7 @@ func LoadConfig() (*Config, error) {
if err := env.Parse(cfg); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
return cfg, cfg.Validate()
}
@@ -153,15 +153,15 @@ func (c *Config) Validate() error {
if len(c.DeviceHosts) == 0 {
return fmt.Errorf("at least one device host must be specified")
}
if c.RequestTimeout < time.Second {
return fmt.Errorf("request timeout must be at least 1 second")
}
if c.EnableTLS && (c.TLSCertFile == "" || c.TLSKeyFile == "") {
return fmt.Errorf("TLS cert and key files required when TLS is enabled")
}
return nil
}
```
@@ -191,7 +191,7 @@ pool:
monitoring:
metrics_enabled: true
health_check_interval: "30s"
logging:
level: "info"
format: "json"
@@ -203,12 +203,12 @@ func LoadConfigFromFile(path string) (*Config, error) {
if err != nil {
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, cfg.Validate()
}
```
@@ -224,14 +224,14 @@ func LoadConfigFromFile(path string) (*Config, error) {
type SecureNetworkConfig struct {
// Allowed source IP ranges
AllowedCIDRs []string
// Rate limiting
RateLimit int
RateLimitWindow time.Duration
// TLS configuration
TLSConfig *tls.Config
// Timeouts for security
ReadTimeout time.Duration
WriteTimeout time.Duration
@@ -240,7 +240,7 @@ type SecureNetworkConfig struct {
func NewSecureServer(config SecureNetworkConfig) *http.Server {
mux := http.NewServeMux()
// Add middleware
handler := applyMiddleware(mux,
corsMiddleware(),
@@ -249,7 +249,7 @@ func NewSecureServer(config SecureNetworkConfig) *http.Server {
loggingMiddleware(),
metricsMiddleware(),
)
return &http.Server{
Handler: handler,
TLSConfig: config.TLSConfig,
@@ -275,12 +275,12 @@ func (r *DeviceControlRequest) Validate() error {
if err := validate.Struct(r); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
// Additional business logic validation
if r.Action == "volume" && r.Volume == nil {
return fmt.Errorf("volume value required for volume action")
}
return nil
}
```
@@ -302,12 +302,12 @@ func loadSecretsFromK8s() (*SecretsConfig, error) {
if err != nil {
return nil, err
}
tlsKey, err := os.ReadFile("/etc/secrets/tls.key")
if err != nil {
return nil, err
}
return &SecretsConfig{
TLSCert: string(tlsCert),
TLSKey: string(tlsKey),
@@ -335,21 +335,21 @@ type Logger struct {
func NewLogger(level, format, component string) (*Logger, error) {
logger := logrus.New()
// Set level
logLevel, err := logrus.ParseLevel(level)
if err != nil {
return nil, err
}
logger.SetLevel(logLevel)
// Set format
if format == "json" {
logger.SetFormatter(&logrus.JSONFormatter{
TimestampFormat: time.RFC3339,
})
}
return &Logger{
Logger: logger,
component: component,
@@ -376,15 +376,15 @@ type Metrics struct {
RequestsTotal prometheus.CounterVec
RequestDuration prometheus.HistogramVec
RequestsInFlight prometheus.GaugeVec
// Device metrics
DevicesConnected prometheus.Gauge
DeviceHealth prometheus.GaugeVec
WebSocketConnections prometheus.Gauge
// Error metrics
ErrorsTotal prometheus.CounterVec
// Business metrics
VolumeChanges prometheus.CounterVec
SourceChanges prometheus.CounterVec
@@ -400,7 +400,7 @@ func NewMetrics() *Metrics {
},
[]string{"method", "endpoint", "status"},
),
RequestDuration: *prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "soundtouch_request_duration_seconds",
@@ -409,14 +409,14 @@ func NewMetrics() *Metrics {
},
[]string{"method", "endpoint"},
),
DevicesConnected: prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "soundtouch_devices_connected",
Help: "Number of connected devices",
},
),
DeviceHealth: *prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "soundtouch_device_health",
@@ -425,7 +425,7 @@ func NewMetrics() *Metrics {
[]string{"device_id", "device_name"},
),
}
// Register metrics
prometheus.MustRegister(
m.RequestsTotal,
@@ -433,7 +433,7 @@ func NewMetrics() *Metrics {
m.DevicesConnected,
m.DeviceHealth,
)
return m
}
@@ -457,7 +457,7 @@ type HealthChecker struct {
func (hc *HealthChecker) Start(ctx context.Context) {
ticker := time.NewTicker(hc.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
@@ -470,7 +470,7 @@ func (hc *HealthChecker) Start(ctx context.Context) {
func (hc *HealthChecker) checkAllDevices() {
var wg sync.WaitGroup
for deviceID, device := range hc.manager.devices {
wg.Add(1)
go func(id string, dev *DeviceInfo) {
@@ -478,18 +478,18 @@ func (hc *HealthChecker) checkAllDevices() {
hc.checkDevice(id, dev)
}(deviceID, device)
}
wg.Wait()
}
func (hc *HealthChecker) checkDevice(deviceID string, device *DeviceInfo) {
ctx, cancel := context.WithTimeout(context.Background(), hc.timeout)
defer cancel()
start := time.Now()
err := device.Client.Ping()
duration := time.Since(start)
if err != nil {
device.Status = DeviceStatusUnhealthy
hc.metrics.DeviceHealth.WithLabelValues(deviceID, device.Name).Set(0)
@@ -507,14 +507,14 @@ func (hc *HealthChecker) HealthHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
healthy := 0
total := 0
for _, device := range hc.manager.devices {
total++
if device.Status == DeviceStatusHealthy {
healthy++
}
}
status := map[string]interface{}{
"status": "ok",
"devices": map[string]interface{}{
@@ -524,14 +524,14 @@ func (hc *HealthChecker) HealthHandler() http.HandlerFunc {
},
"timestamp": time.Now().UTC(),
}
w.Header().Set("Content-Type", "application/json")
if healthy < total {
w.WriteHeader(http.StatusServiceUnavailable)
status["status"] = "degraded"
}
json.NewEncoder(w).Encode(status)
}
}
@@ -560,16 +560,16 @@ func NewConnectionPool(maxIdle, maxActive int, idleTimeout time.Duration) *Conne
maxActive: maxActive,
idleTimeout: idleTimeout,
}
// Start cleanup goroutine
go cp.cleanup()
return cp
}
func (cp *ConnectionPool) Get(host string, port int) (*client.Client, error) {
key := fmt.Sprintf("%s:%d", host, port)
// Check if connection exists and is valid
if val, ok := cp.clients.Load(key); ok {
conn := val.(*pooledConnection)
@@ -580,35 +580,35 @@ func (cp *ConnectionPool) Get(host string, port int) (*client.Client, error) {
// Connection expired, remove it
cp.clients.Delete(key)
}
// Check active connection limit
if atomic.LoadInt64(&cp.activeCount) >= int64(cp.maxActive) {
return nil, fmt.Errorf("connection pool exhausted")
}
// Create new connection
config := client.ClientConfig{
Host: host,
Port: port,
Timeout: 15 * time.Second,
}
newClient := client.NewClient(config)
// Test connection
if err := newClient.Ping(); err != nil {
return nil, fmt.Errorf("failed to connect to %s:%d: %w", host, port, err)
}
conn := &pooledConnection{
client: newClient,
lastUsed: time.Now(),
created: time.Now(),
}
cp.clients.Store(key, conn)
atomic.AddInt64(&cp.activeCount, 1)
return newClient, nil
}
@@ -621,7 +621,7 @@ type pooledConnection struct {
func (cp *ConnectionPool) cleanup() {
ticker := time.NewTicker(cp.idleTimeout / 2)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
cp.clients.Range(func(key, val interface{}) bool {
@@ -649,10 +649,10 @@ func NewCacheManager() *CacheManager {
return &CacheManager{
// Device info rarely changes, cache for 1 hour
deviceInfoCache: cache.New(1*time.Hour, 2*time.Hour),
// Capabilities never change, cache for 24 hours
capabilitiesCache: cache.New(24*time.Hour, 48*time.Hour),
// Volume changes frequently, cache for 5 seconds
volumeCache: cache.New(5*time.Second, 10*time.Second),
}
@@ -662,12 +662,12 @@ func (cm *CacheManager) GetDeviceInfo(deviceID string, fetcher func() (*models.D
if cached, found := cm.deviceInfoCache.Get(deviceID); found {
return cached.(*models.DeviceInfo), nil
}
info, err := fetcher()
if err != nil {
return nil, err
}
cm.deviceInfoCache.Set(deviceID, info, cache.DefaultExpiration)
return info, nil
}
@@ -702,7 +702,7 @@ func NewResilientSoundTouchService(client *client.Client) *ResilientSoundTouchSe
log.Printf("Circuit breaker '%s' changed from '%s' to '%s'", name, from, to)
},
}
return &ResilientSoundTouchService{
client: client,
cb: gobreaker.NewCircuitBreaker(settings),
@@ -713,12 +713,12 @@ func (r *ResilientSoundTouchService) SetVolume(deviceID string, volume int) erro
result, err := r.cb.Execute(func() (interface{}, error) {
return nil, r.client.SetVolume(volume)
})
if err != nil {
r.metrics.ErrorsTotal.WithLabelValues("circuit_breaker", "volume").Inc()
return err
}
return result.(error)
}
```
@@ -730,16 +730,16 @@ func (app *Application) Run(ctx context.Context) error {
// Setup signal handling
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// Start services
g, ctx := errgroup.WithContext(ctx)
// HTTP server
server := &http.Server{
Addr: app.config.ListenAddr,
Handler: app.handler,
}
g.Go(func() error {
app.logger.Info("Starting HTTP server", "addr", app.config.ListenAddr)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
@@ -747,38 +747,38 @@ func (app *Application) Run(ctx context.Context) error {
}
return nil
})
// Health checker
g.Go(func() error {
return app.healthChecker.Start(ctx)
})
// WebSocket manager
g.Go(func() error {
return app.wsManager.Start(ctx)
})
// Wait for shutdown signal
go func() {
<-sigChan
app.logger.Info("Shutdown signal received")
// Graceful shutdown with timeout
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Shutdown HTTP server
if err := server.Shutdown(shutdownCtx); err != nil {
app.logger.Error("HTTP server shutdown error", "error", err)
}
// Close WebSocket connections
app.wsManager.Shutdown(shutdownCtx)
// Close connection pool
app.connectionPool.Close()
}()
return g.Wait()
}
```
@@ -791,7 +791,7 @@ func (app *Application) Run(ctx context.Context) error {
```dockerfile
# Dockerfile
FROM golang:1.25-alpine AS builder
FROM golang:1.27.0-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
@@ -830,7 +830,7 @@ services:
networks:
- soundtouch-net
restart: unless-stopped
prometheus:
image: prom/prometheus:latest
ports:
@@ -839,7 +839,7 @@ services:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
networks:
- soundtouch-net
grafana:
image: grafana/grafana:latest
ports:
@@ -1011,7 +1011,7 @@ groups:
annotations:
summary: "SoundTouch device {{ $labels.device_name }} is unhealthy"
description: "Device {{ $labels.device_id }} has been unhealthy for more than 2 minutes"
- alert: HighErrorRate
expr: rate(soundtouch_errors_total[5m]) > 0.1
for: 5m
@@ -1020,7 +1020,7 @@ groups:
annotations:
summary: "High error rate detected"
description: "Error rate is {{ $value }} errors/second over the last 5 minutes"
- alert: ServiceDown
expr: up{job="soundtouch"} == 0
for: 1m
@@ -1040,33 +1040,33 @@ func (m *Manager) BackupConfigurations() error {
Timestamp: time.Now(),
Devices: make(map[string]DeviceConfig),
}
for deviceID, device := range m.devices {
config := DeviceConfig{}
// Backup presets
if presets, err := device.Client.GetPresets(); err == nil {
config.Presets = presets
}
// Backup settings
if volume, err := device.Client.GetVolume(); err == nil {
config.Volume = volume.TargetVolume
}
if bass, err := device.Client.GetBass(); err == nil {
config.Bass = bass.TargetBass
}
backup.Devices[deviceID] = config
}
// Save to file
data, err := json.MarshalIndent(backup, "", " ")
if err != nil {
return err
}
filename := fmt.Sprintf("backup_%s.json", time.Now().Format("2006-01-02_15-04-05"))
return os.WriteFile(filepath.Join(m.config.BackupDir, filename), data, 0644)
}
@@ -1083,7 +1083,7 @@ func init() {
runtime.GOMAXPROCS(int(limit))
}
}
// Set GC target percentage
if os.Getenv("GOGC") == "" {
debug.SetGCPerc
@@ -99,18 +99,28 @@ After factory restore the speaker enters setup mode automatically; no power-cycl
## 6. AP Mode Wi-Fi Provisioning via Console
When BLE is unavailable (e.g. when using an Android emulator), use AP mode to push Wi-Fi credentials from the Mac command line.
When BLE is unavailable (e.g. when using an Android emulator), use AP mode to push Wi-Fi credentials from the command line. The HTTP steps below (6.2, 6.3) are OS-agnostic; only the Wi-Fi-network-switching commands (6.1, 6.4) are platform-specific — macOS is shown inline, with Linux and Windows equivalents alongside.
### 6.1 Connect Mac to Speaker AP
### 6.1 Connect your machine to the Speaker AP
After factory reset the speaker broadcasts an SSID like `Bose SoundTouch XXXX`. Connect the Mac to it:
After factory reset the speaker broadcasts an SSID like `Bose SoundTouch XXXX`. Connect to it:
```bash
# List nearby SSIDs — use System Settings → Wi-Fi (the airport command was removed in macOS Sequoia+)
# Connect (replace with actual SSID)
# macOS — list nearby SSIDs via System Settings → Wi-Fi (the `airport`
# command was removed in macOS Sequoia+); connect (replace with actual SSID):
networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"
```
```bash
# Linux (NetworkManager) — one-shot connect, no password (open AP):
nmcli device wifi connect "Bose SoundTouch XXXX"
```
```powershell
# Windows — connect via the built-in Wi-Fi menu, or from PowerShell:
netsh wlan connect name="Bose SoundTouch XXXX"
```
The speaker's web UI gateway is at `192.0.2.1` (verified: ST10 assigns `192.0.2.2` to the client via DHCP).
```bash
@@ -143,20 +153,37 @@ Expected response: `<?xml version="1.0" encoding="UTF-8" ?><AddWirelessProfileRe
The speaker will disconnect from AP mode and join the home network within ~1530 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:**
@@ -11,6 +11,16 @@ This guide explains how to link your Spotify or Amazon Music account to AfterTou
---
> **The Local Account tab requires a login.** Authorizing a Spotify or
> Amazon account (Step 3 below) happens on the **Local Account** tab, which
> is protected by AfterTouch's Management API login (HTTP Basic Auth).
> Unless you've changed it, the default is username `admin`, password
> `change_me!` — see
> [Configuration Options](SOUNDTOUCH-SERVICE.md#configuration-options) for
> how to set your own (`MGMT_USERNAME` / `MGMT_PASSWORD`). Your browser will
> prompt for this the first time you open a protected page or click a
> management action — if nothing happens, try reloading the page.
## How it works
Connecting a music service happens in three separate steps, each done once:
@@ -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
+6 -6
View File
@@ -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
+3 -3
View File
@@ -122,12 +122,12 @@ The easiest solution is to assign a **static (fixed) IP address** to the compute
## Security note
AfterTouch's web interface and management API have no login by default. On a typical home network this is fine, since only devices on your local network can reach it.
The main web interface has no login by default — on a typical home network this is fine, since only devices on your local network can reach it.
If you want to restrict access — for example, on a shared network — start the service with a username and password:
The Management API (Spotify/Amazon account linking, the Local Accounts page) is a separate area that's *always* protected by HTTP Basic Auth, but ships with a published default (`admin` / `change_me!`) — anyone who has read the docs can use it. If you want real protection — for example, on a shared network — set your own:
```
./soundtouch-service --mgmt-username admin --mgmt-password yourpassword
```
This protects the Settings tab (where your Spotify and Amazon credentials are stored) from being read or changed by others on the network.
See [Configuration Options](SOUNDTOUCH-SERVICE.md#configuration-options) for the full list of settings and env-var equivalents. Note that this does *not* cover the Settings tab, where your Spotify/Amazon Client ID and Secret are stored — that tab has no separate protection today.
+27 -22
View File
@@ -156,28 +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` |
| `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
+163 -1
View File
@@ -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: 5592 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
```
+2 -2
View File
@@ -1,8 +1,8 @@
module navigation-station-demo
go 1.26.4
go 1.27.0
require github.com/gesellix/bose-soundtouch v0.107.0
require github.com/gesellix/bose-soundtouch v0.128.0
require github.com/gorilla/websocket v1.5.3 // indirect
+2 -2
View File
@@ -1,8 +1,8 @@
module preset-management-example
go 1.26.4
go 1.27.0
require github.com/gesellix/bose-soundtouch v0.107.0
require github.com/gesellix/bose-soundtouch v0.128.0
require github.com/gorilla/websocket v1.5.3 // indirect
+13 -15
View File
@@ -1,40 +1,38 @@
module github.com/gesellix/bose-soundtouch
go 1.26.4
go 1.27.0
require (
filippo.io/age v1.3.1
github.com/chromedp/chromedp v0.15.1
github.com/go-chi/chi/v5 v5.3.0
github.com/chromedp/chromedp v0.16.0
github.com/go-chi/chi/v5 v5.3.2
github.com/google/gopacket v1.1.19
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/mdns v1.0.7
github.com/miekg/dns v1.1.72
github.com/miekg/dns v1.1.73
github.com/russross/blackfriday/v2 v2.1.0
github.com/sergi/go-diff v1.4.0
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.53.0
golang.org/x/net v0.56.0
golang.org/x/term v0.44.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
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
filippo.io/hpke v0.4.0 // indirect
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b // indirect
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836 // indirect
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
golang.org/x/image v0.43.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/tools v0.47.0 // indirect
golang.org/x/image v0.45.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
)
+26 -30
View File
@@ -6,10 +6,10 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b h1:fpvdcCAe2z3H8OvVY00iKOp3Wapbs/Gy375Fn6l/XM4=
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f h1:0Z1zcSLEmnj2c2CmJYBqewtS6pxhB39bNWUSEUAWjgk=
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0=
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
@@ -17,18 +17,16 @@ github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836 h1:5KGUhXZFTN1PrCY4zUZLe1J8n7uBNmPDbCLCn78EbPQ=
github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -40,8 +38,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -63,34 +61,32 @@ 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.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
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.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
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.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
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.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
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=
+12 -4
View File
@@ -8,7 +8,7 @@
"license": "MIT",
"dependencies": {
"htm": "3.1.1",
"preact": "10.29.3"
"preact": "10.29.8"
},
"engines": {
"node": ">=24.0.0"
@@ -21,13 +21,21 @@
"license": "Apache-2.0"
},
"node_modules/preact": {
"version": "10.29.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.3.tgz",
"integrity": "sha512-D9NL1GAnJZhc3RndVs4gDdxEeU9TcHgywMrhhOsnpdlvFjdbx0gAsLUnH6JEhlJH5giL7Tx5biWPUSEXE/HPzw==",
"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",
"url": "https://opencollective.com/preact"
},
"peerDependencies": {
"preact-render-to-string": ">=5"
},
"peerDependenciesMeta": {
"preact-render-to-string": {
"optional": true
}
}
}
}
+1 -1
View File
@@ -10,6 +10,6 @@
},
"dependencies": {
"htm": "3.1.1",
"preact": "10.29.3"
"preact": "10.29.8"
}
}
+3 -2
View File
@@ -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
View File
@@ -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"`
+69
View File
@@ -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)
}
})
}
+15
View File
@@ -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"`
+323 -22
View File
@@ -33,11 +33,30 @@ func exists(path string) bool {
return err == nil
}
// isSafeIdentifier returns true if the given identifier is safe to use
// as a single path component (for account IDs, device IDs, etc.).
// It rejects empty strings, path separators, and parent directory references.
func isSafeIdentifier(id string) bool {
if id == "" {
// maxSafeIdentifierLength bounds account/device IDs accepted from a
// speaker or third-party pairing tool. Well under typical filesystem
// path-component limits (255 bytes); generous for any realistic
// margeAccountUUID or MAC-derived device ID.
const maxSafeIdentifierLength = 128
// IsSafeIdentifier returns true if the given identifier is safe to use
// as a single path component (for account IDs, device IDs, etc.), and
// safe to embed in the other places these values end up: XML sent to a
// speaker, log lines, and datastore-key comparisons. It rejects empty
// or overlong strings, path separators, and parent directory
// references.
//
// The allowed character set intentionally excludes XML/HTML-special
// characters (`< > & " '`), whitespace, and shell/URL metacharacters
// (see #634's `postSetMargeAccount`, which interpolates an account ID
// into an XML body, and `PairAccount`, which interpolates one into a
// literal `envswitch accountid set <id>` telnet command line) even
// though it accepts more than Bose's own 7-digit account format —
// devices paired via third-party or manual tooling (e.g. the
// USB-stick SSH-enable method) can report arbitrary margeAccountUUID
// values such as "stick@local".
func IsSafeIdentifier(id string) bool {
if id == "" || len(id) > maxSafeIdentifierLength {
return false
}
@@ -46,14 +65,17 @@ func isSafeIdentifier(id string) bool {
return false
}
// Allow a conservative set of characters commonly found in IDs:
// letters, digits, underscore, dash, dot, and colon (for MAC-like IDs).
// Letters, digits, and a conservative set of punctuation seen in
// real-world IDs: underscore, dash, dot, colon (MAC-like IDs), and
// '@' (e.g. "stick@local"). Everything else — including all XML,
// HTML, shell, and URL metacharacters, whitespace, and control
// characters — is rejected.
for i := 0; i < len(id); i++ {
c := id[i]
if (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '_' || c == '-' || c == '.' || c == ':' {
c == '_' || c == '-' || c == '.' || c == ':' || c == '@' {
continue
}
@@ -928,7 +950,10 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
// GetPresets retrieves all presets for the specified account and device.
func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset, error) {
presets, needsRewrite, err := ds.readPresetsLocked(account, device)
ds.fileMutex.RLock()
presets, needsRewrite, err := ds.readPresetsNoLock(account, device)
ds.fileMutex.RUnlock()
if err != nil {
return nil, err
}
@@ -944,18 +969,48 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
return presets, nil
}
// readPresetsLocked is the locked read half of GetPresets. It returns the
// parsed presets and a flag indicating whether the on-disk file used the
// legacy <ContentItem> (capital C) format that needs rewriting.
func (ds *DataStore) readPresetsLocked(account, device string) ([]models.ServicePreset, bool, error) {
ds.fileMutex.RLock()
defer ds.fileMutex.RUnlock()
// MutatePresets atomically reads the current preset list, transforms it via
// mutate, and persists the result — holding a single write lock for the
// entire read-mutate-write cycle. Calling GetPresets followed by a separate
// SavePresets leaves a lost-update window open: two concurrent callers can
// each read the same starting list, mutate different entries, and the
// second writer's SavePresets silently clobbers the first writer's update.
// That's the exact interleave that dropped a preset during #614's rapid-fire
// repro (overlapping PUT .../preset/N requests). Callers that read-then-write
// a single device's presets should use this instead of GetPresets+SavePresets.
func (ds *DataStore) MutatePresets(account, device string, mutate func(current []models.ServicePreset) ([]models.ServicePreset, error)) ([]models.ServicePreset, error) {
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
current, _, err := ds.readPresetsNoLock(account, device)
if err != nil {
return nil, err
}
next, err := mutate(current)
if err != nil {
return nil, err
}
if err := ds.savePresetsNoLock(account, device, next); err != nil {
return nil, err
}
return next, nil
}
// readPresetsNoLock is the lock-free read half of GetPresets/MutatePresets.
// Callers must already hold ds.fileMutex (for reading or writing). It
// returns the parsed presets and a flag indicating whether the on-disk file
// used the legacy <ContentItem> (capital C) format that needs rewriting.
func (ds *DataStore) readPresetsNoLock(account, device string) ([]models.ServicePreset, bool, error) {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
data, err := ds.rootReadFile(path)
if err != nil {
if os.IsNotExist(err) {
log.Printf("[Datastore] readPresetsNoLock: no Presets.xml at %s — reporting no presets", sanitizeLog(path))
return []models.ServicePreset{}, false, nil
}
@@ -967,7 +1022,7 @@ func (ds *DataStore) readPresetsLocked(account, device string) ([]models.Service
// error, so the device-level /presets endpoint returns an empty list
// instead of HTTP 500. See #458.
if len(bytes.TrimSpace(data)) == 0 {
log.Printf("[Datastore] readPresetsLocked: empty/0-byte Presets.xml at %s — treating as no presets (#458)", sanitizeLog(path))
log.Printf("[Datastore] readPresetsNoLock: empty/0-byte Presets.xml at %s — treating as no presets (#458)", sanitizeLog(path))
return []models.ServicePreset{}, false, nil
}
@@ -999,7 +1054,7 @@ func (ds *DataStore) readPresetsLocked(account, device string) ([]models.Service
needsRewrite := !bytes.Equal(normalized, data)
if err := xml.Unmarshal(normalized, &presetsWrap); err != nil {
log.Printf("[Datastore] readPresetsLocked: malformed Presets.xml at %s (%s) — treating as no presets (#458)", sanitizeLog(path), sanitizeErr(err))
log.Printf("[Datastore] readPresetsNoLock: malformed Presets.xml at %s (%s) — treating as no presets (#458)", sanitizeLog(path), sanitizeErr(err))
return []models.ServicePreset{}, false, nil
}
@@ -1053,7 +1108,7 @@ func repairLeakedSource(account, device, label, persistedSource, sourceID string
return persistedSource
}
sources, err := ds.getConfiguredSourcesLocked(account, device)
sources, err := ds.getConfiguredSourcesNoLock(account, device)
if err != nil {
return persistedSource
}
@@ -1079,11 +1134,11 @@ func isLeakedSourceValue(s string) bool {
return s == "" || s == "Audio"
}
// getConfiguredSourcesLocked is GetConfiguredSources without the
// getConfiguredSourcesNoLock is GetConfiguredSources without the
// fileMutex.RLock() — callers must already hold it. Used by
// repairLeakedSource from within GetPresets/GetRecents which already
// hold the lock.
func (ds *DataStore) getConfiguredSourcesLocked(account, device string) ([]models.ConfiguredSource, error) {
func (ds *DataStore) getConfiguredSourcesNoLock(account, device string) ([]models.ConfiguredSource, error) {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
data, err := ds.rootReadFile(path)
@@ -1131,6 +1186,12 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
return ds.savePresetsNoLock(account, device, presets)
}
// savePresetsNoLock is the lock-free write half of
// SavePresets/MutatePresets. Callers must already hold ds.fileMutex.Lock().
func (ds *DataStore) savePresetsNoLock(account, device string, presets []models.ServicePreset) error {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
if err := ds.rootMkdirAll(filepath.Dir(path), 0755); err != nil {
return err
@@ -1294,11 +1355,45 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
ds.fileMutex.RLock()
defer ds.fileMutex.RUnlock()
return ds.readRecentsNoLock(account, device)
}
// MutateRecents atomically reads the current recents list, transforms it
// via mutate, and persists the result — holding a single write lock for the
// entire read-mutate-write cycle. See MutatePresets for why this matters: a
// separate GetRecents followed by SaveRecents leaves a lost-update window
// open between concurrent callers.
func (ds *DataStore) MutateRecents(account, device string, mutate func(current []models.ServiceRecent) ([]models.ServiceRecent, error)) ([]models.ServiceRecent, error) {
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
current, err := ds.readRecentsNoLock(account, device)
if err != nil {
return nil, err
}
next, err := mutate(current)
if err != nil {
return nil, err
}
if err := ds.saveRecentsNoLock(account, device, next); err != nil {
return nil, err
}
return next, nil
}
// readRecentsNoLock is the lock-free read half of GetRecents/MutateRecents.
// Callers must already hold ds.fileMutex (for reading or writing).
func (ds *DataStore) readRecentsNoLock(account, device string) ([]models.ServiceRecent, error) {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)
data, err := ds.rootReadFile(path)
if err != nil {
if os.IsNotExist(err) {
log.Printf("[Datastore] GetRecents: no Recents.xml at %s — reporting no recents", sanitizeLog(path))
return []models.ServiceRecent{}, nil
}
@@ -1404,6 +1499,12 @@ func (ds *DataStore) SaveRecents(account, device string, recents []models.Servic
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
return ds.saveRecentsNoLock(account, device, recents)
}
// saveRecentsNoLock is the lock-free write half of
// SaveRecents/MutateRecents. Callers must already hold ds.fileMutex.Lock().
func (ds *DataStore) saveRecentsNoLock(account, device string, recents []models.ServiceRecent) error {
dir := ds.AccountDeviceDir(account, device)
if err := ds.rootMkdirAll(dir, 0755); err != nil {
return err
@@ -1509,7 +1610,7 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
return fmt.Errorf("device ID/name cannot be empty")
}
if !isSafeIdentifier(device) {
if !IsSafeIdentifier(device) {
return fmt.Errorf("invalid device ID")
}
@@ -1517,7 +1618,7 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
return fmt.Errorf("account ID cannot be empty")
}
if !isSafeIdentifier(account) {
if !IsSafeIdentifier(account) {
return fmt.Errorf("invalid account ID")
}
@@ -1705,6 +1806,10 @@ func (ds *DataStore) SaveAccountInfo(accountID string, info *models.ServiceAccou
return nil
}
if !IsSafeIdentifier(accountID) {
return fmt.Errorf("invalid account ID")
}
dir := ds.AccountDir(accountID)
if err := ds.rootMkdirAll(dir, 0755); err != nil {
return err
@@ -1905,6 +2010,40 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
ds.fileMutex.RLock()
defer ds.fileMutex.RUnlock()
return ds.readConfiguredSourcesNoLock(account, device)
}
// MutateConfiguredSources atomically reads the current configured-source
// list, transforms it via mutate, and persists the result — holding a
// single write lock for the entire read-mutate-write cycle. See
// MutatePresets for why this matters: a separate GetConfiguredSources
// followed by SaveConfiguredSources leaves a lost-update window open
// between concurrent callers.
func (ds *DataStore) MutateConfiguredSources(account, device string, mutate func(current []models.ConfiguredSource) ([]models.ConfiguredSource, error)) ([]models.ConfiguredSource, error) {
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
current, err := ds.readConfiguredSourcesNoLock(account, device)
if err != nil {
return nil, err
}
next, err := mutate(current)
if err != nil {
return nil, err
}
if err := ds.saveConfiguredSourcesNoLock(account, device, next); err != nil {
return nil, err
}
return next, nil
}
// readConfiguredSourcesNoLock is the lock-free read half of
// GetConfiguredSources/MutateConfiguredSources. Callers must already hold
// ds.fileMutex (for reading or writing).
func (ds *DataStore) readConfiguredSourcesNoLock(account, device string) ([]models.ConfiguredSource, error) {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
// defaultSources is the fallback used whenever there is no usable
@@ -2076,6 +2215,13 @@ func (ds *DataStore) SaveConfiguredSources(account, device string, sources []mod
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
return ds.saveConfiguredSourcesNoLock(account, device, sources)
}
// saveConfiguredSourcesNoLock is the lock-free write half of
// SaveConfiguredSources/MutateConfiguredSources. Callers must already hold
// ds.fileMutex.Lock().
func (ds *DataStore) saveConfiguredSourcesNoLock(account, device string, sources []models.ConfiguredSource) error {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
if err := ds.rootMkdirAll(filepath.Dir(path), 0755); err != nil {
return err
@@ -2594,6 +2740,8 @@ type Settings struct {
RecordInteractions bool `json:"record_interactions"`
DiscoveryInterval string `json:"discovery_interval,omitempty"`
DiscoveryEnabled bool `json:"discovery_enabled"`
UpdateCheckInterval string `json:"update_check_interval,omitempty"`
UpdateCheckEnabled bool `json:"update_check_enabled"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream []string `json:"dns_upstream,omitempty"`
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
@@ -2657,6 +2805,23 @@ type Settings struct {
// API/speaker clients (non-HTML Accept) always get the version JSON
// regardless of this setting.
DefaultLanding string `json:"default_landing,omitempty"`
// AdminAreaAuth is a tri-state toggle for gating the entire admin area
// (/admin, /setup, /api/setup — minus a small set of routes shared with
// soundtouch-cli/soundtouch-player) behind the same Basic Auth used for
// /api/mgmt/*, rather than just the Local Account / Spotify / Amazon
// linking endpoints as today. Values:
// "" — unset (default). Today this means "not enforced"; a
// later release is expected to flip the *meaning* of ""
// to "enforced" as the project moves the entire admin
// area to require login by default. See #419.
// "enabled" — the whole admin area requires Basic Auth now.
// "disabled" — explicit opt-out. Kept open even after the default
// flips, so an operator's deliberate choice survives
// the upgrade.
// The tri-state (rather than a plain bool) is what lets "never decided"
// be told apart from "explicitly chose off" once that default flips.
AdminAreaAuth string `json:"admin_area_auth,omitempty"`
}
// GetSettings retrieves the global service settings.
@@ -2703,6 +2868,63 @@ func (ds *DataStore) SaveSettings(settings Settings) error {
return ds.atomicWriteFile(path, data)
}
// UpdateCheckState is the small persisted state for the opt-in periodic
// update check (#591, _/i591/design-update-check.md): when it last ran and
// what it last saw, so a restart doesn't lose the "already logged this
// version" and "don't hammer GitHub on every startup" context. Separate
// from Settings, which is operator-editable config, not runtime state.
type UpdateCheckState struct {
LastCheckedAt string `json:"last_checked_at,omitempty"`
LastSeenVersion string `json:"last_seen_version,omitempty"`
LastReleaseURL string `json:"last_release_url,omitempty"`
}
// GetUpdateCheckState retrieves the persisted update-check state. Same
// missing-file-is-not-an-error shape as GetSettings — a fresh install (or
// one that has never had the check enabled) has no file yet.
func (ds *DataStore) GetUpdateCheckState() (UpdateCheckState, error) {
if ds == nil || ds.DataDir == "" {
return UpdateCheckState{}, nil
}
path := filepath.Join(ds.DataDir, "update-check.json")
if !ds.rootExists(path) {
return UpdateCheckState{}, nil
}
data, err := ds.rootReadFile(path)
if err != nil {
return UpdateCheckState{}, err
}
var state UpdateCheckState
if err := json.Unmarshal(data, &state); err != nil {
return UpdateCheckState{}, err
}
return state, nil
}
// SaveUpdateCheckState persists the update-check state.
func (ds *DataStore) SaveUpdateCheckState(state UpdateCheckState) error {
if ds == nil || ds.DataDir == "" {
return nil
}
if err := ds.rootMkdirAll(ds.DataDir, 0755); err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
path := filepath.Join(ds.DataDir, "update-check.json")
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
return ds.atomicWriteFile(path, data)
}
// SaveUsageStats saves usage statistics to the datastore.
func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error {
dir := filepath.Join(ds.DataDir, "stats", "usage")
@@ -2721,6 +2943,85 @@ func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error {
return ds.atomicWriteFile(path, data)
}
// RecordActivity appends one entry to the local admin-UI activity log, under
// DataDir/stats/activity/<kind>/, one file per event (same shape as
// SaveUsageStats/SaveErrorStats above). kind is meant to be a small,
// developer-defined constant (e.g. "notification_dismissed") used directly
// as a directory name — callers must not pass untrusted/user-supplied
// values. id may recur across calls with a new timestamp each time; this is
// an append-only log, not a keyed store. See models.ActivityRecord for the
// local-only/never-transmitted-automatically guarantee this backs.
func (ds *DataStore) RecordActivity(kind, id string, detail map[string]interface{}) error {
dir := filepath.Join(ds.DataDir, "stats", "activity", kind)
if err := ds.rootMkdirAll(dir, 0755); err != nil {
return err
}
now := time.Now()
record := models.ActivityRecord{
Kind: kind,
ID: id,
Timestamp: now.UTC().Format(time.RFC3339Nano),
Detail: detail,
}
// The random suffix guards against two events for the same id landing in
// the same nanosecond (observed as flaky on coarser-resolution clocks)
// silently overwriting one another instead of both being recorded. Not
// a security-sensitive use of randomness — only affects filename
// uniqueness, not any value that's compared or kept secret.
// nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used
filename := fmt.Sprintf("%d_%d_%s.json", now.UnixNano(), rand.Int63n(1_000_000), id) //nolint:gosec
path := filepath.Join(dir, filename)
data, err := json.MarshalIndent(record, "", " ")
if err != nil {
return err
}
return ds.atomicWriteFile(path, data)
}
// GetActivityRecords reads back every entry recorded via RecordActivity for
// the given kind. Unreadable or malformed files are skipped rather than
// failing the whole read — a single corrupt event shouldn't make the rest of
// the log unreadable. Returns an empty slice (not an error) when the
// directory doesn't exist yet, matching the "nothing recorded yet" case.
func (ds *DataStore) GetActivityRecords(kind string) ([]models.ActivityRecord, error) {
dir := filepath.Join(ds.DataDir, "stats", "activity", kind)
entries, err := ds.rootReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
records := make([]models.ActivityRecord, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
data, readErr := ds.rootReadFile(filepath.Join(dir, entry.Name()))
if readErr != nil {
continue
}
var record models.ActivityRecord
if unmarshalErr := json.Unmarshal(data, &record); unmarshalErr != nil {
continue
}
records = append(records, record)
}
return records, nil
}
// SaveErrorStats saves error statistics to the datastore.
func (ds *DataStore) SaveErrorStats(stats models.ErrorStats) error {
dir := filepath.Join(ds.DataDir, "stats", "error")
+146 -4
View File
@@ -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) {
+51 -3
View File
@@ -2,6 +2,7 @@ package datastore
import (
"os"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
@@ -19,6 +20,10 @@ func TestIsSafeIdentifier(t *testing.T) {
{"abc-123", true},
{"abc.123", true},
{"00:11:22:33:44:55", true},
// #634: third-party/manual pairing tools (e.g. the USB-stick
// SSH-enable method) can report a non-numeric margeAccountUUID.
{"stick@local", true},
{strings.Repeat("a", maxSafeIdentifierLength), true},
{"", false},
{"/", false},
{"\\", false},
@@ -30,7 +35,6 @@ func TestIsSafeIdentifier(t *testing.T) {
{"a..b", false},
{"a b", false},
{"a!b", false},
{"a@b", false},
{"a#b", false},
{"a$b", false},
{"a%b", false},
@@ -39,12 +43,17 @@ func TestIsSafeIdentifier(t *testing.T) {
{"a*b", false},
{"a(b", false},
{"a)b", false},
{"a<b", false},
{"a>b", false},
{`a"b`, false},
{"a'b", false},
{strings.Repeat("a", maxSafeIdentifierLength+1), false},
}
for _, test := range tests {
result := isSafeIdentifier(test.id)
result := IsSafeIdentifier(test.id)
if result != test.expected {
t.Errorf("isSafeIdentifier(%q) = %v; expected %v", test.id, result, test.expected)
t.Errorf("IsSafeIdentifier(%q) = %v; expected %v", test.id, result, test.expected)
}
}
}
@@ -72,6 +81,8 @@ func TestSaveDeviceInfo_Validation(t *testing.T) {
{"acc1", "dev/1", true, "invalid device ID"},
{"acc..1", "dev1", true, "invalid account ID"},
{"acc1", "dev..1", true, "invalid device ID"},
// #634: a non-numeric margeAccountUUID is now accepted.
{"stick@local", "dev1", false, ""},
}
for _, test := range tests {
@@ -85,3 +96,40 @@ func TestSaveDeviceInfo_Validation(t *testing.T) {
}
}
}
func TestSaveAccountInfo_Validation(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "datastore-test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
ds := NewDataStore(tmpDir)
tests := []struct {
account string
wantErr bool
errMsg string
}{
{"acc1", false, ""},
// #634: a non-numeric margeAccountUUID reported via
// POST /streaming/account (see HandleMargeCreateAccount) must
// be validated the same way SaveDeviceInfo already validates
// device-reported account IDs.
{"stick@local", false, ""},
{"acc/1", true, "invalid account ID"},
{"acc..1", true, "invalid account ID"},
{"a<b", true, "invalid account ID"},
}
for _, test := range tests {
err := ds.SaveAccountInfo(test.account, &models.ServiceAccountInfo{AccountID: test.account})
if (err != nil) != test.wantErr {
t.Errorf("SaveAccountInfo(%q) error = %v, wantErr %v", test.account, err, test.wantErr)
continue
}
if test.wantErr && err.Error() != test.errMsg {
t.Errorf("SaveAccountInfo(%q) error message = %q, want %q", test.account, err.Error(), test.errMsg)
}
}
}
@@ -0,0 +1,124 @@
package handlers
import (
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// TestIsAnnouncementDismissed_EmptyByDefault verifies a freshly-constructed
// server (no prior activity log) reports nothing as dismissed.
func TestIsAnnouncementDismissed_EmptyByDefault(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dismissal-empty-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
server := NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
if server.IsAnnouncementDismissed("admin-gate-notice") {
t.Error("Expected no announcement to be dismissed on a fresh install")
}
}
// TestRecordDismissal_UpdatesCacheAndPersists is a regression test for the
// #419 design's performance requirement: after RecordDismissal, the
// in-memory cache must reflect it immediately (no disk re-read needed), and
// it must also be durably persisted via the activity log.
func TestRecordDismissal_UpdatesCacheAndPersists(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dismissal-record-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
server := NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
if err := server.RecordDismissal("admin-gate-notice"); err != nil {
t.Fatalf("RecordDismissal failed: %v", err)
}
if !server.IsAnnouncementDismissed("admin-gate-notice") {
t.Error("Expected admin-gate-notice to be dismissed after RecordDismissal")
}
records, err := ds.GetActivityRecords(activityKindNotificationDismissed)
if err != nil {
t.Fatalf("GetActivityRecords failed: %v", err)
}
if len(records) != 1 || records[0].ID != "admin-gate-notice" {
t.Errorf("Expected exactly 1 persisted dismissal record, got: %+v", records)
}
}
// TestLoadDismissedAnnouncements_ReadsPriorHistoryAtStartup verifies a
// restarted server picks up dismissals recorded in a previous run — the
// startup scan, not just the live write-through path.
func TestLoadDismissedAnnouncements_ReadsPriorHistoryAtStartup(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dismissal-startup-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
// Simulate a dismissal recorded in a prior run, before this process's
// Server ever existed.
if err := ds.RecordActivity(activityKindNotificationDismissed, "admin-gate-notice", nil); err != nil {
t.Fatalf("Seeding activity record failed: %v", err)
}
server := NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
if !server.IsAnnouncementDismissed("admin-gate-notice") {
t.Error("Expected startup scan to pick up a dismissal recorded in a prior run")
}
if server.IsAnnouncementDismissed("some-other-notice") {
t.Error("Expected an unrelated id to not be reported as dismissed")
}
}
// TestRecordDismissal_SameIDTwiceAppendsBothKeepsCacheSane verifies dismissing
// the same announcement twice (e.g. re-shown, dismissed again) appends two
// log entries but the in-memory cache still reports it dismissed exactly
// once (a boolean check, not a count).
func TestRecordDismissal_SameIDTwiceAppendsBothKeepsCacheSane(t *testing.T) {
tempDir, err := os.MkdirTemp("", "dismissal-recur-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
server := NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
if err := server.RecordDismissal("admin-gate-notice"); err != nil {
t.Fatalf("First RecordDismissal failed: %v", err)
}
if err := server.RecordDismissal("admin-gate-notice"); err != nil {
t.Fatalf("Second RecordDismissal failed: %v", err)
}
records, err := ds.GetActivityRecords(activityKindNotificationDismissed)
if err != nil {
t.Fatalf("GetActivityRecords failed: %v", err)
}
if len(records) != 2 {
t.Errorf("Expected 2 append-only log entries for a recurring dismissal, got %d", len(records))
}
if !server.IsAnnouncementDismissed("admin-gate-notice") {
t.Error("Expected admin-gate-notice to still be reported dismissed")
}
}
@@ -0,0 +1,230 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
)
// Announcement target values. Mirrors the vocabulary Settings.DefaultLanding
// already uses (see defaultLanding() in handlers_media.go) rather than
// inventing new names — "chooser" is the neutral welcome page, "app" is the
// embedded player, "admin" is this admin console.
const (
announcementTargetChooser = "chooser"
announcementTargetApp = "app"
announcementTargetAdmin = "admin"
)
// Announcement is one entry in the small, in-code (not admin-authored)
// announcement list — see #419 design doc,
// _/i419/design-admin-area-auth-gate.md. ShowWhile lets an entry key off
// live server state (e.g. "only while the admin-area gate hasn't been
// decided yet"); nil means always show (until dismissed).
//
// MessageFunc and DismissKeyFunc (added for #591,
// _/i591/design-update-check.md) are the dynamic counterparts of Message
// and ID: nil means "use the static field", as before; set means "compute
// it from live state". The update-check notice needs both — its text names
// a specific version, and dismissing the notice for v1.2.0 must not
// suppress a later notice for v1.3.0, so its dismissal key has to change
// with the detected version.
type Announcement struct {
ID string
Message string
MessageFunc func(*Server) string
Level string
Targets []string
ShowWhile func(*Server) bool
DismissKeyFunc func(*Server) string
// LinkText/LinkURL add an optional link alongside Message — e.g. a
// release's notes, or a docs page for a future announcement. LinkURLFunc
// is the dynamic counterpart of LinkURL (nil = use the static field),
// for links whose target depends on live state (e.g. which version was
// detected). LinkText has no *Func counterpart: nothing here needs
// dynamic link *text*, only a dynamic *URL* — add one only once
// something actually needs it, per this project's KISS convention.
LinkText string
LinkURL string
LinkURLFunc func(*Server) string
}
// message returns the effective text: MessageFunc(s) if set, else the
// static Message.
func (a Announcement) message(s *Server) string {
if a.MessageFunc != nil {
return a.MessageFunc(s)
}
return a.Message
}
// linkURL returns the effective link URL: LinkURLFunc(s) if set, else the
// static LinkURL (which may be "" — no link).
func (a Announcement) linkURL(s *Server) string {
if a.LinkURLFunc != nil {
return a.LinkURLFunc(s)
}
return a.LinkURL
}
// dismissKey returns the effective dismissal/DTO id: DismissKeyFunc(s) if
// set, else the static ID.
func (a Announcement) dismissKey(s *Server) string {
if a.DismissKeyFunc != nil {
return a.DismissKeyFunc(s)
}
return a.ID
}
// announcements is the full, in-code list. announcementTargetChooser is
// prepared as a valid target value (see the constant above) but no entry
// here uses it yet: the chooser landing page (handlers_media.go, landingHTML)
// is currently fully static with no JS at all, unlike /admin and /app, so it
// can't render or dismiss a banner yet. Wire a chooser-targeted entry only
// once that client-side logic exists.
var announcements = []Announcement{
{
ID: "admin-area-auth-419",
Level: "info",
Targets: []string{announcementTargetAdmin},
Message: "A future release will require login for this entire admin area by default (today, only " +
"Spotify/Amazon linking and the Local Account tab do). You can opt in now in Settings, or " +
"dismiss this once you've decided.",
LinkText: "Issue #419",
LinkURL: "https://github.com/gesellix/Bose-SoundTouch/issues/419",
ShowWhile: func(s *Server) bool {
return s.AdminAreaAuthMode() == ""
},
},
{
ID: "update-available",
Level: "info",
Targets: []string{announcementTargetApp, announcementTargetAdmin},
ShowWhile: func(s *Server) bool {
return s.UpdateCheckResult().Available
},
MessageFunc: func(s *Server) string {
r := s.UpdateCheckResult()
return fmt.Sprintf("AfterTouch %s is available (you're on %s).", r.LatestVersion, r.CurrentVersion)
},
LinkText: "Release notes",
LinkURLFunc: func(s *Server) string {
return s.UpdateCheckResult().ReleaseURL
},
// Per-version, not per-family: dismissing the notice for one version
// must not silently suppress a later, different version's notice.
DismissKeyFunc: func(s *Server) string {
return "update-available-" + s.UpdateCheckResult().LatestVersion
},
},
}
// announcementDTO is the JSON shape returned by HandleListAnnouncements —
// deliberately smaller than Announcement (no ShowWhile func, no Targets;
// the caller already asked for a specific target).
type announcementDTO struct {
ID string `json:"id"`
Message string `json:"message"`
Level string `json:"level"`
LinkText string `json:"link_text,omitempty"`
LinkURL string `json:"link_url,omitempty"`
}
func containsString(haystack []string, needle string) bool {
for _, v := range haystack {
if v == needle {
return true
}
}
return false
}
// HandleListAnnouncements returns the announcements currently active for
// the requested target (query param, one of "app" or "admin" — "chooser" is
// a reserved value, not yet wired to any handler), filtered by ShowWhile and
// excluding anything already dismissed. Deliberately NOT behind
// BasicAuthAdmin: the admin-area-gate notice specifically needs to reach
// operators who haven't set up credentials yet, the exact audience an
// admin-only endpoint would exclude.
func (s *Server) HandleListAnnouncements(w http.ResponseWriter, r *http.Request) {
target := r.URL.Query().Get("target")
switch target {
case announcementTargetApp, announcementTargetAdmin:
default:
http.Error(w, "target must be app or admin", http.StatusBadRequest)
return
}
active := make([]announcementDTO, 0, len(announcements))
for i := range announcements {
a := &announcements[i]
if !containsString(a.Targets, target) {
continue
}
if a.ShowWhile != nil && !a.ShowWhile(s) {
continue
}
key := a.dismissKey(s)
if s.IsAnnouncementDismissed(key) {
continue
}
active = append(active, announcementDTO{
ID: key,
Message: a.message(s),
Level: a.Level,
LinkText: a.LinkText,
LinkURL: a.linkURL(s),
})
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{"announcements": active}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleDismissAnnouncement records a dismissal for the given announcement
// id (see Server.RecordDismissal). The id is validated against the known
// announcements list rather than accepted as arbitrary input — it ends up
// as part of a filename in the local activity log (datastore.RecordActivity),
// and this is the one call site where the id comes from an HTTP request
// rather than a compile-time constant. Also not behind BasicAuthAdmin, for
// the same reason as HandleListAnnouncements.
func (s *Server) HandleDismissAnnouncement(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
found := false
for i := range announcements {
if announcements[i].dismissKey(s) == id {
found = true
break
}
}
if !found {
http.Error(w, "Unknown announcement id", http.StatusNotFound)
return
}
if err := s.RecordDismissal(id); err != nil {
http.Error(w, "Failed to record dismissal: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
@@ -0,0 +1,287 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
"github.com/go-chi/chi/v5"
)
func newAnnouncementsTestServer(t *testing.T) *Server {
t.Helper()
tempDir, err := os.MkdirTemp("", "announcements-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
t.Cleanup(func() { os.RemoveAll(tempDir) })
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
return NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
}
func listAnnouncements(t *testing.T, s *Server, target string) (int, []announcementDTO) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/announcements?target="+target, nil)
rr := httptest.NewRecorder()
s.HandleListAnnouncements(rr, req)
if rr.Code != http.StatusOK {
return rr.Code, nil
}
var body struct {
Announcements []announcementDTO `json:"announcements"`
}
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
return rr.Code, body.Announcements
}
func TestHandleListAnnouncements_InvalidTarget(t *testing.T) {
s := newAnnouncementsTestServer(t)
for _, target := range []string{"", "chooser", "bogus"} {
req := httptest.NewRequest(http.MethodGet, "/api/announcements?target="+target, nil)
rr := httptest.NewRecorder()
s.HandleListAnnouncements(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("target=%q: expected 400 (chooser is reserved, not wired yet), got %d", target, rr.Code)
}
}
}
// TestHandleListAnnouncements_AdminGateNotice is a regression test for the
// #419 admin-gate announcement's ShowWhile/Targets/dismissal behavior end to
// end: visible for "admin" while AdminAreaAuth is unset, invisible for
// "app", invisible once the mode is set, and invisible once dismissed.
func TestHandleListAnnouncements_AdminGateNotice(t *testing.T) {
const noticeID = "admin-area-auth-419"
t.Run("visible for admin target while unset", func(t *testing.T) {
s := newAnnouncementsTestServer(t)
status, active := listAnnouncements(t, s, announcementTargetAdmin)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
if !containsAnnouncementID(active, noticeID) {
t.Errorf("expected %q to be active for target=admin while unset, got %+v", noticeID, active)
}
})
t.Run("not visible for app target", func(t *testing.T) {
s := newAnnouncementsTestServer(t)
_, active := listAnnouncements(t, s, announcementTargetApp)
if containsAnnouncementID(active, noticeID) {
t.Errorf("expected %q to NOT be active for target=app (Targets is admin-only), got %+v", noticeID, active)
}
})
t.Run("not visible once AdminAreaAuth is decided", func(t *testing.T) {
s := newAnnouncementsTestServer(t)
s.SetAdminAreaAuth("enabled")
_, active := listAnnouncements(t, s, announcementTargetAdmin)
if containsAnnouncementID(active, noticeID) {
t.Errorf("expected %q to disappear once the mode is decided, got %+v", noticeID, active)
}
})
t.Run("not visible once dismissed", func(t *testing.T) {
s := newAnnouncementsTestServer(t)
if err := s.RecordDismissal(noticeID); err != nil {
t.Fatalf("RecordDismissal failed: %v", err)
}
_, active := listAnnouncements(t, s, announcementTargetAdmin)
if containsAnnouncementID(active, noticeID) {
t.Errorf("expected %q to disappear once dismissed, got %+v", noticeID, active)
}
})
}
func containsAnnouncementID(active []announcementDTO, id string) bool {
for _, a := range active {
if a.ID == id {
return true
}
}
return false
}
func TestHandleDismissAnnouncement_UnknownID(t *testing.T) {
s := newAnnouncementsTestServer(t)
r := chi.NewRouter()
r.Post("/api/announcements/{id}/dismiss", s.HandleDismissAnnouncement)
req := httptest.NewRequest(http.MethodPost, "/api/announcements/not-a-real-id/dismiss", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("expected 404 for an unknown announcement id, got %d", rr.Code)
}
}
// TestHandleDismissAnnouncement_Success verifies dismissing a known
// announcement both succeeds and is reflected by a subsequent list call —
// end-to-end through the HTTP handlers, not just the underlying
// Server.RecordDismissal/IsAnnouncementDismissed pair.
func TestHandleDismissAnnouncement_Success(t *testing.T) {
s := newAnnouncementsTestServer(t)
r := chi.NewRouter()
r.Post("/api/announcements/{id}/dismiss", s.HandleDismissAnnouncement)
req := httptest.NewRequest(http.MethodPost, "/api/announcements/admin-area-auth-419/dismiss", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
_, active := listAnnouncements(t, s, announcementTargetAdmin)
if containsAnnouncementID(active, "admin-area-auth-419") {
t.Errorf("expected the notice to be gone from the list after dismissal, got %+v", active)
}
}
// newServerWithUpdateAvailable builds a Server whose registered
// updatecheck.Checker reports a newer version than currentVersion, via the
// same persisted-state-seeding path a real restart would use (not a mock —
// exercises the real NewChecker/UpdateCheckResult round trip).
func newServerWithUpdateAvailable(t *testing.T, currentVersion, latestVersion string) *Server {
t.Helper()
s := newAnnouncementsTestServer(t)
ds := datastore.NewDataStore(t.TempDir())
if err := ds.SaveUpdateCheckState(datastore.UpdateCheckState{
LastCheckedAt: "2026-08-09T00:00:00Z",
LastSeenVersion: latestVersion,
LastReleaseURL: "https://example.invalid/releases/" + latestVersion,
}); err != nil {
t.Fatalf("Failed to seed update-check state: %v", err)
}
s.SetUpdateChecker(updatecheck.NewChecker(ds, "owner/repo", currentVersion))
return s
}
// TestHandleListAnnouncements_UpdateAvailable is the regression test for
// #591's reuse of the #419 announcements mechanism: the update-available
// entry's dynamic message/target/dismissal behavior end to end.
func TestHandleListAnnouncements_UpdateAvailable(t *testing.T) {
t.Run("visible for both admin and app targets when available", func(t *testing.T) {
s := newServerWithUpdateAvailable(t, "v1.0.0", "v1.2.0")
for _, target := range []string{announcementTargetAdmin, announcementTargetApp} {
_, active := listAnnouncements(t, s, target)
var found *announcementDTO
for i := range active {
if active[i].ID == "update-available-v1.2.0" {
found = &active[i]
}
}
if found == nil {
t.Fatalf("target=%s: expected an update-available-v1.2.0 entry, got %+v", target, active)
}
if found.Message == "" {
t.Errorf("target=%s: expected a non-empty dynamic message", target)
}
// The release URL belongs in the structured link field, not
// embedded as text in the message — the message must stay
// generic across other future announcements too.
if strings.Contains(found.Message, "http") {
t.Errorf("target=%s: expected the URL out of Message, got %q", target, found.Message)
}
if found.LinkURL == "" {
t.Errorf("target=%s: expected a non-empty LinkURL", target)
}
if found.LinkText == "" {
t.Errorf("target=%s: expected a non-empty LinkText", target)
}
}
})
t.Run("not visible when already up to date", func(t *testing.T) {
s := newServerWithUpdateAvailable(t, "v1.2.0", "v1.2.0")
_, active := listAnnouncements(t, s, announcementTargetAdmin)
if containsAnnouncementID(active, "update-available-v1.2.0") {
t.Errorf("expected no update-available entry when up to date, got %+v", active)
}
})
t.Run("dismissing one version does not suppress a later version", func(t *testing.T) {
s := newServerWithUpdateAvailable(t, "v1.0.0", "v1.2.0")
if err := s.RecordDismissal("update-available-v1.2.0"); err != nil {
t.Fatalf("RecordDismissal failed: %v", err)
}
_, active := listAnnouncements(t, s, announcementTargetAdmin)
if containsAnnouncementID(active, "update-available-v1.2.0") {
t.Error("expected the v1.2.0 notice to be dismissed")
}
// A later check finds a newer version still: must reappear under a
// DIFFERENT dismissal key, not stay suppressed.
newDS := datastore.NewDataStore(t.TempDir())
if err := newDS.SaveUpdateCheckState(datastore.UpdateCheckState{
LastCheckedAt: "2026-08-10T00:00:00Z",
LastSeenVersion: "v1.3.0",
}); err != nil {
t.Fatalf("Failed to seed newer state: %v", err)
}
s.SetUpdateChecker(updatecheck.NewChecker(newDS, "owner/repo", "v1.0.0"))
_, active = listAnnouncements(t, s, announcementTargetAdmin)
if !containsAnnouncementID(active, "update-available-v1.3.0") {
t.Errorf("expected the v1.3.0 notice to appear despite v1.2.0 being dismissed, got %+v", active)
}
})
}
func TestHandleDismissAnnouncement_UpdateAvailable(t *testing.T) {
s := newServerWithUpdateAvailable(t, "v1.0.0", "v1.2.0")
r := chi.NewRouter()
r.Post("/api/announcements/{id}/dismiss", s.HandleDismissAnnouncement)
req := httptest.NewRequest(http.MethodPost, "/api/announcements/update-available-v1.2.0/dismiss", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
_, active := listAnnouncements(t, s, announcementTargetAdmin)
if containsAnnouncementID(active, "update-available-v1.2.0") {
t.Errorf("expected the notice to be gone after dismissal, got %+v", active)
}
}
+8 -8
View File
@@ -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))
}
+90 -2
View File
@@ -9,9 +9,11 @@ import (
"encoding/xml"
"fmt"
"io"
"io/fs"
"log"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
@@ -189,6 +191,7 @@ func (s *Server) buildDiagnosticArchive() ([]byte, error) {
s.addSystemFiles(tw)
s.addServiceLog(tw)
s.addSettingsJSON(tw)
s.addActivityLog(tw)
addEnvVars(tw)
if err := tw.Close(); err != nil {
@@ -253,7 +256,12 @@ func (s *Server) addServiceHTTP(tw *tar.Writer, client *http.Client, devices []m
if !seenAccounts[dev.AccountID] {
seenAccounts[dev.AccountID] = true
pfx := "http/service/account-" + dev.AccountID
acct := base + "/streaming/account/" + dev.AccountID
// url.PathEscape, not raw concatenation: account/device IDs can
// contain characters like '@' (#634) that are safe as datastore
// keys but would otherwise need escaping to survive as URL path
// segments intact (e.g. a literal '?' or '#' would truncate the
// path here, though IsSafeIdentifier already excludes those).
acct := base + "/streaming/account/" + url.PathEscape(dev.AccountID)
tryAdd(pfx+"/full.xml", acct+"/full")
tryAdd(pfx+"/sources.xml", acct+"/sources")
tryAdd(pfx+"/presets.xml", acct+"/presets")
@@ -264,7 +272,7 @@ func (s *Server) addServiceHTTP(tw *tar.Writer, client *http.Client, devices []m
}
dpfx := "http/service/account-" + dev.AccountID + "/device-" + dev.DeviceID
dpath := base + "/streaming/account/" + dev.AccountID + "/device/" + dev.DeviceID
dpath := base + "/streaming/account/" + url.PathEscape(dev.AccountID) + "/device/" + url.PathEscape(dev.DeviceID)
tryAdd(dpfx+"/presets.xml", dpath+"/presets")
tryAdd(dpfx+"/recents.xml", dpath+"/recents")
}
@@ -593,6 +601,36 @@ func (s *Server) collectSpeakerRedirectConfig(tw *tar.Writer) map[string]*redire
return out
}
// readSpeakerBmxRegistryURL reads a single speaker's runtime bmxRegistryUrl from
// its on-device SoundTouchSdkPrivateCfg.xml (via SSH), falling back to `getpdo
// CurrentSystemConfiguration` over telnet. It returns the URL and whether the
// runtime config could be read at all (SSH or telnet succeeded). Unlike
// collectSpeakerRedirectConfig it archives nothing; it's the lightweight read
// used by the runtime_bmx_url_stale health check.
func (s *Server) readSpeakerBmxRegistryURL(ip string) (string, bool) {
if ip == "" {
return "", false
}
// 1. Prefer the persisted XML over SSH.
sc := speakerssh.NewClient(ip)
if data, err := sc.ReadFile(setup.SoundTouchSdkPrivateCfgPath); err == nil {
var cfg setup.PrivateCfg
if xml.Unmarshal(data, &cfg) == nil {
return cfg.BmxRegistryUrl, true
}
}
// 2. Fall back to telnet getpdo when SSH gave us nothing.
if raw, ok := readTelnetSystemConfig(ip); ok {
if fields := setup.ParseGetpdoConfig(raw); len(fields) > 0 {
return fields["bmxRegistryUrl"], true
}
}
return "", false
}
// addServiceLog appends the in-memory service log buffer as logs/service.txt.
// Each entry is formatted as "2006-01-02T15:04:05Z <message>".
func (s *Server) addServiceLog(tw *tar.Writer) {
@@ -668,6 +706,56 @@ type diagSettings struct {
// addSettingsJSON serialises the service settings into the archive as
// settings.json. OAuth client secrets are replaced with "[REDACTED]" so the
// file is safe to share.
// addActivityLog bundles the local admin-UI activity log (announcement
// dismissals, and any other kind recorded via datastore.RecordActivity)
// into the diagnostic archive verbatim, one file per event — same idea as
// the per-device XML bundling above, but for stats/activity/. This is what
// makes the "local-only, but included in an explicitly-triggered diagnostic
// export" claim in DIAGNOSTIC-EXPORT.md actually true. A missing directory
// (nothing recorded yet) is not an error.
func (s *Server) addActivityLog(tw *tar.Writer) {
if s.ds == nil || s.ds.DataDir == "" {
return
}
root := filepath.Join(s.ds.DataDir, "stats", "activity")
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if d.IsDir() {
return nil
}
data, readErr := os.ReadFile(path)
if readErr != nil {
log.Printf("[Export] read activity log %s: %v", sanitizeLog(path), readErr)
return nil
}
rel, relErr := filepath.Rel(s.ds.DataDir, path)
if relErr != nil {
log.Printf("[Export] rel path for %s: %v", sanitizeLog(path), relErr)
return nil
}
if addErr := addTarBytes(tw, rel, data); addErr != nil {
log.Printf("[Export] add %s: %v", sanitizeLog(rel), addErr)
}
return nil
})
if walkErr != nil {
log.Printf("[Export] walk activity log: %v", walkErr)
}
}
func (s *Server) addSettingsJSON(tw *tar.Writer) {
st, err := s.ds.GetSettings()
if err != nil {
@@ -0,0 +1,115 @@
package handlers
import (
"archive/tar"
"bytes"
"errors"
"io"
"os"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// tarEntries reads every file name + content out of a tar written by
// addActivityLog, for assertions.
func tarEntries(t *testing.T, tw *tar.Writer, buf *bytes.Buffer) map[string]string {
t.Helper()
if err := tw.Close(); err != nil {
t.Fatalf("Failed to close tar writer: %v", err)
}
entries := make(map[string]string)
tr := tar.NewReader(buf)
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("Failed to read tar entry: %v", err)
}
data, err := io.ReadAll(tr)
if err != nil {
t.Fatalf("Failed to read tar entry content: %v", err)
}
entries[hdr.Name] = string(data)
}
return entries
}
// TestAddActivityLog_EmptyByDefault verifies a fresh install (nothing
// recorded via datastore.RecordActivity yet — the common case, since
// stats/activity/ won't exist at all) doesn't error and adds nothing.
func TestAddActivityLog_EmptyByDefault(t *testing.T) {
tempDir, err := os.MkdirTemp("", "export-activity-empty-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
server := NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
server.addActivityLog(tw)
entries := tarEntries(t, tw, &buf)
if len(entries) != 0 {
t.Errorf("Expected no tar entries for an empty activity log, got %+v", entries)
}
}
// TestAddActivityLog_IncludesRecordedDismissal is the regression test for
// the #419 design's stated privacy guarantee: a dismissal recorded locally
// must actually show up in the diagnostic export, not just in theory. This
// closes the loop DIAGNOSTIC-EXPORT.md documents.
func TestAddActivityLog_IncludesRecordedDismissal(t *testing.T) {
tempDir, err := os.MkdirTemp("", "export-activity-dismissal-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
server := NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
if err := server.RecordDismissal("admin-area-auth-419"); err != nil {
t.Fatalf("RecordDismissal failed: %v", err)
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
server.addActivityLog(tw)
entries := tarEntries(t, tw, &buf)
if len(entries) != 1 {
t.Fatalf("Expected exactly 1 tar entry, got %+v", entries)
}
var (
name string
content string
)
for n, c := range entries {
name, content = n, c
}
if !strings.HasPrefix(name, "stats/activity/notification_dismissed/") {
t.Errorf("Expected entry under stats/activity/notification_dismissed/, got %q", name)
}
if !strings.Contains(content, "admin-area-auth-419") {
t.Errorf("Expected entry content to reference the dismissed id, got %q", content)
}
}
+6
View File
@@ -14,6 +14,7 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
"github.com/go-chi/chi/v5"
)
@@ -64,6 +65,11 @@ func (s *Server) HandleMargeCreateAccount(w http.ResponseWriter, r *http.Request
}
}
if !datastore.IsSafeIdentifier(id) {
http.Error(w, "Invalid account ID", http.StatusBadRequest)
return
}
info := &models.ServiceAccountInfo{
AccountID: id,
PreferredLanguage: req.PreferredLanguage,
+41
View File
@@ -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")
+110
View File
@@ -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 -5
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/health"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/go-chi/chi/v5"
@@ -61,12 +62,12 @@ type pairAccountResponse struct {
Error string `json:"error,omitempty"`
}
// HandlePairAccount associates the device with the supplied 7-digit account ID,
// HandlePairAccount associates the device with the supplied account ID,
// trying HTTP /setMargeAccount first and falling back to telnet
// `envswitch accountid set`.
//
// Query params:
// - account_id (required) — must pass setup.IsValidAccountID
// - account_id (required) — must pass datastore.IsSafeIdentifier
func (s *Server) HandlePairAccount(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
@@ -75,8 +76,8 @@ func (s *Server) HandlePairAccount(w http.ResponseWriter, r *http.Request) {
}
accountID := r.URL.Query().Get("account_id")
if !setup.IsValidAccountID(accountID) {
writeJSONError(w, http.StatusBadRequest, "account_id must be exactly 7 digits")
if !datastore.IsSafeIdentifier(accountID) {
writeJSONError(w, http.StatusBadRequest, "account_id must be a non-empty, path-safe identifier")
return
}
@@ -145,7 +146,7 @@ func (s *Server) completeSpeakerPairingFix(target health.Target) (string, error)
}
accountID := target.Account
if !setup.IsValidAccountID(accountID) {
if !datastore.IsSafeIdentifier(accountID) {
known, _ := s.ds.ListAccounts()
generated, genErr := setup.GenerateAccountID(known)
+251 -73
View File
@@ -6,6 +6,7 @@ import (
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
@@ -16,6 +17,7 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/health"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/go-chi/chi/v5"
)
@@ -165,11 +167,17 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
httpsOverride := s.httpsOverride
discoveryInterval := s.discoveryInterval.String()
discoveryEnabled := s.discoveryEnabled
// Read the update-check fields directly rather than via
// GetUpdateCheckSettings(): that getter takes s.mu.RLock itself, and Go's
// sync.RWMutex is not reentrant-safe against a concurrent writer.
updateCheckInterval := s.updateCheckInterval.String()
updateCheckEnabled := s.updateCheckEnabled
dnsEnabled := s.dnsEnabled
dnsUpstream := s.dnsUpstream
dnsBindAddr := s.dnsBindAddr
internalPaths := s.internalPaths
redact, logBody, record := s.redactLogs, s.logBodies, s.recordEnabled
adminAreaAuth := s.adminAreaAuth
shortcuts := s.shortcuts
spotifyConfigured := s.spotifyService != nil
spotifyClientID := s.spotifyClientID
@@ -245,6 +253,8 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
"https_443_lan_host": probe443.LANHost,
"discovery_interval": discoveryInterval,
"discovery_enabled": discoveryEnabled,
"update_check_interval": updateCheckInterval,
"update_check_enabled": updateCheckEnabled,
"dns_enabled": dnsEnabled,
"dns_running": dnsRunning,
"dns_actual_bind": actualBind,
@@ -271,12 +281,66 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
"tts_voice": ttsVoice,
"tts_volume": ttsVolume,
"default_landing": defaultLanding,
"admin_area_auth": adminAreaAuth,
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// parseDNSUpstreamList splits a comma-separated DNS upstream list into its
// trimmed, non-empty entries. Returns nil for an empty input.
func parseDNSUpstreamList(dnsUpstream string) []string {
if dnsUpstream == "" {
return nil
}
var upstreamList []string
for _, u := range strings.Split(dnsUpstream, ",") {
u = strings.TrimSpace(u)
if u != "" {
upstreamList = append(upstreamList, u)
}
}
return upstreamList
}
// parseOptionalDuration parses a duration string that the client is allowed to
// omit. An empty value yields a zero duration and no error, so callers can
// treat "field omitted" as "keep the current value" while still rejecting a
// value that was supplied but is unparseable.
func parseOptionalDuration(value string) (time.Duration, error) {
if value == "" {
return 0, nil
}
return time.ParseDuration(value)
}
// resolvePeriodicSetting computes the new (interval, enabled) pair for one of
// the background pollers (device discovery, update check) from a settings
// request. When the request omitted the interval, the current one is kept. A
// zero interval always forces the task off: both pollers treat zero as
// "always due", so leaving the task enabled would make their poll tick the
// work rate.
func resolvePeriodicSetting(
currentInterval, requestedInterval time.Duration,
requestedIntervalProvided, requestedEnabled bool,
) (time.Duration, bool) {
interval := currentInterval
if requestedIntervalProvided {
interval = requestedInterval
}
if interval == 0 {
return interval, false
}
return interval, requestedEnabled
}
// HandleUpdateSettings updates the service settings.
func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
var settings struct {
@@ -284,6 +348,8 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
HTTPSServerURLOverride *string `json:"https_server_url_override"`
DiscoveryInterval string `json:"discovery_interval"`
DiscoveryEnabled bool `json:"discovery_enabled"`
UpdateCheckInterval string `json:"update_check_interval"`
UpdateCheckEnabled bool `json:"update_check_enabled"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream string `json:"dns_upstream"`
DNSBindAddr string `json:"dns_bind_addr"`
@@ -303,6 +369,7 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
TTSVolume int `json:"tts_volume"`
TLSExtraHosts *[]string `json:"tls_extra_hosts"`
DefaultLanding string `json:"default_landing"`
AdminAreaAuth string `json:"admin_area_auth"`
}
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
@@ -318,6 +385,14 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
return
}
// Normalise + validate the admin-area auth mode. Empty means "unset"
// (today: not enforced — see datastore.Settings.AdminAreaAuth, #419).
adminAreaAuth, validAdminAreaAuth := NormalizeAdminAreaAuth(settings.AdminAreaAuth)
if !validAdminAreaAuth {
http.Error(w, "Invalid admin_area_auth: must be empty, enabled, or disabled", http.StatusBadRequest)
return
}
if settings.DNSEnabled && settings.DNSUpstream == "" {
// No strict requirement for DNSUpstream here as SetDNSSettings will
// try to fall back to system DNS. We only log it if both are empty later.
@@ -338,42 +413,46 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
return
}
interval, err := time.ParseDuration(settings.DiscoveryInterval)
if err != nil && settings.DiscoveryInterval != "" {
interval, err := parseOptionalDuration(settings.DiscoveryInterval)
if err != nil {
http.Error(w, "Invalid discovery interval: "+err.Error(), http.StatusBadRequest)
return
}
updateCheckInterval, err := parseOptionalDuration(settings.UpdateCheckInterval)
if err != nil {
http.Error(w, "Invalid update check interval: "+err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
// Guard rail: refuse to enable the admin-area gate while the Management
// API credentials are still the published default — that would let
// anyone in with admin/change_me! anyway, just with extra friction.
if blocksAdminAreaAuthEnable(adminAreaAuth, s.mgmtUsername, s.mgmtPassword) {
s.mu.Unlock()
http.Error(w, "Cannot enable admin_area_auth while Management API credentials are still the "+
"published default (admin/change_me!). Set MGMT_USERNAME and MGMT_PASSWORD to your own "+
"values first.", http.StatusBadRequest)
return
}
s.adminAreaAuth = adminAreaAuth
s.serverURL = settings.ServerURL
// nil override = "field omitted, preserve"; recompute regardless, since
// the Target Domain (which the derived URL follows) may have changed.
s.applyHTTPSOverrideLocked(settings.HTTPSServerURLOverride)
s.discoveryEnabled = settings.DiscoveryEnabled
if settings.DiscoveryInterval != "" {
s.discoveryInterval = interval
}
s.discoveryInterval, s.discoveryEnabled = resolvePeriodicSetting(
s.discoveryInterval, interval, settings.DiscoveryInterval != "", settings.DiscoveryEnabled)
if s.discoveryInterval == 0 {
s.discoveryEnabled = false
}
s.updateCheckInterval, s.updateCheckEnabled = resolvePeriodicSetting(
s.updateCheckInterval, updateCheckInterval, settings.UpdateCheckInterval != "", settings.UpdateCheckEnabled)
s.dnsEnabled = settings.DNSEnabled
// Handle comma-separated upstream DNS servers
var upstreamList []string
if settings.DNSUpstream != "" {
for _, u := range strings.Split(settings.DNSUpstream, ",") {
u = strings.TrimSpace(u)
if u != "" {
upstreamList = append(upstreamList, u)
}
}
}
s.dnsUpstream = upstreamList
s.dnsUpstream = parseDNSUpstreamList(settings.DNSUpstream)
s.dnsBindAddr = settings.DNSBindAddr
s.internalPaths = settings.InternalPaths
@@ -407,42 +486,58 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
// HTTPS URL keeps following the Target Domain across restarts.
currentHTTPS := s.httpsOverride
// Load the persisted settings first and overlay only the fields this
// handler owns, instead of building a fresh struct from scratch. Fields
// with no in-memory counterpart on Server (e.g. TrustForwardedHeaders,
// TrustedProxyCIDRs, TuneInStreamFormats) are only ever set by hand-editing
// settings.json; overwriting with a fresh struct would silently drop them
// (issue #589).
persisted, err := s.ds.GetSettings()
if err != nil {
s.mu.Unlock()
http.Error(w, "Failed to load existing settings: "+err.Error(), http.StatusInternalServerError)
return
}
// Resolve TLS extra hosts: nil pointer means "field omitted, preserve existing";
// non-nil (even empty) means "replace with this list".
resolvedTLSExtraHosts := s.persistedTLSExtraHosts()
resolvedTLSExtraHosts := persisted.TLSExtraHosts
if settings.TLSExtraHosts != nil {
resolvedTLSExtraHosts = normaliseTLSExtraHosts(*settings.TLSExtraHosts)
}
log.Printf("Saving updated settings to %s/settings.json", s.ds.DataDir)
err = s.ds.SaveSettings(datastore.Settings{
ServerURL: s.serverURL,
HTTPServerURL: currentHTTPS,
RedactLogs: currentRedact,
LogBodies: currentLogBody,
RecordInteractions: currentRecord,
DiscoveryInterval: s.discoveryInterval.String(),
DiscoveryEnabled: s.discoveryEnabled,
DNSEnabled: s.dnsEnabled,
DNSUpstream: s.dnsUpstream,
DNSBindAddr: s.dnsBindAddr,
InternalPaths: s.internalPaths,
Shortcuts: s.shortcuts,
SpotifyClientID: s.spotifyClientID,
SpotifyClientSecret: s.spotifyClientSecret,
SpotifyRedirectURI: s.spotifyRedirectURI,
AmazonClientID: s.amazonClientID,
AmazonClientSecret: s.amazonClientSecret,
AmazonRedirectURI: s.amazonRedirectURI,
TTSProvider: s.ttsProvider,
TTSGoogleAPIKey: s.ttsGoogleAPIKey,
TTSAppKey: s.ttsAppKey,
TTSLanguage: s.ttsLanguage,
TTSVoice: s.ttsVoice,
TTSVolume: s.ttsVolume,
TLSExtraHosts: resolvedTLSExtraHosts,
DefaultLanding: defaultLanding,
})
persisted.ServerURL = s.serverURL
persisted.HTTPServerURL = currentHTTPS
persisted.RedactLogs = currentRedact
persisted.LogBodies = currentLogBody
persisted.RecordInteractions = currentRecord
persisted.DiscoveryInterval = s.discoveryInterval.String()
persisted.DiscoveryEnabled = s.discoveryEnabled
persisted.UpdateCheckInterval = s.updateCheckInterval.String()
persisted.UpdateCheckEnabled = s.updateCheckEnabled
persisted.DNSEnabled = s.dnsEnabled
persisted.DNSUpstream = s.dnsUpstream
persisted.DNSBindAddr = s.dnsBindAddr
persisted.InternalPaths = s.internalPaths
persisted.Shortcuts = s.shortcuts
persisted.SpotifyClientID = s.spotifyClientID
persisted.SpotifyClientSecret = s.spotifyClientSecret
persisted.SpotifyRedirectURI = s.spotifyRedirectURI
persisted.AmazonClientID = s.amazonClientID
persisted.AmazonClientSecret = s.amazonClientSecret
persisted.AmazonRedirectURI = s.amazonRedirectURI
persisted.TTSProvider = s.ttsProvider
persisted.TTSGoogleAPIKey = s.ttsGoogleAPIKey
persisted.TTSAppKey = s.ttsAppKey
persisted.TTSLanguage = s.ttsLanguage
persisted.TTSVoice = s.ttsVoice
persisted.TTSVolume = s.ttsVolume
persisted.TLSExtraHosts = resolvedTLSExtraHosts
persisted.DefaultLanding = defaultLanding
persisted.AdminAreaAuth = s.adminAreaAuth
err = s.ds.SaveSettings(persisted)
dnsEnabled := s.dnsEnabled
dnsUpstreamStr := strings.Join(s.dnsUpstream, ",")
@@ -479,6 +574,32 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
}
}
// NormalizeAdminAreaAuth trims/lowercases the admin-area auth mode and
// reports whether it's one of the three valid tri-state values ("", the
// unset default; "enabled"; "disabled" — see datastore.Settings.AdminAreaAuth,
// #419). Exported so main.go can apply the same validation to a persisted
// settings.json value at startup that HandleUpdateSettings applies on write.
func NormalizeAdminAreaAuth(v string) (string, bool) {
normalized := strings.ToLower(strings.TrimSpace(v))
switch normalized {
case "", "enabled", "disabled":
return normalized, true
default:
return "", false
}
}
// blocksAdminAreaAuthEnable reports whether enabling the admin-area gate
// must be refused because the Management API credentials are still the
// published default (admin/change_me!) — enabling it in that state would
// give a false sense of security. Extracted from HandleUpdateSettings to
// keep its cyclomatic complexity in check.
func blocksAdminAreaAuthEnable(mode, mgmtUsername, mgmtPassword string) bool {
return mode == "enabled" &&
mgmtUsername == health.DefaultMgmtUsername && mgmtPassword == health.DefaultMgmtPassword
}
// normaliseTLSExtraHosts trims whitespace from each entry, drops empty
// values, and deduplicates while preserving the first occurrence's
// position. The settings endpoint applies this before persisting so the
@@ -1021,17 +1142,31 @@ func (s *Server) HandleUpdateLoggingSettings(w http.ResponseWriter, r *http.Requ
discoveryInterval := s.discoveryInterval.String()
discoveryEnabled := s.discoveryEnabled
// Load the persisted settings first and overlay only the fields this
// handler owns, instead of building a fresh struct from scratch. This
// handler's DTO only ever covers 3 of ~25 fields, so a from-scratch
// struct used to reset everything else (credentials, DNS config,
// TrustForwardedHeaders/TrustedProxyCIDRs, ...) to its zero value on
// every save (issue #589).
persisted, err := s.ds.GetSettings()
if err != nil {
s.mu.Unlock()
http.Error(w, "Failed to load existing settings: "+err.Error(), http.StatusInternalServerError)
return
}
persisted.ServerURL = serverURL
persisted.HTTPServerURL = httpsOverride
persisted.RedactLogs = s.redactLogs
persisted.LogBodies = s.logBodies
persisted.RecordInteractions = s.recordEnabled
persisted.DiscoveryInterval = discoveryInterval
persisted.DiscoveryEnabled = discoveryEnabled
persisted.Shortcuts = s.shortcuts
log.Printf("Saving updated proxy settings to %s/settings.json", s.ds.DataDir)
err := s.ds.SaveSettings(datastore.Settings{
ServerURL: serverURL,
HTTPServerURL: httpsOverride,
RedactLogs: s.redactLogs,
LogBodies: s.logBodies,
RecordInteractions: s.recordEnabled,
DiscoveryInterval: discoveryInterval,
DiscoveryEnabled: discoveryEnabled,
Shortcuts: s.shortcuts,
})
err = s.ds.SaveSettings(persisted)
s.mu.Unlock()
if err != nil {
@@ -1139,7 +1274,16 @@ func (s *Server) HandleTestDNSRedirection(w http.ResponseWriter, r *http.Request
}
}
// HandleInitialSync fetches presets, recents and sources from the device and saves them to the datastore.
// HandleInitialSync fetches presets, recents and sources from the device
// and saves them to the datastore.
//
// If applying the fetched presets/recents would shrink what's already
// stored, the sync is not applied — the response comes back 409 with the
// diff describing what would be removed — unless the caller passes
// ?confirmed=true, in which case it's applied unconditionally. Every call
// re-fetches live from the speaker at that moment (see
// setup.SyncDeviceData), so a confirmed retry re-checks current reality
// rather than replaying a possibly-stale earlier response.
func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
@@ -1153,13 +1297,25 @@ func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.sm.SyncDeviceData(deviceIP); err != nil {
confirmed := r.URL.Query().Get("confirmed") == "true"
result, err := s.sm.SyncDeviceData(deviceIP, confirmed)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok": true}`))
w.Header().Set("Content-Type", "application/json")
if !result.Applied {
w.WriteHeader(http.StatusConflict)
} else {
w.WriteHeader(http.StatusOK)
}
if encodeErr := json.NewEncoder(w).Encode(result); encodeErr != nil {
log.Printf("HandleInitialSync: failed to encode result for device %s: %s", sanitizeLog(deviceID), sanitizeErr(encodeErr))
}
}
// HandleRebootDevice reboots a device.
@@ -1271,6 +1427,19 @@ func (s *Server) HandleGetVersionInfo(w http.ResponseWriter, _ *http.Request) {
repoURL := s.RepoURL
s.mu.RUnlock()
var dataDir string
if s.ds != nil && s.ds.DataDir != "" {
// Resolve to absolute: the default ("data") and any relative
// --data-dir/DATA_DIR value are otherwise ambiguous without knowing
// the process's working directory at startup.
if abs, err := filepath.Abs(s.ds.DataDir); err == nil {
dataDir = abs
} else {
dataDir = s.ds.DataDir
}
}
w.Header().Set("Content-Type", "application/json")
var (
@@ -1288,13 +1457,22 @@ func (s *Server) HandleGetVersionInfo(w http.ResponseWriter, _ *http.Request) {
releaseURL = fmt.Sprintf("%s/releases/tag/%s", repoURL, version)
}
if err := json.NewEncoder(w).Encode(map[string]string{
"version": version,
"commit": commit,
"date": date,
"repo_url": repoURL,
"release_url": releaseURL,
"commit_url": commitURL,
// Opt-in periodic update check (#591) — UpdateCheckResult is nil-safe and
// returns the zero value (Available: false) when the check was never
// enabled, which is the common case.
updateCheck := s.UpdateCheckResult()
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"version": version,
"commit": commit,
"date": date,
"repo_url": repoURL,
"release_url": releaseURL,
"commit_url": commitURL,
"data_dir": dataDir,
"update_available": updateCheck.Available,
"latest_version": updateCheck.LatestVersion,
"latest_release_url": updateCheck.ReleaseURL,
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
+531
View File
@@ -10,10 +10,12 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/health"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
)
@@ -162,6 +164,529 @@ func TestProxySettingsAPI(t *testing.T) {
}
}
// TestSettingsSavePreservesUnmanagedFields is the regression test for
// issue #589: saving settings via either the main settings form or the
// logging/proxy panel must not drop fields that have no counterpart in
// their respective request DTOs (e.g. hand-edited trust_forwarded_headers /
// trusted_proxy_cidrs, or the other handler's owned fields).
func TestSettingsSavePreservesUnmanagedFields(t *testing.T) {
tempDir, err := os.MkdirTemp("", "settings-preserve-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
// Seed settings.json with fields neither handler's DTO exposes.
seeded := datastore.Settings{
ServerURL: "http://127.0.0.1:8000",
TrustForwardedHeaders: true,
TrustedProxyCIDRs: []string{"10.42.0.0/16"},
}
if err := ds.SaveSettings(seeded); err != nil {
t.Fatalf("Failed to seed settings: %v", err)
}
r, server := setupRouter("http://127.0.0.1:8000", ds)
ts := httptest.NewServer(r)
defer ts.Close()
// Simulate a credential already loaded into the running server (as
// main.go's startup wiring does) but not managed by the logging panel's
// DTO, to catch HandleUpdateLoggingSettings resetting fields it doesn't
// own back to their zero value.
server.spotifyClientID = "seeded-spotify-client-id"
// Saving the main settings form (which knows nothing about
// trust_forwarded_headers / trusted_proxy_cidrs) must not drop them.
sysUpdate := map[string]string{"server_url": "http://127.0.0.1:8000"}
sysBody, err := json.Marshal(sysUpdate)
if err != nil {
t.Fatalf("Failed to marshal update: %v", err)
}
res, err := http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(sysBody))
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("POST /setup/settings: expected status OK, got %v", res.Status)
}
persisted, err := ds.GetSettings()
if err != nil {
t.Fatalf("Failed to reload settings: %v", err)
}
if !persisted.TrustForwardedHeaders {
t.Errorf("POST /setup/settings dropped TrustForwardedHeaders: %+v", persisted)
}
if len(persisted.TrustedProxyCIDRs) != 1 || persisted.TrustedProxyCIDRs[0] != "10.42.0.0/16" {
t.Errorf("POST /setup/settings dropped TrustedProxyCIDRs: %+v", persisted)
}
// Saving the logging/proxy panel (which only knows redact/log_body/record)
// must not drop these fields, or the SpotifyClientID it also doesn't manage.
logUpdate := map[string]bool{"redact": true, "log_body": true, "record": false}
logBody, err := json.Marshal(logUpdate)
if err != nil {
t.Fatalf("Failed to marshal logging update: %v", err)
}
res, err = http.Post(ts.URL+"/setup/logging-settings", "application/json", bytes.NewBuffer(logBody))
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("POST /setup/logging-settings: expected status OK, got %v", res.Status)
}
persisted, err = ds.GetSettings()
if err != nil {
t.Fatalf("Failed to reload settings: %v", err)
}
if !persisted.TrustForwardedHeaders {
t.Errorf("POST /setup/logging-settings dropped TrustForwardedHeaders: %+v", persisted)
}
if len(persisted.TrustedProxyCIDRs) != 1 || persisted.TrustedProxyCIDRs[0] != "10.42.0.0/16" {
t.Errorf("POST /setup/logging-settings dropped TrustedProxyCIDRs: %+v", persisted)
}
if persisted.SpotifyClientID != "seeded-spotify-client-id" {
t.Errorf("POST /setup/logging-settings dropped SpotifyClientID: %+v", persisted)
}
}
// TestAdminAreaAuthInvalidValue is a regression test for #419: an
// unrecognised admin_area_auth value must be rejected outright, not
// silently coerced to the unset default.
func TestAdminAreaAuthInvalidValue(t *testing.T) {
tempDir, err := os.MkdirTemp("", "admin-area-auth-invalid-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
r, _ := setupRouter("http://127.0.0.1:8000", ds)
ts := httptest.NewServer(r)
defer ts.Close()
body, err := json.Marshal(map[string]string{
"server_url": "http://127.0.0.1:8000",
"admin_area_auth": "sometimes",
})
if err != nil {
t.Fatalf("Failed to marshal request body: %v", err)
}
res, err := http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(body))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusBadRequest {
t.Errorf("Expected 400 for invalid admin_area_auth, got %v", res.Status)
}
persisted, err := ds.GetSettings()
if err != nil {
t.Fatalf("Failed to reload settings: %v", err)
}
if persisted.AdminAreaAuth != "" {
t.Errorf("Invalid admin_area_auth must not be persisted, got %q", persisted.AdminAreaAuth)
}
}
// TestAdminAreaAuthGuardRailBlocksDefaultCreds is a regression test for
// #419: enabling the admin-area gate while the Management API credentials
// are still the published default (admin/change_me!) must be rejected —
// otherwise the gate would give a false sense of security.
func TestAdminAreaAuthGuardRailBlocksDefaultCreds(t *testing.T) {
tempDir, err := os.MkdirTemp("", "admin-area-auth-guard-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
r, server := setupRouter("http://127.0.0.1:8000", ds)
ts := httptest.NewServer(r)
defer ts.Close()
server.mgmtUsername = health.DefaultMgmtUsername
server.mgmtPassword = health.DefaultMgmtPassword
body, err := json.Marshal(map[string]string{
"server_url": "http://127.0.0.1:8000",
"admin_area_auth": "enabled",
})
if err != nil {
t.Fatalf("Failed to marshal request body: %v", err)
}
res, err := http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(body))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusBadRequest {
t.Errorf("Expected 400 when enabling admin_area_auth with default creds, got %v", res.Status)
}
if server.AdminAreaAuthMode() != "" {
t.Errorf("Guard rail must not flip the live mode, got %q", server.AdminAreaAuthMode())
}
persisted, err := ds.GetSettings()
if err != nil {
t.Fatalf("Failed to reload settings: %v", err)
}
if persisted.AdminAreaAuth != "" {
t.Errorf("Guard rail must not persist the change, got %q", persisted.AdminAreaAuth)
}
}
// TestAdminAreaAuthRoundTrip verifies enabling (with non-default creds) and
// later disabling admin_area_auth updates both the live server field and
// the persisted settings.json, and is reflected back by GET /setup/settings.
func TestAdminAreaAuthRoundTrip(t *testing.T) {
tempDir, err := os.MkdirTemp("", "admin-area-auth-roundtrip-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
r, server := setupRouter("http://127.0.0.1:8000", ds)
ts := httptest.NewServer(r)
defer ts.Close()
server.mgmtUsername = "custom-admin"
server.mgmtPassword = "custom-password"
enableBody, err := json.Marshal(map[string]string{
"server_url": "http://127.0.0.1:8000",
"admin_area_auth": "Enabled", // mixed case must normalise
})
if err != nil {
t.Fatalf("Failed to marshal request body: %v", err)
}
res, err := http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(enableBody))
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("POST /setup/settings (enable): expected 200, got %v", res.Status)
}
if server.AdminAreaAuthMode() != "enabled" {
t.Errorf("Expected live mode \"enabled\", got %q", server.AdminAreaAuthMode())
}
persisted, err := ds.GetSettings()
if err != nil {
t.Fatalf("Failed to reload settings: %v", err)
}
if persisted.AdminAreaAuth != "enabled" {
t.Errorf("Expected persisted admin_area_auth \"enabled\", got %q", persisted.AdminAreaAuth)
}
res, err = http.Get(ts.URL + "/setup/settings")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var got map[string]interface{}
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
t.Fatalf("Failed to decode GET /setup/settings: %v", err)
}
if got["admin_area_auth"] != "enabled" {
t.Errorf("GET /setup/settings: expected admin_area_auth \"enabled\", got %+v", got["admin_area_auth"])
}
disableBody, err := json.Marshal(map[string]string{
"server_url": "http://127.0.0.1:8000",
"admin_area_auth": "disabled",
})
if err != nil {
t.Fatalf("Failed to marshal request body: %v", err)
}
res, err = http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(disableBody))
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("POST /setup/settings (disable): expected 200, got %v", res.Status)
}
if server.AdminAreaAuthMode() != "disabled" {
t.Errorf("Expected live mode \"disabled\" after explicit opt-out, got %q", server.AdminAreaAuthMode())
}
}
// TestResolvePeriodicSetting covers the shared interval/enabled resolution
// used by both background pollers (device discovery, update check).
func TestResolvePeriodicSetting(t *testing.T) {
cases := []struct {
name string
current time.Duration
requested time.Duration
provided bool
enabled bool
wantInterval time.Duration
wantEnabledState bool
}{
{"interval omitted keeps the current one", 24 * time.Hour, 0, false, true, 24 * time.Hour, true},
{"interval supplied replaces the current one", 24 * time.Hour, 6 * time.Hour, true, true, 6 * time.Hour, true},
{"disabling keeps the interval", 24 * time.Hour, 0, false, false, 24 * time.Hour, false},
{"a zero interval forces it off", 24 * time.Hour, 0, true, true, 0, false},
{"a zero current interval forces it off too", 0, 0, false, true, 0, false},
}
for _, tc := range cases {
gotInterval, gotEnabled := resolvePeriodicSetting(tc.current, tc.requested, tc.provided, tc.enabled)
if gotInterval != tc.wantInterval || gotEnabled != tc.wantEnabledState {
t.Errorf("%s: resolvePeriodicSetting() = %v/%v, want %v/%v",
tc.name, gotInterval, gotEnabled, tc.wantInterval, tc.wantEnabledState)
}
}
}
// TestParseOptionalDuration verifies an omitted duration is not an error,
// while a supplied-but-invalid one is.
func TestParseOptionalDuration(t *testing.T) {
if d, err := parseOptionalDuration(""); err != nil || d != 0 {
t.Errorf("parseOptionalDuration(\"\") = %v/%v, want 0/nil", d, err)
}
if d, err := parseOptionalDuration("90m"); err != nil || d != 90*time.Minute {
t.Errorf("parseOptionalDuration(\"90m\") = %v/%v, want 1h30m0s/nil", d, err)
}
if _, err := parseOptionalDuration("nope"); err == nil {
t.Error("parseOptionalDuration(\"nope\") = nil error, want a parse error")
}
}
// TestUpdateCheckSettingsRoundTrip covers the Settings-page control for the
// opt-in update check (#591 follow-up): POST /setup/settings must update the
// live values the background poller reads, persist them, and hand them back
// on GET so the UI reflects what was saved.
func TestUpdateCheckSettingsRoundTrip(t *testing.T) {
tempDir, err := os.MkdirTemp("", "update-check-settings-roundtrip-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
r, server := setupRouter("http://127.0.0.1:8000", ds)
ts := httptest.NewServer(r)
defer ts.Close()
// Default state: opted out, with a nonzero interval so enabling it later
// doesn't need an interval to be supplied.
if interval, enabled := server.GetUpdateCheckSettings(); enabled || interval == 0 {
t.Fatalf("Expected the check to default to disabled with a nonzero interval, got %v/%v", interval, enabled)
}
enableBody, err := json.Marshal(map[string]interface{}{
"server_url": "http://127.0.0.1:8000",
"update_check_enabled": true,
"update_check_interval": "6h",
})
if err != nil {
t.Fatalf("Failed to marshal request body: %v", err)
}
res, err := http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(enableBody))
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("POST /setup/settings (enable): expected 200, got %v", res.Status)
}
interval, enabled := server.GetUpdateCheckSettings()
if !enabled || interval != 6*time.Hour {
t.Errorf("Expected live settings 6h/true, got %v/%v", interval, enabled)
}
persisted, err := ds.GetSettings()
if err != nil {
t.Fatalf("Failed to reload settings: %v", err)
}
if !persisted.UpdateCheckEnabled || persisted.UpdateCheckInterval != "6h0m0s" {
t.Errorf("Expected persisted 6h0m0s/true, got %q/%v",
persisted.UpdateCheckInterval, persisted.UpdateCheckEnabled)
}
res, err = http.Get(ts.URL + "/setup/settings")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var got map[string]interface{}
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
t.Fatalf("Failed to decode GET /setup/settings: %v", err)
}
if got["update_check_enabled"] != true {
t.Errorf("GET /setup/settings: expected update_check_enabled true, got %+v", got["update_check_enabled"])
}
if got["update_check_interval"] != "6h0m0s" {
t.Errorf("GET /setup/settings: expected update_check_interval 6h0m0s, got %+v", got["update_check_interval"])
}
// An unparseable interval must be rejected before anything is applied.
badBody, err := json.Marshal(map[string]interface{}{
"server_url": "http://127.0.0.1:8000",
"update_check_enabled": true,
"update_check_interval": "not-a-duration",
})
if err != nil {
t.Fatalf("Failed to marshal request body: %v", err)
}
res, err = http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(badBody))
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != http.StatusBadRequest {
t.Errorf("POST /setup/settings (bad interval): expected 400, got %v", res.Status)
}
// A zero interval must force the check off rather than leave the poller
// hitting GitHub on every tick.
zeroBody, err := json.Marshal(map[string]interface{}{
"server_url": "http://127.0.0.1:8000",
"update_check_enabled": true,
"update_check_interval": "0s",
})
if err != nil {
t.Fatalf("Failed to marshal request body: %v", err)
}
res, err = http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(zeroBody))
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("POST /setup/settings (zero interval): expected 200, got %v", res.Status)
}
if _, enabled := server.GetUpdateCheckSettings(); enabled {
t.Error("Expected a zero interval to disable the update check")
}
}
// TestHandleGetVersionInfo_IncludesAbsoluteDataDir verifies /api/setup/version
// reports the actual data directory in use, resolved to an absolute path —
// added so operators running the service locally (not in Docker, where the
// path is obvious from the bind mount) can find it without having to
// inspect the running process. See NEXT.md/#419 session notes.
func TestHandleGetVersionInfo_IncludesAbsoluteDataDir(t *testing.T) {
tempDir, err := os.MkdirTemp("", "version-info-datadir-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
r, _ := setupRouter("http://127.0.0.1:8000", ds)
ts := httptest.NewServer(r)
defer ts.Close()
res, err := http.Get(ts.URL + "/setup/version")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var got map[string]interface{}
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
wantAbs, err := filepath.Abs(tempDir)
if err != nil {
t.Fatalf("Failed to resolve expected absolute path: %v", err)
}
if got["data_dir"] != wantAbs {
t.Errorf("Expected data_dir %q, got %q", wantAbs, got["data_dir"])
}
}
// TestHandleGetVersionInfo_UpdateCheckFields verifies the #591 fields are
// present and reflect a nil-checker default (Available: false) when the
// update check was never enabled — the common case.
func TestHandleGetVersionInfo_UpdateCheckFields(t *testing.T) {
tempDir, err := os.MkdirTemp("", "version-info-updatecheck-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
r, _ := setupRouter("http://127.0.0.1:8000", ds)
ts := httptest.NewServer(r)
defer ts.Close()
res, err := http.Get(ts.URL + "/setup/version")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var got map[string]interface{}
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if got["update_available"] != false {
t.Errorf("Expected update_available=false by default, got %v", got["update_available"])
}
if _, ok := got["latest_version"]; !ok {
t.Error("Expected a latest_version key in the response")
}
if _, ok := got["latest_release_url"]; !ok {
t.Error("Expected a latest_release_url key in the response")
}
}
func TestMigrationAndCA(t *testing.T) {
tempDir, err := os.MkdirTemp("", "handlers-test")
if err != nil {
@@ -440,3 +965,9 @@ func (m *mockSSH) UploadContent(content []byte, remotePath string) error {
return nil
}
// Connect/Close are no-ops here — the mock has no real connection to
// reuse, and every test call already goes through Run/UploadContent above
// regardless of whether Connect was called first.
func (m *mockSSH) Connect() error { return nil }
func (m *mockSSH) Close() error { return nil }
@@ -0,0 +1,153 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/go-chi/chi/v5"
)
// TestHandleInitialSync_DestructiveSyncReturns409ThenAppliesWhenConfirmed is
// an HTTP-level regression test for #614's Sync-button data-loss bug (see
// setup.TestSyncDeviceData_DestructiveSyncRequiresConfirmation for the
// lower-level coverage of the same fix): a device already has more presets
// stored than the mock speaker's live /presets now reports. The first,
// unconfirmed sync request must come back 409 with the diff and must not
// write anything; a retry with ?confirmed=true must apply it.
func TestHandleInitialSync_DestructiveSyncReturns409ThenAppliesWhenConfirmed(t *testing.T) {
const (
accountID = "1234567"
deviceID = "AABBCCDDEEFF"
)
// A real local server, not a black-hole IP: notifySpeakerSourcesUpdated
// (part of the confirmed-apply path) uses its own HTTP client rather
// than the injectable sm.HTTPGet, so it needs somewhere real to fail
// fast against (404) instead of timing out.
mockDevice := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/info":
w.Header().Set("Content-Type", "application/xml")
fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?><info deviceID="%s"><name>Test Device</name><type>SoundTouch 20</type><margeAccountUUID>%s</margeAccountUUID></info>`, deviceID, accountID)
case "/presets":
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?><presets><preset id="1"><ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="/x" isPresetable="true"><itemName>Station 1</itemName></ContentItem></preset></presets>`)
case "/recents":
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?><recents></recents>`)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer mockDevice.Close()
deviceIP := mockDevice.Listener.Addr().String()
tempDir, err := os.MkdirTemp("", "handlers-sync-destructive-guard-*")
if err != nil {
t.Fatalf("tempdir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
seeded := []models.ServicePreset{
{ID: "1", ButtonNumber: "1", ServiceContentItem: models.ServiceContentItem{Name: "Station 1"}},
{ID: "2", ButtonNumber: "2", ServiceContentItem: models.ServiceContentItem{Name: "Station 2"}},
}
if err := ds.SavePresets(accountID, deviceID, seeded); err != nil {
t.Fatalf("seed SavePresets: %v", err)
}
if err := ds.SaveDeviceInfo(accountID, deviceID, &models.ServiceDeviceInfo{
DeviceID: deviceID,
AccountID: accountID,
IPAddress: deviceIP,
}); err != nil {
t.Fatalf("SaveDeviceInfo: %v", err)
}
sm := setup.NewManager("http://localhost:8000", ds, nil)
server := NewServer(ds, sm, "http://localhost:8000", false, false, false)
r := chi.NewRouter()
r.Post("/api/setup/sync/{deviceId}", server.HandleInitialSync)
ts := httptest.NewServer(r)
defer ts.Close()
// First, unconfirmed request: must be refused with 409.
resp, err := http.Post(ts.URL+"/api/setup/sync/"+deviceID, "application/json", nil)
if err != nil {
t.Fatalf("POST sync (unconfirmed): %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusConflict {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 409 for a destructive unconfirmed sync, got %d: %s", resp.StatusCode, body)
}
var result setup.SyncResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("decode 409 body: %v", err)
}
if result.Applied {
t.Fatal("expected Applied=false in the 409 response")
}
if !result.Destructive {
t.Fatal("expected Destructive=true in the 409 response")
}
presetsAfterRefusal, err := ds.GetPresets(accountID, deviceID)
if err != nil {
t.Fatalf("GetPresets after refused sync: %v", err)
}
if len(presetsAfterRefusal) != 2 {
t.Fatalf("expected the original 2 presets to survive the refused sync, got %d", len(presetsAfterRefusal))
}
// Retry, confirmed: must apply.
resp2, err := http.Post(ts.URL+"/api/setup/sync/"+deviceID+"?confirmed=true", "application/json", nil)
if err != nil {
t.Fatalf("POST sync (confirmed): %v", err)
}
defer func() { _ = resp2.Body.Close() }()
if resp2.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp2.Body)
t.Fatalf("expected 200 for a confirmed sync, got %d: %s", resp2.StatusCode, body)
}
var confirmedResult setup.SyncResult
if err := json.NewDecoder(resp2.Body).Decode(&confirmedResult); err != nil {
t.Fatalf("decode 200 body: %v", err)
}
if !confirmedResult.Applied {
t.Fatal("expected Applied=true after confirming")
}
presetsAfterConfirm, err := ds.GetPresets(accountID, deviceID)
if err != nil {
t.Fatalf("GetPresets after confirmed sync: %v", err)
}
if len(presetsAfterConfirm) != 1 {
t.Fatalf("expected confirmed sync to shrink to 1 preset, got %d", len(presetsAfterConfirm))
}
}
@@ -0,0 +1,34 @@
package handlers
import (
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
)
// TestUpdateCheckResult_NilCheckerIsSafe verifies the default (opt-in
// checker never registered) returns a safe zero value rather than
// panicking — the common case, since UPDATE_CHECK_ENABLED defaults to
// false.
func TestUpdateCheckResult_NilCheckerIsSafe(t *testing.T) {
s := NewServer(nil, nil, "http://localhost", false, false, false)
result := s.UpdateCheckResult()
if result.Available {
t.Error("Expected a nil checker to report Available=false")
}
}
// TestUpdateCheckResult_ReflectsRegisteredChecker verifies SetUpdateChecker
// wires the checker in and UpdateCheckResult reads through to it.
func TestUpdateCheckResult_ReflectsRegisteredChecker(t *testing.T) {
s := NewServer(nil, nil, "http://localhost", false, false, false)
checker := updatecheck.NewChecker(nil, "owner/repo", "v1.0.0")
s.SetUpdateChecker(checker)
result := s.UpdateCheckResult()
if result.CurrentVersion != "v1.0.0" {
t.Errorf("Expected UpdateCheckResult to read through to the registered checker, got %+v", result)
}
}
@@ -0,0 +1,125 @@
package handlers
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
)
// TestIssue634_NonNumericMargeAccountUUIDDoesNotLoseDevice reproduces
// https://github.com/gesellix/Bose-SoundTouch/issues/634
//
// A SoundTouch 10 had SSH enabled via the USB-stick method (rather than
// AfterTouch's own telnet-based enable-ssh flow) and, when discovered,
// reported a `margeAccountUUID` of `stick@local` instead of the usual
// 7-digit numeric Bose account ID. `handleDiscoveredDevice`
// (pkg/service/handlers/server.go) passes MargeAccountUUID straight
// through to DataStore.SaveDeviceInfo, which used to reject anything
// containing "@" as an "invalid account ID" via isSafeIdentifier's
// strict alnum-only allowlist. The device was never persisted at all.
//
// The fix widened datastore.IsSafeIdentifier to accept any device-reported
// identifier that's safe to use as a path component / XML value /
// telnet-command token, rather than requiring Bose's own 7-digit numeric
// format. setup's separate, stricter 7-digit-only IsValidAccountID was
// deleted outright in favor of calling datastore.IsSafeIdentifier directly
// everywhere an account ID needs validating — one validator, not two. So
// handleDiscoveredDevice needed no changes: it already passed
// MargeAccountUUID through unmodified, and now the datastore accepts it.
//
// What this test locks in:
//
// - A speaker reporting a non-numeric margeAccountUUID is saved
// under that account verbatim (not coerced to "default" — "default"
// remains reserved for a genuinely empty/unpaired margeAccountUUID).
//
// What this test would catch if it flipped:
//
// - If IsSafeIdentifier's allowlist regresses to reject "@" again,
// GetDeviceInfo below would error with "invalid account ID" instead
// of returning the device — the #634 symptom.
func TestIssue634_NonNumericMargeAccountUUIDDoesNotLoseDevice(t *testing.T) {
tempDir, err := os.MkdirTemp("", "issue634-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
const deviceInfoXML = `<info deviceID="001122334455">
<name>Kitchen SoundTouch</name>
<type>SoundTouch 10</type>
<margeAccountUUID>stick@local</margeAccountUUID>
<components>
<component>
<componentCategory>SCM</componentCategory>
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</softwareVersion>
<serialNumber>I6332527703739342000020</serialNumber>
</component>
<component>
<componentCategory>PackagedProduct</componentCategory>
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</softwareVersion>
<serialNumber>069231P63364828AE</serialNumber>
</component>
</components>
<margeURL>https://streaming.bose.com</margeURL>
<networkInfo type="SCM">
<macAddress>001122334455</macAddress>
<ipAddress>203.0.113.10</ipAddress>
</networkInfo>
<moduleType>sm2</moduleType>
<variant>rhino</variant>
<variantMode>normal</variantMode>
<countryCode>US</countryCode>
<regionCode>US</regionCode>
</info>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/info" {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, deviceInfoXML)
} else {
http.NotFound(w, r)
}
}))
defer server.Close()
deviceIP := server.URL[len("http://"):]
ds := datastore.NewDataStore(tempDir)
sm := setup.NewManager(server.URL, ds, nil)
srv := NewServer(ds, sm, server.URL, false, false, false)
discoveredDevice := models.DiscoveredDevice{
Host: deviceIP,
Name: "Legacy Discovery Name",
ModelID: "SoundTouch 10",
SerialNo: "",
DiscoveryMethod: "UPnP",
}
t.Logf("Test scenario: /info reports non-numeric margeAccountUUID %q", "stick@local")
srv.handleDiscoveredDevice(discoveredDevice)
const (
expectedAccountID = "stick@local"
expectedDeviceID = "001122334455"
)
deviceInfo, err := ds.GetDeviceInfo(expectedAccountID, expectedDeviceID)
if err != nil {
t.Fatalf("device was not saved under account %q: %v (this is the #634 symptom — "+
"SaveDeviceInfo rejects the raw margeAccountUUID as an invalid account ID)",
expectedAccountID, err)
}
if deviceInfo.Name != "Kitchen SoundTouch" {
t.Errorf("Name = %q, want %q", deviceInfo.Name, "Kitchen SoundTouch")
}
}
+1
View File
@@ -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)
+185 -4
View File
@@ -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,16 +143,29 @@ 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)
health.RegisterSpeakerInfoReachable(s.healthRegistry, ds)
health.RegisterSourcesXMLDiff(s.healthRegistry, ds)
health.RegisterSpeakerMargeURLCheck(s.healthRegistry, ds, s.ExpectedHosts)
health.RegisterRuntimeBmxURLStaleCheck(
s.healthRegistry,
ds,
s.readSpeakerBmxRegistryURL,
func() bool {
running, _ := s.GetDNSRunning()
return running
},
)
health.RegisterCertChainCheck(
s.healthRegistry,
func() string {
@@ -196,6 +215,22 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
},
s.GetDNSRunning,
)
health.RegisterSpotifyAccountLinkedCheck(
s.healthRegistry,
func() bool { return s.spotifyService != nil },
func() int {
if s.spotifyService == nil {
return 0
}
return len(s.spotifyService.GetAccounts())
},
)
health.RegisterMgmtDefaultCredentialsCheck(
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
@@ -293,6 +328,8 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
},
)
s.dismissedAnnouncements = loadDismissedAnnouncements(ds)
return s
}
@@ -552,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 —
@@ -1003,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()
+14
View File
@@ -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;
+136 -91
View File
@@ -31,6 +31,8 @@
Admin &amp; 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"
>&#x21bb; 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"
>&#x2715; 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>
+398 -94
View File
@@ -112,6 +112,8 @@ async function fetchSpotifyStatus() {
const settingsResponse = await fetch("/api/setup/settings");
const settings = await settingsResponse.json();
const header = document.getElementById("spotify-status-header");
const nameEl = document.getElementById("spotify-account-name");
const linkBtn = document.getElementById("link-spotify-btn");
if (!settings.spotify_configured) {
if (header) header.style.display = "none";
@@ -121,10 +123,22 @@ async function fetchSpotifyStatus() {
if (header) header.style.display = "flex";
const response = await fetch("/api/mgmt/spotify/accounts");
if (!response.ok) return;
if (!response.ok) {
// Don't fail silently: a stale/hidden Spotify status here is exactly
// what made #269 look like a Spotify bug instead of a Management API
// login that never completed.
if (header) {
header.style.background = "#f8d7da";
header.style.border = "1px solid #dc3545";
}
if (nameEl) {
nameEl.innerText = response.status === 401
? "Unable to check (Management login required — retry, or reload the page)"
: `Unable to check (HTTP ${response.status})`;
}
return;
}
const data = await response.json();
const nameEl = document.getElementById("spotify-account-name");
const linkBtn = document.getElementById("link-spotify-btn");
if (data.accounts && data.accounts.length > 0) {
header.style.background = "#e6ffed";
@@ -162,8 +176,12 @@ async function linkSpotify() {
try {
const response = await fetch("/api/mgmt/spotify/init", {method: "POST"});
if (!response.ok) {
const err = await response.text();
alert("Failed to initialize Spotify link: " + err);
if (response.status === 401) {
alert("Management login failed or was cancelled. Please try again — your browser should prompt for the Management API username/password.");
} else {
const err = await response.text();
alert("Failed to initialize Spotify link: " + err);
}
return;
}
const data = await response.json();
@@ -336,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;
}
@@ -480,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,
@@ -568,7 +598,19 @@ async function fetchDevices() {
if (devices.length === 0) {
container.innerHTML = "No devices known yet.";
} else {
let html = "<table><tr><th>Name & Model</th><th>IP Address</th><th>Device & Account ID</th><th>Firmware & Serial</th><th>Method</th><th>Action</th></tr>";
// Built via DOM APIs rather than innerHTML/template strings: device
// fields (name, IDs, serials, ...) come from speakers and third-party
// pairing tools (see #634) and are not restricted to HTML/JS-safe
// characters, so they must never be parsed as markup or concatenated
// into inline event-handler attributes.
const table = document.createElement("table");
const headerRow = document.createElement("tr");
for (const label of ["Name & Model", "IP Address", "Device & Account ID", "Firmware & Serial", "Method", "Action"]) {
const th = document.createElement("th");
th.textContent = label;
headerRow.appendChild(th);
}
table.appendChild(headerRow);
// Clear and repopulate selectors
const currentSyncVal = syncSelector.value;
@@ -582,25 +624,83 @@ async function fetchDevices() {
devices.forEach((d) => {
const methodLabel = d.discovery_method === "manual" ? "👤 Manual" : "🔍 Auto";
html += `
<tr id="device-row-${d.device_id}">
<td class="col-name-model"><div class="col-name">${d.name}</div><div class="col-model" style="font-size: 0.8em; color: #666;">${d.product_code}</div></td>
<td class="col-ip">${d.ip_address}</td>
<td class="col-ids"><div class="col-deviceid">${d.device_id}</div><div class="col-accountid" style="font-size: 0.8em; color: #666;">${d.account_id || "default"}</div></td>
<td class="col-fw-serial"><div class="col-firmware">${d.firmware_version || "0.0.0"}</div><div class="col-serial" style="font-size: 0.8em; color: #666;">${d.device_serial_number}</div></td>
<td class="col-method">${methodLabel}</td>
<td>
<button onclick="toggleDeviceSummary('${d.device_id}')">Inspect</button>
<button onclick="prepareSync('${d.device_id}')">Sync Data</button>
<button onclick="prepareMigration('${d.device_id}')">Migrate</button>
<button id="prime-spotify-${d.device_id}" class="btn-spotify" style="display: none;" onclick="primeSpotify('${d.device_id}')">Prime Spotify</button>
<button class="btn-danger" onclick="removeDevice('${d.device_id}', '${d.name}')">Remove</button>
</td>
</tr>
<tr id="device-summary-${d.device_id}" style="display: none;">
<td colspan="6" id="device-summary-cell-${d.device_id}" style="background: #fafafa; padding: 12px;"></td>
</tr>
`;
const nameModelCell = document.createElement("td");
nameModelCell.className = "col-name-model";
const nameDiv = document.createElement("div");
nameDiv.className = "col-name";
nameDiv.textContent = d.name;
const modelDiv = document.createElement("div");
modelDiv.className = "col-model";
modelDiv.style.cssText = "font-size: 0.8em; color: #666;";
modelDiv.textContent = d.product_code;
nameModelCell.append(nameDiv, modelDiv);
const ipCell = document.createElement("td");
ipCell.className = "col-ip";
ipCell.textContent = d.ip_address;
const idsCell = document.createElement("td");
idsCell.className = "col-ids";
const deviceIdDiv = document.createElement("div");
deviceIdDiv.className = "col-deviceid";
deviceIdDiv.textContent = d.device_id;
const accountIdDiv = document.createElement("div");
accountIdDiv.className = "col-accountid";
accountIdDiv.style.cssText = "font-size: 0.8em; color: #666;";
accountIdDiv.textContent = d.account_id || "default";
idsCell.append(deviceIdDiv, accountIdDiv);
const fwCell = document.createElement("td");
fwCell.className = "col-fw-serial";
const fwDiv = document.createElement("div");
fwDiv.className = "col-firmware";
fwDiv.textContent = d.firmware_version || "0.0.0";
const serialDiv = document.createElement("div");
serialDiv.className = "col-serial";
serialDiv.style.cssText = "font-size: 0.8em; color: #666;";
serialDiv.textContent = d.device_serial_number;
fwCell.append(fwDiv, serialDiv);
const methodCell = document.createElement("td");
methodCell.className = "col-method";
methodCell.textContent = methodLabel;
const makeActionButton = (label, onClick, extra) => {
const btn = document.createElement("button");
btn.textContent = label;
btn.addEventListener("click", onClick);
if (extra) Object.assign(btn, extra);
return btn;
};
const actionCell = document.createElement("td");
actionCell.append(
makeActionButton("Inspect", () => toggleDeviceSummary(d.device_id)),
makeActionButton("Sync Data", () => prepareSync(d.device_id)),
makeActionButton("Migrate", () => prepareMigration(d.device_id)),
makeActionButton("Prime Spotify", () => primeSpotify(d.device_id), {
id: `prime-spotify-${d.device_id}`,
className: "btn-spotify",
}),
makeActionButton("Remove", () => removeDevice(d.device_id, d.name), {className: "btn-danger"}),
);
actionCell.querySelector(".btn-spotify").style.display = "none";
const row = document.createElement("tr");
row.id = `device-row-${d.device_id}`;
row.append(nameModelCell, ipCell, idsCell, fwCell, methodCell, actionCell);
const summaryRow = document.createElement("tr");
summaryRow.id = `device-summary-${d.device_id}`;
summaryRow.style.display = "none";
const summaryCell = document.createElement("td");
summaryCell.colSpan = 6;
summaryCell.id = `device-summary-cell-${d.device_id}`;
summaryCell.style.cssText = "background: #fafafa; padding: 12px;";
summaryRow.appendChild(summaryCell);
table.append(row, summaryRow);
const optSync = document.createElement("option");
optSync.value = d.device_id;
@@ -619,8 +719,7 @@ async function fetchDevices() {
eventSelector.appendChild(optEvent);
}
});
html += "</table>";
container.innerHTML = html;
container.replaceChildren(table);
if (currentSyncVal) syncSelector.value = currentSyncVal;
if (currentMigrationVal) migrationSelector.value = currentMigrationVal;
@@ -777,6 +876,57 @@ function getDeviceDisplayName(deviceId) {
return deviceId;
}
// buildSyncConfirmMessage renders a human-readable summary of a destructive
// SyncResult (see setup.SyncResult/SyncResourceDiff) for window.confirm() —
// e.g. "Sync would remove 1 preset: Ici Roussillon. Continue?".
function buildSyncConfirmMessage(result) {
const lines = ["This Data Sync would remove data that's currently stored:"];
for (const diff of result.diffs || []) {
if (!diff.destructive) {
continue;
}
const removedNote = diff.removed && diff.removed.length ? ": " + diff.removed.join(", ") : "";
lines.push("- " + diff.resource + ": " + diff.currentCount + " → " + diff.incomingCount + removedNote);
}
lines.push("This usually means the speaker's own live data was incomplete at this moment. Continue anyway?");
return lines.join("\n");
}
// renderSyncResultList builds a <ul> summarising a successful SyncResult —
// one <li> per resource, e.g. "presets: 6 → 6", plus a sources count. Built
// via DOM APIs (not innerHTML string concatenation) since preset/recent
// names ultimately come from user-editable station names on the speaker.
function renderSyncResultList(result) {
const ul = document.createElement("ul");
for (const diff of result.diffs || []) {
const li = document.createElement("li");
li.textContent = diff.resource + ": " + diff.currentCount + " → " + diff.incomingCount;
ul.appendChild(li);
}
const sourcesLi = document.createElement("li");
sourcesLi.textContent = "sources: " + (result.sourcesCount >= 0 ? result.sourcesCount : "sync failed");
ul.appendChild(sourcesLi);
return ul;
}
async function requestSync(deviceId, confirmed) {
let url = "/api/setup/sync/" + encodeURIComponent(deviceId);
if (confirmed) {
url += "?confirmed=true";
}
const response = await fetch(url, {method: "POST"});
let result = null;
try {
result = await response.clone().json();
} catch (e) {
// Non-JSON error body (e.g. a plain-text 500) — handled below via response.text().
}
return {response, result};
}
async function startSync() {
const deviceId = document.getElementById("sync-device-list").value;
if (!deviceId) {
@@ -796,14 +946,30 @@ async function startSync() {
log.innerHTML = "";
try {
const response = await fetch("/api/setup/sync/" + encodeURIComponent(deviceId), {method: "POST"},);
if (response.ok) {
let {response, result} = await requestSync(deviceId, false);
if (response.status === 409 && result) {
if (!confirm(buildSyncConfirmMessage(result))) {
status.style.backgroundColor = "#eef";
status.textContent = "Sync cancelled for " + display + " — nothing was changed.";
return;
}
({response, result} = await requestSync(deviceId, true));
}
if (response.ok && result) {
status.style.backgroundColor = "#dfd";
status.textContent = "✅ Sync completed successfully for " + display + "!";
results.style.display = "block";
log.textContent = "Data fetched and saved to local datastore for " + display + ".\nPresets: OK\nRecents: OK\nSources: OK";
log.innerHTML = "";
const intro = document.createElement("p");
intro.textContent = "Data fetched and saved to local datastore for " + display + ".";
log.appendChild(intro);
log.appendChild(renderSyncResultList(result));
} else {
const err = await response.text();
const err = result ? JSON.stringify(result) : await response.text();
throw new Error(err);
}
} catch (error) {
@@ -812,6 +978,46 @@ async function startSync() {
}
}
// fetchAnnouncements loads and renders the active announcements for this
// area (see #419 design doc, _/i419/design-admin-area-auth-gate.md). Not
// tab-scoped: the container lives outside the tab-content divs so a banner
// stays visible regardless of which tab is open.
async function fetchAnnouncements() {
const container = document.getElementById("announcements-banner");
if (!container) return;
try {
const response = await fetch("/api/announcements?target=admin");
if (!response.ok) return;
const data = await response.json();
const announcements = data.announcements || [];
container.innerHTML = announcements.map(a => `
<div class="announcement-banner announcement-${a.level || "info"}" data-announcement-id="${a.id}" style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:10px 14px; margin-bottom:10px; border-radius:4px; background:#e7f3ff; border:1px solid #b6d9f7; color:#1a4a6e; font-size:0.9em;">
<span>${escapeHtml(a.message)}${a.link_url ? ` <a href="${escapeHtml(a.link_url)}" target="_blank" rel="noopener" style="color:inherit; text-decoration:underline;">${escapeHtml(a.link_text || a.link_url)}</a>` : ""}</span>
<button onclick="dismissAnnouncement('${a.id}')" title="Dismiss" style="background:none; border:none; cursor:pointer; font-size:1.1em; line-height:1; color:inherit; flex-shrink:0;">&times;</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");
@@ -829,6 +1035,11 @@ async function fetchVersion() {
}
info.innerHTML = `AfterTouch ${versionStr} (${commitStr}) • ${data.date}`;
}
const dataDirEl = document.getElementById("settings-data-dir");
if (dataDirEl && data.data_dir) {
dataDirEl.textContent = data.data_dir;
}
} catch (error) {
console.error("Failed to fetch version info", error);
}
@@ -837,11 +1048,26 @@ async function fetchVersion() {
async function fetchAccountList() {
try {
const response = await fetch("/api/mgmt/accounts");
if (!response.ok) return;
if (!response.ok) {
const selector = document.getElementById("account-selector");
if (selector) {
selector.innerHTML = response.status === 401
? `<option value="">Management login required — retry or reload the page</option>`
: `<option value="">Unable to load accounts (HTTP ${response.status})</option>`;
}
return;
}
const data = await response.json();
const selector = document.getElementById("account-selector");
if (selector) {
selector.innerHTML = data.accounts.map(acc => `<option value="${acc}">${acc}</option>`).join("");
// Account IDs can contain non-alphanumeric characters (e.g.
// "stick@local", #634) — built via DOM APIs, not innerHTML.
selector.replaceChildren(...data.accounts.map(acc => {
const opt = document.createElement("option");
opt.value = acc;
opt.textContent = acc;
return opt;
}));
if (data.accounts.length > 0) {
fetchAccountDetails(selector.value);
}
@@ -866,7 +1092,7 @@ async function fetchAccountDetails(accountId) {
try {
const response = await fetch(`/api/mgmt/accounts/${encodeURIComponent(accountId)}`);
if (!response.ok) {
if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Failed to load account details: ${response.statusText}</span>`;
if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Failed to load account details: ${escapeHtml(response.statusText)}</span>`;
return;
}
const data = await response.json();
@@ -875,13 +1101,13 @@ async function fetchAccountDetails(accountId) {
if (metadataEl) {
const warningNotice = data.account.is_placeholder ?
`<div style="background: #fff3cd; color: #856404; padding: 10px; border: 1px solid #ffeeba; border-radius: 4px; margin-bottom: 10px; font-size: 0.85em;">
<strong>Notice:</strong> Account data (account.json) was not found in the expected location for this account ID.
<strong>Notice:</strong> This account hasn't saved any custom settings yet (language, provider preferences). Defaults are in effect they'll be saved once you change something below.
</div>` : "";
metadataEl.innerHTML = `
${warningNotice}
<table style="width: 100%; font-size: 0.9em;">
<tr><td style="padding: 4px"><strong>Account ID:</strong></td><td style="padding: 4px">${data.account.account_id}</td></tr>
<tr><td style="padding: 4px"><strong>Account ID:</strong></td><td style="padding: 4px">${escapeHtml(data.account.account_id)}</td></tr>
<tr><td style="padding: 4px"><strong>Language:</strong></td><td style="padding: 4px">
<select id="account-language-select" style="font-size: 0.9em; padding: 2px;">
<option value="en" ${data.account.preferred_language === "en" || !data.account.preferred_language ? "selected" : ""}>en</option>
@@ -900,7 +1126,7 @@ async function fetchAccountDetails(accountId) {
}, {});
return Object.entries(grouped).map(([pName, settings]) => `
<div style="margin-bottom: 8px;">
<strong>${pName}</strong>
<strong>${escapeHtml(pName)}</strong>
<ul style="margin: 2px 0 0 0; padding-left: 20px; list-style-type: disc;">
${settings.map(s => {
if ((s.provider_name === "SPOTIFY" || s.provider_id === "15") && s.key_name === "STREAMING_QUALITY") {
@@ -908,9 +1134,9 @@ async function fetchAccountDetails(accountId) {
<li style="margin-bottom: 4px;">
Music Streaming Quality:
<select class="provider-setting-select"
data-account-id="${data.account.account_id}"
data-provider-id="${s.provider_id}"
data-key="${s.key_name}"
data-account-id="${escapeHtml(data.account.account_id)}"
data-provider-id="${escapeHtml(s.provider_id)}"
data-key="${escapeHtml(s.key_name)}"
style="font-size: 0.9em; padding: 2px; margin-left: 4px;">
<option value="1" ${s.value === "1" ? "selected" : ""}>Fastest Streaming - up to 128 kbit/s</option>
<option value="2" ${s.value === "2" ? "selected" : ""}>Balanced Quality and Speed - up to 192 kbit/s</option>
@@ -920,7 +1146,7 @@ async function fetchAccountDetails(accountId) {
</li>
`;
}
return `<li>${s.key_name}: ${s.value}</li>`;
return `<li>${escapeHtml(s.key_name)}: ${escapeHtml(s.value)}</li>`;
}).join("")}
</ul>
</div>
@@ -941,7 +1167,7 @@ async function fetchAccountDetails(accountId) {
statusEl.style.color = "#666";
}
try {
const response = await fetch(`/api/mgmt/accounts/${data.account.account_id}/language`, {
const response = await fetch(`/api/mgmt/accounts/${encodeURIComponent(data.account.account_id)}/language`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -985,7 +1211,7 @@ async function fetchAccountDetails(accountId) {
}
try {
const response = await fetch(`/api/mgmt/accounts/${accID}/provider-settings`, {
const response = await fetch(`/api/mgmt/accounts/${encodeURIComponent(accID)}/provider-settings`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -1027,27 +1253,27 @@ async function fetchAccountDetails(accountId) {
devicesEl.innerHTML = data.devices.map(device => `
<div class="summary-box" style="margin-bottom: 15px; border-left: 5px solid #007bff; padding: 15px;">
<div style="display: flex; justify-content: space-between; cursor: pointer; align-items: center;" onclick="toggleInfo('device-details-${device.device_id}')">
<h4 style="margin: 0">${device.name || "Unnamed Device"} (${device.product_code})</h4>
<div class="device-summary-header" data-toggle-target="device-details-${escapeHtml(device.device_id)}" style="display: flex; justify-content: space-between; cursor: pointer; align-items: center;">
<h4 style="margin: 0">${escapeHtml(device.name || "Unnamed Device")} (${escapeHtml(device.product_code)})</h4>
<div style="font-size: 0.8em; color: #666">
${device.ip_address} | ${device.device_id} <span style="font-size: 1.2em; vertical-align: middle;">&#9662;</span>
${escapeHtml(device.ip_address)} | ${escapeHtml(device.device_id)} <span style="font-size: 1.2em; vertical-align: middle;">&#9662;</span>
</div>
</div>
<div id="device-details-${device.device_id}" style="display: none; margin-top: 15px; padding-top: 10px; border-top: 1px solid #eee">
<div id="device-details-${escapeHtml(device.device_id)}" style="display: none; margin-top: 15px; padding-top: 10px; border-top: 1px solid #eee">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px">
<div>
<h5 style="margin: 10px 0 5px 0">Device Metadata</h5>
<div style="font-size: 0.85em; background: #f8f9fa; padding: 8px; border-radius: 4px; border: 1px solid #e9ecef">
<strong>Serial:</strong> ${device.device_serial_number || device.serial_number || "N/A"}<br>
<strong>MAC:</strong> ${device.mac_address || "N/A"}<br>
<strong>Version:</strong> ${device.firmware_version || "N/A"}<br>
<strong>Discovery:</strong> ${device.discovery_method || "N/A"}
<strong>Serial:</strong> ${escapeHtml(device.device_serial_number || device.serial_number || "N/A")}<br>
<strong>MAC:</strong> ${escapeHtml(device.mac_address || "N/A")}<br>
<strong>Version:</strong> ${escapeHtml(device.firmware_version || "N/A")}<br>
<strong>Discovery:</strong> ${escapeHtml(device.discovery_method || "N/A")}
</div>
<h5 style="margin: 15px 0 5px 0">Hardware Components</h5>
<ul style="font-size: 0.8em; padding-left: 20px; margin: 0">
${device.components ? device.components.map(c => `<li><strong>${c.category || c.type || 'Component'}</strong>: ${c.firmware_version || 'N/A'} <br><small style="color:#777">S/N: ${c.serial_number || 'N/A'}</small></li>`).join("") : "<li>No components found</li>"}
${device.components ? device.components.map(c => `<li><strong>${escapeHtml(c.category || c.type || 'Component')}</strong>: ${escapeHtml(c.firmware_version || 'N/A')} <br><small style="color:#777">S/N: ${escapeHtml(c.serial_number || 'N/A')}</small></li>`).join("") : "<li>No components found</li>"}
</ul>
</div>
@@ -1067,14 +1293,14 @@ async function fetchAccountDetails(accountId) {
const account = (s.account && s.account !== s.username && s.account !== name) ? ` [${s.account}]` : "";
const finalName = name || s.type || "Unknown Source";
if (finalName) {
sourceLabel = `<br><small style="color: #666; font-size: 0.85em;">via ${finalName}${account}</small>`;
sourceLabel = `<br><small style="color: #666; font-size: 0.85em;">via ${escapeHtml(finalName)}${escapeHtml(account)}</small>`;
}
}
}
return `
<div style="border: 1px solid #ddd; padding: 5px; font-size: 0.8em; background: ${p ? "#e6ffed" : "#f8f9fa"}; border-radius: 3px;">
<strong>#${i + 1}</strong>: ${itemName}${sourceLabel}
<strong>#${i + 1}</strong>: ${escapeHtml(itemName)}${sourceLabel}
</div>
`;
}).join("")}
@@ -1092,13 +1318,13 @@ async function fetchAccountDetails(accountId) {
const account = (s.account && s.account !== s.username && s.account !== sName) ? ` [${s.account}]` : "";
const finalSName = sName || s.type || "Unknown Source";
if (finalSName) {
sourceLabel = `<br><small style="color: #666; font-size: 0.9em;">via ${finalSName}${account}</small>`;
sourceLabel = `<br><small style="color: #666; font-size: 0.9em;">via ${escapeHtml(finalSName)}${escapeHtml(account)}</small>`;
}
}
const dateRaw = r.last_played_at || r.created_on;
const dateObj = dateRaw ? (isNaN(Number(dateRaw)) ? new Date(dateRaw) : new Date(Number(dateRaw) * 1000)) : null;
const dateStr = dateObj ? dateObj.toLocaleString('sv-SE') : 'N/A'; // sv-SE produces YYYY-MM-DD HH:MM:SS with 24h time
return `<li>${name}${sourceLabel} <br><small style="color:#888">${dateStr}</small></li>`;
return `<li>${escapeHtml(name)}${sourceLabel} <br><small style="color:#888">${escapeHtml(dateStr)}</small></li>`;
}).join("") : "<li>No recents</li>"}
</ul>
</div>
@@ -1113,8 +1339,8 @@ async function fetchAccountDetails(accountId) {
const usernameSuffix = (s.username && s.username !== "Local") ? ` (${s.username})` : "";
const accountSuffix = (s.account && s.account !== s.username && s.account !== sourceName) ? ` [${s.account}]` : "";
return `
<span style="background: #eefbff; color: #0056b3; border: 1px solid #b8daff; padding: 2px 8px; border-radius: 12px; font-size: 0.75em" title="Source Type: ${s.type}">
${sourceName}${usernameSuffix}${accountSuffix}
<span style="background: #eefbff; color: #0056b3; border: 1px solid #b8daff; padding: 2px 8px; border-radius: 12px; font-size: 0.75em" title="Source Type: ${escapeHtml(s.type)}">
${escapeHtml(sourceName)}${escapeHtml(usernameSuffix)}${escapeHtml(accountSuffix)}
</span>
`;
}).join("") : "<small style='color:#999'>None</small>"}
@@ -1123,10 +1349,17 @@ async function fetchAccountDetails(accountId) {
</div>
</div>
`).join("");
// data-toggle-target (not an inline onclick) avoids re-embedding
// speaker-controlled device_id inside a JS-string-in-HTML-attribute
// context, which HTML-escaping alone cannot make safe.
devicesEl.querySelectorAll(".device-summary-header").forEach(el => {
el.addEventListener("click", () => toggleInfo(el.dataset.toggleTarget));
});
}
} catch (error) {
if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Error: ${error.message}</span>`;
if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Error: ${escapeHtml(error.message)}</span>`;
console.error("Failed to fetch account details", error);
}
}
@@ -1684,7 +1917,7 @@ async function fetchDeviceEvents(deviceId) {
list.innerHTML = '<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Loading events...</td></tr>';
try {
const response = await fetch(`/api/setup/devices/${deviceId}/events`);
const response = await fetch(`/api/setup/devices/${encodeURIComponent(deviceId)}/events`);
const data = await response.json();
const events = data.events;
@@ -1771,6 +2004,7 @@ function formatXML(xml) {
document.addEventListener("DOMContentLoaded", async () => {
const cfg = await fetchSettings();
fetchVersion();
fetchAnnouncements();
const deviceCount = await fetchDevices();
// Only sweep automatically on a cold start (no devices known yet). When
// devices are already in the store, rely on the cached list plus the
@@ -1823,7 +2057,7 @@ async function removeDevice(deviceId, name) {
}
try {
const response = await fetch(`/api/setup/devices/${deviceId}`, {
const response = await fetch(`/api/setup/devices/${encodeURIComponent(deviceId)}`, {
method: "DELETE",
});
@@ -2012,7 +2246,7 @@ async function showSummary(deviceId) {
if (accountIdEl && summary.account_id) accountIdEl.innerText = summary.account_id;
}
renderMigrationState(summary);
renderMigrationState(summary, targetUrl);
renderPlan(summary);
renderPlanCurrentURLs(summary);
renderPlanPairing(summary, deviceId);
@@ -2066,6 +2300,20 @@ async function showSummary(deviceId) {
connectionTestPane.style.display = summary.ssh_success ? "block" : "none";
}
// Stays visible either way (the user may still want to check it),
// but the default Suggested Plan never needs HTTPS — only note it
// as required when the Target URL itself is https://.
const connectionTestNote = document.getElementById("connection-test-relevance-note");
if (connectionTestNote) {
if (isHttpsTarget(targetUrl)) {
connectionTestNote.innerText = "Required for your current plan (HTTPS)";
connectionTestNote.style.color = "#c62828";
} else {
connectionTestNote.innerText = "Optional for your current plan (HTTP)";
connectionTestNote.style.color = "#666";
}
}
const currentConfigElem = document.getElementById("current-config");
currentConfigElem.innerText = summary.current_config;
currentConfigElem.style.color = summary.ssh_success ? "black" : "red";
@@ -2474,18 +2722,15 @@ async function migrate(deviceId, ip, method) {
}),
);
// Make reboot button available and prominent
// Make reboot button available and prominent. It lives in the
// always-visible "Speaker controls" row (see #621 — it used to
// be reachable only after expanding "Customize this migration"),
// so no need to force any collapsed container open here.
const rebootBtn = document.getElementById("reboot-speaker-btn");
rebootBtn.style.display = "inline-block";
rebootBtn.disabled = false;
rebootBtn.style.border = "2px solid #000";
// The Reboot button now lives inside the "Customize this
// migration" <details>; expand it so the post-migration
// reboot affordance is reachable from the Plan flow too.
const customize = rebootBtn.closest("details");
if (customize) customize.open = true;
// Re-show summary but with prominence on reboot
summaryDiv.style.display = "block";
} else {
@@ -2692,6 +2937,26 @@ function onPlanTargetURLChange() {
saved.innerText = "✏️ unsaved change — click \"Save as default\" to persist";
saved.style.color = "#bf6900";
}
// Re-derive the four service URL fields from the new Target URL, same
// as the initial pre-fill on summary render. fillPlanURLInputs still
// only overwrites fields the user hasn't hand-edited (tracked via
// dataset.autofilled), so this doesn't clobber genuinely manual edits.
// Without this, changing Target Domain to e.g. localhost left the four
// fields pointed at a stale default with no warning until the user
// edited them by hand (#621 follow-up).
const soundcork = document.getElementById("plan-soundcork-mode") &&
document.getElementById("plan-soundcork-mode").checked;
fillPlanURLInputs(defaultServiceURLs(v, {soundcorkMode: soundcork}));
}
// onPlanURLFieldEdited marks a Plan-card URL input as manually edited so
// fillPlanURLInputs stops treating it as an auto-fillable default, then
// re-validates. Wired from each of the four fields' oninput instead of
// calling validatePlanURLs() directly.
function onPlanURLFieldEdited(el) {
el.dataset.autofilled = "";
validatePlanURLs();
}
// saveTargetURLAsDefault posts the current plan-target-url value to
@@ -2754,8 +3019,12 @@ function defaultServiceURLs(targetUrl, options = {}) {
// fillPlanURLInputs writes the four URLs into the Plan card inputs.
// force=true overwrites existing values (used by Reset and the
// Soundcork toggle); force=false only fills empties (used on summary
// render so manual edits survive a refresh).
// Soundcork toggle); force=false only fills empties and fields still
// flagged dataset.autofilled=true (used on summary render and on Target
// URL changes, so manual edits survive but a still-default value tracks
// Target URL). Every field this function writes to is (re-)flagged
// autofilled; onPlanURLFieldEdited clears the flag the moment a user
// types into a field directly.
function fillPlanURLInputs(urls, {force = false} = {}) {
const fields = [
["plan-marge-url", urls.marge],
@@ -2766,7 +3035,10 @@ function fillPlanURLInputs(urls, {force = false} = {}) {
for (const [id, value] of fields) {
const el = document.getElementById(id);
if (!el) continue;
if (force || !el.value) el.value = value;
if (force || !el.value || el.dataset.autofilled === "true") {
el.value = value;
el.dataset.autofilled = "true";
}
}
validatePlanURLs();
}
@@ -2798,7 +3070,16 @@ function readPlanURLOptions() {
// on the speaker itself). For the typical "AfterTouch on a separate
// host" deployment, the speaker can't reach loopback on a different
// machine, so the URL must be a LAN-reachable IP or hostname.
function validateURL(value) {
//
// referenceOrigin (optional) is the plan's own Target URL origin. A
// loopback value that matches it is exempted from the warning: it means
// this is exactly what the service itself is already configured to
// answer as (e.g. an on-device install's `http://localhost:8000`,
// auto-set since #546), not a mistaken paste. Without this exemption,
// every on-device install's Suggested Plan fails validation by
// default and silently disables Apply/Pre-flight before the user does
// anything (#546 follow-up, reported via #621).
function validateURL(value, referenceOrigin) {
const v = (value || "").trim();
if (!v) return {ok: true, error: ""};
@@ -2815,7 +3096,8 @@ function validateURL(value) {
if (!u.hostname) return {ok: false, error: "hostname is empty"};
if (u.hostname === "localhost" || u.hostname === "127.0.0.1") {
const isLoopback = u.hostname === "localhost" || u.hostname === "127.0.0.1";
if (isLoopback && u.origin !== referenceOrigin) {
return {ok: false, error: "loopback URL — speakers can only reach this if AfterTouch is installed on the speaker itself (on-device install). For the typical multi-device setup, use a LAN-reachable IP or hostname."};
}
@@ -2834,12 +3116,23 @@ function validatePlanURLs() {
["bmxRegistryUrl", "plan-bmx-url"],
];
const targetUrl = (document.getElementById("plan-target-url") || {}).value || "";
let referenceOrigin = "";
try {
referenceOrigin = new URL(targetUrl).origin;
} catch (e) {
// Target URL isn't a valid absolute URL yet (e.g. empty) — leave
// referenceOrigin empty, so a loopback field simply won't match
// it and falls back to today's warning, same as before this
// exemption existed.
}
const errors = [];
for (const [name, elemId] of fields) {
const el = document.getElementById(elemId);
if (!el) continue;
const v = validateURL(el.value);
const v = validateURL(el.value, referenceOrigin);
el.style.borderColor = v.ok ? "" : "#c62828";
if (!v.ok) errors.push(`${name}: ${v.error}`);
}
@@ -3697,7 +3990,11 @@ function looksTransient(msg) {
// DNS interception, CA/TLS), and preconditions (remote_services,
// pairing, backup). Reads only fields the backend already exposes —
// is_migrated remains the OR of the per-axis booleans.
function renderMigrationState(summary) {
//
// targetUrl is the current Target Domain value, used only to judge
// whether CA/TLS is actually relevant to the current plan (see
// isHttpsTarget) — the default Suggested Plan never needs it.
function renderMigrationState(summary, targetUrl) {
// --- Transports ---
setStateChip("state-ssh", summary.ssh_success, "Reachable", "Unreachable");
setStateChip("state-telnet", summary.telnet_reachable, "Reachable", "Unreachable");
@@ -3778,16 +4075,19 @@ function renderMigrationState(summary) {
const caLine = document.getElementById("state-ca-line");
if (caLine) {
caLine.replaceChildren();
const v = caVerdict(summary);
const v = caVerdict(summary, isHttpsTarget(targetUrl));
caLine.appendChild(stateLine(v.icon, v.text, v.note));
}
// --- Preconditions ---
const remoteCell = document.getElementById("state-remote-services-cell");
if (remoteCell) {
remoteCell.replaceChildren();
// Like CA/TLS above, the cell also hosts the Enable/Disable SSH
// buttons as siblings of this line — only rewrite the verdict span so
// they stay put across re-renders.
const remoteLine = document.getElementById("state-remote-services-line");
if (remoteLine) {
remoteLine.replaceChildren();
const v = remoteServicesVerdict(summary);
remoteCell.appendChild(stateLine(v.icon, v.text, v.note));
remoteLine.appendChild(stateLine(v.icon, v.text, v.note));
}
const pairedCell = document.getElementById("state-paired");
@@ -3909,9 +4209,22 @@ function dnsInterceptionVerdict(summary) {
return {icon: "⚠️", text: "/etc/hosts redirects", note: "(deprecated method)"};
}
function caVerdict(summary) {
// isHttpsTarget reports whether a target/service URL uses the https
// scheme. Used to distinguish "CA/TLS optional" (the default Suggested
// Plan for both XML-over-SSH and Telnet migrates over plain HTTP, no CA
// involved) from "CA/TLS required" (Target Domain is https://, or the
// Customize form's DNS-interception method is chosen — that one always
// targets https://*.bose.com).
function isHttpsTarget(url) {
return /^https:/i.test((url || "").trim());
}
function caVerdict(summary, httpsRelevant) {
if (summary.ca_cert_trusted) return {icon: "✅", text: "Local root CA installed", note: ""};
return {icon: "❌", text: "Not installed", note: "(HTTPS to local service will fail TLS validation until injected via SSH)"};
if (httpsRelevant) {
return {icon: "❌", text: "Not installed", note: "(required — your Target URL is HTTPS; install it before migrating, or click Trust CA Now)"};
}
return {icon: "⚪", text: "Not installed", note: "(not needed — your Target URL is HTTP; only required if you switch to HTTPS or use the DNS-interception method)"};
}
function remoteServicesVerdict(summary) {
@@ -4641,14 +4954,14 @@ async function toggleDeviceSummary(deviceId) {
const resp = await fetch(`/api/setup/device-summary/${encodeURIComponent(deviceId)}`);
if (!resp.ok) {
const txt = await resp.text();
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${resp.status} ${escapeHTML(txt)}</span>`;
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${resp.status} ${escapeHtml(txt)}</span>`;
return;
}
const data = await resp.json();
cell.innerHTML = "";
cell.appendChild(renderDeviceSummary(data));
} catch (e) {
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${escapeHTML(e.message || String(e))}</span>`;
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${escapeHtml(e.message || String(e))}</span>`;
}
}
@@ -4853,12 +5166,3 @@ function unreachableBlock(probe) {
return wrap;
}
function escapeHTML(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
@@ -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)
}
}
+8 -8
View File
@@ -215,7 +215,7 @@ func detectOrphanDefaultEntries(ds *datastore.DataStore, paired []models.Service
if speakerAccount != info.account {
log.Printf("[Health] consistency: speaker %s reports margeAccountUUID=%s but ListAllDevices picked %s — preferring the speaker's answer for orphan-deletion suggestions",
deviceID, speakerAccount, info.account)
sanitizeLog(deviceID), sanitizeLog(speakerAccount), sanitizeLog(info.account))
}
}
@@ -289,21 +289,21 @@ func deleteOrphanAccountEntry(ds *datastore.DataStore, target Target) (string, e
if speakerAccount := fetchSpeakerMargeAccount(ctx, speakerIP); speakerAccount != "" {
if speakerAccount == target.Account {
return "", fmt.Errorf("speaker %s reports margeAccountUUID=%s — refusing to delete <data-dir>/accounts/%s/devices/%s because it's the speaker's currently-active binding (re-paired since the consistency check ran?)",
target.Device, speakerAccount, target.Account, target.Device)
sanitizeLog(target.Device), sanitizeLog(speakerAccount), sanitizeLog(target.Account), sanitizeLog(target.Device))
}
log.Printf("[Health] deleteOrphanAccountEntry: speaker %s confirmed margeAccountUUID=%s; target account %s is stale, proceeding with delete",
target.Device, speakerAccount, target.Account)
sanitizeLog(target.Device), sanitizeLog(speakerAccount), sanitizeLog(target.Account))
} else {
log.Printf("[Health] deleteOrphanAccountEntry: speaker %s at %s not reachable for re-confirmation; relying on operator's Confirm click",
target.Device, speakerIP)
sanitizeLog(target.Device), sanitizeLog(speakerIP))
}
} else {
log.Printf("[Health] deleteOrphanAccountEntry: no IP recorded for device %s — skipping speaker re-probe", target.Device)
log.Printf("[Health] deleteOrphanAccountEntry: no IP recorded for device %s — skipping speaker re-probe", sanitizeLog(target.Device))
}
if target.Account == accountIDDefaultPlaceholder {
log.Printf("[Health] deleteOrphanAccountEntry: deleting the \"default\" placeholder entry for device %s; this is normal after pairing completed", target.Device)
log.Printf("[Health] deleteOrphanAccountEntry: deleting the \"default\" placeholder entry for device %s; this is normal after pairing completed", sanitizeLog(target.Device))
}
path := ds.AccountDeviceDir(target.Account, target.Device)
@@ -316,7 +316,7 @@ func deleteOrphanAccountEntry(ds *datastore.DataStore, target Target) (string, e
}
log.Printf("[Health] Removed orphan account entry %s (account=%s device=%s) at operator request",
path, target.Account, target.Device)
path, sanitizeLog(target.Account), sanitizeLog(target.Device))
return fmt.Sprintf("Removed stale account entry %s for device %s.", target.Account, target.Device), nil
}
@@ -479,7 +479,7 @@ func reclassifyCanonicalSourceIDs(ds *datastore.DataStore, target Target) (strin
for i := range sources {
if newID, ok := rename[sources[i].ID]; ok {
log.Printf("[Health] Re-classify %s: id %s → %s (account=%s device=%s)",
sources[i].SourceKeyType, sources[i].ID, newID, target.Account, target.Device)
sanitizeLog(sources[i].SourceKeyType), sanitizeLog(sources[i].ID), sanitizeLog(newID), sanitizeLog(target.Account), sanitizeLog(target.Device))
sources[i].ID = newID
@@ -0,0 +1,51 @@
package health
// CheckIDMgmtDefaultCredentials is the registry id of the
// management-credentials check. It fires when the Management API
// (Basic Auth in front of /api/mgmt/*, guarding Spotify/Amazon
// account linking and Local Accounts) is still using the published
// default username/password. Those defaults are documented publicly,
// so anyone who has read the docs can use them.
//
// This is a visibility-only nudge (see issue #419): it does not gate
// anything and does not change default behavior. A future release is
// expected to require an explicit choice (set a password, or opt out)
// before /admin is reachable; until then this just surfaces the gap.
const CheckIDMgmtDefaultCredentials = "mgmt_default_credentials"
// DefaultMgmtUsername and DefaultMgmtPassword are the published
// defaults (cmd/soundtouch-service flags --mgmt-username/--mgmt-password).
// Duplicated here (rather than imported) to keep this package's
// dependency surface small; keep in sync if the CLI defaults change.
const (
DefaultMgmtUsername = "admin"
DefaultMgmtPassword = "change_me!"
)
// RegisterMgmtDefaultCredentialsCheck registers the check.
// getMgmtCredentials returns the currently-configured management
// username and password (typically Server's mgmtUsername/mgmtPassword).
func RegisterMgmtDefaultCredentialsCheck(r *Registry, getMgmtCredentials func() (username, password string)) {
r.Register(Check{
ID: CheckIDMgmtDefaultCredentials,
Title: "Management API credentials have been changed from the published default",
Run: func() []Finding {
username, password := getMgmtCredentials()
return runMgmtDefaultCredentialsCheck(username, password)
},
})
}
func runMgmtDefaultCredentialsCheck(username, password string) []Finding {
if username != DefaultMgmtUsername || password != DefaultMgmtPassword {
return nil
}
return []Finding{{
Severity: SeverityInfo,
Message: "The Management API (Spotify/Amazon account linking, Local Accounts) is still using " +
"the published default credentials (admin / change_me!). Anyone who has read the docs can use them.",
Details: "Set MGMT_USERNAME and MGMT_PASSWORD (env vars or the matching CLI flags) to your own " +
"values and restart the service.",
}}
}
@@ -0,0 +1,27 @@
package health
import "testing"
func TestMgmtDefaultCredentialsCheck_NoFindingWhenChanged(t *testing.T) {
for _, tc := range []struct{ username, password string }{
{"admin", "somethingElse"},
{"someoneElse", "change_me!"},
{"customadmin", "customsecret"},
} {
got := runMgmtDefaultCredentialsCheck(tc.username, tc.password)
if len(got) != 0 {
t.Errorf("expected no findings for %+v, got %+v", tc, got)
}
}
}
func TestMgmtDefaultCredentialsCheck_WarnsWhenStillDefault(t *testing.T) {
got := runMgmtDefaultCredentialsCheck(DefaultMgmtUsername, DefaultMgmtPassword)
if len(got) != 1 {
t.Fatalf("expected one finding for unchanged default credentials, got %+v", got)
}
if got[0].Severity != SeverityInfo {
t.Errorf("expected SeverityInfo (visibility-only nudge, not a gate), got %v", got[0].Severity)
}
}
+119 -3
View File
@@ -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"))
@@ -0,0 +1,133 @@
package health
import (
"fmt"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// CheckIDRuntimeBmxURLStale is the registry id of the runtime BMX-URL
// staleness check.
const CheckIDRuntimeBmxURLStale = "runtime_bmx_url_stale"
// RegisterRuntimeBmxURLStaleCheck registers a check that reads each reachable
// speaker's *runtime* bmxRegistryUrl (from its on-device
// SoundTouchSdkPrivateCfg.xml via SSH, or `getpdo` over telnet) and flags
// speakers still pointed at the shut-down Bose cloud.
//
// Radio source types (TUNEIN / RADIO_BROWSER / LOCAL_INTERNET_RADIO) are
// delivered to the speaker through the BMX registry, so a speaker whose
// bmxRegistryUrl still names the Bose cloud can never mount them. That is the
// most common "radio missing after migration" cause. The service's own /sources
// listing is correct, which is why the existing sources_xml_diff check only
// reports the symptom ("missing 3 source types"); this check reports the
// per-device cause so the operator knows exactly which speakers need
// re-migrating.
//
// readBmxURL reads a speaker's runtime bmxRegistryUrl by IP, returning the URL
// and whether the runtime config could be read at all. It lives in the handlers
// layer because the health package deliberately avoids importing the SSH/telnet
// and setup packages (see the boundary comments in server.go).
//
// dnsRunningFn reports whether this service is running its own DNS interception.
// This is the false-positive guard: under a DNS-based migration (AfterTouch
// acting as the speaker's DNS server) a cloud URL is EXPECTED, because the
// redirect happens at the DNS layer rather than by rewriting the on-device URL,
// so when our DNS is running the check stays silent. The router-DNS variant (the
// LAN's DNS points at AfterTouch without our DNS server running) is called out
// as a known exception in the finding text rather than suppressed, since we
// cannot detect it here.
func RegisterRuntimeBmxURLStaleCheck(r *Registry, ds *datastore.DataStore, readBmxURL func(ip string) (string, bool), dnsRunningFn func() bool) {
r.Register(Check{
ID: CheckIDRuntimeBmxURLStale,
Title: "Speaker runtime bmxRegistryUrl points at AfterTouch, not the Bose cloud",
Run: func() []Finding {
return runRuntimeBmxURLStaleCheck(ds, readBmxURL, dnsRunningFn)
},
})
}
func runRuntimeBmxURLStaleCheck(ds *datastore.DataStore, readBmxURL func(ip string) (string, bool), dnsRunningFn func() bool) []Finding {
if ds == nil || readBmxURL == nil {
return nil
}
// When this service intercepts DNS, a speaker legitimately keeps the Bose
// cloud hostnames in its on-device config (they resolve to AfterTouch), so a
// cloud URL is not evidence of a stale migration. Don't second-guess it.
if dnsRunningFn != nil && dnsRunningFn() {
return nil
}
devices, err := ds.ListAllDevices()
if err != nil {
return []Finding{{
Severity: SeverityError,
Message: "Could not enumerate devices: " + err.Error(),
}}
}
var findings []Finding
for i := range devices {
dev := &devices[i]
if dev.IPAddress == "" || dev.DeviceID == "" {
continue
}
bmxURL, ok := readBmxURL(dev.IPAddress)
if !ok || bmxURL == "" {
// Couldn't read the runtime config (speaker offline, or neither SSH
// nor telnet available). speaker_info_reachable / sources_xml_diff
// cover the reachability angle; nothing to assert here.
continue
}
findings = append(findings, assessRuntimeBmxURL(dev.AccountID, dev.DeviceID, dev.IPAddress, bmxURL)...)
}
return findings
}
// assessRuntimeBmxURL is the pure, per-device core: given a speaker's runtime
// bmxRegistryUrl, it returns a warning finding when the URL still names the Bose
// cloud. Split out from the datastore iteration so it can be unit-tested
// directly (mirrors assessMargeURLForDeviceWithURL).
func assessRuntimeBmxURL(account, deviceID, ipAddress, bmxURL string) []Finding {
host := hostFromURL(bmxURL)
if host == "" || !isBoseCloudHost(host) {
return nil
}
return []Finding{{
Severity: SeverityWarning,
Target: Target{Account: account, Device: deviceID},
Message: fmt.Sprintf("Speaker's runtime BMX registry URL still points at the Bose cloud (%s).", host),
Details: "Radio source types (TUNEIN, RADIO_BROWSER, LOCAL_INTERNET_RADIO) are fetched from the BMX registry, so while this URL names the shut-down Bose cloud the speaker can never mount them, even though the service's own /sources listing looks correct. Re-migrate this speaker so its runtime bmxRegistryUrl points at AfterTouch, then reboot it. (Exception: if you migrate via DNS, meaning AfterTouch acts as the speaker's DNS server or your router points the LAN's DNS at AfterTouch, a cloud URL is expected and this warning can be ignored.)",
ManualCommands: []ManualCommand{{
Label: "Re-migrate this speaker (the telnet method rewrites all runtime URLs):",
Command: fmt.Sprintf("soundtouch-cli --host %s setup migrate --method telnet --service-url http://<aftertouch-host>:8000", ipAddress),
Hint: "Replace <aftertouch-host> with a LAN-resolvable name or IP of this service. Reboot the speaker afterwards so it reloads the new config.",
}},
}}
}
// isBoseCloudHost reports whether host belongs to one of Bose's (shut-down)
// cloud domains: content.api.bose.io, streaming.bose.com, events.api.bosecm.com,
// worldwide.bose.com, and the like. A domain-suffix match keeps it robust
// against the various sub-domains seen across firmware versions.
func isBoseCloudHost(host string) bool {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" {
return false
}
for _, domain := range []string{"bose.io", "bose.com", "bosecm.com"} {
if host == domain || strings.HasSuffix(host, "."+domain) {
return true
}
}
return false
}
@@ -0,0 +1,64 @@
package health
import "testing"
func TestRuntimeBmxURL_WarnsWhenStillOnBoseCloud(t *testing.T) {
got := assessRuntimeBmxURL("1000001", "DEVICEID01", "192.0.2.10",
"https://content.api.bose.io/bmx/registry/v1/services")
if len(got) != 1 {
t.Fatalf("expected 1 finding for a Bose-cloud bmx URL, got %d: %+v", len(got), got)
}
f := got[0]
if f.Severity != SeverityWarning {
t.Errorf("expected SeverityWarning, got %q", f.Severity)
}
if f.Target.Account != "1000001" || f.Target.Device != "DEVICEID01" {
t.Errorf("unexpected target: %+v", f.Target)
}
if len(f.ManualCommands) != 1 {
t.Fatalf("expected a re-migrate manual command, got %+v", f.ManualCommands)
}
}
func TestRuntimeBmxURL_NoFindingWhenPointingAtAfterTouch(t *testing.T) {
got := assessRuntimeBmxURL("1000001", "DEVICEID01", "192.0.2.10",
"http://192.0.2.10:8000/bmx/registry/v1/services")
if len(got) != 0 {
t.Errorf("expected no findings for an AfterTouch bmx URL, got %+v", got)
}
}
func TestRuntimeBmxURL_NoFindingWhenEmpty(t *testing.T) {
if got := assessRuntimeBmxURL("1000001", "DEVICEID01", "192.0.2.10", ""); len(got) != 0 {
t.Errorf("expected no findings for an empty bmx URL, got %+v", got)
}
}
func TestIsBoseCloudHost(t *testing.T) {
cloud := []string{
"content.api.bose.io",
"streaming.bose.com",
"events.api.bosecm.com",
"worldwide.bose.com",
"bose.com",
}
for _, h := range cloud {
if !isBoseCloudHost(h) {
t.Errorf("expected %q to be a Bose cloud host", h)
}
}
local := []string{
"aftertouch.local",
"192.0.2.10",
"",
"notbose.example.com",
"bose.io.evil.example.com",
}
for _, h := range local {
if isBoseCloudHost(h) {
t.Errorf("expected %q NOT to be a Bose cloud host", h)
}
}
}
+6 -3
View File
@@ -30,9 +30,12 @@ func suggestAccountForPairing(ds *datastore.DataStore, deviceID string) string {
return ""
}
// isSevenDigitAccountID mirrors setup.IsValidAccountID without
// importing the setup package (which would pull in SSH/telnet/certmgr
// transitively — see the boundary comment near speakerInfoXML).
// isSevenDigitAccountID is intentionally narrower than
// datastore.IsSafeIdentifier: it filters suggestAccountForPairing's
// candidates down to directories that look like a real Bose-issued
// account, not merely safe-to-use ones (a device-reported value like
// "stick@local", #634, is a safe identifier but not something to
// suggest as a pre-existing "real" account to reuse).
func isSevenDigitAccountID(s string) bool {
if len(s) != 7 {
return false
@@ -0,0 +1,48 @@
package health
// CheckIDSpotifyAccountLinked is the registry id of the Spotify
// account-link check. It fires when a Spotify developer app is
// configured (client_id/secret set) but no account has completed the
// OAuth link — the state in which every speaker's Spotify token
// request 503s ("No Spotify accounts linked"), and Spotify presets
// only appear to work right after a manual Spotify Connect session
// (which bypasses AfterTouch's token proxy entirely and doesn't
// survive a reboot).
//
// See issue #269: this state is otherwise invisible outside the
// service log / a diagnostic export, so operators mistake it for a
// per-speaker Spotify limitation instead of a link they never
// finished.
const CheckIDSpotifyAccountLinked = "spotify_account_linked"
// RegisterSpotifyAccountLinkedCheck registers the check.
// getSpotifyConfigured reports whether a Spotify client_id/secret is
// set (typically Server's spotifyService != nil); getLinkedAccountCount
// returns how many Spotify accounts have completed the OAuth link
// (typically len(spotifyService.GetAccounts())).
func RegisterSpotifyAccountLinkedCheck(r *Registry, getSpotifyConfigured func() bool, getLinkedAccountCount func() int) {
r.Register(Check{
ID: CheckIDSpotifyAccountLinked,
Title: "Spotify account is linked",
Run: func() []Finding {
return runSpotifyAccountLinkedCheck(getSpotifyConfigured(), getLinkedAccountCount())
},
})
}
func runSpotifyAccountLinkedCheck(configured bool, linkedCount int) []Finding {
if !configured || linkedCount > 0 {
return nil
}
return []Finding{{
Severity: SeverityWarning,
Message: "Spotify is configured (client ID/secret set) but no account is linked yet. " +
"Every speaker's Spotify token request will fail (503 \"No Spotify accounts linked\"), " +
"so stored Spotify presets stay dead until you manually Spotify Connect from a phone — " +
"and that only primes the one speaker you connected to, only until its next reboot.",
Details: "Finish the link on the Admin page: Settings → Spotify → \"Link Spotify Account\", " +
"then complete the consent screen. Once linked, AfterTouch re-primes each speaker " +
"automatically on power-on.",
}}
}
@@ -0,0 +1,28 @@
package health
import "testing"
func TestSpotifyAccountLinkedCheck_NoFindingWhenNotConfigured(t *testing.T) {
got := runSpotifyAccountLinkedCheck(false, 0)
if len(got) != 0 {
t.Errorf("expected no findings when Spotify isn't configured, got %+v", got)
}
}
func TestSpotifyAccountLinkedCheck_NoFindingWhenLinked(t *testing.T) {
got := runSpotifyAccountLinkedCheck(true, 1)
if len(got) != 0 {
t.Errorf("expected no findings when an account is linked, got %+v", got)
}
}
func TestSpotifyAccountLinkedCheck_WarnsWhenConfiguredButUnlinked(t *testing.T) {
got := runSpotifyAccountLinkedCheck(true, 0)
if len(got) != 1 {
t.Fatalf("expected one finding for configured-but-unlinked, got %+v", got)
}
if got[0].Severity != SeverityWarning {
t.Errorf("expected SeverityWarning, got %v", got[0].Severity)
}
}
+13
View File
@@ -0,0 +1,13 @@
package health
import "strings"
// sanitizeLog strips newline characters from s to prevent log-injection
// (CodeQL go/log-injection). Values from speakers (e.g. margeAccountUUID
// read live via :8090/info) may contain attacker-controlled newlines.
func sanitizeLog(s string) string {
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
return s
}
@@ -0,0 +1,88 @@
package marge
import (
"fmt"
"os"
"sync"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// TestConcurrentUpdatePresetNoLostUpdates is a regression test for #614's
// 2026-08-23 reproduction: a reporter's script stored six presets via rapid,
// overlapping PUT .../preset/N requests (visible in the speaker's own log as
// interleaved connection IDs, never waiting for one PUT to complete before
// firing the next). One preset silently vanished from Presets.xml.
//
// UpdatePreset used to do GetPresets, mutate one slot, SavePresets as three
// separate steps with no lock spanning them — a classic lost-update race:
// two concurrent calls can each read the same starting list, mutate
// different slots, and the second writer's SavePresets clobbers the first
// writer's update. Fixed by routing the write through
// datastore.MutatePresets, which holds a single write lock for the whole
// read-mutate-write cycle.
func TestConcurrentUpdatePresetNoLostUpdates(t *testing.T) {
tempDir, err := os.MkdirTemp("", "marge-concurrent-update-preset-*")
if err != nil {
t.Fatalf("tempdir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
account := "1234567"
device := "B0D5CC25479C"
const presetCount = 6
var wg sync.WaitGroup
errs := make([]error, presetCount)
for i := 1; i <= presetCount; i++ {
wg.Add(1)
go func(presetNumber int) {
defer wg.Done()
// sourceid 10003 is the canonical LOCAL_INTERNET_RADIO built-in
// (see CanonicalSourceByID) — same shape as Henri's own repro
// script, which stored six LOCAL_INTERNET_RADIO presets.
putXML := []byte(fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<preset>
<name>Station %d</name>
<sourceid>10003</sourceid>
<location>/custom/v1/playback/station%d</location>
<contentItemType>stationurl</contentItemType>
</preset>`, presetNumber, presetNumber))
_, err := UpdatePreset(ds, account, device, presetNumber, putXML)
errs[presetNumber-1] = err
}(i)
}
wg.Wait()
for i, err := range errs {
if err != nil {
t.Fatalf("UpdatePreset(preset=%d) returned error: %v", i+1, err)
}
}
presets, err := ds.GetPresets(account, device)
if err != nil {
t.Fatalf("GetPresets: %v", err)
}
if len(presets) != presetCount {
t.Fatalf("expected %d presets after %d concurrent UpdatePreset calls, got %d: %+v", presetCount, presetCount, len(presets), presets)
}
for i, p := range presets {
want := fmt.Sprintf("Station %d", i+1)
if p.Name != want {
t.Errorf("preset slot %d: expected name %q, got %q — a concurrent update was lost", i+1, want, p.Name)
}
}
}
+131 -88
View File
@@ -752,6 +752,13 @@ func CreateAccountDevice(ds *datastore.DataStore, account, deviceID string) (mod
device.Presets = mapPresetsToFullResponse(presets, sources)
device.Recents = mapRecentsToFullResponse(recents, sources)
if len(device.Presets) != len(presets) {
log.Printf("[Marge] /full: device %s — read %d preset(s) from disk, embedding %d after source mapping",
sanitizeLog(deviceID), len(presets), len(device.Presets))
} else {
log.Printf("[Marge] /full: device %s — embedding %d preset(s)", sanitizeLog(deviceID), len(device.Presets))
}
return device, nil
}
@@ -1501,19 +1508,18 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
// RemovePreset clears a preset for the specified account and device.
func RemovePreset(ds *datastore.DataStore, account, device string, presetNumber int) error {
presets, err := ds.GetPresets(account, device)
if err != nil {
return err
}
_, err := ds.MutatePresets(account, device, func(presets []models.ServicePreset) ([]models.ServicePreset, error) {
if presetNumber < 1 || presetNumber > len(presets) {
// Preset doesn't exist or index out of range, nothing to do
return presets, nil
}
if presetNumber < 1 || presetNumber > len(presets) {
// Preset doesn't exist or index out of range, nothing to do
return nil
}
presets[presetNumber-1] = models.ServicePreset{}
presets[presetNumber-1] = models.ServicePreset{}
return presets, nil
})
return ds.SavePresets(account, device, presets)
return err
}
// resolvePresetSource resolves the source a preset PUT is referencing,
@@ -1522,42 +1528,74 @@ func RemovePreset(ds *datastore.DataStore, account, device string, presetNumber
// returns the matched source plus the possibly-extended sources slice
// (since auto-add appends). Returns (nil, sources) when no match could be
// resolved — UpdatePreset turns that into a 500 with a diagnostic log line.
func resolvePresetSource(ds *datastore.DataStore, account, device string, sources []models.ConfiguredSource, sourceID string, presetNumber int) (*models.ConfiguredSource, []models.ConfiguredSource) {
// findConfiguredSource looks up sourceID in sources, first by exact ID and
// then — since the speaker sometimes sends the symbolic provider name (e.g.
// <sourceid>TUNEIN</sourceid>) instead of a numeric ID — by SourceKeyType
// for the handful of providers known to do that.
func findConfiguredSource(sources []models.ConfiguredSource, sourceID string) *models.ConfiguredSource {
for i := range sources {
if sources[i].ID == sourceID {
return &sources[i], sources
return &sources[i]
}
}
// Fallback: SourceID is the symbolic provider name (the speaker
// sometimes sends e.g. <sourceid>TUNEIN</sourceid> instead of a
// numeric ID); match by SourceKeyType.
if sourceID == constants.ProviderInternetRadio || sourceID == constants.ProviderTunein || sourceID == constants.ProviderSpotify || sourceID == constants.ProviderAmazon {
for i := range sources {
if sources[i].SourceKeyType == sourceID {
return &sources[i], sources
return &sources[i]
}
}
}
return nil
}
func resolvePresetSource(ds *datastore.DataStore, account, device string, sources []models.ConfiguredSource, sourceID string, presetNumber int) (*models.ConfiguredSource, []models.ConfiguredSource) {
if src := findConfiguredSource(sources, sourceID); src != nil {
return src, sources
}
// Auto-add a canonical built-in source the speaker referenced but
// AfterTouch hasn't been told about (post-factory-reset state). For
// account-bound sources (Spotify, Amazon) we can't synthesise
// credentials, so the caller will reject the PUT instead.
if canonical, ok := ds.CanonicalSourceByID(sourceID); ok {
log.Printf("[Marge] UpdatePreset(preset=%d): auto-adding canonical source id=%s type=%s providerid=%s — speaker referenced a built-in source not yet in AfterTouch's configured-sources list; saving so the preset can land",
presetNumber, sanitizeLog(canonical.ID), sanitizeLog(canonical.SourceKeyType), sanitizeLog(canonical.SourceProviderID))
canonical, ok := ds.CanonicalSourceByID(sourceID)
if !ok {
return nil, sources
}
log.Printf("[Marge] UpdatePreset(preset=%d): auto-adding canonical source id=%s type=%s providerid=%s — speaker referenced a built-in source not yet in AfterTouch's configured-sources list; saving so the preset can land",
presetNumber, sanitizeLog(canonical.ID), sanitizeLog(canonical.SourceKeyType), sanitizeLog(canonical.SourceProviderID))
// Read-mutate-write atomically against the persisted list, not the
// possibly-stale `sources` snapshot the caller already read — a
// concurrent PUT for a different preset could be auto-adding (or have
// just added) a source at the same time, and a plain Get+Save here
// would silently lose whichever write landed second.
updated, saveErr := ds.MutateConfiguredSources(account, device, func(current []models.ConfiguredSource) ([]models.ConfiguredSource, error) {
if src := findConfiguredSource(current, sourceID); src != nil {
// Another concurrent caller already added it; nothing to do.
return current, nil
}
return append(current, canonical), nil
})
if saveErr != nil {
log.Printf("[Marge] UpdatePreset(preset=%d): SaveConfiguredSources after auto-add failed: %s — the preset will land but the source may not survive a service restart",
presetNumber, sanitizeErr(saveErr))
sources = append(sources, canonical)
if saveErr := ds.SaveConfiguredSources(account, device, sources); saveErr != nil {
log.Printf("[Marge] UpdatePreset(preset=%d): SaveConfiguredSources after auto-add failed: %s — the preset will land but the source may not survive a service restart",
presetNumber, sanitizeErr(saveErr))
}
return &sources[len(sources)-1], sources
}
return nil, sources
if src := findConfiguredSource(updated, sourceID); src != nil {
return src, updated
}
updated = append(updated, canonical)
return &updated[len(updated)-1], updated
}
// UpdatePreset updates or creates a preset for the specified account and device.
@@ -1567,11 +1605,6 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
return nil, err
}
presets, err := ds.GetPresets(account, device)
if err != nil {
presets = []models.ServicePreset{}
}
var newPresetElem struct {
Name string `xml:"name"`
Username string `xml:"username"`
@@ -1637,14 +1670,19 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
Username: newPresetElem.Name,
}
// Ensure presets list is large enough
for len(presets) < presetNumber {
presets = append(presets, models.ServicePreset{})
}
// Read-mutate-write atomically: a concurrent PUT for a different preset
// number racing this one must not be able to clobber it. See
// MutatePresets — this is the exact interleave that dropped a preset
// during #614's rapid-fire repro.
if _, err = ds.MutatePresets(account, device, func(presets []models.ServicePreset) ([]models.ServicePreset, error) {
for len(presets) < presetNumber {
presets = append(presets, models.ServicePreset{})
}
presets[presetNumber-1] = presetObj
presets[presetNumber-1] = presetObj
if err = ds.SavePresets(account, device, presets); err != nil {
return presets, nil
}); err != nil {
return nil, err
}
@@ -1773,11 +1811,6 @@ func AddRecent(ds *datastore.DataStore, account, device string, sourceXML []byte
return nil, err
}
recents, err := ds.GetRecents(account, device)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
var input recentInput
if err := xml.Unmarshal(sourceXML, &input); err != nil {
return nil, err
@@ -1822,9 +1855,20 @@ func AddRecent(ds *datastore.DataStore, account, device string, sourceXML []byte
syncMatchingSource(matchingSrc, input)
utcTime := parseLastPlayedAt(input.LastPlayedAt)
recentObj, recents := updateOrCreateRecent(recents, input.Name, matchingSrc, input.ContentItemType, input.Location, device, utcTime)
if err := ds.SaveRecents(account, device, recents); err != nil {
// Read-mutate-write atomically: a concurrent AddRecent/preset call for
// the same device racing this one must not be able to clobber it. See
// MutatePresets/MutateRecents for why a plain GetRecents+SaveRecents
// isn't safe here.
var recentObj *models.ServiceRecent
if _, err := ds.MutateRecents(account, device, func(recents []models.ServiceRecent) ([]models.ServiceRecent, error) {
var updated []models.ServiceRecent
recentObj, updated = updateOrCreateRecent(recents, input.Name, matchingSrc, input.ContentItemType, input.Location, device, utcTime)
return updated, nil
}); err != nil {
return nil, err
}
@@ -1852,7 +1896,7 @@ func learnSource(ds *datastore.DataStore, account, device string, sources []mode
matchingSrc.SecretType = constants.CredentialTypeToken
}
persistLearnedSource(ds, account, device, sources, matchingSrc)
persistLearnedSource(ds, account, device, matchingSrc)
}
return matchingSrc, sourceLearned
@@ -2002,26 +2046,24 @@ func updateSourceFields(src *models.ConfiguredSource, credentialValue, sourceNam
return learned
}
func persistLearnedSource(ds *datastore.DataStore, account, device string, sources []models.ConfiguredSource, matchingSrc *models.ConfiguredSource) {
updatedSources := make([]models.ConfiguredSource, len(sources))
copy(updatedSources, sources)
func persistLearnedSource(ds *datastore.DataStore, account, device string, matchingSrc *models.ConfiguredSource) {
// Read-mutate-write atomically against the persisted list, not a
// snapshot the caller read earlier — AddRecent and UpdatePreset can
// both be learning/auto-adding sources for the same device
// concurrently, and a plain Get+Save here would silently lose
// whichever write landed second.
_, err := ds.MutateConfiguredSources(account, device, func(sources []models.ConfiguredSource) ([]models.ConfiguredSource, error) {
for i := range sources {
if sources[i].ID == matchingSrc.ID {
sources[i] = *matchingSrc
found := false
for i := range updatedSources {
if updatedSources[i].ID == matchingSrc.ID {
updatedSources[i] = *matchingSrc
found = true
break
return sources, nil
}
}
}
if !found {
updatedSources = append(updatedSources, *matchingSrc)
}
if err := ds.SaveConfiguredSources(account, device, updatedSources); err != nil {
return append(sources, *matchingSrc), nil
})
if err != nil {
log.Printf("[MARGE_ERR] Failed to persist learned source for %s: %s", sanitizeLog(device), sanitizeErr(err))
}
}
@@ -2407,7 +2449,6 @@ func AddSource(ds *datastore.DataStore, account, username, providerID, secret, s
}
devID := entry.Name()
sources, _ := ds.GetConfiguredSources(account, devID)
newSrc := models.ConfiguredSource{
ID: sourceID,
@@ -2437,37 +2478,39 @@ func AddSource(ds *datastore.DataStore, account, username, providerID, secret, s
PrepareConfiguredSource(&newSrc)
// Update or append. Most providers are singletons (one account each), so
// the same provider replaces the existing entry. STORED_MUSIC is the
// exception: each DLNA media server is a separate account (username =
// "<UDN>/0"), so it must only replace when the account also matches.
// Otherwise registering a second media server overwrites the first, which
// then vanishes from /full + /sources and the speaker drops it (only one
// media server could ever stay registered).
replaced := false
// Read-mutate-write atomically against the persisted list, not a
// snapshot read before the loop body — see MutateConfiguredSources.
_, err := ds.MutateConfiguredSources(account, devID, func(sources []models.ConfiguredSource) ([]models.ConfiguredSource, error) {
// Update or append. Most providers are singletons (one account
// each), so the same provider replaces the existing entry.
// STORED_MUSIC is the exception: each DLNA media server is a
// separate account (username = "<UDN>/0"), so it must only
// replace when the account also matches. Otherwise registering
// a second media server overwrites the first, which then
// vanishes from /full + /sources and the speaker drops it
// (only one media server could ever stay registered).
for i := range sources {
sameProvider := sources[i].SourceProviderID == providerID
if providerID == strconv.Itoa(constants.StoredMusicProviderID) {
// Match on the persisted account identity
// (SourceKey.Account), not Username, which does not
// round-trip through the datastore.
sameProvider = sameProvider && sources[i].SourceKey.Account == username
}
for i := range sources {
sameProvider := sources[i].SourceProviderID == providerID
if providerID == strconv.Itoa(constants.StoredMusicProviderID) {
// Match on the persisted account identity (SourceKey.Account),
// not Username, which does not round-trip through the datastore.
sameProvider = sameProvider && sources[i].SourceKey.Account == username
if sameProvider ||
(providerID == strconv.Itoa(constants.SpotifyProviderID) && sources[i].SourceKey.Type == constants.ProviderSpotify) {
sources[i] = newSrc
return sources, nil
}
}
if sameProvider ||
(providerID == strconv.Itoa(constants.SpotifyProviderID) && sources[i].SourceKey.Type == constants.ProviderSpotify) {
sources[i] = newSrc
replaced = true
break
}
return append(sources, newSrc), nil
})
if err != nil {
log.Printf("[Marge] AddSource: failed to save source %s for device %s: %s", sanitizeLog(newSrc.SourceKey.Type), sanitizeLog(devID), sanitizeErr(err))
}
if !replaced {
sources = append(sources, newSrc)
}
_ = ds.SaveConfiguredSources(account, devID, sources)
}
return sourceID, nil

Some files were not shown because too many files have changed in this diff Show More