Compare commits

...
31 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Sonnet 4.6 96e2c2e3cf fix(web): restore SourceAccount guard in HandleDevicePlay
The guard was accidentally placed in HandlePlayRadioBrowser instead of
HandleDevicePlay in the initial fix commit, then removed from there by
the build-fix commit — leaving HandleDevicePlay with no guard at all.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 15:03:04 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ca466ec2a3 fix(web): remove stray SourceAccount guard from RadioBrowser handler
The previous edit accidentally inserted the TUNEIN placeholder guard
into HandlePlayRadioBrowser, which uses a different req struct without
SourceAccount/Source fields, breaking the build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 15:03:04 +02:00
Tobias GesellchenandClaude Sonnet 4.6 abe9079382 fix(web): strip placeholder SourceAccount before replaying recents
Speakers echo back the source name as SourceAccount when no real
credential is set (e.g. SourceAccount="TUNEIN" for a TUNEIN source).
HandleDevicePlay was forwarding this verbatim, causing the speaker to
try authenticating with the source name as a TuneIn account and
returning INVALID_SOURCE.

Clear SourceAccount when it equals Source; preserve it when it differs
(real credentials such as Spotify or STORED_MUSIC UUIDs).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 15:03:04 +02:00
Tobias GesellchenandClaude Sonnet 4.6 11a6515f4d feat(tunein): add section-grouped results and load-more pagination
TuneIn's profiles API caps initial results at ~10 per container (Stations,
Shows, etc.) and exposes a Pivots.More.Url cursor for the remainder. This
change wires that cursor through the stack so users can load additional
results without leaving the search view.

- tuneInSearchSection now extracts Pivots.More.Url as bmx_next when
  itemToken is present; absent for containers already at their limit
- TuneInSearchNext fetches the cursor URL, which returns a flat Items[]
  (not nested containers), and maps Station/Program/Topic items using
  the existing play/profile builders
- New GET /v1/search/next and /api/tunein/search/next endpoints with
  matching handlers in both service paths
- TuneInBrowser: flat items state replaced with per-section sections
  state; each section shows a header label and a Load more button when
  a cursor is available; browse/navigate mode is unaffected

Relates to #336.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:25:01 +02:00
Tobias GesellchenandClaude Sonnet 4.6 38c771ad75 fix(web): skip auto-discovery on page load when periodic discovery is disabled
When `discovery_enabled` is false the page no longer fires a discovery
scan on load. Both DOMContentLoaded handlers now await fetchSettings()
and gate triggerDiscovery() on the returned flag — default true keeps
existing behaviour for installations that never touched the setting.

Also renames the UI label from "Enable Automated Discovery" to
"Enable Periodic Discovery" to make clear the checkbox controls the
background timer, not the manual trigger button or IP-entry form.

Relates to #269

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:19:46 +02:00
Tobias GesellchenandClaude Sonnet 4.6 1eb1fefc1d fix(datastore): parse legacy <ContentItem> (capital C) in Presets.xml
encoding/xml is case-sensitive, so Presets.xml files written by older
AfterTouch versions using <ContentItem> (capital C) had all source,
location, and type attributes silently dropped on read. Every preset for
such a device had empty fields, causing mapPresetsToFullResponse to skip
them all — the speaker received /full with zero presets and stored nothing.

Fix: normalise <ContentItem> → <contentItem> before unmarshaling in the
new readPresetsLocked helper. If normalisation was needed, GetPresets
rewrites the file in canonical form after releasing the read lock, so the
issue self-heals on first service start with no manual intervention.

Diagnosed via the i218 encrypted diagnostic export (device 304511B46CBC,
ST30 Master Bedroom): health check speaker_presets_count reported
"Speaker shows 0 preset slot(s); service Presets.xml has 6", and the
service log showed six [Marge] /full: skipping preset N — source ""
messages per /full call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:08:51 +02:00
Tobias Gesellchen b3bab622cd Bump 2026-05-23 01:30:40 +02:00
Tobias GesellchenandClaude Sonnet 4.6 93248659a5 fix(tls): also cover derived OAuth subdomain in served cert SAN list
#337's first commit added the OAuth-derivation to the DNS interceptor
but missed the served TLS certificate. With a serverURL of
`http://mac.fritz.box:8000` the cert SAN list covered `mac.fritz.box`
but not `macoauth.fritz.box`, so the speaker would resolve the OAuth
host correctly (via the new DNS hijack) and then immediately fail the
TLS handshake — Spotify / Amazon Music token refresh dies before
reaching AfterTouch.

getDomains now calls discovery.DeriveOAuthHostnames(serverURL) and
discovery.DeriveOAuthHostnames(httpsServerURL), feeding the derived
names into the SAN map alongside the existing entries. IP-based
serverURLs continue to produce no derivation (the OAuth construction
is unrecoverable for them — see the existing oauth_target_reachable
health check).

Tests in cmd/soundtouch-service/main_test.go lock in:
  - Hostname serverURL → derived OAuth variant present in SAN list.
  - IP serverURL → no malformed `192oauth.…` entry leaks in.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 00:34:36 +02:00
Tobias GesellchenandClaude Sonnet 4.6 90a8bb25cf fix(docs): update docs/README.md after archive moves
The markdown-link-check CI step caught five stale links in
docs/README.md's Concept Documentation section pointing at files
the previous commit moved into docs/archive/. Replaced with a
pointer to SUMMARY.md's Concepts section + a short curated list
of the currently-relevant docs (Spotify Overview, Spotify OAuth,
Amazon Music OAuth, Encrypted Export, Request Recording). The
archived planning artefacts get a single line acknowledging
their existence under docs/archive/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 00:34:36 +02:00
Tobias GesellchenandClaude Sonnet 4.6 6861063935 feat(dns): auto-derive OAuth subdomain from serverURL hostname (#337)
The speaker firmware constructs the OAuth host by appending "oauth" to
the first label of the configured streaming hostname (aftertouch.lan
→ aftertouchoauth.lan, used by both Spotify and Amazon Music token
refresh). AfterTouch's DNS server previously only hijacked the
hardcoded list of Bose hostnames, so operators self-hosting at a
custom hostname had to add the OAuth alias themselves — and the
amazon-music-oauth.md / spotify-overview.md docs incorrectly
claimed the DNS server handled it automatically.

ofthesun9 (#337) caught this via the worst variant: IP-based
serverURL (192.168.0.30 → 192oauth.168.0.30), which is a malformed
hostname no DNS resolver can answer for. There is no clean DNS
workaround for the IP case — the operator must use a hostname.

Three changes:

- pkg/discovery/dns.go DeriveOAuthHostnames parses the configured
  serverURL, derives <first-label>oauth.<rest> when the host is a
  hostname (not IP), and adds it to the DNSDiscovery hijack list. IP
  serverURLs deliberately yield no derivation — the malformed name
  isn't worth handling and the new health check surfaces the trap.
- New checks_oauth_target health check fires a Warning when serverURL
  is an IP literal, with a concrete example of the malformed name
  (`192oauth.168.0.30`) and a ManualCommand pointing at the switch.
- amazon-music-oauth.md and spotify-overview.md rewritten: drop the
  false "automatic" claim, document the three resolution paths
  (AfterTouch DNS + speaker resolves via it / external LAN DNS /
  per-speaker /etc/hosts), and explicitly flag IP-based --server-url
  as incompatible with OAuth on either provider.

Tests cover the derivation matrix (hostname / IPv4 / IPv6 / single
label / empty / garbage URL), shouldIntercept's new behaviour
(derived host hit, base host not auto-hijacked, case-insensitive),
the health check's four states, and the malformed-host helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 00:34:36 +02:00
Tobias GesellchenandClaude Sonnet 4.6 1421ad5ce1 chore(docs): widen docs-consistency test, archive stale concept docs
The TestDocsConsistency walk only iterated [".", "guides", "reference",
"analysis"] — concepts/ was silently invisible, which is why
amazon-music-oauth.md slipped into the tree without a SUMMARY entry.

Refactored to walk the entire docs/ tree, with a small dirsToSkip
allow-list (_includes, archive, diagrams, images) for asset trees.
New top-level narrative directories are picked up automatically;
only asset dirs need an explicit entry.

The wider walk surfaced six previously-hidden concepts/* files. Five
older planning artefacts ("Enhanced State Management System",
"Upstream Bose Service Simulation") moved into docs/archive/ where
the dirsToSkip already excludes them; concepts/README.md renamed to
upstream-service-simulation-overview.md since "README.md" inside
archive/ would be misleading. Spotify Overview and Amazon Music
OAuth are user-facing narrative docs and are now linked under
Concepts in SUMMARY.md.

Note: concepts/streborn-patterns.md is internal review notes (its
own opening line says so) and is currently unlinked from SUMMARY.md;
will be handled separately by the maintainer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 00:34:36 +02:00
Tobias Gesellchen 56ace2f960 Update screenshots 2026-05-22 22:03:25 +02:00
Tobias GesellchenandClaude Sonnet 4.6 89bfa8c2fb feat(discovery): quiet per-packet logs by default; CLI keeps verbose
Discovery cycles emit one line per UPnP M-SEARCH header, one per
parsed response, and one per enrichment step — by default. A typical
service-binary cycle prints ~50–80 lines for a 3-speaker LAN. Most
operators want a startup-and-summary view; the per-packet trace is
only useful for debugging.

- New SetVerbose/IsVerbose/logVerbose helpers in pkg/discovery (atomic
  bool, zero-value off).
- Chatty log.Printf calls in upnp.go and mdns.go demoted to logVerbose:
  per-header dumps, per-response dumps, per-device enrichment steps,
  M-SEARCH details, read-deadline / cancel-context noise.
- Kept at default level: discovery start ("Starting SSDP discovery
  for…"), end ("Discovery completed. Processed N responses, found N
  unique devices" + per-device summary), warnings ("Configured
  interface not found", "Failed to fetch device description", …), and
  the new "Rejecting non-Bose device" classifier.
- cmd/soundtouch-cli/discover devices grew a --verbose / -v flag that
  flips the package toggle on; the service binary leaves it at the
  zero value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:15:34 +02:00
Tobias GesellchenandClaude Sonnet 4.6 1cd4226f5b feat: tighter discovery filter + UX cleanups (#269, #345, #355, #359)
Four small, independent improvements bundled into one cut:

1. Restrict device discovery to SoundTouch-family services (#269/#359).
   - mDNS now queries all three SoundTouch service-type variants in
     parallel (_soundtouch._tcp, _bose-soundtouch._tcp, _soundtouchstick._tcp)
     and deduplicates results by host:port. mDNS has no native wildcard
     for service types, so we fan out one query per variant.
   - UPnP/SSDP M-SEARCH receives a manufacturer/modelName check after
     fetching the device description: devices whose manufacturer doesn't
     contain "bose" AND whose model doesn't contain "soundtouch" are
     rejected. Closes the loop on NorbertBauer's diagnostic bundle that
     showed a Dreambox dm920 and Onkyo HT-R695 living under the default
     account because they answered our generic MediaRenderer:1 probe.

2. New health check: default-account-contains-non-Bose-devices (#269).
   Walks devices keyed under data/accounts/default/devices/, flags any
   whose ProductCode/Name doesn't look SoundTouch, and offers an Evict
   QuickFix. Bose devices still in default (legitimate pre-pair) are
   intentionally ignored — that's the consistency check's domain.

3. Clipboard fallback for Copy buttons (#355). The two health-tab Copy
   buttons used navigator.clipboard.writeText, which requires a secure
   context. Over plain HTTP at a LAN IP the browser blocks it silently
   and the button shows "Copy failed". New copyTextToClipboard helper
   tries the modern API first, falls back to document.execCommand("copy")
   via an off-screen textarea.

4. Web UI static-asset cache-busting (#345). dekiesel needed Ctrl+F5 to
   see the v0.89 Download button after upgrade. The root HTML now
   carries a ?v=<hash> query string on /web/js/script.js and
   /web/css/style.css references. Hash is sha256 over the embedded asset
   bodies, truncated to 12 hex chars — stable per binary, changes when
   the assets change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:15:34 +02:00
Tobias GesellchenandClaude Sonnet 4.6 dbe123226c docs(ui): move diagnostic export block above findings list
When the findings list grows the diagnostic-export subsection got
pushed below the visible viewport. Moving it above the findings list
(but below the Refresh header and description) keeps the Download
button in reach regardless of how many checks fire.

Wrapped in a subtle gray box to visually distinguish it from the
checks themselves.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:32:44 +02:00
Tobias GesellchenandClaude Sonnet 4.6 d6302985f4 docs(ui): group diagnostic-report button with its explanation
Previously the Download button sat at the top-right with the Refresh
button, while the "What does the report contain?" details block lived
below the health-checks description paragraph — visually separated by
the description and an entire section's worth of layout.

Now the diagnostic export lives in its own subsection at the bottom of
the Health tab, with the button, a one-line tagline, the details
block, and the post-download status indicator all adjacent. The
header keeps just Refresh, which controls the health-checks view it
sits next to.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:32:44 +02:00
Tobias GesellchenandClaude Sonnet 4.6 3d1eed6b65 docs(ui): make TLS extra hosts section answer "do I need this?" first
Previous text explained how the merge works but didn't give operators a
clear signal for when to act. New structure leads with:

- "When you need this": rarely; symptoms a user actually sees (presets
  reset, BoseApp offline) instead of a syslog string most users won't
  consult.
- "How to tell": open the Health tab, look for speaker_marge_url; if
  clean, leave this empty.
- "Manual path": only after the user has decided they need it.

Adds a small, always-visible hint below the label that points to the
Health tab — most operators won't expand the ⓘ panel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:32:44 +02:00
Tobias GesellchenandClaude Sonnet 4.6 3a0b30bc33 feat(tls): persist TLSExtraHosts + Settings UI + speaker_marge_url QuickFix
Operators who deploy AfterTouch on an IP-only host (no DNS hostname) and
who get a speaker_marge_url health warning previously had to SSH in, edit
their systemd unit or docker-compose, add --tls-extra-host, and restart.
The fix is now reachable from the UI:

- datastore.Settings gains TLSExtraHosts []string. At startup
  applyPersistedSettings merges CLI/env values (still authoritative)
  with persisted ones, deduplicating while preserving order.
- /setup/settings (GET) exposes tls_extra_hosts (editable list) and
  tls_san_hosts (the full effective SAN list, read-only).
- /setup/settings (POST) accepts tls_extra_hosts (*[]string so callers
  can distinguish "field omitted" from "explicitly empty").
- Settings tab grows a "TLS extra hosts" textarea + an info panel
  explaining the restart-required dance.
- speaker_marge_url emits a QuickFix labelled "Add <host> to TLS hosts"
  alongside the existing CLI manual command. The fix re-probes the
  device's /info, extracts the margeURL host, and appends it to the
  persisted list — race-safe against stale findings.
- HTTPS-SETUP.md documents both paths.

Tests cover: merge dedup + ordering + whitespace, the new QuickFix
emission shape, and the margeURL host extraction across HTTPS/HTTP/bare
input forms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:32:44 +02:00
Tobias GesellchenandClaude Sonnet 4.6 377fa9ceda feat(preflight): skip :443 check in HTTP-only deployments + OUTPUT-chain caveat
The :443 reachability preflight was emitting a WARN on every deployment
where AfterTouch's configured --server-url is HTTP (not HTTPS), even
when speakers were migrated to that HTTP URL and never connect to :443.
Operators reproduced this on #218 (CTonyPeterson) and #344
(california444) — both saw the warning even though their setups had no
need for iptables port forwarding, and CTonyPeterson followed the
recommended iptables OUTPUT rule which then caught his host's own
outbound HTTPS traffic and broke `go install` and his browser.

Two changes:

- Probe443Result gains NotApplicable + Reason. Check443Reachability
  returns the NotApplicable verdict when the parsed serverURL scheme is
  http. The settings UI renders an ℹ️ info badge with the reason instead
  of a red ✗.
- FormatPreflightGuidance grows a one-line caveat about the iptables
  OUTPUT chain: it catches all outbound :443 on the host, including
  browsers / go install / apt-get, which is rarely what the operator
  wants.

HTTPS-SETUP.md gains the same caveat plus a section documenting the
new not-applicable verdict.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:32:44 +02:00
Tobias GesellchenandClaude Sonnet 4.6 a1754a4500 feat(setup): remote_services CLI integration
- setup remote-services subcommand: enables (default) or removes
  (--remove) the remote_services SSH-enablement marker via SSH, targeting
  persistent locations (/etc or /mnt/nv) before the volatile /tmp fallback
- setup plan now includes a "persist remote_services" step when the marker
  is only in /tmp (would be lost on next reboot, breaking SSH mid-migration)
- setup plan state header shows a [⚠] line when remote_services is
  enabled but not persistent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 19:09:41 +02:00
Tobias Gesellchen 722b2ca9a6 lint 2026-05-22 19:04:13 +02:00
Tobias GesellchenandClaude Sonnet 4.6 2ec5efde7f feat(setup): dual DNS preflight check — CLI and speaker perspectives
Replaces the single-sided requireAfterTouchDNSReachable with runDNSPreflight
that probes both the CLI machine and the speaker (via SSH nslookup) in
parallel, then renders a two-row table when results differ.

The speaker's perspective is authoritative: a CLI-only failure no longer
blocks the migration (the speaker may reach the DNS listener via a network
path the CLI host cannot). Migration is only aborted when the speaker itself
definitively cannot reach AfterTouch's DNS listener.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 19:04:13 +02:00
Tobias Gesellchen ff61f0ca65 lint 2026-05-22 18:58:57 +02:00
Tobias GesellchenandClaude Sonnet 4.6 b0df8ba963 fix(setup): correct false-positive migration detection and plan command errors
- isXMLMigrated and isResolvConfMigrated now guard against empty hostname
  (Go's strings.Contains(s, "") is always true, causing any speaker to
  appear migrated when --service-url has a malformed single-slash scheme)
- renderPlanSteps message no longer claims "and paired" when --include-pair=false
- validateServiceURL rejects malformed service URLs early with a hint
  (e.g. "did you mean https://soundtouch.fritz.box?")
- Generated plan-step commands move --host before the subcommand name
  (urfave/cli/v2 requires global flags before the first subcommand token)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:58:57 +02:00
Tobias Gesellchen a684c88325 Bump 2026-05-22 18:55:48 +02:00
Tobias GesellchenandClaude Sonnet 4.6 915fca496f feat(export): improve diagnostic report web UI
Add a <details> block listing what the encrypted archive contains so
reporters know what they're sharing before clicking. After a successful
download, show two submission options with email preferred:
aftertouch-support@gesellix.net (mailto link with pre-filled subject and
filename) or a GitHub issue with the file renamed to <filename>.txt (GitHub
blocks .age uploads).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:39:20 +02:00
dependabot[bot] 95979137af 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.0.0 to 4.1.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd...d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-22 18:38:53 +02:00
dependabot[bot] ea1e5f3794 ci(deps): bump docker/build-push-action from 7.1.0 to 7.2.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 7.1.0 to 7.2.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/bcafcacb16a39f128d818304e6c9c0c18556b85f...f9f3042f7e2789586610d6e8b85c8f03e5195baf)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-22 18:38:31 +02:00
dependabot[bot] ff0f3e1e66 ci(deps): bump docker/login-action from 4.1.0 to 4.2.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.1.0 to 4.2.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/4907a6ddec9925e35a0a9e82d7399ccc52663121...650006c6eb7dba73a995cc03b0b2d7f5ca915bee)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-22 18:38:21 +02:00
dependabot[bot] db37372393 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/net](https://github.com/golang/net), [golang.org/x/image](https://github.com/golang/image) and [golang.org/x/sys](https://github.com/golang/sys).


Updates `golang.org/x/crypto` from 0.51.0 to 0.52.0
- [Commits](https://github.com/golang/crypto/compare/v0.51.0...v0.52.0)

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-22 18:36:02 +02:00
Tobias Gesellchen abfe540864 chore: update default version to v0.89.0 2026-05-21 23:40:00 +02:00
69 changed files with 2775 additions and 306 deletions
+4 -4
View File
@@ -306,7 +306,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Determine push eligibility
id: push-check
@@ -324,7 +324,7 @@ jobs:
- name: Log in to GitHub Container Registry
if: steps.push-check.outputs.should-push == 'true'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -342,7 +342,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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
target: soundtouch-service
@@ -365,7 +365,7 @@ jobs:
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
- name: Build and push soundtouch-web Docker image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
target: soundtouch-web
+4 -4
View File
@@ -524,10 +524,10 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -544,7 +544,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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
target: soundtouch-service
@@ -566,7 +566,7 @@ jobs:
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
- name: Build and push soundtouch-web Docker image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
target: soundtouch-web
+5
View File
@@ -15,6 +15,11 @@ import (
func discoverDevices(c *cli.Context) error {
fmt.Printf("Discovering SoundTouch devices...\n")
// CLI discovery is interactive — flip on verbose protocol logging
// so operators can see per-packet / per-header detail. The service
// binary leaves this off so its log stays terse.
discovery.SetVerbose(c.Bool("verbose"))
// Load configuration
cfg, err := config.LoadFromEnv()
if err != nil {
+220 -35
View File
@@ -48,6 +48,7 @@ func setupCommand() *cli.Command {
setupWaitAPCmd(),
setupWaitOnlineCmd(),
setupSSHCheckCmd(),
setupRemoteServicesCmd(),
setupInstallCACmd(),
setupMigrateCmd(),
setupRebootCmd(),
@@ -536,6 +537,51 @@ func setupSSHCheckCmd() *cli.Command {
}
}
func setupRemoteServicesCmd() *cli.Command {
return &cli.Command{
Name: "remote-services",
Usage: "Enable (default) or disable the remote_services SSH-enablement marker on the speaker",
Before: RequireHost,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "remove",
Usage: "Remove all remote_services marker files (disables SSH after next reboot)",
},
},
Action: func(c *cli.Context) error {
cfg := GetClientConfig(c)
m := setup.NewManager("", nil, nil)
var (
logs string
err error
)
if c.Bool("remove") {
logs, err = m.RemoveRemoteServices(cfg.Host)
} else {
logs, err = m.EnsureRemoteServices(cfg.Host)
}
if logs != "" {
fmt.Print(logs)
}
if err != nil {
PrintError(err.Error())
return err
}
if c.Bool("remove") {
PrintSuccess("remote_services removed — SSH will no longer be enabled after next reboot")
} else {
PrintSuccess("remote_services enabled at a persistent location")
}
return nil
},
}
}
func setupInstallCACmd() *cli.Command {
return &cli.Command{
Name: "install-ca",
@@ -549,6 +595,11 @@ func setupInstallCACmd() *cli.Command {
cfg := GetClientConfig(c)
serviceURL := strings.TrimRight(c.String("service-url"), "/")
if err := validateServiceURL(serviceURL); err != nil {
PrintError(err.Error())
return err
}
certPEM, err := fetchCACert(serviceURL, c.String("auth"))
if err != nil {
PrintError(err.Error())
@@ -698,11 +749,17 @@ func setupMigrateCmd() *cli.Command {
method := setup.MigrationMethod(c.String("method"))
serviceURL := c.String("service-url")
// For DNS-redirect methods, prove AfterTouch's DNS listener
// is alive by sending it a real query — that's the truth,
// regardless of what its settings claim.
if err := validateServiceURL(serviceURL); err != nil {
PrintError(err.Error())
return err
}
m := setup.NewManager(serviceURL, nil, nil)
// For DNS-redirect methods check that AfterTouch's DNS listener
// is reachable — both from this machine and from the speaker.
if !c.Bool("skip-preflight") && (method == setup.MigrationMethodResolvConf || method == setup.MigrationMethodHosts) {
if err := requireAfterTouchDNSReachable(serviceURL); err != nil {
if err := runDNSPreflight(cfg.Host, serviceURL, m.NewSSH); err != nil {
PrintError(err.Error())
return err
}
@@ -720,8 +777,6 @@ func setupMigrateCmd() *cli.Command {
fmt.Printf("Migrating %s → %s using method=%s\n", cfg.Host, serviceURL, method)
m := setup.NewManager(serviceURL, nil, nil)
logs, err := m.MigrateSpeaker(cfg.Host, serviceURL, c.String("proxy-url"), nil, method)
if logs != "" {
fmt.Print(logs)
@@ -762,31 +817,53 @@ func preInstallCAForCLI(deviceIP, serviceURL string) error {
return nil
}
// requireAfterTouchDNSReachable sends a real DNS query to AfterTouch's
// port-53 listener and confirms it responds. This is the ground-truth
// preflight for DNS-redirect migration methods — config inspection (the
// previous approach via GET /setup/settings) can lag the actual listener
// state and can't tell us whether queries succeed end-to-end.
//
// We query a known-intercepted hostname (streaming.bose.com). Any IP in
// the response proves AfterTouch's DNS is alive on :53; if the listener
// is down the custom Dial just times out and the user gets a clear error.
func requireAfterTouchDNSReachable(serviceURL string) error {
// validateServiceURL returns an error if serviceURL cannot be parsed or has no
// hostname. A common mistake is a single-slash scheme (https:/host instead of
// https://host); the error message hints at the correction in that case.
func validateServiceURL(serviceURL string) error {
parsed, err := url.Parse(serviceURL)
if err != nil {
return fmt.Errorf("preflight: parse service URL %q: %w", serviceURL, err)
return fmt.Errorf("invalid --service-url %q: %w", serviceURL, err)
}
host := parsed.Hostname()
if host == "" {
return fmt.Errorf("preflight: service URL %q has no hostname", serviceURL)
if parsed.Hostname() == "" {
hint := ""
if parsed.Scheme != "" && parsed.Opaque != "" {
hint = fmt.Sprintf(" (did you mean %s://%s?)", parsed.Scheme, strings.TrimPrefix(parsed.Opaque, "/"))
}
return fmt.Errorf("invalid --service-url %q: no hostname found%s", serviceURL, hint)
}
return nil
}
// dnsCheckResult holds the outcome of one DNS reachability probe.
type dnsCheckResult struct {
ok bool
unknown bool // SSH unavailable or nslookup not present — result indeterminate
detail string // "works" on success, error reason otherwise
}
func (r dnsCheckResult) label() string {
switch {
case r.ok:
return "✓ works"
case r.unknown:
return "? " + r.detail
default:
return "✗ " + r.detail
}
}
// cliDNSCheck sends a real DNS query for streaming.bose.com through the
// AfterTouch DNS listener to verify it is alive from this machine.
func cliDNSCheck(dnsHost string) dnsCheckResult {
resolver := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
d := net.Dialer{Timeout: 3 * time.Second}
return d.DialContext(ctx, "udp", net.JoinHostPort(host, "53"))
return d.DialContext(ctx, "udp", net.JoinHostPort(dnsHost, "53"))
},
}
@@ -795,14 +872,91 @@ func requireAfterTouchDNSReachable(serviceURL string) error {
ips, err := resolver.LookupHost(ctx, "streaming.bose.com")
if err != nil {
return fmt.Errorf(
"preflight: DNS query to %s:53 failed: %w. AfterTouch's DNS listener is unreachable or not bound to port 53. Use --skip-preflight to bypass once you've verified DNS some other way",
host, err,
)
return dnsCheckResult{detail: err.Error()}
}
if len(ips) == 0 {
return fmt.Errorf("preflight: %s:53 returned no answers for streaming.bose.com — listener may be misconfigured", host)
return dnsCheckResult{detail: "no answers for streaming.bose.com — listener may be misconfigured"}
}
return dnsCheckResult{ok: true, detail: "works"}
}
// speakerDNSCheck SSHes into the speaker and runs nslookup streaming.bose.com
// against the AfterTouch DNS server to verify reachability from the device.
func speakerDNSCheck(deviceIP, dnsHost string, newSSH func(string) setup.SSHClient) dnsCheckResult {
addrs, err := net.LookupHost(dnsHost)
if err != nil || len(addrs) == 0 {
return dnsCheckResult{unknown: true, detail: fmt.Sprintf("cannot resolve %s locally to run speaker-side check", dnsHost)}
}
dnsIP := addrs[0]
client := newSSH(deviceIP)
out, sshErr := client.Run(fmt.Sprintf("nslookup streaming.bose.com %s", dnsIP))
if sshErr != nil {
if strings.Contains(out, "not found") || strings.Contains(out, "No such file") {
return dnsCheckResult{unknown: true, detail: "nslookup not available on speaker"}
}
if strings.Contains(sshErr.Error(), "dial") || strings.Contains(sshErr.Error(), "connect") {
return dnsCheckResult{unknown: true, detail: fmt.Sprintf("SSH unavailable: %s", sshErr)}
}
msg := strings.TrimSpace(out)
if msg == "" {
msg = sshErr.Error()
}
return dnsCheckResult{detail: msg}
}
return dnsCheckResult{ok: true, detail: "works"}
}
// runDNSPreflight checks AfterTouch DNS reachability from both the CLI machine
// and the speaker, prints a table, and returns an error only when the speaker
// side definitively cannot reach the DNS listener (CLI-only failures are
// informational — the speaker's perspective is authoritative).
func runDNSPreflight(deviceIP, serviceURL string, newSSH func(string) setup.SSHClient) error {
parsed, _ := url.Parse(serviceURL)
dnsHost := parsed.Hostname()
type result struct {
cli dnsCheckResult
speaker dnsCheckResult
}
ch := make(chan result, 1)
go func() {
cliCh := make(chan dnsCheckResult, 1)
speakerCh := make(chan dnsCheckResult, 1)
go func() { cliCh <- cliDNSCheck(dnsHost) }()
go func() { speakerCh <- speakerDNSCheck(deviceIP, dnsHost, newSSH) }()
ch <- result{cli: <-cliCh, speaker: <-speakerCh}
}()
r := <-ch
if r.cli.ok && r.speaker.ok {
fmt.Printf("DNS preflight (%s:53) ✓ works\n", dnsHost)
return nil
}
fmt.Printf("DNS preflight (%s:53)\n", dnsHost)
fmt.Printf(" CLI host %s\n", r.cli.label())
fmt.Printf(" Speaker %s\n", r.speaker.label())
fmt.Println()
if !r.speaker.ok && !r.speaker.unknown {
return fmt.Errorf("AfterTouch DNS unreachable from speaker — %s migration would fail", serviceURL)
}
if r.speaker.unknown && !r.cli.ok {
return fmt.Errorf("cannot confirm DNS reachability (SSH unavailable from speaker, CLI probe also failed) — use --skip-preflight to bypass")
}
return nil
@@ -822,6 +976,11 @@ func setupVerifyCmd() *cli.Command {
cfg := GetClientConfig(c)
serviceURL := c.String("service-url")
if err := validateServiceURL(serviceURL); err != nil {
PrintError(err.Error())
return err
}
m := setup.NewManager(serviceURL, nil, nil)
summary, err := m.GetMigrationSummary(cfg.Host, serviceURL, c.String("proxy-url"), nil)
@@ -1013,6 +1172,11 @@ func setupPlanCmd() *cli.Command {
wifiSSID := c.String("wifi-ssid")
includePair := c.Bool("include-pair")
if err := validateServiceURL(serviceURL); err != nil {
PrintError(err.Error())
return err
}
m := setup.NewManager(serviceURL, nil, nil)
fmt.Printf("Probing %s …\n\n", cfg.Host)
@@ -1036,7 +1200,7 @@ func setupPlanCmd() *cli.Command {
renderPreResetNote()
}
renderPlanSteps(steps)
renderPlanSteps(steps, includePair)
return nil
},
@@ -1093,6 +1257,10 @@ func renderPlanState(deviceIP string, inspect *setup.InspectReport, summary *set
check(summary.IsPaired), check(summary.IsMigrated),
yesNo(summary.TelnetMigrated), yesNo(summary.XMLMigrated),
yesNo(summary.HostsMigrated), yesNo(summary.ResolvMigrated))
if summary.RemoteServicesEnabled && !summary.RemoteServicesPersistent {
fmt.Println(" [⚠] remote_services enabled but not persistent (will be lost on reboot)")
}
}
func firmwareOf(info *setup.DeviceInfoXML) string {
@@ -1135,7 +1303,19 @@ func buildPlanSteps(
host = "<NEW_IP>" // subsequent commands target the discovered IP
}
if !reset && summary != nil && summary.IsMigrated && (!includePair || summary.IsPaired) {
// Persist remote_services before anything else when it's only in /tmp.
// SSH is reachable now, but the marker would be lost on the next reboot —
// which could happen mid-migration if power is cut or the reboot step runs
// before persistence is confirmed.
if !reset && summary != nil && summary.RemoteServicesEnabled && !summary.RemoteServicesPersistent {
steps = append(steps, planStep{
title: "Persist remote_services so SSH survives a reboot",
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup remote-services", host),
reason: "Marker is currently in /tmp only — lost on next reboot, which would break SSH mid-migration.",
})
}
if !reset && summary != nil && summary.IsMigrated && (!includePair || summary.IsPaired) && len(steps) == 0 {
return steps
}
@@ -1146,7 +1326,7 @@ func buildPlanSteps(
if includePair && (reset || (summary != nil && !summary.IsPaired)) {
steps = append(steps, planStep{
title: "Pair the device with an AfterTouch account",
cmd: fmt.Sprintf("soundtouch-cli setup pair --host=%s --service-url=%s", host, serviceURL),
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup pair --service-url=%s", host, serviceURL),
reason: "Required for preset persistence, streaming services, multi-room zones.",
})
}
@@ -1178,7 +1358,7 @@ func resetSteps(host, wifiSSID string, inspect *setup.InspectReport) []planStep
return []planStep{
{
title: "Factory-reset the speaker",
cmd: fmt.Sprintf("soundtouch-cli setup factory-reset --host=%s", host),
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup factory-reset", host),
reason: "Wipes account pairing, presets, Wi-Fi — gives a clean baseline for the SETUP state machine.",
},
{
@@ -1236,20 +1416,20 @@ func migrationSteps(host, serviceURL string, summary *setup.MigrationSummary, re
if dnsRedirect && summary != nil && !summary.CACertTrusted {
steps = append(steps, planStep{
title: "Install AfterTouch's CA cert on the speaker",
cmd: fmt.Sprintf("soundtouch-cli setup install-ca --host=%s --service-url=%s", host, serviceURL),
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup install-ca --service-url=%s", host, serviceURL),
reason: "DNS-redirect methods keep using https://*.bose.com URLs — the device needs to trust AfterTouch's cert.",
})
}
steps = append(steps, planStep{
title: fmt.Sprintf("Apply URL migration using method=%s", method),
cmd: fmt.Sprintf("soundtouch-cli setup migrate --host=%s --service-url=%s --method=%s", host, serviceURL, method),
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup migrate --service-url=%s --method=%s", host, serviceURL, method),
reason: methodReason,
})
steps = append(steps, planStep{
title: "Reboot the speaker",
cmd: fmt.Sprintf("soundtouch-cli setup reboot --host=%s", host),
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup reboot", host),
reason: "The envswitch parallel-persistence layer only fully wins on next boot; reboot now to lock the new URLs in before pairing.",
})
@@ -1314,9 +1494,14 @@ func renderPreResetNote() {
fmt.Println()
}
func renderPlanSteps(steps []planStep) {
func renderPlanSteps(steps []planStep, includePair bool) {
if len(steps) == 0 {
PrintSuccess("Speaker is already migrated and paired. No action required.")
if includePair {
PrintSuccess("Speaker is already migrated and paired. No action required.")
} else {
PrintSuccess("Speaker is already migrated. No action required.")
}
return
}
+5
View File
@@ -132,6 +132,11 @@ func main() {
Aliases: []string{"a"},
Usage: "Show detailed information for all devices",
},
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
Usage: "Print per-packet/per-header SSDP and mDNS trace logs",
},
},
},
},
+58 -1
View File
@@ -747,6 +747,22 @@ func getDomains(serverURL, httpsServerURL, hostname string, extraHosts []string)
domainsMap[strings.ToLower(u.Hostname())] = true
}
// The speaker firmware constructs the OAuth host by appending `oauth`
// to the first label of the streaming hostname (see issue #337 and
// pkg/discovery/dns.go DeriveOAuthHostnames). The DNS hijack catches
// it; the TLS cert must also cover it, otherwise the speaker rejects
// the handshake and Spotify / Amazon Music OAuth dies before reaching
// AfterTouch. Derive once from each of serverURL and httpsServerURL —
// they typically share a hostname but a multi-homed deployment may
// differ.
for _, h := range discovery.DeriveOAuthHostnames(serverURL) {
domainsMap[h] = true
}
for _, h := range discovery.DeriveOAuthHostnames(httpsServerURL) {
domainsMap[h] = true
}
// Explicit overrides / additions for multi-homed hosts, reverse proxies,
// or browsing the admin UI via a LAN IP that isn't part of serverURL.
for _, h := range extraHosts {
@@ -810,9 +826,44 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
// CLI/env args take precedence; only apply persisted credentials when not set via CLI.
applyPersistedMusicServiceCredentials(config, persisted)
config.tlsExtraHosts = mergeTLSExtraHosts(config.tlsExtraHosts, persisted.TLSExtraHosts)
return persisted
}
// mergeTLSExtraHosts merges the CLI/env-supplied hosts with the persisted
// list. CLI/env wins (so an operator who pinned a host via systemd unit
// always sees it applied); persisted values are additive. Returns a
// deduplicated, order-preserving slice with CLI/env entries first.
func mergeTLSExtraHosts(cli, persisted []string) []string {
seen := make(map[string]bool, len(cli)+len(persisted))
out := make([]string, 0, len(cli)+len(persisted))
for _, h := range cli {
h = strings.TrimSpace(h)
if h == "" || seen[h] {
continue
}
seen[h] = true
out = append(out, h)
}
for _, h := range persisted {
h = strings.TrimSpace(h)
if h == "" || seen[h] {
continue
}
seen[h] = true
out = append(out, h)
}
return out
}
// applyPersistedMusicServiceCredentials fills in music service credentials from persisted
// settings when they have not been supplied via CLI flags or environment variables.
func applyPersistedMusicServiceCredentials(config *serviceConfig, persisted datastore.Settings) {
@@ -954,6 +1005,7 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Get("/v1/navigate", server.HandleTuneInNavigate)
r.Get("/v1/navigate/*", server.HandleTuneInNavigate)
r.Get("/v1/search", server.HandleTuneInSearch)
r.Get("/v1/search/next", server.HandleTuneInSearchNext)
r.Post("/v1/favorite/{stationID}", server.HandleTuneInFavorite)
r.Delete("/v1/favorite/{stationID}", server.HandleTuneInDeleteFavorite)
})
@@ -1304,7 +1356,12 @@ func runHTTPSPreflight(httpsServerURL, serverURL string, dnsEnabled bool, resolv
guidance := handlers.FormatPreflightGuidance(port, res)
if guidance == "" {
if !res.Skipped {
switch {
case res.Skipped:
// Listener already on :443 — nothing to say.
case res.NotApplicable:
log.Printf("HTTPS pre-flight: :443 check skipped — %s", res.Reason)
default:
log.Printf("HTTPS pre-flight: :443 reachable at localhost and %s ✓", res.LANHost)
}
+97
View File
@@ -90,3 +90,100 @@ func TestApplyPersistedSettings(t *testing.T) {
}
})
}
func TestMergeTLSExtraHosts(t *testing.T) {
cases := []struct {
name string
cli []string
persisted []string
want []string
}{
{
name: "CLI only",
cli: []string{"a.example"},
persisted: nil,
want: []string{"a.example"},
},
{
name: "Persisted only",
cli: nil,
persisted: []string{"b.example"},
want: []string{"b.example"},
},
{
name: "CLI wins ordering, persisted appended",
cli: []string{"a.example"},
persisted: []string{"b.example"},
want: []string{"a.example", "b.example"},
},
{
name: "Dedupes overlap",
cli: []string{"a.example", "b.example"},
persisted: []string{"b.example", "c.example"},
want: []string{"a.example", "b.example", "c.example"},
},
{
name: "Drops empty + whitespace",
cli: []string{" ", "a.example", ""},
persisted: []string{"", " b.example "},
want: []string{"a.example", "b.example"},
},
{
name: "Both empty",
cli: nil,
persisted: nil,
want: []string{},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := mergeTLSExtraHosts(tc.cli, tc.persisted)
if len(got) != len(tc.want) {
t.Fatalf("len mismatch: got %v, want %v", got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("index %d: got %q, want %q (full: %v vs %v)", i, got[i], tc.want[i], got, tc.want)
}
}
})
}
}
func TestGetDomains_IncludesOAuthDerivation(t *testing.T) {
// Hostname-based serverURL: the derived OAuth variant must end up
// in the served TLS cert SAN list, otherwise the speaker rejects
// the TLS handshake on Spotify / Amazon Music token refresh.
got := getDomains("http://mac.fritz.box:8000", "https://mac.fritz.box:8443", "mac.fritz.box", nil)
want := "macoauth.fritz.box"
if !contains(got, want) {
t.Errorf("expected SAN list to include %q (derived from serverURL), got: %v", want, got)
}
}
func TestGetDomains_IPServerURLProducesNoOAuthDerivation(t *testing.T) {
// IP-based serverURL deliberately yields no derivation (the speaker's
// `<first-label>oauth.<rest>` construction would be malformed for an
// IP and no DNS resolver can answer for it). The cert SAN list must
// not pretend to cover something that can never be queried.
got := getDomains("http://192.168.0.30:8000", "https://192.168.0.30:8443", "192.168.0.30", nil)
for _, h := range got {
if h == "192oauth.168.0.30" {
t.Errorf("SAN list must not include malformed IP-derived OAuth name, got: %v", got)
}
}
}
func contains(haystack []string, needle string) bool {
for _, h := range haystack {
if h == needle {
return true
}
}
return false
}
+1
View File
@@ -31,6 +31,7 @@ GET /bmx/tunein/v1/playback/episode/{podcastID} handlers.(
GET /bmx/tunein/v1/playback/episodes/{podcastID} handlers.(*Server).HandleTuneInPodcastInfo-fm
GET /bmx/tunein/v1/playback/station/{stationID} handlers.(*Server).HandleTuneInPlayback-fm
GET /bmx/tunein/v1/search handlers.(*Server).HandleTuneInSearch-fm
GET /bmx/tunein/v1/search/next handlers.(*Server).HandleTuneInSearchNext-fm
GET /ced/* handlers.(*Server).HandleCedStatic
GET /core02/svc-bmx-adapter-orion/prod/orion/station handlers.(*Server).HandleOrionPlayback-fm
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
+8 -7
View File
@@ -66,14 +66,15 @@ The documentation is organized into three main categories:
## 🏗 Concept Documentation
### Enhanced Service Architecture
- **[Concept Overview](concepts/README.md)** - High-level architecture vision
- [Upstream Service Simulation](concepts/upstream-service-simulation.md) - Complete concept design
- [Implementation Plan](concepts/implementation-plan.md) - Development roadmap
- [Technical Specification](concepts/technical-specification.md) - Detailed specifications
Current concept docs are listed under the **Concepts** section of [SUMMARY.md](SUMMARY.md#concepts). Highlights:
### Development Planning
- [Implementation Roadmap](concepts/implementation-roadmap.md) - Project phases and milestones
- [Spotify Overview](concepts/spotify-overview.md) — mental model, Spotify Connect vs OAuth-intercept, DNS rewrite gotcha
- [Spotify OAuth](concepts/spotify-oauth.md) — flows and management endpoints
- [Amazon Music OAuth](concepts/amazon-music-oauth.md) — companion to Spotify OAuth; same protocol shape, different scopes
- [Encrypted Export](concepts/ENCRYPTED-EXPORT.md) — `.age`-encrypted diagnostic bundles
- [Request Recording](REQUEST_RECORDING_CONCEPT.md) — how the proxy captures live device traffic for parity testing
Older planning artefacts ("Enhanced State Management System", "Upstream Service Simulation") live under [docs/archive/](archive/) — kept for the record, no longer current.
## 💡 Quick Reference
+2
View File
@@ -53,8 +53,10 @@
## Concepts
* [Request Recording](REQUEST_RECORDING_CONCEPT.md)
* [Spotify Overview](concepts/spotify-overview.md)
* [Spotify Priming Strategy](concepts/spotify-priming-strategy.md)
* [Spotify OAuth](concepts/spotify-oauth.md)
* [Amazon Music OAuth](concepts/amazon-music-oauth.md)
* [Encrypted Export](concepts/ENCRYPTED-EXPORT.md)
* [Diagnostic Export (Maintainer Setup)](DIAGNOSTIC-EXPORT.md)
* [soundtouch-web Roadmap](soundtouch-web-roadmap.md)
+12 -2
View File
@@ -67,7 +67,7 @@ The service must respond with a fresh Amazon access token. The speaker then uses
The `cs1` suffix (credential schema 1) is Amazon-specific; Spotify uses `cs3`. This route is already registered.
> **DNS note:** The speaker constructs the OAuth hostname by appending `oauth` to the streaming service subdomain. If the service is reachable at `myhost.local`, the speaker will call `myhostoauth.local`. A DNS alias pointing `myhostoauth.<domain>` to the same IP as the service is required.
> **DNS note:** The speaker constructs the OAuth hostname by appending `oauth` to the **first label** of the configured streaming hostname. If the service is reachable at `myhost.local`, the speaker calls `myhostoauth.local`. That alias must resolve to AfterTouch's IP — see the [DNS requirement](#dns-requirement) section below for the available mechanisms. **IP-based `--server-url` is incompatible with OAuth**: the construction produces a malformed hostname (`192oauth.168.0.30`) that no DNS resolver can answer. Use a real LAN hostname.
---
@@ -317,7 +317,17 @@ The service looks up the account by refresh token, refreshes it via LWA, and ret
### DNS requirement
The speaker derives the OAuth hostname by appending `oauth` to its configured streaming subdomain. If the service is at `myhost.local`, the speaker calls `myhostoauth.local`. A DNS alias pointing `myhostoauth.<domain>` to the same IP is required — the built-in DNS discovery server handles this automatically when `--dns-discovery` is enabled.
The speaker derives the OAuth hostname by appending `oauth` to the **first label** of its configured streaming hostname. If the service is at `myhost.lan`, the speaker calls `myhostoauth.lan`. A DNS alias pointing `myhostoauth.<rest>` to the same IP as AfterTouch is required.
**The configured `--server-url` must be a real LAN hostname.** An IP-based target produces a malformed OAuth hostname (e.g. `192oauth.168.0.30`) that no DNS resolver can answer, so OAuth never reaches AfterTouch. Switch to something like `https://aftertouch.lan:8443` before configuring Spotify or Amazon Music.
Three ways to make the OAuth alias resolvable, in increasing order of operator effort:
1. **AfterTouch's own DNS server** (auto-derived). When `--dns-discovery` is enabled, AfterTouch parses the configured `--server-url`, derives `<first-label>oauth.<rest>` automatically, and hijacks it to its own IP. The speaker must be using AfterTouch as a DNS resolver for this to take effect — set AfterTouch's IP as the primary DNS in your LAN's DHCP, or run the `setup migrate --method=resolv` flow to write each speaker's `/etc/resolv.conf` directly.
2. **External LAN DNS** (Pi-hole, OPNsense, …). Add a static A record `<host>oauth.<rest> → <AfterTouch IP>` alongside the existing one for the AfterTouch hostname. AfterTouch's own DNS server doesn't need to be running.
3. **Per-speaker `/etc/hosts`** (last resort). SSH into each speaker and append `<AfterTouch-IP> <host>oauth.<rest>`. Tedious; doesn't survive a factory reset.
The implementation lives in `pkg/discovery/dns.go` `DeriveOAuthHostnames`.
### Open question: `site_id`
+16 -2
View File
@@ -79,8 +79,22 @@ token refresh will silently die while the speaker still pulls sources.
Symptom: the speaker briefly streams Spotify after priming, then stops at the
first token refresh ~1 hour later.
If you self-host AfterTouch at e.g. `aftertouch.local`, you would equivalently
need `aftertouchoauth.local` for the OAuth interception path.
If you self-host AfterTouch at e.g. `aftertouch.lan`, the speaker derives
`aftertouchoauth.lan` and queries that hostname for token refresh. AfterTouch's
DNS server **auto-derives this alias** from the configured `--server-url` and
adds it to the hijack list automatically — the operator does not have to
configure it as long as speakers resolve names via AfterTouch's DNS server
(via DHCP, the `setup migrate --method=resolv` flow, or an external LAN DNS
that delegates to AfterTouch for these names). The implementation lives in
`pkg/discovery/dns.go` `DeriveOAuthHostnames`.
> **IP-based `--server-url` is incompatible with OAuth (both Spotify and Amazon
> Music).** The speaker's hostname construction appends `oauth` to the first
> label only, so `192.168.0.30` would produce `192oauth.168.0.30` — malformed,
> no DNS resolver will answer for it, and there is no clean workaround on the
> AfterTouch side. **Use a real LAN hostname** before configuring Spotify or
> Amazon Music. The Health-tab `oauth_target_reachable` check warns when this
> trap is wired up.
## End-to-end token lifecycle
+16 -1
View File
@@ -58,6 +58,8 @@ Speakers expect HTTPS on the default port 443. Since binding to port 443 require
The first rule covers traffic arriving from speakers; the second covers loopback connections from the host itself (useful for the in-built pre-flight probe).
> **Caveat — OUTPUT chain.** The second rule catches **all** outbound `:443` traffic from this host, including the AfterTouch host's own connections to the wider internet (browsers, `go install` against `proxy.golang.org`, `apt-get`, `git clone https://...`, etc.). Speakers reaching AfterTouch from the LAN only ever pass through `PREROUTING`. If you don't run the in-built pre-flight probe from this host, or if you've seen other software break with TLS errors after adding both rules, add only the `PREROUTING` rule and skip `OUTPUT`. The pre-flight's "localhost:443" probe will then report unreachable — that's expected and harmless.
2. **Capabilities**: Grant the binary permission to bind low ports and start the listener directly on `:443`:
```bash
@@ -82,7 +84,20 @@ The same check runs once at service startup and prints a `[WARN]` log line if `:
The `:443` indicator is only displayed when **AfterTouch's DNS interception is enabled** (Settings → "Enable DNS Discovery Server"). The check is only meaningful for the **DNS migration method**, where speakers reach AfterTouch via intercepted Bose hostnames and therefore on the implicit `:443`. The other migration method — writing direct `https://<host>:8443/...` URLs into the speaker's private config via SSH — uses the port that's literally in the URL, so `:443` is irrelevant and the check would only add noise.
If you intercept Bose hostnames **outside** AfterTouch (Pi-hole, router DNS rule, `/etc/hosts` on a gateway), the UI gate above will hide the indicator. The data is still in the `GET /setup/settings` JSON response (`https_443_localhost_reachable`, `https_443_lan_reachable`, `https_443_lan_host`) if you want to inspect it directly, or you can briefly enable AfterTouch's DNS server to see the indicator render.
#### Not applicable in HTTP-only deployments
When AfterTouch's configured `--server-url` is `http://…`, the pre-flight short-circuits to an `️ :443 reachability check not applicable` info line. Speakers that were migrated to that HTTP URL never connect to `:443`, so the iptables / setcap / reverse-proxy work is only needed if you also expect unmigrated speakers to fall back to `streaming.bose.com:443` via DNS hijack. If that's not your situation, the iptables rules above are optional.
#### Adding extra hosts to the TLS certificate
If speakers reach AfterTouch via a hostname or IP that isn't already covered by the served certificate, the speaker rejects the TLS handshake (typical syslog: `CURLE_SSL_CACERT (60)`). Two paths to fix this:
* **One-click QuickFix on the Health tab.** The `speaker_marge_url` check detects the mismatch and offers an `Add <host> to TLS hosts` button. Clicking it appends the missing host to `settings.json` (`tls_extra_hosts`). A subsequent service restart regenerates the certificate.
* **Settings tab → "TLS extra hosts" textarea.** Add one host per line and click Save. Same persistence path; restart required to apply. The textarea is pre-filled with the persisted list; the read-only "Currently covered by TLS cert" line below it shows the full effective SAN list (including the values from `--server-url`, `--https-server-url`, the system hostname, and any `--tls-extra-host` / `TLS_EXTRA_HOST` CLI/env entries).
CLI/env values still win over persisted ones, so an operator who pinned a host via systemd unit doesn't have to migrate it into `settings.json` — the merge in `applyPersistedSettings` deduplicates while preserving order.
If you intercept Bose hostnames **outside** AfterTouch (Pi-hole, router DNS rule, `/etc/hosts` on a gateway), the UI gate above will hide the indicator. The data is still in the `GET /setup/settings` JSON response (`https_443_localhost_reachable`, `https_443_lan_reachable`, `https_443_lan_host`, `https_443_not_applicable`, `https_443_reason`) if you want to inspect it directly, or you can briefly enable AfterTouch's DNS server to see the indicator render.
---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 518 KiB

After

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 94 KiB

+1 -1
View File
@@ -2,7 +2,7 @@ module navigation-station-demo
go 1.26.3
require github.com/gesellix/bose-soundtouch v0.78.0
require github.com/gesellix/bose-soundtouch v0.91.0
require github.com/gorilla/websocket v1.5.3 // indirect
+1 -1
View File
@@ -2,7 +2,7 @@ module preset-management-example
go 1.26.3
require github.com/gesellix/bose-soundtouch v0.78.0
require github.com/gesellix/bose-soundtouch v0.91.0
require github.com/gorilla/websocket v1.5.3 // indirect
+4 -4
View File
@@ -15,8 +15,8 @@ require (
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.51.0
golang.org/x/net v0.54.0
golang.org/x/crypto v0.52.0
golang.org/x/net v0.55.0
golang.org/x/term v0.43.0
)
@@ -31,10 +31,10 @@ require (
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
golang.org/x/image v0.40.0 // indirect
golang.org/x/image v0.41.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.45.0 // indirect
)
+8 -8
View File
@@ -70,10 +70,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -95,8 +95,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -120,8 +120,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+38 -2
View File
@@ -1,7 +1,10 @@
// Package discovery provides device discovery functionality for Bose SoundTouch devices using mDNS and UPnP protocols.
package discovery
import "time"
import (
"strings"
"time"
)
const (
// SSDP multicast address and port
@@ -10,7 +13,9 @@ const (
// SoundTouch device URN for UPnP discovery
soundTouchURN = "urn:schemas-upnp-org:device:MediaRenderer:1"
// mDNS service type for SoundTouch devices (matches Bose's actual service name)
// mDNS service type for SoundTouch devices (matches Bose's actual service name).
// Retained as the canonical / primary service type for log lines and tests;
// the full set of accepted variants lives in soundTouchServiceTypes below.
soundTouchServiceType = "_soundtouch._tcp"
soundTouchDomain = "local."
@@ -20,3 +25,34 @@ const (
// Default cache TTL
defaultCacheTTL = 30 * time.Second
)
// soundTouchServiceTypes lists every mDNS service-type variant we consider
// part of the SoundTouch family. mDNS doesn't support wildcard service-type
// queries at the protocol level, so the discovery code issues one parallel
// query per entry below and merges the results. Add new variants here as
// they're observed in the wild — Bose has historically advertised at
// least three:
//
// - _soundtouch._tcp : classic SoundTouch speakers (ST10/20/30, …)
// - _bose-soundtouch._tcp : seen on some newer firmware variants
// - _soundtouchstick._tcp : SoundTouch Wireless Adapter / dongle
var soundTouchServiceTypes = []string{
"_soundtouch._tcp",
"_bose-soundtouch._tcp",
"_soundtouchstick._tcp",
}
// isSoundTouchServiceName reports whether the mDNS service entry name
// belongs to any registered SoundTouch service type. Case-insensitive
// substring match — Bose's mDNS entries embed the service type after a
// dot (e.g. "Speaker._soundtouch._tcp.local.").
func isSoundTouchServiceName(name string) bool {
lower := strings.ToLower(name)
for _, t := range soundTouchServiceTypes {
if strings.Contains(lower, strings.ToLower(t)) {
return true
}
}
return false
}
+85 -8
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"net"
"net/url"
"strings"
"sync"
"time"
@@ -18,6 +19,15 @@ type DNSDiscovery struct {
upstreamDNS []string
serviceIP string
// derivedHosts is the auto-derived list of additional hostnames the
// interceptor should hijack alongside the Bose cloud list. Populated
// from the operator's configured serverURL at construction time —
// today this means `<first-label>oauth.<rest>`, the hostname the
// speaker firmware constructs for the Spotify / Amazon Music OAuth
// flow. Empty when serverURL is IP-based, missing, or has no domain
// part to derive from.
derivedHosts []string
// State
discovered map[string]*DiscoveredHost
mu sync.RWMutex
@@ -51,15 +61,75 @@ type DiscoveredHost struct {
RemoteAddr string `json:"remote_addr,omitempty"`
}
// NewDNSDiscovery creates a new DNSDiscovery instance.
func NewDNSDiscovery(upstreamDNS []string, serviceIP string) *DNSDiscovery {
return &DNSDiscovery{
upstreamDNS: upstreamDNS,
serviceIP: serviceIP,
discovered: make(map[string]*DiscoveredHost),
timeout: 2 * time.Second,
lastLog: make(map[string]time.Time),
// NewDNSDiscovery creates a new DNSDiscovery instance. serverURL is the
// operator's configured streaming endpoint; its hostname is used to
// derive the OAuth-subdomain alias the speaker constructs (see
// DeriveOAuthHostnames). Pass an empty string when no serverURL is
// available (the derivation is a no-op in that case).
func NewDNSDiscovery(upstreamDNS []string, serviceIP, serverURL string) *DNSDiscovery {
derived := DeriveOAuthHostnames(serverURL)
if len(derived) > 0 {
log.Printf("[DNS] Auto-hijacking OAuth subdomains derived from serverURL %q: %s", serverURL, strings.Join(derived, ", "))
}
return &DNSDiscovery{
upstreamDNS: upstreamDNS,
serviceIP: serviceIP,
derivedHosts: derived,
discovered: make(map[string]*DiscoveredHost),
timeout: 2 * time.Second,
lastLog: make(map[string]time.Time),
}
}
// DeriveOAuthHostnames returns the list of additional hostnames the DNS
// interceptor should hijack to support Spotify / Amazon Music OAuth on a
// non-Bose target. SoundTouch firmware constructs the OAuth endpoint by
// appending `oauth` to the first label of the configured streaming
// hostname (e.g. `aftertouch.lan` → `aftertouchoauth.lan`). When the
// target is an IP address the derivation produces a malformed hostname
// no resolver will answer for, so we deliberately return an empty
// slice — the caller's behaviour stays unchanged, but the operator
// (and the health-tab check) can detect the misconfiguration via the
// missing entry.
//
// Returned hostnames are lower-cased. An empty serverURL, a URL that
// fails to parse, or a hostname without a domain part (single-label
// "aftertouch") all yield an empty slice.
func DeriveOAuthHostnames(serverURL string) []string {
if serverURL == "" {
return nil
}
u, err := url.Parse(serverURL)
if err != nil {
return nil
}
host := strings.ToLower(u.Hostname())
if host == "" {
return nil
}
if net.ParseIP(host) != nil {
// IP-based deployment — the speaker's `<first-label>oauth.<rest>`
// construction is meaningless (e.g. `192oauth.168.0.30`) and no
// DNS server can resolve it. Operators in this situation need to
// switch to a real LAN hostname; see docs/concepts/amazon-music-oauth.md.
return nil
}
idx := strings.IndexByte(host, '.')
if idx <= 0 {
// Single-label hostname (e.g. "aftertouch") — no domain part to
// append after the inserted "oauth". The speaker firmware does
// the same: it appends "oauth" inside the first label, so a
// single-label name would produce "aftertouchoauth", which most
// DNS resolvers won't answer for either.
return nil
}
return []string{host[:idx] + "oauth" + host[idx:]}
}
// ServeDNS implements the dns.Handler interface.
@@ -183,6 +253,13 @@ func (d *DNSDiscovery) shouldIntercept(hostname string) bool {
}
}
lower := strings.ToLower(hostname)
for _, h := range d.derivedHosts {
if lower == h {
return true
}
}
return false
}
+130
View File
@@ -0,0 +1,130 @@
package discovery
import (
"strings"
"testing"
)
func TestDeriveOAuthHostnames(t *testing.T) {
cases := []struct {
name string
serverURL string
want []string
}{
{
name: "hostname with single domain part",
serverURL: "https://aftertouch.lan:8443",
want: []string{"aftertouchoauth.lan"},
},
{
name: "hostname with multiple domain parts",
serverURL: "https://aftertouch.example.local:8443",
want: []string{"aftertouchoauth.example.local"},
},
{
name: "HTTP scheme also works",
serverURL: "http://aftertouch.lan:8000",
want: []string{"aftertouchoauth.lan"},
},
{
name: "Case is normalised to lower",
serverURL: "https://AfterTouch.LAN:8443",
want: []string{"aftertouchoauth.lan"},
},
{
name: "IPv4 yields no derivation (malformed result)",
serverURL: "https://192.168.0.30:8443",
want: nil,
},
{
name: "IPv6 yields no derivation",
serverURL: "https://[fd00::1]:8443",
want: nil,
},
{
name: "Single-label hostname yields no derivation",
serverURL: "https://aftertouch:8443",
want: nil,
},
{
name: "Empty serverURL is a no-op",
serverURL: "",
want: nil,
},
{
name: "Garbage URL is a no-op",
serverURL: ":::not a url",
want: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := DeriveOAuthHostnames(tc.serverURL)
if len(got) != len(tc.want) {
t.Fatalf("len mismatch: got %v, want %v", got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("index %d: got %q, want %q", i, got[i], tc.want[i])
}
}
})
}
}
func TestShouldIntercept_DerivedHostnameFromHostnameServerURL(t *testing.T) {
d := NewDNSDiscovery([]string{"8.8.8.8"}, "192.0.2.10", "https://aftertouch.lan:8443")
// Bose hostnames still match by substring.
if !d.shouldIntercept("streamingoauth.bose.com") {
t.Errorf("expected Bose oauth host to be intercepted")
}
// Derived host matches exactly (case-insensitive).
if !d.shouldIntercept("aftertouchoauth.lan") {
t.Errorf("expected derived OAuth subdomain to be intercepted")
}
if !d.shouldIntercept("AFTERTOUCHOAUTH.LAN") {
t.Errorf("expected case-insensitive match on derived OAuth subdomain")
}
// Unrelated hosts are not hijacked.
if d.shouldIntercept("example.com") {
t.Errorf("unrelated host must not be intercepted")
}
// The base host (without -oauth) is NOT auto-hijacked — only the
// OAuth-derivation. Bose-substring filter and the operator's own
// migration handle the base host.
if d.shouldIntercept("aftertouch.lan") {
t.Errorf("base hostname must not be auto-intercepted; only the OAuth variant is derived")
}
}
func TestShouldIntercept_NoDerivationFromIPServerURL(t *testing.T) {
d := NewDNSDiscovery([]string{"8.8.8.8"}, "192.168.0.30", "https://192.168.0.30:8443")
if len(d.derivedHosts) != 0 {
t.Errorf("expected no derived hosts for IP-based serverURL, got %v", d.derivedHosts)
}
if d.shouldIntercept("192oauth.168.0.30") {
t.Errorf("malformed IP-derived OAuth name must not be intercepted (it's never a valid DNS query in the first place)")
}
}
func TestNewDNSDiscovery_LogsDerivationOnce(t *testing.T) {
// This is a smoke test — the constructor should not panic and should
// store the derivation. We don't capture the log output here (the
// dns.go init path uses package log.Printf and isn't easily diverted
// without test infrastructure), but we do confirm the derivedHosts
// field is populated as expected.
d := NewDNSDiscovery(nil, "192.0.2.10", "https://aftertouch.lan")
if len(d.derivedHosts) != 1 || !strings.Contains(d.derivedHosts[0], "oauth") {
t.Errorf("expected derivedHosts to carry the OAuth variant, got %v", d.derivedHosts)
}
}
+13 -13
View File
@@ -13,7 +13,7 @@ import (
func TestDNSDiscovery_Interception(t *testing.T) {
serviceIP := "192.0.2.100"
upstreamDNS := []string{"8.8.8.8"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
// Test intercepting Bose service
m := new(dns.Msg)
@@ -67,7 +67,7 @@ func TestDNSDiscovery_Forwarding(t *testing.T) {
// For now, let's just test that it calls forward and record.
serviceIP := "192.0.2.100"
upstreamDNS := []string{"127.0.0.1:5353"} // Use a port that is likely closed or we can mock
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
m := new(dns.Msg)
m.SetQuestion("google.com.", dns.TypeA)
@@ -108,7 +108,7 @@ func TestDNSDiscovery_Forwarding(t *testing.T) {
func TestDNSDiscovery_StartTCP(t *testing.T) {
serviceIP := "192.0.2.100"
upstreamDNS := []string{"8.8.8.8"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
addr := "127.0.0.1:5354"
go func() {
@@ -157,7 +157,7 @@ func TestDNSDiscovery_StartTCP(t *testing.T) {
func TestDNSDiscovery_SelfForwarding(t *testing.T) {
serviceIP := "soundtouch.local"
upstreamDNS := []string{"127.0.0.1:5357"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
// Mock upstream DNS server for soundtouch.local
mux := dns.NewServeMux()
@@ -216,7 +216,7 @@ func TestDNSDiscovery_SelfForwarding(t *testing.T) {
func TestDNSDiscovery_ForwardLocal(t *testing.T) {
serviceIP := "192.0.2.100"
upstreamDNS := []string{"127.0.0.1:5356"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
m := new(dns.Msg)
m.SetQuestion("someone-else.local.", dns.TypeA)
@@ -257,7 +257,7 @@ func TestDNSDiscovery_ForwardLocal(t *testing.T) {
func TestDNSDiscovery_IsRunning(t *testing.T) {
serviceIP := "192.0.2.100"
upstreamDNS := []string{"8.8.8.8"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
addr := "127.0.0.1:5355"
@@ -301,7 +301,7 @@ func (m *mockResponseWriter) TsigTimersOnly(bool) {}
func (m *mockResponseWriter) Hijack() {}
func TestDNSDiscovery_LogThrottling(t *testing.T) {
d := NewDNSDiscovery([]string{"8.8.8.8"}, "192.0.2.100")
d := NewDNSDiscovery([]string{"8.8.8.8"}, "192.0.2.100", "")
// Capture log output
var logBuf strings.Builder
@@ -335,7 +335,7 @@ func TestDNSDiscovery_LoopPrevention(t *testing.T) {
serviceIP := "192.0.2.100"
bindAddr := "127.0.0.1:53"
upstreamDNS := []string{"127.0.0.1:53"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
d.bindAddr = bindAddr
// Capture log output to avoid panic if it's being throttled/logged
@@ -362,7 +362,7 @@ func TestDNSDiscovery_LoopPrevention(t *testing.T) {
func TestDNSDiscovery_EmptyUpstream(t *testing.T) {
serviceIP := "192.0.2.100"
var upstreamDNS []string // Empty upstream
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
d.bindAddr = ":53"
m := new(dns.Msg)
@@ -402,7 +402,7 @@ func TestDNSDiscovery_ForwardTimeout(t *testing.T) {
time.Sleep(50 * time.Millisecond)
upstreamDNS := []string{"127.0.0.1:5358"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
d.timeout = 100 * time.Millisecond
m := new(dns.Msg)
@@ -458,7 +458,7 @@ func TestDNSDiscovery_MultipleUpstreams(t *testing.T) {
time.Sleep(100 * time.Millisecond)
upstreamDNS := []string{"127.0.0.1:5356", "127.0.0.1:5357"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
m := new(dns.Msg)
m.SetQuestion("test.com.", dns.TypeA)
@@ -484,7 +484,7 @@ func TestDNSDiscovery_HostnameServiceIP(t *testing.T) {
// Use localhost which should resolve to 127.0.0.1
serviceIP := "localhost"
upstreamDNS := []string{"8.8.8.8"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
m := new(dns.Msg)
m.SetQuestion("api.bose.com.", dns.TypeA)
@@ -520,7 +520,7 @@ func TestDNSDiscovery_UnresolvableHostname(t *testing.T) {
// Use a likely unresolvable hostname
serviceIP := "this.hostname.does.not.exist.at.all.invalid"
upstreamDNS := []string{"8.8.8.8"}
d := NewDNSDiscovery(upstreamDNS, serviceIP)
d := NewDNSDiscovery(upstreamDNS, serviceIP, "")
m := new(dns.Msg)
m.SetQuestion("api.bose.com.", dns.TypeA)
+40
View File
@@ -0,0 +1,40 @@
package discovery
import (
"log"
"sync/atomic"
)
// verboseLogging toggles the per-packet / per-header diagnostic output
// that was historically emitted unconditionally during UPnP and mDNS
// discovery. The service binary leaves it at its zero value (off) so
// the log stays useful at info-level; the CLI's `discover` command
// flips it on so interactive runs surface full protocol details.
//
// Stored as an int32 so the read path in logVerbose is allocation-
// free (atomic.Bool would work too on Go 1.19+, but a uint8 lookup
// keeps the toggle hot-path even on older toolchains we still build
// against in CI).
var verboseLogging atomic.Bool
// SetVerbose enables (or disables) the package-wide verbose-discovery
// log toggle. Safe to call from any goroutine.
func SetVerbose(v bool) {
verboseLogging.Store(v)
}
// IsVerbose reports the current value of the verbose toggle. Mainly
// for tests that want to assert the CLI flipped it on.
func IsVerbose() bool {
return verboseLogging.Load()
}
// logVerbose forwards to log.Printf only when verbose-discovery
// logging is enabled. The fast path (verbose off) is a single
// atomic load + branch, so it's safe to scatter calls liberally
// across the hot path.
func logVerbose(format string, args ...any) {
if verboseLogging.Load() {
log.Printf(format, args...)
}
}
+73
View File
@@ -0,0 +1,73 @@
package discovery
import (
"bytes"
"log"
"strings"
"testing"
)
// captureLog redirects log output into a buffer and returns the buffer
// plus a cleanup func that restores the original log destination. Used
// by the tests below to assert what logVerbose / SetVerbose actually
// produces under each toggle state.
func captureLog(t *testing.T) (*bytes.Buffer, func()) {
t.Helper()
buf := &bytes.Buffer{}
original := log.Writer()
log.SetOutput(buf)
return buf, func() {
log.SetOutput(original)
}
}
func TestVerboseToggle_DefaultIsQuiet(t *testing.T) {
// Reset to default state (zero value of atomic.Bool is false).
SetVerbose(false)
t.Cleanup(func() { SetVerbose(false) })
buf, restore := captureLog(t)
defer restore()
logVerbose("noisy: should-be-suppressed message")
if buf.Len() != 0 {
t.Errorf("expected no log output when verbose is off, got: %q", buf.String())
}
if IsVerbose() {
t.Errorf("IsVerbose() = true, want false")
}
}
func TestVerboseToggle_OnEmitsToLog(t *testing.T) {
SetVerbose(true)
t.Cleanup(func() { SetVerbose(false) })
buf, restore := captureLog(t)
defer restore()
logVerbose("trace: %s = %d", "answer", 42)
if !strings.Contains(buf.String(), "trace: answer = 42") {
t.Errorf("expected trace output, got: %q", buf.String())
}
if !IsVerbose() {
t.Errorf("IsVerbose() = false, want true")
}
}
func TestVerboseToggle_StaysOffByDefaultAfterPackageInit(t *testing.T) {
// New goroutines / new processes see the zero-value default. This
// codifies that contract for callers like cmd/soundtouch-service
// that rely on never having to call SetVerbose.
t.Cleanup(func() { SetVerbose(false) })
SetVerbose(false)
if IsVerbose() {
t.Errorf("default verbose state must be false")
}
}
+86 -52
View File
@@ -6,6 +6,7 @@ import (
"log"
"net"
"strings"
"sync"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
@@ -49,49 +50,37 @@ func (m *MDNSDiscoveryService) DiscoverDevices(ctx context.Context) ([]*models.D
timeoutCtx, cancel := context.WithTimeout(ctx, m.timeout)
defer cancel()
// Start mDNS query in a goroutine
// Fan out one query per SoundTouch service-type variant; mDNS has no
// wildcard service-type query, so we issue them in parallel and merge
// into a single entries channel. close(entries) only once all queries
// are done (or the timeout fires).
go func() {
defer close(entries)
log.Printf("mDNS: Starting discovery for service '%s.%s' with timeout %v",
soundTouchServiceType, soundTouchDomain, m.timeout)
log.Printf("mDNS: Starting discovery for %d service-type variant(s) with timeout %v",
len(soundTouchServiceTypes), m.timeout)
// IPv4-only query to fix "no route to host" errors on IPv6
// This addresses the issue where hashicorp/mdns fails with:
// "write udp6 [::]:port->[ff02::fb]:5353: sendto: no route to host"
// The trailing dot in service names is handled correctly by separating
// service and domain parameters as expected by the library.
err := mdns.Query(&mdns.QueryParam{
Service: "_soundtouch._tcp",
Domain: "local.",
Timeout: m.timeout,
Entries: entries,
DisableIPv6: true, // Force IPv4 only to avoid routing issues
Interface: m.getIPv4Interface(), // Use specific interface if available
})
if err != nil {
log.Printf("mDNS IPv4 query failed: %v", err)
var wg sync.WaitGroup
// Fallback to standard query (both IPv4 and IPv6)
log.Printf("mDNS: Falling back to standard query...")
for _, service := range soundTouchServiceTypes {
wg.Add(1)
err = mdns.Query(&mdns.QueryParam{
Service: "_soundtouch._tcp",
Domain: "local.",
Timeout: m.timeout,
Entries: entries,
})
if err != nil {
log.Printf("mDNS query completed with error: %v", err)
} else {
log.Printf("mDNS query completed successfully")
}
} else {
log.Printf("mDNS IPv4 query completed successfully")
go func(service string) {
defer wg.Done()
m.queryService(service, entries)
}(service)
}
wg.Wait()
logVerbose("mDNS: All %d service-type queries finished", len(soundTouchServiceTypes))
}()
// Collect discovered devices
// Collect discovered devices, deduplicating by host:port since a single
// speaker may answer multiple service types (older firmware advertises
// both `_soundtouch._tcp` and `_bose-soundtouch._tcp` simultaneously).
seen := make(map[string]bool)
for {
select {
case <-timeoutCtx.Done():
@@ -104,30 +93,75 @@ func (m *MDNSDiscoveryService) DiscoverDevices(ctx context.Context) ([]*models.D
return devices, nil
}
log.Printf("mDNS: Received service entry: Name='%s', Host='%s', Port=%d, AddrV4=%v, AddrV6=%v",
logVerbose("mDNS: Received service entry: Name='%s', Host='%s', Port=%d, AddrV4=%v, AddrV6=%v",
entry.Name, entry.Host, entry.Port, entry.AddrV4, entry.AddrV6)
// Only process SoundTouch devices
if !strings.Contains(entry.Name, "_soundtouch._tcp") {
log.Printf("mDNS: Skipping non-SoundTouch service: %s", entry.Name)
// Only process SoundTouch-family services.
if !isSoundTouchServiceName(entry.Name) {
logVerbose("mDNS: Skipping non-SoundTouch service: %s", entry.Name)
continue
}
device := m.serviceEntryToDevice(entry)
if device != nil {
log.Printf("mDNS: Successfully converted to device: %s at %s:%d", device.Name, device.Host, device.Port)
devices = append(devices, device)
} else {
if device == nil {
log.Printf("mDNS: Failed to convert service entry to device (no valid IP address)")
continue
}
key := fmt.Sprintf("%s:%d", device.Host, device.Port)
if seen[key] {
logVerbose("mDNS: Skipping duplicate device %s (already seen via another service-type query)", key)
continue
}
seen[key] = true
logVerbose("mDNS: Successfully converted to device: %s at %s:%d", device.Name, device.Host, device.Port)
devices = append(devices, device)
}
}
}
// queryService issues a single mDNS Query for the given service type
// against the IPv4 interface first, with a graceful fallback to the
// library's default (IPv4+IPv6) behaviour if the IPv4-only path fails.
// All results stream into the shared entries channel; the caller is
// responsible for fan-in deduplication.
func (m *MDNSDiscoveryService) queryService(service string, entries chan<- *mdns.ServiceEntry) {
logVerbose("mDNS: Query '%s.%s' starting", service, soundTouchDomain)
err := mdns.Query(&mdns.QueryParam{
Service: service,
Domain: "local.",
Timeout: m.timeout,
Entries: entries,
DisableIPv6: true,
Interface: m.getIPv4Interface(),
})
if err == nil {
logVerbose("mDNS: Query '%s' (IPv4) completed successfully", service)
return
}
log.Printf("mDNS: Query '%s' (IPv4) failed: %v — falling back to dual-stack", service, err)
err = mdns.Query(&mdns.QueryParam{
Service: service,
Domain: "local.",
Timeout: m.timeout,
Entries: entries,
})
if err != nil {
log.Printf("mDNS: Query '%s' (dual-stack) failed: %v", service, err)
} else {
logVerbose("mDNS: Query '%s' (dual-stack) completed successfully", service)
}
}
// serviceEntryToDevice converts an mdns ServiceEntry to a DiscoveredDevice
func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *models.DiscoveredDevice {
if entry == nil {
log.Printf("mDNS: Received nil service entry")
logVerbose("mDNS: Received nil service entry")
return nil
}
@@ -142,15 +176,15 @@ func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *m
host = entry.AddrV4.String()
ipSource = "IPv4"
log.Printf("mDNS: Using IPv4 address: %s", host)
logVerbose("mDNS: Using IPv4 address: %s", host)
case entry.AddrV6 != nil:
host = entry.AddrV6.String()
ipSource = "IPv6"
log.Printf("mDNS: Using IPv6 address: %s", host)
logVerbose("mDNS: Using IPv6 address: %s", host)
default:
// Try to resolve from hostname
log.Printf("mDNS: No direct IP address, trying to resolve hostname: %s", entry.Host)
logVerbose("mDNS: No direct IP address, trying to resolve hostname: %s", entry.Host)
ips, err := net.LookupIP(entry.Host)
if err != nil || len(ips) == 0 {
@@ -164,7 +198,7 @@ func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *m
host = ip.String()
ipSource = "resolved IPv4"
log.Printf("mDNS: Resolved to IPv4 address: %s", host)
logVerbose("mDNS: Resolved to IPv4 address: %s", host)
break
}
@@ -179,7 +213,7 @@ func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *m
ipSource = "resolved IPv6 (fallback)"
}
log.Printf("mDNS: Using fallback address (%s): %s", ipSource, host)
logVerbose("mDNS: Using fallback address (%s): %s", ipSource, host)
}
}
@@ -222,7 +256,7 @@ func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *m
MDNSService: entry.Name,
}
log.Printf("mDNS: Created device '%s' at %s:%d (IP source: %s)", name, host, port, ipSource)
logVerbose("mDNS: Created device '%s' at %s:%d (IP source: %s)", name, host, port, ipSource)
return device
}
@@ -243,7 +277,7 @@ func (m *MDNSDiscoveryService) getIPv4Interface() *net.Interface {
return nil
}
log.Printf("mDNS: Using configured IPv4 interface: %s", iface.Name)
logVerbose("mDNS: Using configured IPv4 interface: %s", iface.Name)
return iface
}
@@ -266,12 +300,12 @@ func (m *MDNSDiscoveryService) getIPv4Interface() *net.Interface {
continue
}
log.Printf("mDNS: Using IPv4 interface: %s", iface.Name)
logVerbose("mDNS: Using IPv4 interface: %s", iface.Name)
return &iface
}
log.Printf("mDNS: No suitable IPv4 interface found")
logVerbose("mDNS: No suitable IPv4 interface found")
return nil
}
+65 -28
View File
@@ -225,7 +225,7 @@ func (d *Service) setupUDPListener() (*net.UDPConn, error) {
}
}
log.Printf("UPnP: Created UDP listener on %s", localAddr.String())
logVerbose("UPnP: Created UDP listener on %s", localAddr.String())
return listener, nil
}
@@ -274,7 +274,7 @@ func (d *Service) resolveListenInterface() (net.IP, *net.Interface, error) {
func (d *Service) sendMSearch(listener *net.UDPConn, multicastAddr *net.UDPAddr) error {
msearchRequest := d.buildMSearchRequest()
log.Printf("UPnP: Sending M-SEARCH request to %s:\n%s", ssdpAddr, strings.TrimSpace(msearchRequest))
logVerbose("UPnP: Sending M-SEARCH request to %s:\n%s", ssdpAddr, strings.TrimSpace(msearchRequest))
bytesWritten, err := listener.WriteToUDP([]byte(msearchRequest), multicastAddr)
if err != nil {
@@ -282,7 +282,7 @@ func (d *Service) sendMSearch(listener *net.UDPConn, multicastAddr *net.UDPAddr)
return fmt.Errorf("failed to send M-SEARCH: %w", err)
}
log.Printf("UPnP: Successfully sent M-SEARCH request (%d bytes)", bytesWritten)
logVerbose("UPnP: Successfully sent M-SEARCH request (%d bytes)", bytesWritten)
return nil
}
@@ -297,21 +297,21 @@ func (d *Service) listenForResponses(ctx context.Context, listener *net.UDPConn,
return 0, fmt.Errorf("failed to set read deadline: %w", err)
}
log.Printf("UPnP: Set read deadline to %v, now listening for responses...", deadline.Format("15:04:05.000"))
logVerbose("UPnP: Set read deadline to %v, now listening for responses...", deadline.Format("15:04:05.000"))
buffer := make([]byte, 4096)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
log.Printf("UPnP: Discovery cancelled by context")
logVerbose("UPnP: Discovery cancelled by context")
return responseCount, ctx.Err()
default:
n, remoteAddr, err := listener.ReadFromUDP(buffer)
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
log.Printf("UPnP: Read timeout reached after %v, stopping discovery", d.timeout)
logVerbose("UPnP: Read timeout reached after %v, stopping discovery", d.timeout)
return responseCount, nil
}
@@ -322,7 +322,7 @@ func (d *Service) listenForResponses(ctx context.Context, listener *net.UDPConn,
responseCount++
responseText := string(buffer[:n])
log.Printf("UPnP: Received response #%d (%d bytes) from %s:\n%s", responseCount, n, remoteAddr.String(), strings.TrimSpace(responseText))
logVerbose("UPnP: Received response #%d (%d bytes) from %s:\n%s", responseCount, n, remoteAddr.String(), strings.TrimSpace(responseText))
device, err := d.parseResponse(responseText)
if err != nil {
@@ -331,10 +331,10 @@ func (d *Service) listenForResponses(ctx context.Context, listener *net.UDPConn,
}
if device != nil {
log.Printf("UPnP: Successfully parsed device from response #%d: %s at %s:%d", responseCount, device.Name, device.Host, device.Port)
logVerbose("UPnP: Successfully parsed device from response #%d: %s at %s:%d", responseCount, device.Name, device.Host, device.Port)
devices[device.Host] = device
} else {
log.Printf("UPnP: Response #%d from %s did not contain a valid SoundTouch device", responseCount, remoteAddr.String())
logVerbose("UPnP: Response #%d from %s did not contain a valid SoundTouch device", responseCount, remoteAddr.String())
}
}
}
@@ -368,7 +368,7 @@ func (d *Service) buildMSearchRequest() string {
// parseResponse parses UPnP SSDP response and extracts device information
func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, error) {
log.Printf("UPnP: Parsing response (%d chars): %.100s...", len(response), strings.ReplaceAll(response, "\r\n", "\\r\\n"))
logVerbose("UPnP: Parsing response (%d chars): %.100s...", len(response), strings.ReplaceAll(response, "\r\n", "\\r\\n"))
// Try both \r\n and \n line endings
var lines []string
@@ -384,7 +384,7 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro
return nil, fmt.Errorf("invalid HTTP response")
}
log.Printf("UPnP: Valid HTTP response detected")
logVerbose("UPnP: Valid HTTP response detected")
headers := make(map[string]string)
@@ -402,10 +402,10 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro
}
}
log.Printf("UPnP: Parsed %d headers from response", len(headers))
logVerbose("UPnP: Parsed %d headers from response", len(headers))
for key, value := range headers {
log.Printf("UPnP: Header: %s = %s", key, value)
logVerbose("UPnP: Header: %s = %s", key, value)
}
// Check if it's a SoundTouch device
@@ -415,7 +415,7 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro
return nil, fmt.Errorf("no ST header found")
}
log.Printf("UPnP: Found ST header: %s", st)
logVerbose("UPnP: Found ST header: %s", st)
// Accept both MediaRenderer and any device type for now - we'll validate it's a SoundTouch later
if !strings.Contains(strings.ToLower(st), "mediarenderer") && !strings.Contains(strings.ToLower(st), "upnp:rootdevice") {
@@ -423,7 +423,7 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro
return nil, fmt.Errorf("not a MediaRenderer device")
}
log.Printf("UPnP: Device type '%s' is acceptable", st)
logVerbose("UPnP: Device type '%s' is acceptable", st)
location, exists := headers["location"]
if !exists {
@@ -431,7 +431,7 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro
return nil, fmt.Errorf("no location header found")
}
log.Printf("UPnP: Found Location header: %s", location)
logVerbose("UPnP: Found Location header: %s", location)
// Extract device information from location URL
device, err := d.parseLocationURL(location, headers["usn"])
@@ -440,23 +440,55 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro
return nil, fmt.Errorf("failed to parse location URL: %w", err)
}
log.Printf("UPnP: Successfully parsed device from location: %s at %s:%d", device.Name, device.Host, device.Port)
logVerbose("UPnP: Successfully parsed device from location: %s at %s:%d", device.Name, device.Host, device.Port)
// Try to get more device info from the location URL
// Try to get more device info from the location URL. Crucially this
// also lets us reject non-Bose UPnP MediaRenderers (LG TVs, Onkyo /
// Yamaha receivers, Dreambox tuners, etc.) that responded to our
// generic `ST: …MediaRenderer:1` M-SEARCH. See issues #269 / #359.
if err := d.EnrichDeviceInfo(device, location); err != nil {
log.Printf("UPnP: Could not enrich device info from location '%s': %v", location, err)
// Don't fail if we can't get additional info
// The basic info from URL parsing should be sufficient
logVerbose("UPnP: Could not enrich device info from location '%s': %v — accepting tentatively (will be re-verified by /info probe)", location, err)
} else if !isBoseUPnPDevice(device) {
log.Printf("UPnP: Rejecting non-Bose device: model=%q (manufacturer not Bose / model not SoundTouch)", device.ModelID)
return nil, fmt.Errorf("non-Bose UPnP device: %s", device.ModelID)
} else {
log.Printf("UPnP: Successfully enriched device info for %s", device.Name)
logVerbose("UPnP: Successfully enriched device info for %s (model=%q)", device.Name, device.ModelID)
}
return device, nil
}
// isBoseUPnPDevice classifies an enriched UPnP device as Bose vs. not.
// Returns true when either the manufacturer string contains "bose" or
// the model name carries a SoundTouch-family marker. Case-insensitive.
//
// This is the discrimination point that keeps non-Bose UPnP
// MediaRenderers (LG TVs, Onkyo receivers, Dreambox tuners) from
// landing in the `default` account on the service side — they all
// reply to our generic MediaRenderer:1 M-SEARCH because that URN is
// not Bose-specific.
func isBoseUPnPDevice(device *models.DiscoveredDevice) bool {
if device == nil {
return false
}
manuf := strings.ToLower(device.Manufacturer)
model := strings.ToLower(device.ModelID)
if strings.Contains(manuf, "bose") {
return true
}
if strings.Contains(model, "soundtouch") {
return true
}
return false
}
// parseLocationURL extracts basic device info from the location URL
func (d *Service) parseLocationURL(location, usn string) (*models.DiscoveredDevice, error) {
log.Printf("UPnP: Parsing location URL: %s", location)
logVerbose("UPnP: Parsing location URL: %s", location)
// Parse the URL to extract host and port
re := regexp.MustCompile(`http://([^:]+):(\d+)`)
@@ -469,7 +501,7 @@ func (d *Service) parseLocationURL(location, usn string) (*models.DiscoveredDevi
host := matches[1]
port := 8090 // Default SoundTouch port
log.Printf("UPnP: Extracted host='%s', using default port=%d", host, port)
logVerbose("UPnP: Extracted host='%s', using default port=%d", host, port)
device := &models.DiscoveredDevice{
Host: host,
@@ -488,7 +520,7 @@ func (d *Service) parseLocationURL(location, usn string) (*models.DiscoveredDevi
// EnrichDeviceInfo tries to get additional device information from the device description
func (d *Service) EnrichDeviceInfo(device *models.DiscoveredDevice, location string) error {
log.Printf("UPnP: Attempting to enrich device info by fetching %s", location)
logVerbose("UPnP: Attempting to enrich device info by fetching %s", location)
resp, err := d.httpClient.Get(location)
if err != nil {
@@ -500,7 +532,7 @@ func (d *Service) EnrichDeviceInfo(device *models.DiscoveredDevice, location str
_ = resp.Body.Close()
}()
log.Printf("UPnP: Successfully fetched device description from %s (Status: %s)", location, resp.Status)
logVerbose("UPnP: Successfully fetched device description from %s (Status: %s)", location, resp.Status)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
@@ -515,6 +547,7 @@ func (d *Service) EnrichDeviceInfo(device *models.DiscoveredDevice, location str
XMLName xml.Name `xml:"root"`
Device struct {
FriendlyName string `xml:"friendlyName"`
Manufacturer string `xml:"manufacturer"`
ModelName string `xml:"modelName"`
SerialNumber string `xml:"serialNumber"`
} `xml:"device"`
@@ -529,6 +562,10 @@ func (d *Service) EnrichDeviceInfo(device *models.DiscoveredDevice, location str
device.Name = upnpRoot.Device.FriendlyName
}
if upnpRoot.Device.Manufacturer != "" {
device.Manufacturer = upnpRoot.Device.Manufacturer
}
if upnpRoot.Device.ModelName != "" {
device.ModelID = upnpRoot.Device.ModelName
}
@@ -537,8 +574,8 @@ func (d *Service) EnrichDeviceInfo(device *models.DiscoveredDevice, location str
device.UPnPSerial = upnpRoot.Device.SerialNumber
}
log.Printf("UPnP: Enriched device info: Name='%s', Model='%s', UPnPSerial='%s'",
device.Name, device.ModelID, device.UPnPSerial)
logVerbose("UPnP: Enriched device info: Name='%s', Manufacturer='%s', Model='%s', UPnPSerial='%s'",
device.Name, device.Manufacturer, device.ModelID, device.UPnPSerial)
return nil
}
+88
View File
@@ -0,0 +1,88 @@
package discovery
import (
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestIsBoseUPnPDevice(t *testing.T) {
cases := []struct {
name string
dev *models.DiscoveredDevice
want bool
}{
{
name: "Bose manufacturer wins",
dev: &models.DiscoveredDevice{Manufacturer: "Bose Corporation", ModelID: "Generic"},
want: true,
},
{
name: "SoundTouch model wins even without manufacturer",
dev: &models.DiscoveredDevice{Manufacturer: "", ModelID: "SoundTouch 30 sm2"},
want: true,
},
{
name: "Case-insensitive manufacturer",
dev: &models.DiscoveredDevice{Manufacturer: "BOSE CORP"},
want: true,
},
{
name: "LG TV rejected",
dev: &models.DiscoveredDevice{Manufacturer: "LG Electronics", ModelID: "OLED55G2"},
want: false,
},
{
name: "Onkyo AVR rejected",
dev: &models.DiscoveredDevice{Manufacturer: "Onkyo Corporation", ModelID: "HT-R695"},
want: false,
},
{
name: "Dreambox rejected",
dev: &models.DiscoveredDevice{Manufacturer: "Dream Multimedia", ModelID: "dm920"},
want: false,
},
{
name: "Empty fields rejected",
dev: &models.DiscoveredDevice{},
want: false,
},
{
name: "Nil rejected",
dev: nil,
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isBoseUPnPDevice(tc.dev); got != tc.want {
t.Errorf("got %v, want %v (dev=%+v)", got, tc.want, tc.dev)
}
})
}
}
func TestIsSoundTouchServiceName(t *testing.T) {
cases := []struct {
name string
want bool
}{
{"Bose-Wohnzimmer._soundtouch._tcp.local.", true},
{"SoundTouch-Stick._soundtouchstick._tcp.local.", true},
{"NewSpeaker._bose-soundtouch._tcp.local.", true},
{"PrinterA._ipp._tcp.local.", false},
{"TV._smarttv._tcp.local.", false},
{"", false},
// Case-insensitive: firmware might emit mixed-case
{"Speaker._SoundTouch._tcp.local.", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isSoundTouchServiceName(tc.name); got != tc.want {
t.Errorf("isSoundTouchServiceName(%q) = %v, want %v", tc.name, got, tc.want)
}
})
}
}
+1
View File
@@ -111,6 +111,7 @@ type DiscoveredDevice struct {
UPnPLocation string `json:"upnp_location,omitempty"` // UPnP device description XML URL
UPnPUSN string `json:"upnp_usn,omitempty"` // UPnP Unique Service Name
UPnPSerial string `json:"upnp_serial,omitempty"` // Serial number from UPnP (MAC address)
Manufacturer string `json:"manufacturer,omitempty"` // Manufacturer from UPnP device description (used to reject non-Bose devices)
MDNSHostname string `json:"mdns_hostname,omitempty"` // mDNS hostname (e.g., "device.local.")
MDNSService string `json:"mdns_service,omitempty"` // mDNS service name
ConfigName string `json:"config_name,omitempty"` // Original name from config
+1
View File
@@ -36,6 +36,7 @@ type Links struct {
BmxSearch *Link `json:"bmx_search,omitempty" xml:"-"`
BmxPlayback *Link `json:"bmx_playback,omitempty" xml:"-"`
BmxPreset *Link `json:"bmx_preset,omitempty" xml:"-"`
BmxNext *Link `json:"bmx_next,omitempty" xml:"-"`
}
// BmxNavItem represents a single item in a TuneIn browse or search result.
+70
View File
@@ -361,6 +361,24 @@ func tuneInSearchSection(item map[string]interface{}, idx int, query, layout str
}
}
// Pivots.More.Url is the "load more" cursor from the TuneIn profiles API.
// It is only present when there are more results beyond the first page.
if pivots, ok := item["Pivots"].(map[string]interface{}); ok {
if more, ok := pivots["More"].(map[string]interface{}); ok {
if containerURL, _ := more["Url"].(string); strings.Contains(containerURL, "itemToken") {
if u, err := url.Parse(containerURL); err == nil && allowedTuneInHosts[u.Hostname()] {
encoded := base64.RawURLEncoding.EncodeToString([]byte(containerURL))
if section.Links == nil {
section.Links = &models.Links{}
}
section.Links.BmxNext = &models.Link{Href: "/v1/search/next?cursor=" + encoded}
}
}
}
}
for _, child := range children {
cm, ok := child.(map[string]interface{})
if !ok {
@@ -386,6 +404,58 @@ func tuneInSearchSection(item map[string]interface{}, idx int, query, layout str
return section
}
// TuneInSearchNext fetches the remaining results for a section using the opaque
// cursor produced by TuneInSearch. The cursor URL returns a flat Items[] list
// (not nested containers), so we parse items directly rather than via
// tuneInSearchSection. TuneIn typically returns all remaining results in one
// shot; Paging is empty and no further cursor is generated.
func TuneInSearchNext(encodedCursor string) (*models.BmxNavResponse, error) {
cursorBytes, err := base64.RawURLEncoding.DecodeString(encodedCursor)
if err != nil {
return nil, fmt.Errorf("invalid cursor: %w", err)
}
cursorURL := string(cursorBytes)
u, err := url.Parse(cursorURL)
if err != nil || !allowedTuneInHosts[u.Hostname()] {
return nil, fmt.Errorf("cursor URL not allowed")
}
data, err := fetchJSON(cursorURL)
if err != nil {
return nil, err
}
rawItems, ok := data["Items"].([]interface{})
if !ok {
rawItems, _ = data["body"].([]interface{})
}
navItems := make([]models.BmxNavItem, 0, len(rawItems))
for _, raw := range rawItems {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
typeStr, _ := m["Type"].(string)
switch typeStr {
case "Station", "PlayItem", "Topic":
navItems = append(navItems, tuneInSearchPlayItem(m))
case "Program", "Profile":
navItems = append(navItems, tuneInSearchProfile(m, ""))
}
}
return &models.BmxNavResponse{
Layout: "classic",
BmxSections: []models.BmxNavSection{
{Items: navItems, Layout: "grid"},
},
}, nil
}
func tuneInSearchPlayItem(item map[string]interface{}) models.BmxNavItem {
name, _ := item["Title"].(string)
if name == "" {
@@ -0,0 +1,131 @@
package datastore
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestGetPresets_CapitalCContentItem checks that Presets.xml files written by
// older AfterTouch versions (or verbatim speaker XML) using <ContentItem>
// (capital C) are parsed correctly. encoding/xml is case-sensitive, so without
// the normalisation in GetPresets the source and location fields would be empty
// and /full would silently skip all presets for the device (i218 diagnostic).
func TestGetPresets_CapitalCContentItem(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore-capital-c-*")
if err != nil {
t.Fatalf("tempdir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
account := "7961999"
device := "304511B46CBC"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// Verbatim format from the i218 diagnostic: capital-C ContentItem, no
// <sourceid> child element, only the first preset has createdOn/updatedOn.
presetsXML := `<?xml version="1.0" encoding="UTF-8" ?>
<presets>
<preset id="1" createdOn="1778969808" updatedOn="1778969808">
<ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="http://192.168.1.11/OPB.json" sourceAccount="" isPresetable="true">
<itemName>Internet Radio</itemName>
</ContentItem>
</preset>
<preset id="2">
<ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="http://192.168.1.11/AllClassicalPortland.json" sourceAccount="" isPresetable="true">
<itemName>Internet Radio</itemName>
</ContentItem>
</preset>
<preset id="3">
<ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="http://192.168.1.11/AncientFM.json" sourceAccount="" isPresetable="true">
<itemName>Internet Radio</itemName>
</ContentItem>
</preset>
</presets>`
if err := os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(presetsXML), 0644); err != nil {
t.Fatalf("write Presets.xml: %v", err)
}
ds := NewDataStore(tempDir)
presets, err := ds.GetPresets(account, device)
if err != nil {
t.Fatalf("GetPresets: %v", err)
}
if len(presets) != 3 {
t.Fatalf("expected 3 presets, got %d (capital-C ContentItem not parsed)", len(presets))
}
for i, p := range presets {
if p.Source != "LOCAL_INTERNET_RADIO" {
t.Errorf("preset %d: expected Source=LOCAL_INTERNET_RADIO, got %q", i+1, p.Source)
}
if p.Location == "" {
t.Errorf("preset %d: Location is empty — ContentItem attributes not parsed", i+1)
}
if p.Name != "Internet Radio" {
t.Errorf("preset %d: expected Name=Internet Radio, got %q", i+1, p.Name)
}
}
if presets[0].CreatedOn != "1778969808" {
t.Errorf("preset 1: expected CreatedOn=1778969808, got %q", presets[0].CreatedOn)
}
}
// TestGetPresets_CapitalCRewritesFile checks that after GetPresets detects the
// legacy <ContentItem> format it rewrites Presets.xml in canonical lowercase
// form, so subsequent reads are clean without needing the compat shim.
func TestGetPresets_CapitalCRewritesFile(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore-capital-c-rewrite-*")
if err != nil {
t.Fatalf("tempdir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
account := "7961999"
device := "304511B46CBC"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
presetsPath := filepath.Join(deviceDir, "Presets.xml")
presetsXML := `<?xml version="1.0" encoding="UTF-8" ?>
<presets>
<preset id="1" createdOn="1778969808" updatedOn="1778969808">
<ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="http://192.168.1.11/OPB.json" sourceAccount="" isPresetable="true">
<itemName>Internet Radio</itemName>
</ContentItem>
</preset>
</presets>`
if err := os.WriteFile(presetsPath, []byte(presetsXML), 0644); err != nil {
t.Fatalf("write Presets.xml: %v", err)
}
ds := NewDataStore(tempDir)
if _, err := ds.GetPresets(account, device); err != nil {
t.Fatalf("GetPresets: %v", err)
}
rewritten, err := os.ReadFile(presetsPath)
if err != nil {
t.Fatalf("read rewritten Presets.xml: %v", err)
}
if strings.Contains(string(rewritten), "<ContentItem") {
t.Errorf("Presets.xml still contains <ContentItem> after auto-rewrite:\n%s", string(rewritten))
}
if !strings.Contains(string(rewritten), "<contentItem") {
t.Errorf("Presets.xml missing canonical <contentItem> after auto-rewrite:\n%s", string(rewritten))
}
}
+41 -5
View File
@@ -928,6 +928,26 @@ 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)
if err != nil {
return nil, err
}
if needsRewrite {
log.Printf("[Datastore] Presets.xml for device %s used legacy <ContentItem> format; rewriting in canonical form", device)
if werr := ds.SavePresets(account, device, presets); werr != nil {
log.Printf("[Datastore] failed to rewrite normalised Presets.xml for device %s: %v", device, werr)
}
}
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()
@@ -936,10 +956,10 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
data, err := ds.rootReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return []models.ServicePreset{}, nil
return []models.ServicePreset{}, false, nil
}
return nil, err
return nil, false, err
}
var presetsWrap struct {
@@ -960,8 +980,16 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
} `xml:"preset"`
}
if err := xml.Unmarshal(data, &presetsWrap); err != nil {
return nil, fmt.Errorf("malformed presets XML at %s: %w", path, err)
// encoding/xml is case-sensitive. Older AfterTouch versions (and raw
// speaker XML) used <ContentItem> (capital C); normalise to lowercase
// before unmarshaling so legacy files are parsed correctly.
normalized := bytes.ReplaceAll(data, []byte("<ContentItem"), []byte("<contentItem"))
normalized = bytes.ReplaceAll(normalized, []byte("</ContentItem>"), []byte("</contentItem>"))
needsRewrite := !bytes.Equal(normalized, data)
if err := xml.Unmarshal(normalized, &presetsWrap); err != nil {
return nil, false, fmt.Errorf("malformed presets XML at %s: %w", path, err)
}
presets := []models.ServicePreset{}
@@ -988,7 +1016,7 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
})
}
return presets, nil
return presets, needsRewrite, nil
}
// repairLeakedSource quietly substitutes the speaker-perspective
@@ -2346,6 +2374,14 @@ type Settings struct {
// different host within a known-good private subnet.
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
// TLSExtraHosts is the persisted list of additional DNS names or IPs
// to include in the TLS certificate SAN list. Merged with the
// CLI/env --tls-extra-host values at startup (CLI/env wins; persisted
// values are additive and deduplicated). Applying a change requires a
// service restart so the TLS cert can be regenerated. Used by the
// `speaker_marge_url` health check's QuickFix and the Settings tab UI.
TLSExtraHosts []string `json:"tls_extra_hosts,omitempty"`
// TuneInStreamFormats overrides the comma-separated format list
// AfterTouch sends to TuneIn's Tune.ashx (formats=…). Empty value
// uses bmx.DefaultTuneInStreamFormats ("mp3,aac,ogg"), which
+76 -43
View File
@@ -9,6 +9,27 @@ import (
"testing"
)
// dirsToSkip names docs/ subdirectories whose contents are not meant
// to appear in SUMMARY.md. These are asset / partial / archive trees,
// not narrative documentation:
//
// - _includes : HTML partials consumed by the docs site renderer
// - archive : superseded plans / status reports kept for the
// record but deliberately unlinked
// - diagrams : Mermaid sources for embedded diagrams
// - images : binary assets + a directory README that explains them
//
// Adding a new top-level dir under docs/ does NOT require touching
// this list — only add the dir name here when its contents should
// stay out of SUMMARY.md by design. Individual file exclusions live
// in .docsignore instead.
var dirsToSkip = map[string]bool{
"_includes": true,
"archive": true,
"diagrams": true,
"images": true,
}
func TestDocsConsistency(t *testing.T) {
// Root of the project relative to this test file
// The test runs in the directory of the package
@@ -22,61 +43,73 @@ func TestDocsConsistency(t *testing.T) {
}
summaryText := string(summaryContent)
docsIgnore := readDocsIgnore(t, filepath.Join(projectRoot, ".docsignore"))
// List of directories to check
dirsToCheck := []string{".", "guides", "reference", "analysis"}
// Walk the entire docs tree. Directory-level exclusions live in
// dirsToSkip above (asset / archive trees); file-level exclusions
// live in .docsignore (individual narrative docs that are
// intentionally unlinked). New subdirectories are picked up
// automatically — this is the behaviour amazon-music-oauth.md
// surprised us by lacking.
err = filepath.WalkDir(docsDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
for _, dir := range dirsToCheck {
dirPath := filepath.Join(docsDir, dir)
err := filepath.WalkDir(dirPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
// Don't recurse into subdirectories if we are checking the root,
// as they are handled separately or ignored (like archive)
if dir == "." && path != dirPath {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(d.Name(), ".md") {
if d.IsDir() {
if path == docsDir {
return nil
}
// Skip SUMMARY.md itself
if d.Name() == "SUMMARY.md" {
return nil
rel, relErr := filepath.Rel(docsDir, path)
if relErr != nil {
return relErr
}
// Skip files listed in .docsignore at the project root
for _, skip := range docsIgnore {
if strings.HasSuffix(path, filepath.FromSlash(skip)) {
return nil
}
}
// Get relative path from docs/
relPath, err := filepath.Rel(docsDir, path)
if err != nil {
return err
}
// Check if this file is linked in SUMMARY.md
// We look for [Label](relPath)
linkPattern := "(" + relPath + ")"
if !strings.Contains(summaryText, linkPattern) {
t.Errorf("Documentation file %s is not linked in docs/SUMMARY.md", relPath)
// Skip only top-level asset / archive directories. Nested
// directories inside narrative trees (e.g. docs/guides/foo/)
// would still be walked.
if !strings.ContainsRune(rel, filepath.Separator) && dirsToSkip[d.Name()] {
return filepath.SkipDir
}
return nil
})
if err != nil {
t.Errorf("Error walking directory %s: %v", dir, err)
}
if !strings.HasSuffix(d.Name(), ".md") {
return nil
}
// Skip SUMMARY.md itself
if d.Name() == "SUMMARY.md" {
return nil
}
// Skip files listed in .docsignore at the project root
for _, skip := range docsIgnore {
if strings.HasSuffix(path, filepath.FromSlash(skip)) {
return nil
}
}
// Get relative path from docs/
relPath, err := filepath.Rel(docsDir, path)
if err != nil {
return err
}
// Check if this file is linked in SUMMARY.md
// We look for [Label](relPath)
linkPattern := "(" + relPath + ")"
if !strings.Contains(summaryText, linkPattern) {
t.Errorf("Documentation file %s is not linked in docs/SUMMARY.md", relPath)
}
return nil
})
if err != nil {
t.Errorf("Error walking docs directory: %v", err)
}
}
@@ -290,6 +290,33 @@ func (s *Server) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
}
}
// HandleTuneInSearchNext returns the next page of TuneIn search results using
// an opaque cursor produced by HandleTuneInSearch.
func (s *Server) HandleTuneInSearchNext(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
log.Printf("[BMX] Authorization missing (gate temporarily disabled, see handlers_bmx.go); path=%q ua=%q",
r.URL.Path, r.UserAgent())
}
cursor := r.URL.Query().Get("cursor")
if cursor == "" {
http.Error(w, "cursor parameter required", http.StatusBadRequest)
return
}
resp, err := bmx.TuneInSearchNext(cursor)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if encErr := json.NewEncoder(w).Encode(resp); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleTuneInFavorite handles POST /bmx/tunein/v1/favorite/{stationID}.
func (s *Server) HandleTuneInFavorite(w http.ResponseWriter, r *http.Request) {
stationID := chi.URLParam(r, "stationID")
+55 -1
View File
@@ -1,7 +1,9 @@
package handlers
import (
"crypto/sha256"
"embed"
"encoding/hex"
"encoding/json"
"io/fs"
"net/http"
@@ -14,6 +16,58 @@ var indexHTML []byte
//go:embed web/css/* web/js/* web/img/favicon-braille* web/img/favicon*
var webFS embed.FS
// indexHTMLVersioned is the HTML the root handler serves: identical to
// indexHTML except the script.js and style.css references carry a
// ?v=<hash> query string so the browser cache invalidates whenever
// the asset content changes. Computed once at package init and reused
// per-request. webAssetHash is the truncated SHA-256 over the asset
// bodies; it's exposed for /setup/settings consumers that want to
// build versioned URLs against /web/* from their own DOM constructors.
var (
indexHTMLVersioned []byte
webAssetHash string
)
func init() {
webAssetHash = computeWebAssetHash()
indexHTMLVersioned = applyAssetVersionToHTML(indexHTML, webAssetHash)
}
// computeWebAssetHash hashes the embedded script.js and style.css
// bodies into a short stable identifier. SHA-256 truncated to 12
// hex chars is more than enough to detect content changes across
// release builds without bloating the URL.
func computeWebAssetHash() string {
h := sha256.New()
for _, path := range []string{"web/js/script.js", "web/css/style.css"} {
data, err := webFS.ReadFile(path)
if err != nil {
continue
}
_, _ = h.Write(data)
}
return hex.EncodeToString(h.Sum(nil))[:12]
}
// applyAssetVersionToHTML rewrites the script and stylesheet src/href
// attributes in the embedded HTML to carry a ?v=<hash> query string.
// Operates on the byte slice once at startup; HandleRoot then serves
// the cached output verbatim per request.
func applyAssetVersionToHTML(html []byte, hash string) []byte {
if hash == "" {
return html
}
out := string(html)
out = strings.Replace(out, `href="/web/css/style.css"`, `href="/web/css/style.css?v=`+hash+`"`, 1)
out = strings.Replace(out, `src="/web/js/script.js"`, `src="/web/js/script.js?v=`+hash+`"`, 1)
return []byte(out)
}
//go:embed static/media/*
var mediaFS embed.FS
@@ -60,7 +114,7 @@ func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write(indexHTML)
_, _ = w.Write(indexHTMLVersioned)
}
// HandleWeb returns a handler for serving web resources.
@@ -185,3 +185,57 @@ func TestStaticWeb(t *testing.T) {
t.Errorf("Web Root Favicon: Expected status NotFound, got %v", res.Status)
}
}
func TestComputeWebAssetHash_StableAndShort(t *testing.T) {
got := computeWebAssetHash()
if len(got) != 12 {
t.Errorf("expected 12-char hash, got %d (%q)", len(got), got)
}
if got != computeWebAssetHash() {
t.Errorf("hash should be stable across calls — same embedded FS")
}
for _, c := range got {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
t.Errorf("hash must be lowercase hex, got %q", got)
break
}
}
}
func TestApplyAssetVersionToHTML_InjectsQueryString(t *testing.T) {
const html = `<link rel="stylesheet" href="/web/css/style.css"/>` +
`<script src="/web/js/script.js"></script>`
out := string(applyAssetVersionToHTML([]byte(html), "abc123"))
if !strings.Contains(out, `/web/css/style.css?v=abc123`) {
t.Errorf("expected style.css to carry ?v=abc123, got: %s", out)
}
if !strings.Contains(out, `/web/js/script.js?v=abc123`) {
t.Errorf("expected script.js to carry ?v=abc123, got: %s", out)
}
}
func TestApplyAssetVersionToHTML_EmptyHashPassthrough(t *testing.T) {
const html = `<script src="/web/js/script.js"></script>`
out := applyAssetVersionToHTML([]byte(html), "")
if string(out) != html {
t.Errorf("expected unchanged HTML for empty hash, got: %s", out)
}
}
func TestIndexHTMLVersioned_CarriesHash(t *testing.T) {
body := string(indexHTMLVersioned)
if !strings.Contains(body, "/web/js/script.js?v=") {
t.Errorf("indexHTMLVersioned must carry ?v= on script.js reference")
}
if !strings.Contains(body, "/web/css/style.css?v=") {
t.Errorf("indexHTMLVersioned must carry ?v= on style.css reference")
}
}
+35
View File
@@ -195,6 +195,10 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
"https_server_url": httpsServerURL,
"https_listener_port": httpsListenerPort,
"https_443_check_skipped": probe443.Skipped,
"https_443_not_applicable": probe443.NotApplicable,
"https_443_reason": probe443.Reason,
"tls_extra_hosts": s.persistedTLSExtraHosts(),
"tls_san_hosts": s.ExpectedHosts(),
"https_443_localhost_reachable": probe443.Localhost.Reachable,
"https_443_localhost_error": probe443.Localhost.Error,
"https_443_lan_reachable": probe443.LAN.Reachable,
@@ -243,6 +247,7 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
AmazonClientID string `json:"amazon_client_id"`
AmazonClientSecret string `json:"amazon_client_secret"`
AmazonRedirectURI string `json:"amazon_redirect_uri"`
TLSExtraHosts *[]string `json:"tls_extra_hosts"`
}
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
@@ -322,6 +327,13 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
currentRecord := s.recordEnabled
currentHTTPS := s.httpsServerURL
// Resolve TLS extra hosts: nil pointer means "field omitted, preserve existing";
// non-nil (even empty) means "replace with this list".
resolvedTLSExtraHosts := s.persistedTLSExtraHosts()
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,
@@ -342,6 +354,7 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
AmazonClientID: s.amazonClientID,
AmazonClientSecret: s.amazonClientSecret,
AmazonRedirectURI: s.amazonRedirectURI,
TLSExtraHosts: resolvedTLSExtraHosts,
})
dnsEnabled := s.dnsEnabled
@@ -375,6 +388,28 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
}
}
// 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
// stored list is always canonical.
func normaliseTLSExtraHosts(in []string) []string {
out := make([]string, 0, len(in))
seen := make(map[string]bool, len(in))
for _, h := range in {
h = strings.TrimSpace(h)
if h == "" || seen[h] {
continue
}
seen[h] = true
out = append(out, h)
}
return out
}
// HandleGetDeviceInfo returns live information for a device.
func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
@@ -0,0 +1,109 @@
package handlers
import (
"context"
"encoding/xml"
"fmt"
"net/url"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/health"
)
// addMargeHostToTLSFix is the FixFunc registered for the
// (CheckIDSpeakerMargeURL, FixIDAddMargeHostToTLS) pair. It re-probes
// the target device's <margeURL>, extracts the host portion, and
// appends it to the persisted Settings.TLSExtraHosts. A subsequent
// service restart picks up the change via the regular settings
// merge path in applyPersistedSettings (cmd/soundtouch-service/main.go).
//
// The re-probe is deliberate: the persisted Settings only become
// authoritative after the operator restarts AfterTouch, so reading
// the margeURL fresh from the speaker avoids racing a stale finding
// that was rendered before the speaker rebooted.
//
// Returns a success message that names the host and instructs the
// operator to restart the service. Returns an error if the device
// can't be located, the probe fails, or the marge URL is empty /
// unparseable.
func (s *Server) addMargeHostToTLSFix(target health.Target) (string, error) {
if target.Device == "" {
return "", fmt.Errorf("device is required")
}
deviceIP, err := s.resolveDeviceIDToIP(target.Device)
if err != nil {
return "", fmt.Errorf("locate device %s: %w", target.Device, err)
}
probeURL := fmt.Sprintf("http://%s:8090/info", deviceIP)
margeHost, err := fetchMargeHostFromSpeaker(probeURL, 2*time.Second)
if err != nil {
return "", err
}
if margeHost == "" {
return "", fmt.Errorf("speaker %s has no <margeURL>; nothing to add", target.Device)
}
persisted, err := s.ds.GetSettings()
if err != nil {
return "", fmt.Errorf("load settings: %w", err)
}
for _, existing := range persisted.TLSExtraHosts {
if strings.EqualFold(strings.TrimSpace(existing), margeHost) {
return fmt.Sprintf("%s is already in the persisted TLS hosts. Restart AfterTouch to regenerate the TLS certificate if you haven't already.", margeHost), nil
}
}
persisted.TLSExtraHosts = append(persisted.TLSExtraHosts, margeHost)
if err := s.ds.SaveSettings(persisted); err != nil {
return "", fmt.Errorf("save settings: %w", err)
}
return fmt.Sprintf("Added %s to persisted TLS hosts (tls_extra_hosts). Restart AfterTouch for the TLS certificate to be regenerated and include this host in its SAN list.", margeHost), nil
}
// fetchMargeHostFromSpeaker probes the given speaker /info URL and
// returns the host portion of the <margeURL> XML element. Returns an
// empty string with no error when the speaker responds but doesn't
// carry a margeURL. Returns a non-nil error when the probe itself
// fails or the response can't be parsed.
func fetchMargeHostFromSpeaker(probeURL string, timeout time.Duration) (string, error) {
res := health.ProbeGet(context.Background(), probeURL, timeout)
if !res.Reachable {
return "", fmt.Errorf("speaker probe failed: %s", res.Err)
}
if res.Status != 200 {
return "", fmt.Errorf("speaker /info returned HTTP %d", res.Status)
}
var parsed struct {
MargeURL string `xml:"margeURL"`
}
if err := xml.Unmarshal(res.Body, &parsed); err != nil {
return "", fmt.Errorf("parse /info: %w", err)
}
if parsed.MargeURL == "" {
return "", nil
}
u, err := url.Parse(parsed.MargeURL)
if err != nil {
return "", fmt.Errorf("parse margeURL %q: %w", parsed.MargeURL, err)
}
host := u.Hostname()
if host == "" {
host = strings.TrimSpace(parsed.MargeURL)
}
return host, nil
}
@@ -0,0 +1,92 @@
package handlers
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func stubInfoForFix(t *testing.T, margeURL string) string {
t.Helper()
body := `<?xml version="1.0" encoding="UTF-8" ?><info deviceID="DEVICEID01"><name>Test</name><margeAccountUUID>1000001</margeAccountUUID><margeURL>` + margeURL + `</margeURL></info>`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/info" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv.URL + "/info"
}
func TestFetchMargeHostFromSpeaker_ReturnsHostOnly(t *testing.T) {
cases := []struct {
name string
margeURL string
want string
}{
{
name: "HTTPS with port",
margeURL: "https://aftertouch.example:8443/",
want: "aftertouch.example",
},
{
name: "HTTP IP with port",
margeURL: "http://192.0.2.10:8000/",
want: "192.0.2.10",
},
{
name: "Bare host fallback",
margeURL: "aftertouch.example",
want: "aftertouch.example",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
probeURL := stubInfoForFix(t, tc.margeURL)
got, err := fetchMargeHostFromSpeaker(probeURL, 2*time.Second)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
func TestFetchMargeHostFromSpeaker_EmptyMargeURLReturnsEmpty(t *testing.T) {
probeURL := stubInfoForFix(t, "")
got, err := fetchMargeHostFromSpeaker(probeURL, 2*time.Second)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "" {
t.Errorf("expected empty host for empty margeURL, got %q", got)
}
}
func TestFetchMargeHostFromSpeaker_UnreachableReturnsError(t *testing.T) {
// Point at a closed port; the probe should fail without panicking.
_, err := fetchMargeHostFromSpeaker("http://127.0.0.1:1/info", 200*time.Millisecond)
if err == nil {
t.Errorf("expected error for unreachable speaker, got nil")
}
if !strings.Contains(err.Error(), "probe failed") {
t.Errorf("expected 'probe failed' in error, got %q", err.Error())
}
}
+39 -5
View File
@@ -5,17 +5,26 @@ import (
"net"
"net/url"
"strconv"
"strings"
"time"
)
// Probe443Result captures the outcome of probing a host on :443.
// Skipped is true when the running HTTPS listener is already on :443
// (in which case the listener itself is the proof of reachability).
// NotApplicable is true when the operator has chosen an HTTP-only
// deployment (configured serverURL is http://...) — speakers migrated
// to that URL never connect to :443, so the iptables/setcap dance
// would only matter for unmigrated speakers falling back to
// streaming.bose.com via DNS hijack. Reason carries a short
// human-readable explanation rendered in the UI.
type Probe443Result struct {
Skipped bool
Localhost ProbeOutcome
LAN ProbeOutcome
LANHost string
Skipped bool
NotApplicable bool
Reason string
Localhost ProbeOutcome
LAN ProbeOutcome
LANHost string
}
// ProbeOutcome describes a single TCP-connect probe. Exactly one of
@@ -72,6 +81,14 @@ func Check443Reachability(
return Probe443Result{Skipped: true}
}
if scheme := schemeOf(serverURL); scheme == "http" {
return Probe443Result{
NotApplicable: true,
Reason: "AfterTouch's configured serverURL is HTTP, so migrated speakers connect over plain HTTP and never use :443. " +
"The iptables / setcap / reverse-proxy dance is only needed if you also expect unmigrated speakers to fall back to streaming.bose.com via DNS hijack.",
}
}
res := Probe443Result{}
if err := ProbeTCP("127.0.0.1", 443, timeout); err != nil {
@@ -97,6 +114,22 @@ func Check443Reachability(
return res
}
// schemeOf returns the lowercased URL scheme of s, or "" if s is empty or
// unparseable. Used to decide whether the :443 reachability check is even
// applicable to the deployment.
func schemeOf(s string) string {
if s == "" {
return ""
}
u, err := url.Parse(s)
if err != nil {
return ""
}
return strings.ToLower(u.Scheme)
}
// PortFromHTTPSServerURL extracts the numeric port from httpsServerURL. It
// returns 0 if the URL is empty, malformed, or has no explicit port — in
// that case the caller cannot make a determination about :443 and should
@@ -129,7 +162,7 @@ func PortFromHTTPSServerURL(httpsServerURL string) int {
// returned string ends without a trailing newline so callers may use it
// with log.Print or log.Printf as they prefer.
func FormatPreflightGuidance(httpsListenerPort int, res Probe443Result) string {
if res.Skipped {
if res.Skipped || res.NotApplicable {
return ""
}
@@ -161,6 +194,7 @@ func FormatPreflightGuidance(httpsListenerPort int, res Probe443Result) string {
" 1. iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port "+strconv.Itoa(httpsListenerPort),
" 2. setcap cap_net_bind_service=+ep <binary> and pass --https-port=443",
" 3. reverse proxy (nginx/caddy) terminating TLS on :443",
" Caveat: do NOT add the same REDIRECT rule on the OUTPUT chain. That would catch this host's own outbound :443 traffic (browsers, `go install`, `apt-get`) and route it to AfterTouch.",
" See docs/guides/HTTPS-SETUP.md for details.",
)
+73 -3
View File
@@ -48,7 +48,8 @@ func TestCheck443Reachability_SkipsWhenListenerOn443(t *testing.T) {
}
func TestCheck443Reachability_ReportsResolverError(t *testing.T) {
res := Check443Reachability(8443, "http://broken", func(string) (string, error) {
// Use an HTTPS server URL so the NotApplicable short-circuit doesn't fire.
res := Check443Reachability(8443, "https://broken", func(string) (string, error) {
return "", errResolve("no DNS")
}, 100*time.Millisecond)
@@ -65,6 +66,57 @@ func TestCheck443Reachability_ReportsResolverError(t *testing.T) {
}
}
func TestCheck443Reachability_NotApplicableWhenServerURLIsHTTP(t *testing.T) {
res := Check443Reachability(8443, "http://aftertouch.local:8000", func(string) (string, error) {
t.Errorf("resolver should not be called when serverURL scheme is HTTP")
return "", nil
}, 100*time.Millisecond)
if !res.NotApplicable {
t.Errorf("expected NotApplicable=true when serverURL is HTTP, got %+v", res)
}
if res.Reason == "" {
t.Errorf("expected NotApplicable verdict to carry a human-readable Reason, got empty")
}
if res.Skipped {
t.Errorf("Skipped should only be set when the listener is already on :443; got Skipped=true for HTTP serverURL")
}
}
func TestCheck443Reachability_NotApplicableTakesPrecedenceOverProbe(t *testing.T) {
// Even if the listener isn't on :443 and probes would fail, an HTTP
// serverURL should short-circuit to NotApplicable.
res := Check443Reachability(8443, "http://1.2.3.4:8000", func(string) (string, error) {
return "1.2.3.4", nil
}, 100*time.Millisecond)
if !res.NotApplicable {
t.Errorf("expected NotApplicable=true, got %+v", res)
}
if res.LAN.Error != "" || res.Localhost.Error != "" {
t.Errorf("expected no probe errors when NotApplicable short-circuits, got %+v", res)
}
}
func TestCheck443Reachability_HTTPSServerURLStillProbes(t *testing.T) {
resolverCalled := false
res := Check443Reachability(8443, "https://aftertouch.local:8443", func(string) (string, error) {
resolverCalled = true
return "127.0.0.1", nil
}, 100*time.Millisecond)
if !resolverCalled {
t.Errorf("resolver should be called for HTTPS serverURL")
}
if res.NotApplicable {
t.Errorf("HTTPS serverURL should not produce NotApplicable, got %+v", res)
}
}
func TestPortFromHTTPSServerURL(t *testing.T) {
cases := []struct {
in string
@@ -98,6 +150,10 @@ func TestFormatPreflightGuidance_SkippedAndAllOK(t *testing.T) {
if FormatPreflightGuidance(8443, bothOK) != "" {
t.Errorf("expected empty guidance when both probes succeed")
}
if FormatPreflightGuidance(8443, Probe443Result{NotApplicable: true, Reason: "HTTP only"}) != "" {
t.Errorf("expected empty guidance when NotApplicable (UI renders the reason separately)")
}
}
func TestFormatPreflightGuidance_BothFailMentionsRedirectPort(t *testing.T) {
@@ -121,6 +177,19 @@ func TestFormatPreflightGuidance_BothFailMentionsRedirectPort(t *testing.T) {
}
}
func TestFormatPreflightGuidance_IncludesOutputChainCaveat(t *testing.T) {
res := Probe443Result{
Localhost: ProbeOutcome{Error: "connection refused"},
LAN: ProbeOutcome{Error: "connection refused"},
LANHost: "192.0.2.151",
}
out := FormatPreflightGuidance(8443, res)
if !strings.Contains(out, "OUTPUT") {
t.Errorf("guidance must warn about the iptables OUTPUT chain side-effect, got: %s", out)
}
}
type errResolve string
func (e errResolve) Error() string { return string(e) }
@@ -131,8 +200,9 @@ func TestCheck443Reachability_LANProbeMatchesListenerOutcome(t *testing.T) {
// nothing answers on :443 in test environments. The point of this test
// is to lock in the result-shape: when localhost:443 is closed (the
// default in CI), the function still returns a well-formed result and
// reports the resolved LAN host.
res := Check443Reachability(8443, "http://1.2.3.4:8000", func(string) (string, error) {
// reports the resolved LAN host. Uses HTTPS so the NotApplicable
// short-circuit doesn't fire.
res := Check443Reachability(8443, "https://1.2.3.4:8443", func(string) (string, error) {
return "1.2.3.4", nil
}, 200*time.Millisecond)
+38 -1
View File
@@ -133,6 +133,15 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
health.RegisterPresetsCountCheck(s.healthRegistry, ds)
health.RegisterPresetsConsistencyCheck(s.healthRegistry, ds)
health.RegisterRefreshSourcesCheck(s.healthRegistry, ds)
health.RegisterDefaultAccountNonBoseDevicesCheck(s.healthRegistry, ds)
health.RegisterOAuthTargetReachableCheck(
s.healthRegistry,
func() string {
serverURL, _ := s.GetSettings()
return serverURL
},
s.GetDNSRunning,
)
// Health QuickFix executor for the empty-margeAccountUUID
// finding from RegisterSpeakerInfoReachable. Lives here (not in
@@ -145,6 +154,15 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
health.FixIDCompleteSpeakerPairing,
s.completeSpeakerPairingFix,
)
// QuickFix executor for the speaker_marge_url mismatch finding.
// Adds the speaker's actual margeURL host to settings.TLSExtraHosts
// so a subsequent restart picks it up via applyPersistedSettings.
s.healthRegistry.RegisterFix(
health.CheckIDSpeakerMargeURL,
health.FixIDAddMargeHostToTLS,
s.addMargeHostToTLSFix,
)
health.RegisterDNSSanityCheck(
s.healthRegistry,
s.GetDNSRunning,
@@ -188,6 +206,25 @@ func (s *Server) ExpectedHosts() []string {
return out
}
// persistedTLSExtraHosts returns the slice of TLS extra hosts that
// live in settings.json. Used by HandleGetSettings to render the
// "edit list" UI separately from the full effective SAN list
// (ExpectedHosts also contains serverURL host, httpsServerURL host,
// hostname, and CLI/env-pinned extras). Returns an empty slice if
// the settings file is missing or unreadable — the caller should
// treat that the same as "operator hasn't added anything yet".
func (s *Server) persistedTLSExtraHosts() []string {
persisted, err := s.ds.GetSettings()
if err != nil {
return []string{}
}
out := make([]string, len(persisted.TLSExtraHosts))
copy(out, persisted.TLSExtraHosts)
return out
}
// ownCACertPath returns the on-disk path of AfterTouch's own CA
// cert (PEM). Empty string when the certmanager isn't wired in.
// Used by the Health-tab CA-expiry check to render an accurate
@@ -470,7 +507,7 @@ func (s *Server) startDNSDiscovery(bind string, upstreamList []string) {
return
}
s.dnsDiscovery = discovery.NewDNSDiscovery(upstreamList, serviceIP)
s.dnsDiscovery = discovery.NewDNSDiscovery(upstreamList, serviceIP, s.serverURL)
go func(d *discovery.DNSDiscovery, addr string) {
if err := d.Start(addr); err != nil {
log.Printf("Warning: DNS discovery server error: %v", err)
+71 -6
View File
@@ -165,11 +165,58 @@
<div id="target-domain-resolved" style="font-size: 0.85em; margin-top: 4px; min-height: 1.2em"></div>
<div id="https-443-status" style="font-size: 0.85em; margin-top: 4px; min-height: 1.2em"></div>
</div>
<div style="margin-bottom: 20px">
<strong>TLS extra hosts:</strong>
<span class="info-toggle" onclick="toggleInfo('tls-extra-hosts-info')"></span>
<div id="tls-extra-hosts-info" class="info-details">
<strong>When you need this:</strong> rarely. The TLS
certificate already covers AfterTouch's configured
server URL, HTTPS URL, and the host's own name. Add
entries here only when a speaker can't reach
AfterTouch over TLS — typical symptoms include
presets resetting on reboot, the BoseApp showing
the speaker as offline, or
<code>CURLE_SSL_CACERT (60)</code> in the speaker
syslog.<br/>
<strong>How to tell:</strong> open the
<strong>Health tab</strong> and look for
<code>speaker_marge_url</code> warnings. Each
warning names the host a speaker is pointing at;
clicking the <em>Add &lt;host&gt; to TLS hosts</em>
QuickFix fills this list for you. If that check is
clean, this list can stay empty.<br/>
<strong>Manual path:</strong> add one host per line
and save. The TLS certificate is regenerated at
startup from the merged list of
<code>--server-url</code> host,
<code>--https-server-url</code> host, the system
hostname, any <code>--tls-extra-host</code> /
<code>TLS_EXTRA_HOST</code> CLI/env values, and the
hosts persisted here. CLI/env wins over persisted
on overlap.<br/>
<strong>Applying changes requires a service
restart.</strong>
</div>
<div style="font-size: 0.85em; color: #666; margin-top: 4px;">
Usually empty. Add a host here only if the
<a href="#" onclick="openTab(null, 'tab-health'); return false;">Health tab</a>
flags a <code>speaker_marge_url</code> warning — or use
the one-click QuickFix on that warning to fill it for you.
</div>
<div style="margin-top: 5px">
<textarea
id="tls-extra-hosts"
placeholder="One host per line, e.g. 192.0.2.10 or aftertouch.lan"
style="width: 360px; height: 84px; font-family: monospace; font-size: 0.9em;"
></textarea>
</div>
<div id="tls-san-effective" style="font-size: 0.8em; color: #666; margin-top: 4px;"></div>
</div>
<div style="margin-bottom: 20px">
<strong>Device Discovery:</strong>
<div style="margin-top: 5px">
<label style="display: block; margin-bottom: 5px">
<input type="checkbox" id="discovery-enabled"/> Enable Automated Discovery
<input type="checkbox" id="discovery-enabled"/> Enable Periodic Discovery
</label>
<div style="margin-left: 20px">
<label for="discovery-interval">Discovery Interval:</label>
@@ -1501,18 +1548,36 @@
<div id="tab-health" class="tab-content">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
<h2 style="margin: 0;">Service Health Checks</h2>
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">
<button onclick="fetchHealth()">Refresh</button>
<button onclick="downloadDiagnostic()" title="Download an encrypted diagnostic report to share with the project maintainer">Download diagnostic report</button>
</div>
<button onclick="fetchHealth()">Refresh</button>
</div>
<p style="font-size: 0.9em; color: #555;">
Runs a set of checks against the local datastore and flags
findings that may need attention. Quick fixes are offered
for issues the service knows how to remediate.
</p>
<div style="margin: 12px 0 16px 0; padding: 10px 12px; background: #fafafa; border: 1px solid #eee; border-radius: 4px;">
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
<button onclick="downloadDiagnostic()" title="Download an encrypted diagnostic report to share with the project maintainer">Download diagnostic report</button>
<span style="font-size: 0.9em; color: #555;">Share a diagnostic snapshot with the project maintainer.</span>
</div>
<details style="font-size: 0.85em; color: #555; margin-top: 8px;">
<summary style="cursor: pointer;">What does the diagnostic report contain?</summary>
<ul style="margin: 6px 0 0 0; padding-left: 1.4em;">
<li>Health check results and current device state</li>
<li>Device XML files from the datastore (no passwords)</li>
<li>HTTP response samples from speaker endpoints</li>
<li>System files: CA bundle, DNS resolver config</li>
<li>Speaker CA bundle and kernel log (via SSH, if reachable)</li>
<li>Service log tail</li>
<li>Settings file with secrets redacted</li>
</ul>
<p style="margin: 6px 0 0 0;">The archive is encrypted with the project maintainer's public key — only they can open it.</p>
</details>
<div id="health-diagnostic-status" style="font-size: 0.85em; margin-top: 8px;"></div>
</div>
<div id="health-generated-at" style="font-size: 0.8em; color: #888; margin-bottom: 10px;"></div>
<div id="health-diagnostic-status" style="font-size: 0.85em; margin-bottom: 8px;"></div>
<div id="health-findings">Loading…</div>
</div>
+94 -20
View File
@@ -7,6 +7,51 @@
// to the user — they'd be misleading without context.
const FAST_ERROR_MS = 150;
// copyTextToClipboard attempts navigator.clipboard.writeText first (modern
// async API, requires a secure context — HTTPS or localhost). On insecure
// contexts (plain HTTP at a LAN IP), the Clipboard API is unavailable, so
// we fall back to the legacy document.execCommand("copy") path using a
// throwaway off-screen textarea. Returns true on success, false on
// failure. Both paths preserve the page's current focus.
async function copyTextToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (e) {
// Fall through to the legacy path — some browsers still reject
// even when isSecureContext claims true (e.g. iframes without
// the clipboard-write permission).
}
}
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "absolute";
ta.style.left = "-9999px";
ta.style.top = "0";
document.body.appendChild(ta);
const previousActive = document.activeElement;
ta.select();
let ok = false;
try {
ok = document.execCommand("copy");
} catch (e) {
ok = false;
}
document.body.removeChild(ta);
if (previousActive && typeof previousActive.focus === "function") {
previousActive.focus();
}
return ok;
}
async function probeBrowser443(lanHost, listenerPort, statusEl, serverLocalhostOK, serverLanOK) {
const line = document.createElement("div");
line.style.fontSize = "0.85em";
@@ -216,6 +261,10 @@ async function fetchSettings() {
} else if (settings.https_443_check_skipped) {
port443.style.color = "#2e7d32";
port443.innerHTML = "✅ HTTPS listener bound directly to <code>:443</code> — speakers can connect.";
} else if (settings.https_443_not_applicable) {
port443.style.color = "#1565c0";
port443.innerHTML = "️ <code>:443</code> reachability check not applicable. " +
(settings.https_443_reason || "");
} else {
const localhostOK = settings.https_443_localhost_reachable;
const lanOK = settings.https_443_lan_reachable;
@@ -279,6 +328,18 @@ async function fetchSettings() {
document.getElementById("internal-paths").value = settings.internal_paths.join("\n");
}
if (Array.isArray(settings.tls_extra_hosts)) {
document.getElementById("tls-extra-hosts").value = settings.tls_extra_hosts.join("\n");
}
const effective = document.getElementById("tls-san-effective");
if (effective) {
if (Array.isArray(settings.tls_san_hosts) && settings.tls_san_hosts.length) {
effective.innerText = "Currently covered by TLS cert: " + settings.tls_san_hosts.join(", ");
} else {
effective.innerText = "";
}
}
// Spotify credential fields
if (settings.spotify_client_id !== undefined) {
document.getElementById("spotify-client-id").value = settings.spotify_client_id || "";
@@ -321,6 +382,7 @@ async function fetchSettings() {
fetchLoggingSettings();
fetchSpotifyStatus();
return settings;
} catch (error) {
console.error("Failed to fetch settings", error);
}
@@ -372,6 +434,11 @@ async function updateSettings() {
amazon_client_id: document.getElementById("amazon-client-id").value,
amazon_client_secret: document.getElementById("amazon-client-secret").value,
amazon_redirect_uri: document.getElementById("amazon-redirect-uri").value,
tls_extra_hosts: document
.getElementById("tls-extra-hosts")
.value.split("\n")
.map((s) => s.trim())
.filter((s) => s !== ""),
};
const status = document.getElementById("settings-status");
status.innerText = "Saving...";
@@ -1599,8 +1666,10 @@ function formatXML(xml) {
}
document.addEventListener("DOMContentLoaded", async () => {
fetchSettings();
triggerDiscovery();
const cfg = await fetchSettings();
if (cfg?.discovery_enabled !== false) {
triggerDiscovery();
}
fetchVersion();
await fetchDevices();
@@ -3985,10 +4054,12 @@ async function applyCustomPlan() {
}
}
document.addEventListener("DOMContentLoaded", () => {
document.addEventListener("DOMContentLoaded", async () => {
fetchDevices();
fetchSettings();
triggerDiscovery();
const cfg = await fetchSettings();
if (cfg?.discovery_enabled !== false) {
triggerDiscovery();
}
});
// ---------------------------------------------------------------------------
@@ -4038,7 +4109,16 @@ async function downloadDiagnostic() {
document.body.removeChild(a);
URL.revokeObjectURL(url);
if (statusEl) statusEl.textContent = `Downloaded: ${filename}`;
if (statusEl) {
const safe = filename.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
statusEl.innerHTML =
`Downloaded: <strong>${safe}</strong><br>` +
`To share it, please <strong>prefer email</strong>: ` +
`<a href="mailto:aftertouch-support@gesellix.net?subject=Diagnostic%20report&body=Please%20attach%20${encodeURIComponent(safe)}%20to%20this%20email.">aftertouch-support@gesellix.net</a>. ` +
`Alternatively, open a <a href="https://github.com/gesellix/Bose-SoundTouch/issues" target="_blank" rel="noopener">GitHub issue</a> ` +
`and attach the file renamed to <code>${safe}.txt</code> ` +
`(GitHub blocks <code>.age</code> uploads; adding <code>.txt</code> works around that).`;
}
} catch (e) {
if (statusEl) statusEl.textContent = `Failed to download diagnostic: ${e.message || e}`;
}
@@ -4191,13 +4271,10 @@ function renderManualCommand(cmd) {
copyBtn.textContent = "Copy";
copyBtn.style.alignSelf = "flex-start";
copyBtn.onclick = async () => {
try {
await navigator.clipboard.writeText(cmd.command);
const orig = copyBtn.textContent;
copyBtn.textContent = "Copied";
setTimeout(() => { copyBtn.textContent = orig; }, 1200);
} catch (e) {
copyBtn.textContent = "Copy failed";
const ok = await copyTextToClipboard(cmd.command);
copyBtn.textContent = ok ? "Copied" : "Copy failed";
if (ok) {
setTimeout(() => { copyBtn.textContent = "Copy"; }, 1200);
}
};
row.appendChild(copyBtn);
@@ -4623,13 +4700,10 @@ function unreachableBlock(probe) {
const btn = document.createElement("button");
btn.textContent = "Copy";
btn.onclick = async () => {
try {
await navigator.clipboard.writeText(probe.curl_command);
const orig = btn.textContent;
btn.textContent = "Copied";
setTimeout(() => { btn.textContent = orig; }, 1200);
} catch (e) {
btn.textContent = "Copy failed";
const ok = await copyTextToClipboard(probe.curl_command);
btn.textContent = ok ? "Copied" : "Copy failed";
if (ok) {
setTimeout(() => { btn.textContent = "Copy"; }, 1200);
}
};
row.appendChild(btn);
@@ -0,0 +1,132 @@
package health
import (
"fmt"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// CheckIDDefaultAccountNonBoseDevices is the registry id of the
// non-Bose-default-account-devices check.
const CheckIDDefaultAccountNonBoseDevices = "default_account_non_bose_devices"
// FixIDEvictDefaultNonBoseDevice removes a non-Bose UPnP device from
// the "default" account directory. Implemented by the existing
// DataStore.RemoveDevice — this constant ties it to the finding it
// remediates.
const FixIDEvictDefaultNonBoseDevice = "evict_default_non_bose_device"
// RegisterDefaultAccountNonBoseDevicesCheck registers the
// non-Bose-default-account-devices health check. Walks the entries
// under data/accounts/default/devices/ and flags any whose
// DeviceInfo.xml model/type doesn't look like a Bose SoundTouch
// product. These are leftover discovery hits from the LAN's broader
// UPnP MediaRenderer population — LG TVs, Onkyo / Yamaha receivers,
// Dreambox tuners — that responded to our generic
// `urn:schemas-upnp-org:device:MediaRenderer:1` M-SEARCH.
//
// Each flagged entry comes with an "Evict" QuickFix that removes the
// device's data directory; the live discovery filter (see
// `pkg/discovery/upnp.go isBoseUPnPDevice`) prevents the entry from
// being re-created on the next scan.
//
// Bose devices that still live under "default" (e.g. a fresh speaker
// before pairing completes) are intentionally ignored here — that's
// the consistency check's domain.
func RegisterDefaultAccountNonBoseDevicesCheck(r *Registry, ds *datastore.DataStore) {
r.Register(Check{
ID: CheckIDDefaultAccountNonBoseDevices,
Title: "Default-account devices are SoundTouch speakers",
Run: func() []Finding {
return runDefaultAccountNonBoseDevicesCheck(ds)
},
})
r.RegisterFix(
CheckIDDefaultAccountNonBoseDevices,
FixIDEvictDefaultNonBoseDevice,
func(target Target) (string, error) {
if target.Device == "" {
return "", fmt.Errorf("device is required")
}
if err := ds.RemoveDevice("default", target.Device); err != nil {
return "", fmt.Errorf("remove default/%s: %w", target.Device, err)
}
return fmt.Sprintf("Evicted %s from the default account. If it returns on the next scan, AfterTouch's discovery filter needs an update — please file a bug.", target.Device), nil
},
)
}
func runDefaultAccountNonBoseDevicesCheck(ds *datastore.DataStore) []Finding {
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.AccountID != "default" {
continue
}
if looksLikeSoundTouch(dev) {
continue
}
findings = append(findings, Finding{
Severity: SeverityWarning,
Target: Target{Account: "default", Device: dev.DeviceID},
Message: fmt.Sprintf(
"Non-Bose device %q (type=%q) is stored under the default account.",
labelForDevice(dev), dev.ProductCode,
),
Details: "Likely a UPnP MediaRenderer (TV / AV receiver / set-top box) that answered AfterTouch's generic discovery probe. " +
"Evict it via the QuickFix; the discovery filter introduced alongside this check (#269/#359) prevents it from being re-created.",
QuickFixes: []QuickFix{{
ID: FixIDEvictDefaultNonBoseDevice,
Label: "Evict from default account",
Confirm: fmt.Sprintf("This will delete data/accounts/default/devices/%s/ and all its contents. The device entry was created by AfterTouch's discovery; no real speaker state is affected.", dev.DeviceID),
}},
})
}
return findings
}
// looksLikeSoundTouch returns true when the device's ProductCode /
// Name suggests it's a Bose SoundTouch product. The signal we have on
// disk is the `<type>` element from /info, which Bose devices populate
// with strings like "SoundTouch 10 sm2" or just "SoundTouch"; non-Bose
// devices populate it with their own model name ("HT-R695", "dm920",
// "OLED55G2", …). Case-insensitive substring match — the on-disk file
// preserves whatever the device emitted, so we don't normalise.
func looksLikeSoundTouch(dev *models.ServiceDeviceInfo) bool {
if dev == nil {
return false
}
hay := strings.ToLower(dev.ProductCode + " " + dev.Name)
return strings.Contains(hay, "soundtouch") || strings.Contains(hay, "wave music system")
}
func labelForDevice(dev *models.ServiceDeviceInfo) string {
if dev.Name != "" {
return dev.Name
}
if dev.DeviceID != "" {
return dev.DeviceID
}
return "(unnamed)"
}
@@ -0,0 +1,98 @@
package health
import (
"os"
"path/filepath"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestLooksLikeSoundTouch(t *testing.T) {
cases := []struct {
name string
dev *models.ServiceDeviceInfo
want bool
}{
{name: "SoundTouch type", dev: &models.ServiceDeviceInfo{ProductCode: "SoundTouch", Name: "Bose_Bad"}, want: true},
{name: "SoundTouch 10 sm2 type", dev: &models.ServiceDeviceInfo{ProductCode: "SoundTouch 10 sm2"}, want: true},
{name: "Wave Music System III", dev: &models.ServiceDeviceInfo{ProductCode: "Wave Music System III"}, want: true},
{name: "Onkyo HT-R695", dev: &models.ServiceDeviceInfo{ProductCode: "HT-R695", Name: "Onkyo HT-R695 E9A20F"}, want: false},
{name: "Dreambox dm920", dev: &models.ServiceDeviceInfo{ProductCode: "dm920", Name: "dm920"}, want: false},
{name: "LG OLED", dev: &models.ServiceDeviceInfo{ProductCode: "OLED55G2", Name: "[LG] webOS TV"}, want: false},
{name: "Empty", dev: &models.ServiceDeviceInfo{}, want: false},
{name: "Nil", dev: nil, want: false},
{name: "Name only", dev: &models.ServiceDeviceInfo{ProductCode: "", Name: "My SoundTouch 30"}, want: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := looksLikeSoundTouch(tc.dev); got != tc.want {
t.Errorf("got %v, want %v (dev=%+v)", got, tc.want, tc.dev)
}
})
}
}
// TestDefaultAccountNonBoseCheck_FlagsNonBoseAndIgnoresBose drives the
// check end-to-end against a temporary datastore seeded with the same
// shape we saw in NorbertBauer's #269 diagnostic bundle: a Dreambox
// and an Onkyo under default, plus an unpaired Bose SoundTouch that
// must NOT trigger the warning.
func TestDefaultAccountNonBoseCheck_FlagsNonBoseAndIgnoresBose(t *testing.T) {
tmp := t.TempDir()
ds := datastore.NewDataStore(tmp)
t.Cleanup(func() { _ = ds.Close() })
mustWriteDeviceInfo(t, tmp, "default", "192.168.1.10",
`<?xml version="1.0" encoding="UTF-8"?><info deviceID="192.168.1.10"><name>dm920</name><type>dm920</type><discoveryMethod>SSDP/UPnP</discoveryMethod></info>`)
mustWriteDeviceInfo(t, tmp, "default", "192.168.1.12",
`<?xml version="1.0" encoding="UTF-8"?><info deviceID="192.168.1.12"><name>Onkyo HT-R695 E9A20F</name><type>HT-R695</type><discoveryMethod>SSDP/UPnP</discoveryMethod></info>`)
mustWriteDeviceInfo(t, tmp, "default", "AABBCCDDEEFF",
`<?xml version="1.0" encoding="UTF-8"?><info deviceID="AABBCCDDEEFF"><name>Bose Living Room</name><type>SoundTouch 30 sm2</type><discoveryMethod>SSDP/UPnP</discoveryMethod></info>`)
got := runDefaultAccountNonBoseDevicesCheck(ds)
if len(got) != 2 {
t.Fatalf("expected 2 findings (Dreambox + Onkyo), got %d: %+v", len(got), got)
}
flaggedIDs := map[string]bool{}
for _, f := range got {
flaggedIDs[f.Target.Device] = true
if f.Severity != SeverityWarning {
t.Errorf("expected SeverityWarning, got %v on %+v", f.Severity, f)
}
if len(f.QuickFixes) != 1 || f.QuickFixes[0].ID != FixIDEvictDefaultNonBoseDevice {
t.Errorf("expected one Evict QuickFix, got %+v", f.QuickFixes)
}
}
if !flaggedIDs["192.168.1.10"] || !flaggedIDs["192.168.1.12"] {
t.Errorf("expected both Dreambox + Onkyo flagged, got: %v", flaggedIDs)
}
if flaggedIDs["AABBCCDDEEFF"] {
t.Errorf("unpaired Bose SoundTouch must not be flagged; got: %v", flaggedIDs)
}
}
// mustWriteDeviceInfo writes a DeviceInfo.xml under
// <baseDir>/accounts/<account>/devices/<device>/DeviceInfo.xml.
// Fails the test on any IO error.
func mustWriteDeviceInfo(t *testing.T, baseDir, account, device, body string) {
t.Helper()
dir := filepath.Join(baseDir, "accounts", account, "devices", device)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "DeviceInfo.xml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
+16 -2
View File
@@ -11,6 +11,15 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// FixIDAddMargeHostToTLS is the QuickFix that re-probes the speaker
// at the target device's known IP, extracts the host portion of its
// <margeURL>, and appends it to the persisted TLSExtraHosts in
// settings.json. A service restart is then required for the TLS cert
// to be regenerated. The fix lives in the handlers package because
// it needs the datastore writer; the constant lives here so check
// and fix share the same identifier.
const FixIDAddMargeHostToTLS = "add_marge_host_to_tls"
// CheckIDSpeakerMargeURL is the registry id of the Marge-URL
// consistency check.
const CheckIDSpeakerMargeURL = "speaker_marge_url"
@@ -112,11 +121,16 @@ func assessMargeURLForDeviceWithURL(account, deviceID, probeURL string, expected
parsed.MargeURL,
),
Details: fmt.Sprintf(
"Configured hosts: %s. If the speaker should reach this service via %q, restart with `--tls-extra-host=%s` so the served TLS cert covers it. Otherwise, re-migrate the speaker to the correct URL.",
"Configured hosts: %s. If the speaker should reach this service via %q, click the QuickFix below (or restart with `--tls-extra-host=%s`) so the served TLS cert covers it. Otherwise, re-migrate the speaker to the correct URL.",
joinHosts(expected), margeHost, margeHost,
),
QuickFixes: []QuickFix{{
ID: FixIDAddMargeHostToTLS,
Label: fmt.Sprintf("Add %s to TLS hosts", margeHost),
Confirm: fmt.Sprintf("This will append %s to settings.json (tls_extra_hosts) and persist it. A service restart is required afterwards for the TLS certificate to be regenerated.", margeHost),
}},
ManualCommands: []ManualCommand{{
Label: "Add the speaker's expected hostname to AfterTouch's TLS cert:",
Label: "Or set via CLI/env and restart:",
Command: fmt.Sprintf("soundtouch-service --tls-extra-host=%s …", margeHost),
Hint: "Append to your existing service command-line / env (TLS_EXTRA_HOST). Requires a restart.",
}},
@@ -64,6 +64,18 @@ func TestMargeURL_FlagsMismatch(t *testing.T) {
if !strings.Contains(cmd, "tls-extra-host=other-host.example") {
t.Errorf("expected --tls-extra-host suggestion, got %q", cmd)
}
if len(got[0].QuickFixes) != 1 || got[0].QuickFixes[0].ID != FixIDAddMargeHostToTLS {
t.Fatalf("expected QuickFix with ID=%s, got %+v", FixIDAddMargeHostToTLS, got[0].QuickFixes)
}
if !strings.Contains(got[0].QuickFixes[0].Label, "other-host.example") {
t.Errorf("expected QuickFix label to name the missing host, got %q", got[0].QuickFixes[0].Label)
}
if got[0].QuickFixes[0].Confirm == "" {
t.Errorf("expected QuickFix to carry a Confirm message (operator needs to know a restart is required)")
}
}
func TestMargeURL_SkipsWhenMargeURLEmpty(t *testing.T) {
+111
View File
@@ -0,0 +1,111 @@
package health
import (
"fmt"
"net"
"net/url"
"strings"
)
// CheckIDOAuthTargetReachable is the registry id of the OAuth-target
// configuration check. It fires when AfterTouch's configured serverURL
// is an IP literal AND the built-in DNS hijack is running — the
// combination that breaks Spotify / Amazon Music OAuth because the
// speaker firmware constructs `<first-label>oauth.<rest>` from the
// streaming hostname, producing a malformed name (e.g. `192oauth.168.0.30`)
// when the first label is the numeric part of an IP.
//
// See docs/concepts/amazon-music-oauth.md for the underlying mechanism
// and pkg/discovery/dns.go DeriveOAuthHostnames for the auto-derivation
// that makes the hostname case work without operator intervention.
const CheckIDOAuthTargetReachable = "oauth_target_reachable"
// RegisterOAuthTargetReachableCheck registers the OAuth-target check.
// getServerURL returns the operator's currently-configured streaming
// URL (typically Server.GetSettings's first return value);
// getDNSRunning reports whether AfterTouch's DNS hijack server is
// actually serving.
//
// The check is intentionally narrow: it doesn't probe the OAuth flow
// end-to-end. It surfaces the one misconfiguration the speaker firmware
// cannot recover from — IP-based serverURL — so operators see the
// problem before they wire up Spotify / Amazon Music and wonder why
// the speaker's OAuth callback never reaches them.
func RegisterOAuthTargetReachableCheck(r *Registry, getServerURL func() string, getDNSRunning func() (bool, string)) {
r.Register(Check{
ID: CheckIDOAuthTargetReachable,
Title: "OAuth subdomain is resolvable from the configured serverURL",
Run: func() []Finding {
return runOAuthTargetReachableCheck(getServerURL(), getDNSRunning)
},
})
}
func runOAuthTargetReachableCheck(serverURL string, getDNSRunning func() (bool, string)) []Finding {
if strings.TrimSpace(serverURL) == "" {
return nil
}
u, err := url.Parse(serverURL)
if err != nil {
return nil
}
host := u.Hostname()
if host == "" {
return nil
}
// IP-based serverURL is the only case the speaker can't recover from.
// Hostname-based serverURLs are auto-handled by the DNS interceptor
// (see pkg/discovery/dns.go DeriveOAuthHostnames).
if net.ParseIP(host) == nil {
return nil
}
dnsRunning := false
if getDNSRunning != nil {
dnsRunning, _ = getDNSRunning()
}
return []Finding{{
Severity: SeverityWarning,
Message: fmt.Sprintf(
"Configured serverURL %q uses an IP literal. Spotify and Amazon Music OAuth won't work — the speaker firmware constructs the OAuth host by appending \"oauth\" to the first label of the streaming hostname, which for an IP yields a malformed name no DNS resolver can answer (e.g. %s).",
serverURL, exampleMalformedOAuthHost(host),
),
Details: oauthTargetDetails(dnsRunning),
ManualCommands: []ManualCommand{
{
Label: "Switch the service URL to a real LAN hostname (restart required):",
Command: "soundtouch-service --server-url=https://aftertouch.lan:8443 …",
Hint: "Replace `aftertouch.lan` with whatever LAN-resolvable name you prefer; ensure DNS resolves it to this host's IP.",
},
{
Label: "Or set via the web UI:",
Command: "Settings tab → Target Domain → enter the hostname-based URL → Save → restart the service.",
},
},
}}
}
// exampleMalformedOAuthHost returns what the speaker firmware would
// construct given the configured IP. Used in the warning message to
// make the failure mode concrete for the operator.
func exampleMalformedOAuthHost(ipHost string) string {
idx := strings.IndexByte(ipHost, '.')
if idx <= 0 {
return ipHost + "oauth"
}
return ipHost[:idx] + "oauth" + ipHost[idx:]
}
func oauthTargetDetails(dnsRunning bool) string {
base := "After switching to a hostname-based serverURL and restarting, AfterTouch's DNS server auto-derives the `<host>oauth.<rest>` alias and hijacks it to its own IP — no manual DNS-alias work needed."
if !dnsRunning {
base += " (The DNS hijack server isn't currently running on this host. Enable it via Settings → DNS Discovery, or set up the alias on an external LAN DNS / each speaker's /etc/hosts. See docs/concepts/amazon-music-oauth.md.)"
}
return base
}
@@ -0,0 +1,76 @@
package health
import (
"strings"
"testing"
)
func TestOAuthTargetCheck_NoFindingForHostnameServerURL(t *testing.T) {
dnsRunning := func() (bool, string) { return true, ":53" }
got := runOAuthTargetReachableCheck("https://aftertouch.lan:8443", dnsRunning)
if len(got) != 0 {
t.Errorf("expected no findings for hostname-based serverURL, got %+v", got)
}
}
func TestOAuthTargetCheck_WarnsForIPv4ServerURL(t *testing.T) {
dnsRunning := func() (bool, string) { return true, ":53" }
got := runOAuthTargetReachableCheck("https://192.168.0.30:8443", dnsRunning)
if len(got) != 1 {
t.Fatalf("expected one finding for IP-based serverURL, got %+v", got)
}
if got[0].Severity != SeverityWarning {
t.Errorf("expected SeverityWarning, got %v", got[0].Severity)
}
if !strings.Contains(got[0].Message, "192oauth.168.0.30") {
t.Errorf("expected the malformed example host in the message, got %q", got[0].Message)
}
if len(got[0].ManualCommands) == 0 {
t.Errorf("expected at least one ManualCommand pointing at the fix")
}
}
func TestOAuthTargetCheck_HintReflectsDNSRunningState(t *testing.T) {
dnsRunning := func() (bool, string) { return false, "" }
got := runOAuthTargetReachableCheck("https://10.0.0.5:8443", dnsRunning)
if len(got) != 1 {
t.Fatalf("expected one finding, got %+v", got)
}
if !strings.Contains(got[0].Details, "DNS hijack server isn't currently running") {
t.Errorf("expected DNS-not-running fallback hint in Details, got %q", got[0].Details)
}
}
func TestOAuthTargetCheck_EmptyOrUnparseableIsNoOp(t *testing.T) {
dnsRunning := func() (bool, string) { return true, ":53" }
for _, url := range []string{"", " ", ":::not a url"} {
got := runOAuthTargetReachableCheck(url, dnsRunning)
if len(got) != 0 {
t.Errorf("expected no findings for %q, got %+v", url, got)
}
}
}
func TestExampleMalformedOAuthHost(t *testing.T) {
cases := []struct {
in, want string
}{
{"192.168.0.30", "192oauth.168.0.30"},
{"10.0.0.5", "10oauth.0.0.5"},
{"aftertouch", "aftertouchoauth"},
}
for _, c := range cases {
if got := exampleMalformedOAuthHost(c.in); got != c.want {
t.Errorf("exampleMalformedOAuthHost(%q) = %q, want %q", c.in, got, c.want)
}
}
}
@@ -39,6 +39,50 @@ func TestIsTelnetMigrated_EmptyVerifiedConfig(t *testing.T) {
}
}
// TestIsXMLMigrated_MalformedServiceURL guards against the false-positive that
// occurs when --service-url is a malformed URL with an empty hostname (e.g.
// "https:/host" instead of "https://host"). url.Parse succeeds but Hostname()
// returns "", and strings.Contains(anything, "") is always true in Go.
func TestIsXMLMigrated_MalformedServiceURL(t *testing.T) {
m := &Manager{ServerURL: "https:/soundtouch.fritz.box"} // single slash — malformed
summary := &MigrationSummary{
ParsedCurrentConfig: &PrivateCfg{
MargeServerUrl: "https://streaming.bose.com",
},
}
if m.isXMLMigrated(summary) {
t.Error("isXMLMigrated = true, want false when ServerURL has no resolvable hostname")
}
}
// TestCheckIsMigrated_MalformedServiceURL ensures a malformed --service-url
// does not produce IsMigrated=true on an unmigrated speaker.
func TestCheckIsMigrated_MalformedServiceURL(t *testing.T) {
m := &Manager{
ServerURL: "https:/soundtouch.fritz.box", // single slash — malformed
NewSSH: func(string) SSHClient {
return &mockSSH{runFunc: func(string) (string, error) { return "", errors.New("unused") }}
},
}
summary := &MigrationSummary{
SSHSuccess: true,
TelnetVerifiedConfig: "", // empty — not migrated via telnet either
ParsedCurrentConfig: &PrivateCfg{
MargeServerUrl: "https://streaming.bose.com",
},
CurrentResolvConf: "nameserver 8.8.8.8\n",
}
m.checkIsMigrated(summary, "192.0.2.1")
if summary.IsMigrated {
t.Error("IsMigrated = true, want false when service URL is malformed and speaker still points at streaming.bose.com")
}
}
// TestCheckIsMigrated_TelnetOnlyMigratedDevice covers the gap that motivated
// this iteration: SSH is unreachable, but the speaker has been pointed at
// our service via telnet (e.g. a firmware that refuses USB unlock). The
+7
View File
@@ -543,6 +543,9 @@ func (m *Manager) isXMLMigrated(summary *MigrationSummary) bool {
}
targetHost := parsedTarget.Hostname()
if targetHost == "" {
return false
}
return strings.Contains(summary.ParsedCurrentConfig.MargeServerUrl, targetHost) ||
strings.Contains(summary.ParsedCurrentConfig.StatsServerUrl, targetHost) ||
@@ -595,6 +598,10 @@ func (m *Manager) isResolvConfMigrated(client SSHClient, summary *MigrationSumma
}
targetHost := parsedTarget.Hostname()
if targetHost == "" {
return false
}
if strings.Contains(summary.CurrentResolvConf, targetHost) && summary.CACertTrusted {
return true
}
+32 -7
View File
@@ -638,6 +638,27 @@ func (app *WebApp) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
}
}
// HandleTuneInSearchNext returns the next page of TuneIn search results using an opaque cursor.
func (app *WebApp) HandleTuneInSearchNext(w http.ResponseWriter, r *http.Request) {
cursor := r.URL.Query().Get("cursor")
if cursor == "" {
app.sendError(w, "cursor parameter required", http.StatusBadRequest)
return
}
resp, err := bmxpkg.TuneInSearchNext(cursor)
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleTuneInNavigate handles TuneIn browse/navigate requests, proxying directly to the bmx package.
// Supported path suffixes (relative to /api/tunein/navigate):
// - (empty) → top-level browse
@@ -1010,13 +1031,17 @@ func (app *WebApp) HandleDevicePlay(w http.ResponseWriter, r *http.Request) {
}
contentItem := &models.ContentItem{
Source: req.Source,
Type: req.Type,
Location: req.Location,
SourceAccount: req.SourceAccount,
ItemName: req.ItemName,
ContainerArt: req.ContainerArt,
IsPresetable: req.IsPresetable,
Source: req.Source,
Type: req.Type,
Location: req.Location,
ItemName: req.ItemName,
ContainerArt: req.ContainerArt,
IsPresetable: req.IsPresetable,
}
// Only pass SourceAccount when it's a real credential, not the placeholder
// value that speakers echo back (source name == source account, e.g. "TUNEIN").
if req.SourceAccount != "" && req.SourceAccount != req.Source {
contentItem.SourceAccount = req.SourceAccount
}
if err := device.Client.SelectContentItem(contentItem); err != nil {
+82
View File
@@ -4,6 +4,7 @@ package soundtouchweb
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -622,3 +623,84 @@ func BenchmarkSendError(b *testing.B) {
app.sendError(w, "Test error", http.StatusBadRequest)
}
}
// TestHandleDevicePlay_SourceAccountFiltering verifies that a SourceAccount
// equal to Source (the placeholder speakers echo back, e.g. "TUNEIN") is
// stripped before the ContentItem XML is sent to the speaker, while a real
// credential (SourceAccount != Source) is preserved.
func TestHandleDevicePlay_SourceAccountFiltering(t *testing.T) {
tests := []struct {
name string
source string
sourceAccount string
wantSourceAccount string // empty means the XML attr must be absent
}{
{
name: "placeholder echoed back — stripped",
source: "TUNEIN",
sourceAccount: "TUNEIN",
wantSourceAccount: "",
},
{
name: "real credential — preserved",
source: "TUNEIN",
sourceAccount: "real-account-id",
wantSourceAccount: "real-account-id",
},
{
name: "empty account — stays empty",
source: "TUNEIN",
sourceAccount: "",
wantSourceAccount: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var capturedBody string
// Fake speaker that captures the /select POST body.
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/select" {
b, _ := io.ReadAll(r.Body)
capturedBody = string(b)
}
w.WriteHeader(http.StatusOK)
}))
defer speaker.Close()
speakerClient := client.NewClient(&client.Config{Host: speaker.URL})
app := NewWebApp()
deviceInfo := &models.DeviceInfo{Name: "Test Speaker"}
conn := webtypes.NewDeviceConnection(speakerClient, deviceInfo)
conn.SetStatus(&webtypes.DeviceStatus{IsConnected: true, LastActivity: time.Now()})
app.AddDevice("play-device", conn)
body := strings.NewReader(`{
"source":"` + tt.source + `",
"type":"stationurl",
"location":"/v1/playback/station/s6634",
"sourceAccount":"` + tt.sourceAccount + `",
"itemName":"Venice Classic Radio"
}`)
req := httptest.NewRequest("POST", "/api/device-play/play-device", body)
req.Header.Set("Content-Type", "application/json")
req = withChiParams(req, map[string]string{"id": "play-device"})
w := httptest.NewRecorder()
app.HandleDevicePlay(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// SourceAccount XML attribute is always emitted (no omitempty on the struct
// tag), so check its value rather than its presence/absence.
want := `sourceAccount="` + tt.wantSourceAccount + `"`
if !strings.Contains(capturedBody, want) {
t.Errorf("XML should contain %q, got: %s", want, capturedBody)
}
})
}
}
+1
View File
@@ -50,6 +50,7 @@ func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscov
// TuneIn browse, search, and playback
r.Get("/api/tunein/search", app.HandleTuneInSearch)
r.Get("/api/tunein/search/next", app.HandleTuneInSearchNext)
r.Get("/api/tunein/navigate", app.HandleTuneInNavigate)
r.Get("/api/tunein/navigate/*", app.HandleTuneInNavigate)
r.Post("/api/tunein/play/{id}", app.HandlePlayTuneIn)
@@ -581,6 +581,8 @@ img { display: block; max-width: 100%; }
transition: background .15s, border-color .15s;
}
.tunein-play-btn:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
.tunein-section-name { font-size: .85rem; font-weight: 600; color: var(--text-dim); padding: 8px 0 2px; margin: 0; }
.tunein-load-more { margin: 4px 0 12px; }
/* ── Device picker overlay ───────────────────────────────────────────────── */
.overlay {
@@ -30,6 +30,7 @@ export const api = {
}),
tuneInBrowse: (path) => req(path ? `/api/tunein/navigate/${path}` : '/api/tunein/navigate'),
tuneInSearch: (q) => req(`/api/tunein/search?q=${encodeURIComponent(q)}`),
tuneInSearchNext: (cursor) => req(`/api/tunein/search/next?cursor=${encodeURIComponent(cursor)}`),
control: (id, action, presetId) => req(`/api/control/${id}/${action}?id=${presetId}`),
selectSource: (id, source, account) => req(`/api/control/${id}/source?name=${encodeURIComponent(source)}&account=${encodeURIComponent(account || '')}`),
tuneInPlay: (deviceId, item) => req(`/api/tunein/play/${deviceId}`, {
@@ -5,9 +5,10 @@ import { api } from '../api.js';
const html = htm.bind(h);
// BmxNavResponse has shape { bmx_sections: [{ name, items: [{ name, imageUrl, subtitle, _links }] }] }
// BmxNavResponse has shape { bmx_sections: [{ name, items: [{ name, imageUrl, subtitle, _links }], _links }] }
// _links.bmx_navigate.href = "/v1/navigate/{encodedPath}" — strip prefix for API call
// _links.bmx_playback.href = station/track URL, type = "stationurl"|"tracklisturl"
// _links.bmx_next.href = "/v1/search/next?cursor={base64}" — load-more cursor
function navPath(item) {
const href = item._links?.bmx_navigate?.href;
@@ -19,15 +20,23 @@ function playbackInfo(item) {
return link ? { location: link.href, type: link.type || 'stationurl' } : null;
}
function flattenSections(data) {
function sectionCursor(section) {
const href = section._links?.bmx_next?.href;
if (!href) return null;
return new URLSearchParams(href.split('?')[1] || '').get('cursor');
}
function toSections(data) {
if (!data?.bmx_sections) return [];
return data.bmx_sections.flatMap(section =>
(section.items || []).map(item => ({ ...item, _sectionName: section.name }))
);
return data.bmx_sections.map(s => ({
name: s.name,
items: s.items || [],
nextCursor: sectionCursor(s),
}));
}
export function TuneInBrowser({ devices }) {
const [items, setItems] = useState([]);
const [sections, setSections] = useState([]);
const [navStack, setNavStack] = useState([{ label: 'TuneIn', path: null }]);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(false);
@@ -39,7 +48,7 @@ export function TuneInBrowser({ devices }) {
setLoading(true);
const resp = await api.tuneInBrowse(path);
setLoading(false);
if (resp.success) setItems(flattenSections(resp.data));
if (resp.success) setSections(toSections(resp.data));
}
async function search(q) {
@@ -49,10 +58,25 @@ export function TuneInBrowser({ devices }) {
setLoading(false);
if (resp.success) {
setNavStack([{ label: 'TuneIn', path: null }, { label: `"${q}"`, path: null }]);
setItems(flattenSections(resp.data));
setSections(toSections(resp.data));
}
}
async function loadMore(section) {
setLoading(true);
const resp = await api.tuneInSearchNext(section.nextCursor);
setLoading(false);
if (!resp.success) return;
const next = toSections(resp.data);
const newItems = next.flatMap(s => s.items);
const newCursor = next[0]?.nextCursor || null;
setSections(prev => prev.map(s =>
s.name === section.name
? { ...s, items: [...s.items, ...newItems], nextCursor: newCursor }
: s
));
}
function navigate(item) {
const path = navPath(item);
const play = playbackInfo(item);
@@ -111,32 +135,42 @@ export function TuneInBrowser({ devices }) {
${loading ? html`<div class="loading-bar"></div>` : null}
<ul class="tunein-list">
${items.map((item, i) => {
const isNav = !!navPath(item);
const play = playbackInfo(item);
return html`
<li key=${item._links?.self?.href || i} class="tunein-item" onClick=${() => navigate(item)}>
${item.imageUrl ? html`<img class="tunein-thumb" src=${item.imageUrl} alt="" />` : null}
<div class="tunein-item-info">
<span class="tunein-item-name">${item.name}</span>
${item.subtitle ? html`<span class="tunein-item-desc">${item.subtitle}</span>` : null}
</div>
${play ? html`
<button
class="tunein-play-btn"
title="Play"
onClick=${(e) => {
e.stopPropagation();
setPendingPlay({ ...play, name: item.name, image: item.imageUrl });
}}
></button>
` : null}
${isNav ? html`<span class="tunein-item-arrow"></span>` : null}
</li>
`;
})}
</ul>
${sections.map(section => html`
<div>
${section.name ? html`<h4 class="tunein-section-name">${section.name}</h4>` : null}
<ul class="tunein-list">
${section.items.map((item, i) => {
const isNav = !!navPath(item);
const play = playbackInfo(item);
return html`
<li key=${item._links?.self?.href || i} class="tunein-item" onClick=${() => navigate(item)}>
${item.imageUrl ? html`<img class="tunein-thumb" src=${item.imageUrl} alt="" />` : null}
<div class="tunein-item-info">
<span class="tunein-item-name">${item.name}</span>
${item.subtitle ? html`<span class="tunein-item-desc">${item.subtitle}</span>` : null}
</div>
${play ? html`
<button
class="tunein-play-btn"
title="Play"
onClick=${(e) => {
e.stopPropagation();
setPendingPlay({ ...play, name: item.name, image: item.imageUrl });
}}
></button>
` : null}
${isNav ? html`<span class="tunein-item-arrow"></span>` : null}
</li>
`;
})}
</ul>
${section.nextCursor ? html`
<button class="btn-secondary tunein-load-more" onClick=${() => loadMore(section)}>
Load more
</button>
` : null}
</div>
`)}
${pendingPlay ? html`
<div class="overlay" onClick=${() => setPendingPlay(null)}>
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/bash
set -eo pipefail
VERSION=${VERSION:-0.80.1}
VERSION=${VERSION:-0.91.0}
GH_REPO=${GH_REPO:-gesellix/Bose-SoundTouch}
BINARY_URL=${BINARY_URL:-https://github.com/$GH_REPO/releases/download/v$VERSION/soundtouch-service-v$VERSION-linux-armv7}
INIT_SCRIPT_URL=${INIT_SCRIPT_URL:-https://raw.githubusercontent.com/$GH_REPO/v$VERSION/scripts/on-device-install/aftertouch}
+1 -1
View File
@@ -28,7 +28,7 @@ You can override defaults:
```bash
sudo \
VERSION=v0.80.1 \
VERSION=v0.91.0 \
HOSTNAME_FQDN=soundtouch.local \
HTTP_PORT=80 \
HTTPS_PORT=443 \
+4 -4
View File
@@ -10,7 +10,7 @@ set -euo pipefail
# Examples (override defaults via env vars):
#
# sudo \
# VERSION=v0.80.0 \
# VERSION=v0.91.0 \
# HOSTNAME_FQDN=soundtouch.local \
# HTTP_PORT=80 \
# HTTPS_PORT=443 \
@@ -18,7 +18,7 @@ set -euo pipefail
# bash install.sh
#
# Or with a version argument to perform an update:
# sudo bash install.sh v0.80.1
# sudo bash install.sh v0.91.0
#
# Notes:
# - This script downloads a release binary for your CPU (auto-detects armv7/arm64/amd64).
@@ -28,7 +28,7 @@ set -euo pipefail
# - Safe to re-run; it will update binary/config/unit and restart the service.
# ==============================================================================
VERSION="${1:-${VERSION:-v0.80.1}}"
VERSION="${1:-${VERSION:-v0.91.0}}"
# Normalize version prefix
if [[ ! "$VERSION" =~ ^v ]]; then
VERSION="v${VERSION}"
@@ -117,7 +117,7 @@ detect_arch_asset() {
download_url_for() {
local asset="$1"
# Release asset pattern used by you earlier:
# soundtouch-service-v0.80.1-linux-armv7
# soundtouch-service-v0.91.0-linux-armv7
echo "https://github.com/gesellix/Bose-SoundTouch/releases/download/${VERSION}/soundtouch-service-${VERSION}-${asset}"
}