Compare commits

...
73 Commits
Author SHA1 Message Date
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
Tobias GesellchenandClaude Sonnet 4.6 3cfb3da498 feat(export): encrypted diagnostic report for issue reporting
Adds a "Download diagnostic report" button on the Health tab that
produces an age-encrypted .age file the user can attach to a GitHub
issue without exposing sensitive data.

Archive contents (tar.gz, then age-encrypted with the maintainer's
SSH ed25519 public key):
- diagnostic.json         structured health/device summary (no secrets)
- datastore/…/*.xml       raw on-disk XML verbatim for diff vs HTTP
- http/service/…          live service HTTP responses per account/device
- http/speaker/…          live speaker API responses (port 8090)
- ssh/speaker/…           CA bundles + logread (last 20 min, 127.0.0.1
                          filtered) + dmesg fetched via SSH
- system/ca.pem           service CA cert
- system/resolv.conf      host DNS resolver config
- settings.json           service settings (OAuth secrets redacted)
- env.txt                 filtered process environment
- logs/service.txt        in-memory service log buffer

Supporting tooling:
- scripts/setup-diagnostic-key.sh  one-time SSH key-pair generation
- scripts/decrypt-diagnostic.go    go run helper for maintainer decryption
- keys/public/diagnostic.pub       committed public key (matches github.com/gesellix.keys)
- docs/DIAGNOSTIC-EXPORT.md        maintainer setup + user workflow guide
- docs/concepts/ENCRYPTED-EXPORT.md  research notes and architecture rationale

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:02:57 +02:00
Tobias GesellchenandClaude Sonnet 4.6 9312e27019 feat(health): operator-confirmable QuickFix to complete speaker pairing (#329)
Closes the loop on the empty-<margeAccountUUID> finding from
RegisterSpeakerInfoReachable. Operators in #329 quoted that finding
verbatim and asked "What is the recommended way to complete pairing?"
— the framework detected the condition but offered no in-UI recourse.

The QuickFix completes pairing in-place by dispatching through
setup.Manager.PairAccount, which tries HTTP /setMargeAccount first
and falls back to telnet `envswitch accountid set` — same code path
the existing POST /setup/pair-account/{deviceId} handler uses.

Account ID is picked at finding-time when a real (7-digit) account
directory already contains this device on disk (typical scenario:
AfterTouch remembers a previous pairing the speaker forgot). When
no such account exists, the executor generates a fresh 7-digit ID
via setup.GenerateAccountID at click time. Either way, the chosen
ID is named in the Confirm dialog and the CLI ManualCommand
fallback so the operator can see what's about to happen.

Architecturally: the FixID constant lives in the health package
alongside the check that emits the finding, but the executor is
registered from handlers/server.go where setup.Manager is
available. This keeps the health package's transitive dep surface
small (the boundary comment near speakerInfoXML deliberately
forbids importing setup, which would pull SSH/telnet/certmgr).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:44:57 +02:00
Tobias GesellchenandClaude Sonnet 4.6 2b50ef0c98 fix(datastore): prefer named default entry when two default dirs collide in ListAllDevices
When the same device appears under `default/` in two separate data dirs
(e.g. primary DataDir and the legacy st-go/data path), the first-seen entry
was kept unconditionally even when it had an empty name. A subsequent
default entry carrying a real name was silently dropped, causing name loss
in SyncFromAccountFull.

Addresses TestReproduceMissingName regression introduced by the
dedup-default-last change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 fa2f7cd17d feat(health): confirm orphan-account deletion against speaker /info
The orphan-account QuickFix used to rely solely on the operator's
manual log inspection ("Before deleting, verify the speaker isn't
currently PUTting to account X") plus the Confirm dialog. Adds a
defensive layer: the speaker itself answers "which account do I
belong to?" via :8090/info's <margeAccountUUID> element. Wire that
into both ends of the flow.

Detection (consistency check): on each scan we probe /info for each
device with a known IP. When the speaker answers, its
margeAccountUUID overrides the on-disk ListAllDevices guess, and the
finding's Details/Confirm copy quotes the speaker verbatim — "Speaker
/info reports margeAccountUUID=1111111; this directory (account
9569497) is stale because the speaker has stopped targeting it." If
the probe fails the wording falls back to the manual-verify hint.

Executor (deleteOrphanAccountEntry): re-probes /info before deleting
and refuses when the speaker reports target.Account as live. That
closes the race where the operator re-paired between scan and click.
Logs every successful probe + decision for auditability.

fetchSpeakerMargeAccount split into a URL-injectable variant so the
httptest-driven tests can verify the probe end-to-end without
hard-coding :8090 onto an unreachable address.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 884e19c791 feat(health): operator-confirmable QuickFix to reassign canonical source IDs
og-gh's #343 reproducer is built-in radio sources sitting on
non-canonical IDs (the 2000001+i fallback that GetConfiguredSources
hands out when on-disk sources lack canonical IDs). After re-pair
churn, presets binding by <sourceid> end up rebound to whichever
source happened to get the colliding numeric ID — silently rewriting
e.g. a TUNEIN preset to RADIOPLAYER on the next /full fetch.

The strict-match commit (aa449fb) keeps that drift from corrupting
emission downstream, but the underlying Sources.xml is still wrong
and the operator has to either pull-from-speaker (online) or
hand-edit XML (tedious). This commit adds an offline QuickFix that
rewrites the source IDs in Sources.xml back to canonical
(TUNEIN→10004, INTERNET_RADIO→10002, LOCAL_INTERNET_RADIO→10003,
RADIO_BROWSER→10005) and updates every <sourceid> reference in
Presets.xml/Recents.xml in lockstep.

Skipped when the canonical ID is already in use by another source
(e.g. duplicate TUNEIN entries from manual XML editing) — collisions
need operator review. Idempotent: a second click is a no-op when
everything is already canonical.

The fix is reachable from the consistency check finding, gated by
the framework's standard Confirm dialog which enumerates the exact
ID rewrites before executing. No speaker contact required; the
speaker re-fetches /full on its own and picks up the new IDs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 99d7111514 feat(health): operator-confirmable QuickFix to delete orphan account dirs
The orphan-account-entry finding (introduced in 0ac140f) currently just
points the operator at a copy-pasteable rm -rf command. Adds a
QuickFix button that does the same delete in-process after the
operator confirms via the standard health-framework Confirm dialog.

Findings are now one-per-(stale_account, device) pair so each delete
button targets exactly one directory. The Confirm copy spells out the
full path being removed and reminds the operator that the active
account isn't touched. The companion ManualCommands entry keeps the
shell-side rm available for operators who prefer to run it themselves.

deleteOrphanAccountEntry refuses on missing account/device, errors
explicitly when the directory was already cleaned up by hand, and
logs every successful removal so the action is auditable from the
service log.

The framework gates the click on Confirm — destructive operations
need operator consent per CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 8604f1e6ba fix(datastore,health): enumerate all stale account dirs per device
User reported "we might have another issue with the account mapping"
after the prior commit only handled the default-vs-real case. The
backup at /backup/var_20260520_01 showed device A81B6A536A98 living
under four directories — accounts/9569497, accounts/default,
accounts/1111111, and the top-level default/ — only the third of
which currently receives the speaker's PUTs.

The authoritative "which account does this device belong to" signal
is the URL of the speaker's incoming PUT (per "speaker decides"),
which only the live handler observes. mtime is a proxy and can be
fooled by backup tools, manual touches, etc., so this commit drops
the mtime tiebreaker the previous attempt added.

Instead:
  - ListAllDevices' dedup keeps default-deprioritisation (clear
    placeholder semantics) but otherwise picks the first real account
    encountered in stable alphabetical order. No heuristic guessing
    among real accounts.
  - New AllAccountsForDevice(deviceID) enumerates every on-disk
    account directory containing the deviceID.
  - The consistency check's orphan finding now lists every stale
    account dir for each device, with the path the operator needs to
    inspect and a pointer to the service log so they can verify which
    account the speaker is actually targeting before deleting
    anything.

We don't delete automatically — destructive filesystem actions need
explicit operator consent (CLAUDE.md "destructive actions" rule).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 e6954eed60 fix(datastore): real account wins over "default" placeholder in dedup
ListAllDevices used to let an entry under accounts/default/devices/<id>
replace the real-account entry for the same physical device whenever
the default-side DeviceInfo.xml had a non-empty <name>. The consistency
check then reported the device under "account default" even while the
speaker was happily POST/PUT'ing to its actually-paired account — the
operator saw "preset slot 1 present on speaker but missing from
service" for slots that very obviously did exist, just under the real
account they couldn't see.

The dedup now treats "default" as a fallback placeholder: sorts it to
the back of the iteration, and never lets it replace a real-account
entry. A default-only device (fresh discovery, never paired) is still
returned exactly as before.

Also adds an orphan-detection finding in the consistency check that
walks accounts/default/devices/ directly and flags entries whose
deviceID is also paired under a real account, with a copy-pasteable
rm -rf hint. We don't delete automatically — destructive filesystem
actions need explicit operator consent (CLAUDE.md).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 97238eb07a feat(marge): log GH-343-shaped source mismatch on UpdatePreset
The speaker's preset PUT carries only <sourceid> — no symbolic source
name — so we can't strict-match at write time the way we do on /full
emission. Adds a diagnostic-only inference from the preset's location
URL pattern (/v1/playback/station/sNNN -> TUNEIN, /playback/container/
-> SPOTIFY, /custom/v1/playback/ -> LOCAL_INTERNET_RADIO) and logs
when the inference disagrees with the bound source's SourceKeyType.

This is visibility, not enforcement: the binding still proceeds as
the speaker requested (per "speaker wins"). The log gives the operator
a concrete pointer — "the URL looks like TUNEIN but I bound to
RADIOPLAYER, your Sources.xml may be stale, try setup.syncSources" —
instead of leaving them to discover the drift via the consistency
check days later.

URL inference is deliberately fuzzy and one-way: it only triggers a
log when confident, returns "" otherwise, and never feeds the
binding decision. That keeps it from re-introducing the guesswork
the user pushed back on for the actual GH-343 fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 f0a63f19f4 fix(datastore): self-heal legacy Audio leak on read, preserve speaker intent
The pre-fix marge.syncPresets / syncRecents path persisted the upstream
cloud's <source type="Audio"> attribute into ServicePreset.Source /
ServiceRecent.Source. That value doesn't match what the speaker writes
via its own /presets endpoint (which is the source of truth), and one
operator's consistency-check scan surfaced ~50 recent_mismatch findings
all tracing back to this single leak.

GetPresets / GetRecents now repair the leak on load: when persisted
Source is "Audio" (or empty) AND SourceID resolves in the current
Sources.xml, substitute the speaker-perspective SourceKeyType. The
repair fires only on the *leak signature* — when persisted Source
carries a non-leak symbolic value like "TUNEIN", we never touch it.

That asymmetry is load-bearing for GH-343: a TUNEIN preset whose
SourceID has been re-classified to RADIOPLAYER in Sources.xml stays
TUNEIN here. The speaker's previously-stored intent wins over a stale
current source-list entry — soundcork's blind matching_src.source_key_type
substitution is the silent rewrite we're protecting against.

Also:
  - sourceKeyTypeFromFullSource now logs when the providerid isn't
    canonical and we fall back to upstream Type, so future leak
    signatures are visible instead of silent.
  - Removes the loadServiceView workaround that resolved Source via
    SourceID at consistency-check time — datastore now repairs at
    the layer where every consumer benefits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 9fafe9f960 fix(marge): syncPresets/syncRecents persist speaker-perspective Source
marge.syncPresets / syncRecents were writing the upstream cloud's
<source type="Audio"> attribute into ServicePreset.Source /
ServiceRecent.Source on disk. That's a protocol-level classification,
not the symbolic name the speaker itself uses (TUNEIN, INTERNET_RADIO,
…). The on-disk shape ended up disagreeing with what the speaker writes
via its own /presets endpoint, which IS the source of truth — and the
disagreement surfaced as cross-side mismatches in the new consistency
check (one user saw 30+ recent_mismatch findings, all "speaker source=X
vs service source=Audio").

Project the upstream FullResponseSource back to the speaker's
perspective at persist time via SourceProviderID lookup against
StaticProviders (the inverse of canonicalProviderIDByID). Falls back
to the upstream Type for unknown providerids so non-canonical sources
stay no-worse-than-before.

The consistency-check workaround in loadServiceView (which resolves
Source via SourceID lookup on read) stays in place to cover legacy
on-disk data written by the previous behaviour — that data only gets
cleaned up when the operator re-runs setup.syncPresets from the
speaker directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ce2935a4bd fix(health): consistency report — cut noise, fix Audio leak, group unsynced
First operator run of the new consistency check surfaced both real bugs
and a lot of noise. This commit refines the report so the remaining
findings are actionable.

Real bugs fixed:

- loadServiceView now resolves preset/recent Source via SourceID lookup
  against Sources.xml, instead of trusting the persisted Source field.
  syncPresets / syncRecents in sync.go currently writes the upstream
  FullResponseSource.Type ("Audio") into ServicePreset.Source, which
  made every cross-side mismatch finding read "service source='Audio'".
  Underlying syncPresets/Recents misfeature is a separate fix; the
  consistency check stops being fooled by it.

- Duplicate-source dedup keyed by type+account, not just type.
  SpotifyConnectUserName + SpotifyAlexaUserName, QPlay1UserName +
  QPlay2UserName are legitimate sub-accounts of the same source type
  and used to falsely trip duplicate_source warnings.

Noise removed:

- Cross-side source_mismatch comparison dropped. Speaker /sources
  enumerates local I/O sources (AUX, BLUETOOTH, AIRPLAY, QPLAY, …),
  service Sources.xml tracks credentialed streaming sources (TUNEIN,
  INTERNET_RADIO, …). They legitimately don't overlap on most types,
  so the asymmetry was pure noise.

- Internal-consistency check restricted to the service side. Streaming
  sources are never in the speaker's /sources by design (they're
  proxied through BMX), so a TUNEIN preset on the speaker always
  looked "dangling" against speaker /sources.

- Service-only / speaker-only recent cascade collapsed into one
  summary line when 5+ speaker recents are missing from service.

- New short-circuit: when the service has nothing (presets, recents,
  sources all empty) for a device the speaker clearly has state for,
  emit one "this device looks unsynced, click Sync" warning instead
  of dozens of per-slot mismatches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 c47cf81a93 feat(health): cross-reference presets/recents/sources consistency check
Adds a new health check that surfaces preset / recent / sources
inconsistencies operators previously had to dig out by hand. For every
paired device, the check runs three analyses:

1. Service-side internal consistency. Verifies every Presets.xml and
   Recents.xml entry's <sourceid> resolves to a Sources.xml entry, and
   flags duplicate source-type entries (mapPresetsToFullResponse picks
   the first match, so duplicates can mask GH-343-style cross-type
   binds).

2. Speaker-side internal consistency. Same analysis applied to the
   speaker's :8090 XML — catches the case where the speaker locally
   knows a TUNEIN preset but the speaker's /sources list doesn't
   advertise TuneIn (a #253-class trigger).

3. Cross-side comparison. Speaker vs service per slot / per recent /
   per source type. A preset whose source attribute disagrees between
   sides is flagged with both values in the detail — that's the
   GH-343 footprint after a reboot, and now it shows up as a Finding
   instead of a forum thread.

Speaker probes fail gracefully with a copy-pasteable curl block; the
service-side internal check still runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 5ff72f2af8 fix(marge): strict-match preset/recent source by type, refuse cross-type binds
GH-343: a TUNEIN preset surviving a reboot used to come back from /full
re-attributed to RADIOPLAYER because mapPresetsToFullResponse's step-1
exact-ID match accepted any source with the matching numeric ID,
regardless of what the preset originally claimed for its Source. The
speaker trusts /full as ground truth, so the local preset got its
source attribute silently rewritten.

Tighten step-1: refuse the bind when the preset's claimed Source and
the configured source's SourceKeyType disagree (both populated). The
existing step-2 type/account fallback then finds the right source, or
synthesise/skip handles the no-match case. The refusal is logged so
the cross-type collision is visible in service logs.

Same fix applied to findMatchingSourceForRecent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ccdc2bd6a4 fix(datastore): preserve speaker's isPresetable verdict in SavePresets
SavePresets hard-coded isPresetable="true" on every persisted preset,
overwriting the speaker firmware's verdict. The speaker sets
isPresetable="false" for content it can't independently recall later
(notably Spotify Connect pushes from a phone — see GH-235); masking
that flag made the on-disk XML look valid while pressing the preset
on the speaker still did nothing, leaving users debugging a phantom
"stored but won't play" state.

Now preserve the caller's value and default to "true" only when it's
empty. A non-recallable preset is logged at info level so users can
tell from the service log why a stored preset isn't playing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:30:53 +02:00
Tobias GesellchenandClaude Sonnet 4.6 9f8c1cf536 fix(marge): mirror skip-or-synthesise into mapRecentsToFullResponse
Recents had the same protobuf-required-field hazard as presets — an
empty <source/> block inside <recent> would also abort the speaker's
/full sync (the recents poisoned-sourceproviderid regression
documented this once for a related sub-symptom). Apply the same
skip-or-synthesise filter so an orphaned recent can never take the
whole account sync down.

The synthesise/skip code paths log at info level; same visibility
posture as the preset side.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:30:53 +02:00
Tobias GesellchenandClaude Sonnet 4.6 22f60459ba fix(marge): auto-add canonical sources on UpdatePreset, accept Stockholm <username>
UpdatePreset returned "invalid account/source" with a 500 when the
speaker's preset PUT referenced a source that wasn't in AfterTouch's
per-device configured-sources list. After a factory reset the speaker
locally knows the built-in radio sources but AfterTouch's Sources.xml
may not, so a long-press appeared to succeed on the speaker but the
preset was never persisted — and the next /full sync wiped the local
copy. Closes GH-314 (and the underlying trigger described in GH-253).

For the canonical built-in IDs (10001..10005) AfterTouch now auto-adds
the source from the same template post-pair would have used, then lets
the preset land. Non-canonical / account-bound IDs (Spotify "100004",
Amazon, custom) are still rejected — we can't fabricate per-account
credentials. The rejection now logs the diagnostic context so users
don't have to grep source to understand why their long-press didn't
stick.

Also accepts the Stockholm mobile app's <username> field as the preset
name when <name> is empty (soundcork documents the same divergence).

Every code path that silently repairs preset data now logs at info
level: synthesised /full source blocks, skipped presets, auto-added
canonical sources, and the Stockholm name fallback. This makes user
diagnostic dumps actionable without source-spelunking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:30:53 +02:00
Tobias GesellchenandClaude Sonnet 4.6 09ec332375 fix(marge): synthesise or skip presets with unresolvable sources in /full
When a preset on disk referenced a source no longer in the configured-
sources list, mapPresetsToFullResponse appended it with an empty
<source/> block. The speaker decodes /full as protobuf and treats the
inner source fields (id, type, sourceproviderid, credential) as
required, so the malformed block aborted the whole account sync and
wiped the speaker's locally stored presets — the GH-269 symptom of
"/presets empty within seconds of AfterTouch coming online".

For well-known radio providers (TuneIn, InternetRadio,
LocalInternetRadio, RadioBrowser) the preset now gets a synthesised
source block built from canonical defaults; account-bound providers
(Spotify, Amazon) are skipped with a log line so other presets in the
response survive the sync.

Also folds RADIO_BROWSER into resolveSourceName's fallback switch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:27:15 +02:00
Tobias Gesellchen e643495287 chore: update screenshots (v0.87.x) 2026-05-19 23:42:21 +02:00
Tobias Gesellchen 8513d90e34 chore: update screenshots 2026-05-19 23:41:26 +02:00
Tobias GesellchenandClaude Opus 4.7 ce3b0e582a fix(health): drop InsecureSkipVerify from cert-chain probe
CodeQL alert 147 flagged the Phase-2 re-dial with
InsecureSkipVerify=true, used to read the served leaf after
Phase 1's strict verification failed.

The leaf is already reachable without a second connection:
tls.CertificateVerificationError carries
UnverifiedCertificates, and the three x509.* verification-
error types each carry the offending Cert. errors.As over
those covers darwin (Security.framework) and linux
(crypto/x509) consistently.

Same three classifier outcomes
(leafFromOwnCA/leafSubjectEqualsIssuer/leafForeign), same
chainContext rendering — the classifier reads only the leaf,
which is byte-identical to the Phase-2 peers[0]. Removes the
only InsecureSkipVerify literal in the tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 b149580c19 fix(ding): clamp sample rate against int -> uint32 truncation
CodeQL flagged the writeWAV cast of strconv.Atoi's result to
uint32 (alert 148). Two-layer defence: the handler rejects
sample-rate query params outside [8000, 192000] before parsing
ever reaches Render, and WithDefaults snaps any out-of-range
caller-supplied SampleRate back to the default before
renderChirp allocates buffers sized by it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 4f6f4c497a fix(health): self-signed AfterTouch chain is INFO, not WARN
The previous classifier always returned SeverityWarning when the
served leaf didn't validate against the service host's system
trust store. For AfterTouch's *default* deployment shape (its
own self-signed CA), that's the expected, healthy state — the
service host's trust store deliberately doesn't include our CA;
speakers establish trust via `setup install-ca`, not via system
roots. Reporting it as a warning misled non-technical operators
into thinking something was broken.

Rework the severity matrix:

  - leafFromOwnCA (signature-verified): INFO. Message says
    "AfterTouch is serving its own self-signed CA chain
    (expected)". Details explain the service-host trust-store
    state is by design. Manual command becomes a reminder
    rather than a fix.
  - leafSubjectEqualsIssuer (heuristic): INFO. Explains the
    heuristic and offers both install-ca (if it is AfterTouch)
    and openssl (if it isn't) as paths.
  - leafForeign (genuinely unexpected): WARN. Unchanged
    semantics; this is the case that actually wants attention.
  - connection failure: ERROR. Unchanged.

Title renamed from "HTTPS endpoint certificate validates" (which
read as a binary assertion the finding contradicted) to
"HTTPS endpoint TLS configuration".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 8571595aef feat(health): add CA cert expiry check
Separate check from service_cert_chain: that one inspects what's
served right now, this one watches when the trust anchor itself
will stop being usable. Even when the served leaf validates,
the CA's NotAfter will eventually expire every leaf it has ever
issued — and every paired speaker would then need
`setup install-ca` again with a freshly generated CA.

Three thresholds against the loaded CA's NotAfter:

  > 90 days remaining   → no finding (rolls up to OK)
  31..90 days           → INFO, surfaces the renewal date so it
                          isn't a surprise
  1..30 days            → WARNING with regeneration guidance
  expired               → ERROR — speakers will reject leaves

ManualCommand renders the actual cert path from
certmanager.GetCACertPath() so operators don't have to guess
where to delete. Sibling .key path inferred from the cert path
basename — close enough for a copy-paste hint; operators verify
before running.

Rounded day arithmetic via (d + 12h) / 24h to avoid the
"expires in 59 days" surprise caused by ASN.1 GeneralizedTime
truncating sub-second precision on the CreateCertificate /
ParseCertificate round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 29d611f9c1 feat(health): aggregate device-summary panel on Devices tab
Audit item #1 (11+ recurrences in issues / discussions): pull
speaker /info + /sources + /presets, plus service-side state
and pairing inference, into one view per device.

Backend: GET /setup/device-summary/{deviceId} probes the three
speaker endpoints concurrently (sync.WaitGroup, 3 s per probe)
and merges the result with what the datastore knows for the
same device. Partial failures don't break the response — each
sub-section carries its own reachability + error + curl_command
so the UI can render copy-paste fallbacks when the service host
can't reach the speaker.

JSON shape covers four panels:
  - device      identity + firmware
  - speaker     {info, sources, presets} with raw outcomes
  - service     server URL, expected hosts, Sources.xml /
                Presets.xml presence and counts
  - pairing     paired flag, marge host, host match

UI: new "Inspect" button per row on the Devices tab. Clicking
expands a sibling row with five summary cards (info / sources /
presets / service / pairing). Each unreachable card renders the
matching curl command with a Copy button — same dual-mode
pattern as Health findings. Closes the gap operators were
filling by manually concatenating curl output across the three
speaker endpoints when filing bug reports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 cb81be3143 fix(health): translate wildcard/empty DNS bind into a dialable target
The DNS sanity check passed bindAddr directly to dns.Client.Exchange.
Wildcard binds like "0.0.0.0:53", "[::]:53", or the empty
string (which the dns lib treats as default port 53 on all
interfaces) aren't actually dialable from inside the same
host — net would refuse the empty string outright, and our
finding rendered "Queried ." in the operator's UI.

resolveDNSQueryTarget now translates:

  ""              → 127.0.0.1:53
  ":53"           → 127.0.0.1:53
  "0.0.0.0:53"    → 127.0.0.1:53
  "[::]:53"       → 127.0.0.1:53
  "192.0.2.10:53" → unchanged
  "53"            → 127.0.0.1:53
  "example.com"   → example.com:53

The finding's Details now exposes both the configured bind and
the effective query target separately, so when queries still
fail the operator can tell whether the server simply isn't
listening on a dialable address vs. responding with the wrong IP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 a59f71a6e1 docs(ding): document supported knobs + caching on HandleDing
Mirrors what I had in the conversation summary: parameter list
with types and defaults, the sync.Once cache behaviour for the
default-options request shape, a copy-paste curl example, and a
pointer to the renderer package + offline CLI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 c90fdf234f fix(health): classify self-signed leaves via real CA signature check
The Subject==Issuer heuristic for "this is AfterTouch's
self-signed cert" misses the common case: AfterTouch's internal
CA has CN="SoundTouch Local Root CA" while leaves it issues have
CN="soundtouch" — different Subject and Issuer strings, so the
classifier was falling through to "foreign chain" and suggesting
openssl s_client when install-ca was actually the right fix.

Replace the heuristic with a definitive check: load AfterTouch's
own CA leaf via setup.Manager.Crypto.GetCACertPath() and call
x509.Certificate.CheckSignatureFrom(ca). When that succeeds we
*know* the leaf came from our own CA. The Subject==Issuer
heuristic stays as a fallback for environments where the CA
isn't loadable (with a clarifying note in the hint).

Server.loadOwnCACert caches the parsed CA via sync.Once so
repeated Health polls don't re-read the PEM.

Fixes the case shown in soundtouch.fritz.box deployments where
Subject=CN=soundtouch,O=AfterTouch and Issuer=CN=SoundTouch
Local Root CA,O=SoundTouch Local Service confused the
classifier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 147a69d1c3 refactor(ding): synthesise on demand instead of vendoring the WAV
Move the ding renderer into pkg/service/ding so it can run both
at request time (from the new HandleDing handler) and offline
(from the existing scripts/gen-aftertouch-ding CLI, now a thin
wrapper around the same package).

- GET /media/aftertouch-ding.wav synthesises on first call,
  caches the default-options bytes via sync.Once, and accepts
  query-string overrides for every knob (pitch-{high,mid,low},
  chirp-ms, gap-ms, attack-ms, release-ms, sample-rate, peak).
  Invalid / out-of-range values silently fall back to defaults.
- Embedded WAV is gone from VCS — no 52 KB binary in the
  repo, and tweaking the sound is now a query-param away rather
  than a regenerate-and-commit cycle.
- Health-tab playback_test check is unchanged: the URL it
  references (/media/aftertouch-ding.wav) keeps the same shape,
  the handler just produces the bytes dynamically now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 6356ca588b chore: gitignore SERVICE-HEALTH.md alongside NEXT/DONE
Companion working-tree note for the Health-tab debug-utility
programme. Same status as NEXT.md and DONE.md — session-local
plan/tracking artifact, not a project document.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 56efb4fcdb feat(health): add per-device "refresh sources" affordance
Standalone version of the sources-refresh trigger the
sources_xml_diff check emits opportunistically — exposed per
device regardless of whether drift was detected, since operators
also use it after manual Sources.xml edits or after running the
sources_xml_present quick fix.

Quick fix POSTs `<updates><sourcesUpdated/></updates>` to the
speaker's /notification endpoint. Manual command of equivalent
shape provided for cloud-deployed setups where the service
can't reach the speaker.

Recurring debug pattern from #175, disc #223, implied in #314.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 bb11b9d48c feat(health): add DNS interception sanity check
Queries this service's own DNS server for every intercepted
Bose hostname (api.bose.com, content.api.bose.io, etc.) and
verifies the answer is the configured service IP. Catches:

  - DNS subsystem disabled or unbound (speakers using us as
    their resolver get NXDOMAIN).
  - DNS running but answers point at a stale IP (operator
    changed the LAN address without restarting).
  - Subset of intercepts silently failing — emits the failing
    hostname list explicitly so it's obvious which patterns are
    falling through shouldIntercept.

For the mismatch case the finding includes a copyable
`nslookup … <our-dns-bind>` so operators can verify the same
behaviour from the speaker's network.

To avoid duplicating the intercept list, exports it as
`discovery.InterceptedBoseHosts` instead — same string slice
that DNSDiscovery.shouldIntercept walks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 7d46ae2280 feat(health): compare speaker /presets count with service Presets.xml
Probes http://<ip>:8090/presets for each device and counts the
returned <preset id=…> entries against the service-side
Presets.xml count. Three outcomes:

  - Match: no finding.
  - Speaker has 0 while service has entries: WARNING — the
    post-migration / post-reset preset-loss pattern from
    discussion #295 and #235.
  - Counts differ otherwise: INFO with both numbers in the
    message, so the operator can decide whether to sync.

Reachability / parse failures degrade to info-level findings
with a copyable curl command, matching the dual-mode pattern
the rest of the slice uses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 77188418a7 feat(health): detect dead Bose orion URLs in service Presets.xml
Recurring failure mode in issues #218 and #224: presets saved
before the May 2026 cloud shutdown still carry
content.api.bose.io/.../orion URLs in their <location>, which
the speaker fetches directly post-migration. Result: playback
silently fails because the dead host can't serve the request
and the speaker has no fallback path.

Passive filesystem scan over every device's service-side
Presets.xml; emits a warning per device listing the affected
preset slot IDs and a copyable sed snippet that strips the dead
host prefix, leaving the BMX-relative /v1/playback/... path
that this service can resolve.

No probe, no LAN access needed — purely a service-side data
check, so it's also safe to run on cloud-deployed AfterTouch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 41f21f3761 feat(health): add per-device "play ding" affordance
For each known device, surface an info-level finding with a
"Play ding" quick fix and an equivalent curl command. The fix
POSTs an INTERNET_RADIO ContentItem to the speaker's /select
endpoint pointing at <serverURL>/media/aftertouch-ding.wav — the
asset committed earlier in this branch.

No external dependency (unlike TuneIn-based playback tests from
issues #94, #175, #188, #214, #218, #224, #235, #253, #262,
#272), so it works for cloud-deployed AfterTouch as long as the
speaker can reach the service URL.

Dual-mode by construction: the curl command in ManualCommands
is the same shape the server-side fix uses, so operators on
LAN-isolated setups can paste it and trigger the same playback
from a reachable host. Skipped (with an explanatory finding)
when SERVER_URL isn't configured — the speaker would have
nowhere to fetch the audio from.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 b18272480a feat(health): probe HTTPS endpoint cert chain against system roots
Cloud-deploy reports (discussion #295 et al.) repeatedly came
down to "does the speaker trust AfterTouch's cert?". Add a check
that dials the configured HTTPS endpoint, attempts validation
against the system trust store, and:

  - Says nothing when the chain validates — typical for a public
    CA chain (Let's Encrypt, etc.) the speaker firmware trusts
    natively. No action needed.
  - Warns when validation fails and surfaces the chain context:
    subject, issuer, SANs, expiry, and the underlying error so
    operators can copy a diagnosis into a bug report. Includes a
    copyable suggestion — install-ca when the leaf looks
    self-signed (Subject == Issuer heuristic), or an
    `openssl s_client` invocation for unknown/foreign chains.

Reads the HTTPS URL via a closure on Server.GetSettings(), so
later restarts pick up new URLs without re-registration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 dee5a0146c feat(health): check speaker <margeURL> against configured hosts
For each device, probe /info and extract the <margeURL> the
speaker is configured to talk to. Compare the hostname against
the service's expected-hosts list (serverURL host +
httpsServerURL host + --tls-extra-host values).

When the speaker is pointed at a host AfterTouch doesn't claim,
emit a warning with two pieces of context:
  - the actual <margeURL>, so the operator sees the drift
  - a copyable `soundtouch-service --tls-extra-host=<host>`
    suggestion, which is the right fix when the speaker should
    keep talking to AfterTouch via the unexpected hostname (the
    other fix is re-migration, which is mentioned in the details).

Reachability / parse failures are intentionally silent here —
speaker_info_reachable already covers those, no need to double-warn.

Required plumbing: Server.SetExpectedHosts so main.go can pass
config.domains in, plus an ExpectedHosts() getter the closure-form
registration reads at run time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 255dd655b5 feat(health): compare speaker /sources with service Sources.xml
For each device, probe http://<ip>:8090/sources and compare the
set of source types against the service-side Sources.xml. The two
documents have *different* schemas (sourceItem attributes vs.
source elements with sourceKey children), so we compare the
extracted type sets rather than diffing XML directly.

Two finding shapes:
  - WARN: service advertises types the speaker doesn't have
    (e.g. TUNEIN, RADIO_BROWSER missing after a factory reset).
    Includes a copyable POST /notification command that triggers
    a sourcesUpdated refresh without a reboot.
  - INFO: speaker has types the service doesn't know about
    (mostly harmless — usually AUX or BLUETOOTH-style local-only
    sources). Surfaces it so operators notice managed sources
    that drifted out of the service config.

Recurring debug pattern from issues #175, #195, #214, #218, #236,
disc #315.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 195403f42a feat(health): add speaker /info reachability check
For every known device, probe http://<ip>:8090/info from the
service and emit findings for:
  - Unreachable speakers — surfaces a copyable curl command the
    operator can run from a host on the speaker's LAN.
  - Speakers replying 200 but with empty <margeAccountUUID> —
    the TPDA pairing-state failure mode documented in
    discussion #223 ("Account ID = (empty)" in logread).
  - Non-200 HTTP responses and malformed /info bodies, both as
    warnings with the underlying detail in the finding.

Uses the ProbeGet helper from the previous commit; the dual-mode
fallback is the curl command emitted via ManualCommands when
server-side reach fails — appropriate when AfterTouch is hosted
off the speaker's LAN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 90a30f9fce feat(health): add ProbeGet helper and ManualCommands on findings
Diagnostic checks coming next need to talk to speakers on the
LAN, which the service can't always reach — e.g. AfterTouch
hosted publicly while the operator's browser sits on the speaker
subnet. Establish the dual-mode primitive first so subsequent
checks can use it consistently:

- ProbeGet(ctx, url, timeout) issues a short-timeout GET and
  always returns a CurlCommand the operator can run from a host
  that can reach the target, regardless of whether the
  server-side fetch succeeded.
- Finding gains an optional ManualCommands field; the admin UI
  renders each as a labelled, copyable code block with a Copy
  button and an optional hint line.

No new checks yet — that's the next commit. This one only adds
the primitive and the rendering path so each subsequent check is
a one-file diff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 c89a66b08a feat(media): add AfterTouch "ding" signature audio
A 600 ms two-chirp sound derived from the braille S+T pair that
makes up the AfterTouch logo. Used as the test-playback target so
operators can confirm a freshly migrated speaker actually emits
audio without depending on TuneIn or any external service.

Mapping: dot rows → pitches (A5/E5/A4), dot columns → stereo
channels. S (dots 2,3,4) renders first, then T (dots 2,3,4,5) —
audibly "S plus one more voice".

Generator under scripts/gen-aftertouch-ding regenerates the file
on demand:

  go run ./scripts/gen-aftertouch-ding \
    -o pkg/service/handlers/static/media/aftertouch-ding.wav

22050 Hz stereo 16-bit PCM, ~52 KB. Picked up by the existing
static/media/* embed in handlers_media.go, so it's served at
GET /media/aftertouch-ding.wav once handlers can play it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 c3723dc0e6 feat(service): add Logs tab streaming the live stderr trace
Cloud-deploy operators on Discussion #295 needed to leave the
admin UI for docker logs / journalctl to see what the service was
doing. Mirror log.Default() output into an in-memory ring buffer
and expose it under /setup/logs so the admin UI can show a live
trace alongside the existing tabs.

The buffer is a second sink under log.SetOutput(io.MultiWriter(
os.Stderr, buf)) — stderr keeps receiving every line verbatim,
so docker logs / journalctl are unaffected. Default capacity
2000 lines (~400 KB), tunable via SOUNDTOUCH_LOG_BUFFER_LINES.

- pkg/service/logbuf: io.Writer ring with \n splitting,
  partial-line buffering, monotonic Seq, Since(since, limit)
  reporting dropped count when the caller falls behind.
- New /setup/logs (GET) returns {entries, nextSince, dropped,
  capacity}. Polls at 1.5s while the tab is active; paused on
  document.hidden.
- "8. Logs" tab with substring filter, tail-follow toggle
  (auto-disables when the user scrolls up), monospace dark view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:12:25 +02:00
Tobias GesellchenandClaude Opus 4.7 f791145976 feat(service): add Health tab with datastore checks and quick fixes
Discussion #295 surfaced that a paired device without Sources.xml
silently breaks playback — /full omits TUNEIN and selection fails
with 1005. initializeDefaultSources only runs at startup over
existing devices, so a device that checks in later is never
seeded.

Add a Health tab to the admin UI that runs registered checks
against the datastore and offers one-click remediations. The
first check flags missing Sources.xml per device; its quick fix
writes the canonical defaults via SaveConfiguredSources. The
check/fix registry is designed so adding Presets.xml,
Recents.xml, or future reachability probes is a one-file diff.

- New /setup/health (GET) and /setup/health/fix (POST) routes
- pkg/service/health: Registry, Check, Finding, QuickFix types
- Sources.xml-present check + create_default_sources fix
- "7. Health" tab in pkg/service/handlers/web/

Inspired by issue #327's MAINTENANCE tab proposal; curl/URL
helper content from that issue can slot into the same tab in
a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:00:16 +02:00
Tobias GesellchenandClaude Opus 4.7 882d0633fb fix(bmx): emit BMX-relative playback hrefs in TuneIn nav/search results
The b95bdae split changed BmxPlayback.Href to raw `Tune.ashx?id=…` URLs,
which the speaker's BMX module fetches directly — failing `IsItBose`,
sending no auth, and getting 401 from radiotime. Restore the v0.85.0
shape (`/v1/playback/{station|episodes}/{id}`) so playback flows back
through HandleTuneInPlayback. Also restore play-link emission for Topic
search results (single podcast episodes); `Tune.ashx?id=t<N>` accepts
them like station IDs, so the same path works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:24:34 +02:00
Tobias Gesellchen 68760a8977 feat(service): add --tls-extra-host for additional TLS cert SAN entries
The leaf cert generator already routes IP-shaped entries into the
IPAddresses SAN, and getDomains already feeds it the hostnames parsed
from --server-url and --https-server-url. Add an explicit
--tls-extra-host flag (repeatable, env TLS_EXTRA_HOST) for the
remaining cases: multi-homed hosts, reverse-proxy frontends, or
browsing the admin UI via a LAN IP that isn't part of the configured
server URLs.

Resolves the ERR_CERT_COMMON_NAME_INVALID Chrome refuses when the URL
bar hostname (e.g. the host's LAN IP) isn't in any cert SAN, even
when the local CA is trusted.
2026-05-18 23:09:28 +02:00
dependabot[bot] e1d009de04 ci(deps): bump codecov/codecov-action in the security-actions group
Bumps the security-actions group with 1 update: [codecov/codecov-action](https://github.com/codecov/codecov-action).


Updates `codecov/codecov-action` from 6.0.0 to 6.0.1
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/57e3a136b779b570ffcdbf80b3bdc90e7fab3de2...e79a6962e0d4c0c17b229090214935d2e33f8354)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 22:40:55 +02:00
dependabot[bot] cee11b4799 ci(deps): bump github/codeql-action from 4.35.4 to 4.35.5
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.4 to 4.35.5.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/68bde559dea0fdcac2102bfdf6230c5f70eb485e...9e0d7b8d25671d64c341c19c0152d693099fb5ba)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 22:40:36 +02:00
github-actions[bot] 4057e4b1a1 chore: sync static dependencies with package.json 2026-05-18 22:38:40 +02:00
dependabot[bot] 3331b1e93d deps(deps): bump preact from 10.26.1 to 10.29.2
Bumps [preact](https://github.com/preactjs/preact) from 10.26.1 to 10.29.2.
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.26.1...10.29.2)

---
updated-dependencies:
- dependency-name: preact
  dependency-version: 10.29.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 22:38:40 +02:00
132 changed files with 15751 additions and 386 deletions
+5 -5
View File
@@ -53,7 +53,7 @@ jobs:
run: make test-http-client
- name: Upload coverage to Codecov
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
file: ./coverage.out
flags: unittests
@@ -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
+4 -4
View File
@@ -95,7 +95,7 @@ jobs:
- name: Upload Semgrep SARIF results
if: always()
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
sarif_file: semgrep.sarif
continue-on-error: true
@@ -116,16 +116,16 @@ jobs:
run: sudo apt-get install -y libpcap-dev
- name: Initialize CodeQL
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
languages: go
config-file: ./.github/codeql-config.yml
- name: Autobuild
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
category: "/language:go"
+8
View File
@@ -114,3 +114,11 @@ stockholm_zip/*.zip
# resolved items (DONE). Both are session-local scratch, not project docs.
NEXT.md
DONE.md
# Plan/tracking note for the Health-tab debug-utility programme.
# Living document; commit history of the checks themselves is the
# source of truth for what shipped.
SERVICE-HEALTH.md
# Diagnostic encryption keys — private key stays local with the maintainer
keys/private/
+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",
},
},
},
},
+124 -4
View File
@@ -7,6 +7,7 @@ import (
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
@@ -15,6 +16,7 @@ import (
"path/filepath"
"runtime"
"runtime/debug"
"strconv"
"strings"
"time"
@@ -23,6 +25,7 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
"github.com/gesellix/bose-soundtouch/pkg/service/logbuf"
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
@@ -173,9 +176,44 @@ func initMusicServices(config serviceConfig, server *handlers.Server) {
}
}
// logBufferCapacityFromEnv reads SOUNDTOUCH_LOG_BUFFER_LINES and
// returns a positive capacity. Invalid or unset values fall back
// to the default; a value of 0 or negative is treated as "disable"
// and returns 0 so the caller can skip wiring the buffer.
func logBufferCapacityFromEnv(defaultCap int) int {
raw := os.Getenv("SOUNDTOUCH_LOG_BUFFER_LINES")
if raw == "" {
return defaultCap
}
v, err := strconv.Atoi(raw)
if err != nil {
log.Printf("[Logs] Invalid SOUNDTOUCH_LOG_BUFFER_LINES=%q, using default %d", raw, defaultCap)
return defaultCap
}
if v < 0 {
return 0
}
return v
}
func main() {
updateBuildInfo()
// Mirror log output to an in-memory ring buffer so the admin
// UI can show a live trace. Stderr keeps receiving every line
// verbatim — the buffer is a second sink, not a replacement.
// Installing this before the cli.Action runs means every
// log.Printf from initialisation onwards is captured.
var logBuf *logbuf.Buffer
if bufCap := logBufferCapacityFromEnv(2000); bufCap > 0 {
logBuf = logbuf.New(bufCap)
log.SetOutput(io.MultiWriter(os.Stderr, logBuf))
}
app := &cli.App{
Name: "soundtouch-service",
Usage: "Local service for Bose SoundTouch cloud emulation and management",
@@ -342,6 +380,11 @@ func main() {
Usage: "Paths for internal requests (comma-separated or multiple flags)",
EnvVars: []string{"INTERNAL_PATHS"},
},
&cli.StringSliceFlag{
Name: "tls-extra-host",
Usage: "Additional DNS name or IP to include in the server TLS certificate SAN list (repeatable)",
EnvVars: []string{"TLS_EXTRA_HOST"},
},
&cli.BoolFlag{
Name: "migration-enabled",
Usage: "Enable device directory migration from serial to MAC-based structure",
@@ -382,7 +425,7 @@ func main() {
hostname = "localhost"
}
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname)
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname, config.tlsExtraHosts)
cm := initCertificateManager(config.dataDir, config.hostname)
sm := setup.NewManager(config.serverURL, ds, cm)
@@ -390,7 +433,9 @@ func main() {
sm.MgmtPassword = config.mgmtPassword
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record)
sm.GetDNSRunning = server.GetDNSRunning
server.SetLogBuffer(logBuf)
server.SetHTTPServerURL(config.httpsServerURL)
server.SetExpectedHosts(config.domains)
server.SetVersionInfo(version, commit, date, repoURL)
server.SetDiscoverySettings(config.discoveryInterval, config.discoveryEnabled)
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
@@ -534,6 +579,7 @@ type serviceConfig struct {
dnsUpstream string
dnsBind string
internalPaths []string
tlsExtraHosts []string
discoveryEnabled bool
discoveryInterval time.Duration
domains []string
@@ -590,7 +636,8 @@ func loadConfig(c *cli.Context) serviceConfig {
httpsServerURL = "https://" + hostname + ":" + httpsPort
}
domains := getDomains(serverURL, httpsServerURL, hostname)
tlsExtraHosts := c.StringSlice("tls-extra-host")
domains := getDomains(serverURL, httpsServerURL, hostname, tlsExtraHosts)
redact := c.Bool("redact-logs")
logBody := c.Bool("log-bodies")
@@ -644,6 +691,7 @@ func loadConfig(c *cli.Context) serviceConfig {
dnsUpstream: dnsUpstream,
dnsBind: dnsBind,
internalPaths: internalPaths,
tlsExtraHosts: tlsExtraHosts,
discoveryEnabled: discoveryEnabled,
discoveryInterval: discoveryInterval,
domains: domains,
@@ -666,7 +714,7 @@ func loadConfig(c *cli.Context) serviceConfig {
}
}
func getDomains(serverURL, httpsServerURL, hostname string) []string {
func getDomains(serverURL, httpsServerURL, hostname string, extraHosts []string) []string {
domainsMap := map[string]bool{
// RFC-compliant wildcards for API patterns
"*.api.bose.io": true,
@@ -699,6 +747,31 @@ func getDomains(serverURL, httpsServerURL, hostname string) []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 {
h = strings.ToLower(strings.TrimSpace(h))
if h != "" {
domainsMap[h] = true
}
}
domains := make([]string, 0, len(domainsMap))
for d := range domainsMap {
domains = append(domains, d)
@@ -753,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) {
@@ -876,6 +984,7 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
server.HandleWeb()(w, r)
})
r.Get("/media/aftertouch-ding.wav", server.HandleDing)
r.Get("/media/*", server.HandleMedia())
r.Get("/bmx-icons/*", server.HandleBmxIcons())
r.Get("/ced/*", server.HandleCedStatic())
@@ -1136,6 +1245,12 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
r.Get("/device-summary/{deviceId}", server.HandleDeviceSummary)
r.Get("/health", server.HandleHealthChecks)
r.Post("/health/fix", server.HandleHealthFix)
r.Get("/export/diagnostic", server.HandleExportDiagnostic)
r.Get("/logs", server.HandleGetLogs)
// Serve Stockholm setup wizard pages for paths not matched by the management API.
// The Stockholm frontend has a setup/ directory that must be accessible at /setup/*.
@@ -1240,7 +1355,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
}
+6
View File
@@ -41,6 +41,7 @@ GET /docs/* handlers.(
GET /favicon.ico setupRouter
GET /health handlers.(*Server).HandleHealth-fm
GET /media/* handlers.(*Server).HandleMedia
GET /media/aftertouch-ding.wav handlers.(*Server).HandleDing-fm
GET /mgmt/accounts/ handlers.(*Server).HandleMgmtListAccounts-fm
GET /mgmt/accounts/{accountId} handlers.(*Server).HandleMgmtAccountDetails-fm
GET /mgmt/accounts/{accountId}/speakers handlers.(*Server).HandleMgmtListSpeakers-fm
@@ -53,17 +54,21 @@ GET /mgmt/spotify/callback handlers.(
GET /mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
GET /setup/account-id-suggestions/{deviceId} handlers.(*Server).HandleAccountIDSuggestions-fm
GET /setup/ca.crt handlers.(*Server).HandleGetCACert-fm
GET /setup/device-summary/{deviceId} handlers.(*Server).HandleDeviceSummary-fm
GET /setup/devices handlers.(*Server).HandleListDiscoveredDevices-fm
GET /setup/devices/{deviceId}/events handlers.(*Server).HandleGetDeviceEvents-fm
GET /setup/discovery-status handlers.(*Server).HandleGetDiscoveryStatus-fm
GET /setup/dns-discoveries handlers.(*Server).HandleGetDNSDiscoveries-fm
GET /setup/dns-discoveries/download handlers.(*Server).HandleDownloadDNSDiscoveries-fm
GET /setup/export/diagnostic handlers.(*Server).HandleExportDiagnostic-fm
GET /setup/health handlers.(*Server).HandleHealthChecks-fm
GET /setup/info/{deviceId} handlers.(*Server).HandleGetDeviceInfo-fm
GET /setup/interaction-content handlers.(*Server).HandleGetInteractionContent-fm
GET /setup/interaction-stats handlers.(*Server).HandleGetInteractionStats-fm
GET /setup/interactions handlers.(*Server).HandleListInteractions-fm
GET /setup/interactions/sessions/{session}/download handlers.(*Server).HandleDownloadSession-fm
GET /setup/logging-settings handlers.(*Server).HandleGetLoggingSettings-fm
GET /setup/logs handlers.(*Server).HandleGetLogs-fm
GET /setup/settings handlers.(*Server).HandleGetSettings-fm
GET /setup/summary/{deviceId} handlers.(*Server).HandleGetMigrationSummary-fm
GET /setup/version handlers.(*Server).HandleGetVersionInfo-fm
@@ -127,6 +132,7 @@ POST /setup/backup/{deviceId} handlers.(
POST /setup/devices handlers.(*Server).HandleAddManualDevice-fm
POST /setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
POST /setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
POST /setup/health/fix handlers.(*Server).HandleHealthFix-fm
POST /setup/logging-settings handlers.(*Server).HandleUpdateLoggingSettings-fm
POST /setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
POST /setup/pair-account/{deviceId} handlers.(*Server).HandlePairAccount-fm
+277
View File
@@ -0,0 +1,277 @@
# Device-Local Install: Four User Journeys
A user-journey-shaped view of where AfterTouch sits today and where it could go. The same speaker, the same constraints, but four different audiences with non-overlapping needs:
1. **Initial setup / install** — getting AfterTouch onto a fresh or freshly-orphaned speaker.
2. **Less-technical admin** — migration, maintenance, and recovery without a terminal.
3. **Daily usage** — playing music, switching presets, on the couch or on the phone.
4. **Automation** — driving the speaker from scripts, home automation, schedules.
Each journey is served by a different surface (CLI, web UI, GUI app, REST). Some surfaces serve more than one journey; some journeys are served badly today. This doc is informational; nothing here is a roadmap commitment.
Cross-cutting reference material — lessons from `GameTec-live/soundtouch-tiny`, plus a per-surface capability map — lives in the appendix.
---
## Journey 1: Initial setup / install
**Who.** Someone with a Bose speaker whose cloud just died. Could be technical (knows what SSH is) or not (knows what a USB stick is). Wants the speaker to play Internet Radio again with minimum fuss.
**Goal.** Get an AfterTouch instance reachable from the speaker, whether that instance lives on a separate host or on the speaker itself.
**Surfaces.** Shell (today), GUI installer (planned), pre-flashed stick (commercial offering, hypothetical).
### The three install patterns
#### Pattern A — External host
A separate machine (Raspberry Pi, NAS, always-on laptop) runs `soundtouch-service`. Speakers point at it via DNS rewrite at the router. No code on the speaker, no firmware risk.
- **Pros:** zero invasiveness, easy update (single host), unified for many speakers, no per-speaker storage limit.
- **Cons:** requires an always-on host on the LAN, DNS rewrite at router scope, single point of failure.
#### Pattern B — SSH-curl on-device (current `scripts/on-device-install/`)
User SSHes in once, pipes the installer. Installs to `/mnt/nv/aftertouch`, symlinks `/opt/aftertouch`, registers `/etc/init.d/aftertouch` via `update-rc.d`. Daemon serves `:8000` on the speaker's own LAN address.
- **Pros:** no separate host, per-speaker isolation, survives router replacement.
- **Cons:** SSH required for install and updates, ~12 MB binary stresses tiny rootfs partitions, no in-process restart on crash, some firmware images bind only loopback (issue #196).
#### Pattern C — Stick-driven on-device (*not* implemented here)
USB stick holds binary + bootstrap scripts. First install needs SSH (placing `/mnt/nv/rc.local`). After that, the NAND `rc.local` auto-syncs from any stick inserted with newer files. Stick can also carry one-shot configs (`wlan.conf`, `region.conf`, `name.conf`) consumed and wiped during boot.
- **Pros:** post-bootstrap updates need no SSH, stick wipe behavior keeps credentials short-lived, watchdog inside the bootstrap script restarts the agent on crash without a reboot.
- **Cons:** first install still needs SSH; FAT32 stick on the speaker is unreliable for writes; user has to keep a stick around.
### The technical underpinning: `/mnt/nv/rc.local`
Both pattern C and any "shepherd-less" install on stock firmware depend on a single line in the stock init scripts:
```
# /etc/init.d/shelby_local, start case
[ -x /mnt/nv/rc.local ] && /mnt/nv/rc.local
```
`shelby_local` is a stock Bose SysV script. Its `start` case fires at every boot from an `S`-symlink in `rcS.d/` (the misleading `K99shelby_local` symlink in `rc1.d/` is the *shutdown* path — same script, different case). `/mnt/nv` is the persistent read-write NAND partition; `rc.local` is intentionally exposed as an extension point. By the time it runs, rootfs is mounted read-only, `/mnt/nv` is read-write, network is configured, and `/media/sda1` is *typically* mounted by udev if a USB stick is present — but the mount is asynchronous and races the hook (polling for up to 30 s is one way to handle this).
**Stock firmware does not auto-copy anything from a USB stick into `/mnt/nv/rc.local`.** Inserting a stick alone is not enough. There is no udev rule, no autorun convention, no `shelby_usb` branch that handles this; `shelby_usb` only manages USB ethernet-gadget mode (`g_ether`) and the `microbswitch` helper on certain variants.
Placement happens one of two ways:
1. **Manual SSH bootstrap, once.** Shell access (via the `remote_services` stick trick) runs an installer that writes `/mnt/nv/rc.local`, makes it executable, and exits. After that single SSH session, the stick is no longer required to *trigger* anything — the NAND copy fires on every boot.
2. **Self-update from a newer stick, after step 1.** Once `/mnt/nv/rc.local` exists *and contains the self-update logic*, inserting a stick with a newer `rc.local` (compared by mtime) lets the running NAND copy overwrite itself for the next boot. This gives the stick its "repair channel" property.
**The very first placement requires SSH.** Any zero-SSH install would need either a different stock-firmware hook (we have not found one usable across SoundTouch variants) or a custom firmware image. The `remote_services` stick is the only stick-content convention the stock firmware honors out of the box, and all it does is enable `sshd`.
### App-driven install (the missing middle)
The SSH session does **not** have to be a human SSH session. `pkg/ssh` (`NewClient`, `Run`, `ReadFile`, `ReadDir`, `UploadContent`) is already used by `pkg/service/setup/` to drive migration probes; the same primitives can drive an installer. The user never sees a terminal.
User-visible flow:
1. User runs an admin app on their laptop or phone.
2. App walks them through preparing a `remote_services` stick — or writes one for them, if it can reach the host's USB subsystem.
3. User inserts the stick into the speaker and power-cycles it. Stock firmware's `sshd` starts.
4. App discovers the speaker via mDNS, dials SSH, runs the installer steps that today live behind `curl ... \| sh`. No `ssh` invocation, no `rw &&`, no copy-pasted IP.
5. App verifies `curl http://<box>:8000` from inside the speaker via SSH and surfaces a clear success / failure state.
6. App optionally removes `remote_services` from the stick and reboots the speaker, closing the SSH backdoor automatically.
Mapping each step to existing code:
| Step | Today's installer | App equivalent (`pkg/ssh`) |
|---------------------|-------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------|
| Remount rootfs rw | `mount -o remount,rw /` (inside init script) | `Client.Run("mount -o remount,rw /")` |
| Make NAND dir | `mkdir -p $INSTALL_DIR` | `Client.Run("mkdir -p /mnt/nv/aftertouch")` |
| Download binary | `curl -sSL ... -o binary` | local download on the app side, then `Client.UploadContent(bytes, "/mnt/nv/aftertouch/aftertouch-service")` |
| Mark executable | `chmod +x` | `Client.Run("chmod +x ...")` |
| Symlink `/opt` | `ln -sf $INSTALL_DIR /opt/aftertouch` | `Client.Run("ln -sf ...")` |
| Install init script | `curl ... -o /etc/init.d/aftertouch && update-rc.d aftertouch defaults` | `Client.UploadContent` + `Client.Run` |
| Start | `/etc/init.d/aftertouch start` | `Client.Run("/etc/init.d/aftertouch start")` |
| Verify listener | `curl -fsS http://localhost:8000` inside the box | `Client.Run("curl -fsS http://localhost:8000")` |
No new SSH plumbing required. The pieces already exist for the setup probes.
### Storage budget
The on-device patterns share one hard constraint: storage. ST20 stock rootfs has ~4 MB free (issue #268); even with `/mnt/nv` (~30 MB free) the budget is tight, and a second binary for safe OTA updates doubles it. This is the primary motivation for a slimmer `soundtouch-service-mini` build target — see the appendix.
### Open decisions for this journey
- Do we keep pattern B as the technical-user path while building a Gio admin app for the rest?
- Do we add a pattern-C-style "register a stick-update hook in `/mnt/nv/rc.local`" option as an opt-in, so users who do want a repair stick get one?
- Pre-flashed sticks shipped as a kit: in scope or out?
---
## Journey 2: Less-technical admin (migration + maintenance)
**Who.** The person who already has AfterTouch installed somewhere and now needs to do something *after* install. They are comfortable opening apps and clicking buttons; they are not comfortable opening a terminal. The whole-household admin: parent, partner, roommate doing it for the household.
**Goal.** Migrate a speaker to a new AfterTouch instance, update the agent, view what's going on, recover a stuck device, change WLAN credentials, reapply config after factory reset — all without SSH.
**Surfaces.** GUI admin app (Gio, planned), `soundtouch-service` embedded web UI (today, technical-leaning), CLI (today, technical-only).
### What "admin" covers in practice
- **Migration of a new (or factory-reset) speaker** to an AfterTouch instance: rewrite the server URLs in `/mnt/nv/persistence.json`, restart the device, verify it talks to us.
- **Agent update on an on-device install** (pattern B or C): push a new binary, restart, verify.
- **Status and diagnostics**: is `aftertouch` running, is `:8000` listening, did the last preset save succeed, what does syslog say?
- **Recovery**: speaker is stuck (won't respond to web UI, won't pair, lost WLAN). Today this almost always means SSH; with `pkg/ssh` behind a GUI, it can mean "click 'Diagnose' in the app."
- **Bulk operations**: do all of the above across several speakers at once.
- **Configuration drift**: WLAN password changed, region changed, speaker name changed, hosts file got rewritten — restore the AfterTouch overlay.
### How the GUI admin app shape would serve this
Same `pkg/ssh` primitives as Journey 1's installer, applied to post-install tasks. mDNS discovers all speakers on the LAN; the app fans operations out across them; SSH-driven actions stay hidden behind buttons. On a phone, the same app is the "speakers are unreachable, what now" diagnostic tool from another room.
Where today's surfaces fall short for this user:
- `soundtouch-service` web UI assumes the service is running and reachable. It cannot recover a broken installation or a stuck device.
- CLI works but presumes terminal comfort.
- The setup wizard in `soundtouch-service` handles initial migration well, but reapplying after factory reset is not first-class — see `docs/analysis/FACTORY-RESET-PROTOCOL.md`.
### Open decisions for this journey
- Does the admin app subsume the service web UI's admin tab, or do they coexist (admin app = onboarding + recovery; service web UI = ongoing operations once everything is healthy)?
- WASM as a fallback surface: today's service web UI is browser-accessible from anywhere. Does a Gio admin app sacrifice that, or do we ship both?
- Multi-household / multi-speaker: how much does the admin app need to know about distinguishing speakers vs distinguishing AfterTouch instances?
---
## Journey 3: Daily usage
**Who.** Anyone in the household using the speaker. Children pressing a preset button. The user opening a phone to switch from kitchen to living room. Guests asked to "just put on some jazz." Zero awareness of AfterTouch as a thing; the speaker is the speaker.
**Goal.** Music plays. Pressing preset 3 gives them what preset 3 should give them. Skipping a station, adjusting volume, browsing for a new station — all fast, no friction.
**Surfaces.** Physical preset buttons (always there), `soundtouch-web` (today), mobile app (Journey 2 admin app's daily-use mode), WASM-served browser UI (planned), Bose app while it still functions, voice assistants where wired up.
### What this layer needs to be good at
- **Preset playback works first try, every time.** The reliability bar is "is the kitchen radio still working?" Anything that fails on cold boot or after a Wi-Fi outage breaks the user's trust in the whole system.
- **Switching stations quickly**, including discovery of new ones (e.g. `radio-browser.info`-style search).
- **Volume and play / pause from any device the user has in hand.** Phone in pocket, laptop on table, browser tab open — all should work.
- **Multi-room awareness** if the household has more than one speaker: which speaker is playing what, can I send this to the bedroom.
- **Looking good.** This is the surface that gets seen daily by non-technical users. Visual polish matters more here than anywhere else in the stack.
### How surfaces map
- `soundtouch-web`: primary daily UI for desktop browsers and (responsively) for tablets. This is already shipped.
- Mobile app: daily-use mode of the same Gio app that handles admin. Capability split — admin features only show up when the user is in admin mode.
- WASM: same Gio app, served from `soundtouch-service` to anyone on the LAN. The "I forgot which device my login is on, just open a browser" fallback.
- Physical preset buttons: handled at the agent level (the Bose firmware fires them; AfterTouch or the on-device agent reacts).
### Open decisions for this journey
- Do we keep `soundtouch-web` as a separate codebase (HTML/JS), or does it become a Gio WASM build sharing code with the admin app?
- Mobile app store distribution: TestFlight for iOS (gated, slow), Play Store for Android (faster, AAB only), F-Droid as an open-source-friendly side path.
- Multi-user state: presets per-user vs per-household. Out of scope here, but the daily surface is where it gets felt.
---
## Journey 4: Automation
**Who.** The same household, but acting through code: a Home Assistant config, a NodeRED flow, a cron job, a shell script, a webhook from a smart doorbell. The user is not present at the speaker; they want music to start when something else happens.
**Goal.** Headless, scriptable control. "Play preset 2 at 7:00 every weekday." "When the kids' bedtime alarm fires, fade volume to zero." "If I get home and the speaker is on, switch to my dinner playlist."
**Surfaces.** `soundtouch-cli` (today), REST endpoints on `soundtouch-service` (today), MQTT bridge / webhook outputs (hypothetical), Home Assistant integration (community).
### What this layer needs to be good at
- **Stable, versioned API surface.** Scripts and home automation flows live for years; breaking changes are expensive for users.
- **CLI that works in pipelines.** Exit codes, machine-readable output (JSON), stable flag names. The reverse of the daily UI: zero polish, full predictability.
- **Discoverability of capabilities.** Users need to find out what's possible (`soundtouch-cli help`, openapi spec on the service, examples in the docs).
- **Idempotency.** Calling "set volume to 40" twice should not result in volume 80. Calling "switch to preset 3" when already on preset 3 should be a no-op.
### How surfaces map
- `soundtouch-cli`: the canonical surface for scripted control. Already covers most of the API.
- `soundtouch-service` REST endpoints: same surface, network-accessible. Used by `soundtouch-web` and by third-party automation.
- Home Assistant: external integration; track but do not own.
- Webhooks / MQTT: not present today; would let speakers participate in event-driven flows. Out of scope for a first pass; worth a separate design doc when demand surfaces.
### Open decisions for this journey
- Stability commitments for the CLI and REST API: do we adopt semver for the public surface separately from the service version?
- Authentication for the REST surface when exposed beyond loopback: needed before any internet exposure is sane.
- OpenAPI / typed-client output for the service: nice-to-have for integration developers.
---
## Appendix: which surface serves which journey
| Surface | Journey 1 (install) | Journey 2 (admin) | Journey 3 (daily) | Journey 4 (automation) |
|------------------------------------|---------------------|-------------------|-------------------|------------------------|
| `soundtouch-cli` | partial (today) | partial (today) | no | primary |
| `soundtouch-service` web UI | wizard portion | primary | partial | indirect (REST) |
| `soundtouch-web` | no | no | primary | no |
| GUI admin app (Gio, planned) | primary | primary | mobile mode | no |
| Pre-flashed stick (hypothetical) | primary | recovery | no | no |
| Physical preset buttons | no | no | primary | no |
| Home Assistant / webhooks (future) | no | no | no | primary |
The diagonal isn't full because some journeys lack a polished surface today (Journey 1 mostly works but is shell-only; Journey 2 has gaps for recovery scenarios). The journey frame is what tells us *which* gaps to fill first.
## Appendix: per-surface capability constraints
The Gio admin app, if built, can target Windows / macOS / Linux / iOS / Android / WASM from one codebase. Each target has hard constraints:
- **WASM (browser).** Post-install REST control, device list and status, preset editing, station search. No mDNS (browsers cannot do raw multicast — fall back to manual IP entry or a backend bridge); no raw TCP, so no SSH and no install; no block-device access, so no stick writing. This is the "I just want to use my speakers" surface, equivalent to today's `soundtouch-web`.
- **Mobile iOS.** Everything WASM does, plus Bonjour-based mDNS, plus full SSH client (so app-driven install and recovery work). No FAT32 stick writing — iOS has no filesystem-level block device access for third-party apps. Best paired with a pre-flashed stick or a friend's desktop install for the bootstrap.
- **Mobile Android.** Same as iOS, plus FAT32 stick writing *if* the user grants USB-OTG host permission. UX caveat: most users will not know what USB host mode is.
- **Desktop (Gio).** Full capability set. mDNS, SSH-driven install, FAT32 stick writing via standard block-device APIs, post-install control, recovery. The primary onboarding surface.
The pattern to follow is to write code so each capability degrades automatically based on what the runtime actually offers, rather than gating with build tags.
## Appendix: lessons from adjacent projects
### soundtouch-tiny (GameTec-live)
Minimal on-device cloud replacement: Internet Radio + TuneIn proxy + optional presets. Go stdlib only, small binary. Inspired by AfterTouch but trimmed. The author offered collaboration in PR #292.
This is the gap a **`soundtouch-service-mini` build target** would fill. The full `soundtouch-service` is justified for the external-host pattern (Pattern A) where space is not pressed; on-device (patterns B and C) the calculus is different — many users only need Internet Radio because that's the surface most affected by the cloud shutdown.
A mini build target in this repo would look like:
- same codebase, different `cmd/` entry point,
- compiled with only the packages needed for Internet Radio + TuneIn shim + presets,
- no Spotify, no parity tests, no setup wizard, no Bose-protocol-level proxy,
- target size: under 4 MB so it fits the rootfs without `/mnt/nv` gymnastics, leaving room for a second binary for safe updates.
Open questions before committing:
1. Collaborate upstream with soundtouch-tiny, or build our own mini that shares code with the full service?
2. Where to draw the feature line — "Internet Radio only" is clear; "Spotify too" would already blow the budget on ST20.
3. Mini ships via Pattern B (SSH-curl) or Pattern C (stick)?
4. Full service and mini service coexisting on the same LAN — mDNS service name, port choice, web UI port.
### Wails vs Gio
Both are Go. Different tradeoffs:
- **Wails v2**: bundles a WebView per OS, frontend is HTML/CSS/JS. Faster to a working UI if the team is comfortable with HTML. Targets Windows / macOS / Linux. No mobile, no WASM.
- **Gio**: immediate-mode pure-Go UI. Smaller binaries, no WebView dependency. Targets Windows / macOS / Linux / iOS / Android / WASM. Steeper UI learning curve, mitigated by `gio-mw`.
The deciding factor is **mobile + WASM** (Journey 2 and Journey 3), not desktop alone. If "use a phone to set up a speaker" or "open the admin tool from any browser" is on the roadmap, Wails does not get us there.
## Appendix: documentation gap to close
Separate user-facing material to produce when we are ready (not in this comparison doc):
- **The `/mnt/nv/rc.local` hook** explained in user terms: what it does, when it fires, when *not* to use it, how to remove it cleanly. Bridges Journey 1 and Journey 2.
- **Hooks we already maintain** at OS level: resolv.conf stability, `/etc/hosts` overlay, anything in `pkg/service/setup/` that touches device state. Reference, not narrative. Journey 2 troubleshooting.
- **Storage budget per model**: rootfs free, `/mnt/nv` free, where the binary lands, which path applies to which ST model. Journey 1 sizing.
- **Decision matrix**: external host vs on-device vs mini, plus "do I need Spotify? do I need migration? do I want one host or per-speaker isolation?" Journey 1 entry point.
- **Stick file conventions**: what the `remote_services` stick does today, what we *might* add (presets / wlan / region) if we build a stick-driven path, and how that interacts with FAT credentials residency. Journey 1.
- **Automation cookbook**: example Home Assistant config, example shell scripts, common pitfalls. Journey 4.
## Cross-references
- AfterTouch installer: `scripts/on-device-install/install.sh`, `scripts/on-device-install/aftertouch` (init script), `scripts/on-device-install/README.md`.
- AfterTouch SSH client: `pkg/ssh/ssh.go` (`NewClient`, `Run`, `ReadFile`, `ReadDir`, `UploadContent`), already used by `pkg/service/setup/`.
- Storage limitations: issue #268 (ST20 rootfs free space), issue #196 (loopback-only bind), issue #250 (status reports running but unreachable).
- soundtouch-tiny: `https://github.com/GameTec-live/soundtouch-tiny`, raised in PR #292 (`https://github.com/gesellix/Bose-SoundTouch/pull/292`).
- opencloudtouch parallel discussion: `https://github.com/scheilch/opencloudtouch/discussions/201`.
- Existing parity doc shape: `docs/PARITY-OPENCLOUDTOUCH.md` is the precedent for cross-project comparison documents.
+138
View File
@@ -0,0 +1,138 @@
# Encrypted Diagnostic Export
AfterTouch can produce an encrypted diagnostic report that users can download and
send to the project maintainer without exposing sensitive data to third parties.
The report is encrypted with an SSH public key using
[`age`](https://github.com/FiloSottile/age); only the holder of the matching
private key can read it.
---
## What the report contains
The encrypted `.age` file decrypts to a `.tar.gz` archive with:
- `diagnostic.json` — structured summary:
- Service version and build info
- Full health-check results (same data as the Health tab)
- Per-device state: sources (IDs, names, SourceKeyTypes), presets (slot, name,
Source, SourceID, location), device product code, firmware version, IP, name
- `datastore/accounts/{id}/devices/{id}/*.xml` — raw XML files verbatim from
the sender's datastore (`Presets.xml`, `Sources.xml`, `Recents.xml`, …)
Having both the structured JSON and the raw XML lets you compare what the
service serves via HTTP against what is actually stored on disk.
**What is excluded from the JSON:** authentication tokens, credentials, OAuth
secrets, Spotify refresh tokens. The raw XML files are included as-is.
---
## Maintainer setup (one-time)
> This section is for the project maintainer only.
> Users never need to touch keys.
### 1. Generate the key pair
```bash
bash scripts/setup-diagnostic-key.sh
```
This creates:
- `keys/private/diagnostic` — SSH ed25519 private key (**gitignored**, never commit)
- `keys/private/diagnostic.pub` — copy for reference (**gitignored**)
- `keys/public/diagnostic.pub` — public key committed to the repo
### 2. Add the public key to GitHub
Go to <https://github.com/settings/ssh/new> and paste the contents of
`keys/public/diagnostic.pub`. This makes the key visible at
<https://github.com/gesellix.keys> so users can independently verify that the
key embedded in the binary matches a key actually controlled by the maintainer.
### 3. Embed the public key in the binary
Open `pkg/service/export/encrypt.go` and update the `DiagnosticPublicKey`
constant to match the new public key:
```go
const DiagnosticPublicKey = "ssh-ed25519 AAAA... aftertouch-diagnostic@gesellix"
```
### 4. Commit
```bash
git add keys/public/diagnostic.pub pkg/service/export/encrypt.go
git commit -m "keys: add diagnostic SSH public key"
```
`keys/private/` is `.gitignore`d — the private key will not be committed.
### 5. Back up the private key
The private key is **not** stored in git. Keep a copy in a secure location
(password manager, encrypted USB drive, etc.). If it is lost, a new key pair
must be generated and the constant in `encrypt.go` updated.
---
## Verifying the embedded key (users)
Users who want to confirm that the key embedded in their running binary matches
the maintainer's GitHub SSH keys can run:
```bash
# Compare the raw key text — both should show the same line:
curl -s https://github.com/gesellix.keys
cat keys/public/diagnostic.pub
```
The key should appear verbatim in both outputs.
---
## Decrypting a received report (maintainer)
When a user sends you an `aftertouch-diagnostic-*.age` file, use the helper
script (no extra tools needed — only Go and the private key). Run from the
repository root directory:
```bash
# Decrypt and extract in one step:
go run scripts/decrypt-diagnostic.go aftertouch-diagnostic-<timestamp>.age | tar xz
# Or decrypt to a .tar.gz first, then inspect:
go run scripts/decrypt-diagnostic.go aftertouch-diagnostic-<timestamp>.age > report.tar.gz
tar xzf report.tar.gz
# → diagnostic.json
# → datastore/accounts/{id}/devices/{id}/Presets.xml (and Sources.xml, Recents.xml, …)
```
The script uses only the `filippo.io/age` Go module — no separate `age` CLI
installation required.
---
## User workflow
1. Open the AfterTouch admin UI and go to the **Health** tab.
2. Click **Download diagnostic report**.
3. The browser downloads `aftertouch-diagnostic-<timestamp>.age`.
4. Attach the file to the GitHub issue or send it via a direct channel.
The file is opaque binary — the user cannot read it. All they see is that the
report was generated and downloaded.
---
## Key rotation
If the private key is compromised or lost:
1. Run `scripts/setup-diagnostic-key.sh` (delete the old `keys/private/diagnostic` first).
2. Add the new public key to GitHub and remove the old one.
3. Update `DiagnosticPublicKey` in `encrypt.go`.
4. Commit and tag a new release.
Old reports encrypted with the previous key cannot be decrypted with the new key.
+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
+5
View File
@@ -3,6 +3,7 @@
* [Introduction](README.md)
## User Guides
* [Device-Local Install Journeys](DEVICE-LOCAL-INSTALL.md)
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
* [Self-Hosting AfterTouch](guides/SELF-HOSTING.md)
* [Connecting Music Services](guides/MUSIC-SERVICES.md)
@@ -52,8 +53,12 @@
## 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)
## Analysis & Research
+454
View File
@@ -0,0 +1,454 @@
# Encrypting Sensitive Data Exports with SSH/age or GPG
## Problem
Allow users of our software to export potentially sensitive data, encrypt it locally, and send it to us. We decrypt on our side. Goal: no key exchange, minimal user friction.
Two viable options are documented here: **Option A — `age`** (simpler, modern) and **Option B — GPG** (widely known, interoperable with existing tooling). Both support fetching a recipient key from GitHub so users don't need to hand us anything.
## Key Findings
### GPG via SSH keys: not possible
- GitHub's `https://github.com/<user>.keys` serves SSH public keys, not GPG keys.
- SSH and GPG/OpenPGP use different formats, capability flags, and key material (auth vs. encrypt/sign/certify).
- Ed25519 SSH keys can't be directly reused for GPG encryption (encryption requires X25519/ECDH).
### GPG via published GPG keys: possible
- GitHub exposes GPG public keys at `https://github.com/<user>.gpg` — these are real OpenPGP armored keys, not SSH keys.
- Any key the user has uploaded to their GitHub account (or a keyserver like `keys.openpgp.org`) can be used directly for encryption.
- Decryption requires the matching GPG private key on our side.
- The `github.com/ProtonMail/go-crypto/openpgp` package is the actively maintained Go OpenPGP implementation (`golang.org/x/crypto/openpgp` is deprecated and points to it).
### `age` with SSH or native keys: possible (simpler)
- [`age`](https://github.com/FiloSottile/age) natively supports `ssh-rsa` and `ssh-ed25519` public keys as recipients, fetched from `https://github.com/<user>.keys`.
- Also supports its own `age1...` native keys (`age-keygen`), which are X25519-based.
- Written in Go; library is `filippo.io/age` + `filippo.io/age/agessh`.
- Output is age format (not GPG-interoperable). Decrypt with `age -i key file.age` or the Go library.
---
## Option A: `age`
### Architecture
1. Generate a dedicated age key: `age-keygen -o decrypt.key` (produces `age1...` public key).
2. Embed the public key as a constant in the binary — users need no setup.
3. Optionally accept a GitHub username and fetch their SSH keys as recipients so the user can verify independently.
4. Store the private key securely (secret manager, HSM, offline backup).
### Workflow
**User side:**
```
soundtouch-cli export --encrypt
# or: soundtouch-cli export --encrypt-for github:gesellix
```
The CLI encrypts the export using the embedded key (or fetched SSH keys) and writes `export.age`.
The user sends that file through any channel.
**Maintainer side:**
```bash
age -d -i decrypt.key -o export.tar.gz export.age
# or with an SSH private key:
age -d -i ~/.ssh/id_ed25519 -o export.tar.gz export.age
```
### Go Implementation
#### Encrypt with embedded key
```go
import (
"io"
"os"
"filippo.io/age"
)
const recipientKey = "age1..." // embedded public key
func exportEncrypted(plaintext io.Reader, outPath string) error {
recipient, err := age.ParseX25519Recipient(recipientKey)
if err != nil {
return err
}
out, err := os.Create(outPath)
if err != nil {
return err
}
defer out.Close()
w, err := age.Encrypt(out, recipient)
if err != nil {
return err
}
defer w.Close()
_, err = io.Copy(w, plaintext)
return err
}
```
#### Encrypt to a GitHub user's SSH keys (alternative / verification path)
```go
import (
"bufio"
"io"
"log"
"net/http"
"strings"
"filippo.io/age"
"filippo.io/age/agessh"
)
func recipientsFromGitHub(user string) ([]age.Recipient, error) {
resp, err := http.Get("https://github.com/" + user + ".keys")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var recipients []age.Recipient
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
r, err := agessh.ParseRecipient(line)
if err != nil {
log.Printf("skipping unsupported key: %v", err)
continue
}
recipients = append(recipients, r)
}
return recipients, nil
}
```
#### Decrypt (maintainer side)
With a native age key:
```go
package main
import "filippo.io/age"
func decryptAge(encryptedReader io.Reader, privateKeyString string) (io.Reader, error) {
identity, err := age.ParseX25519Identity(privateKeyString)
if err != nil {
return nil, err
}
return age.Decrypt(encryptedReader, identity)
}
```
With an SSH private key:
```go
package main
import (
"io"
"os"
"filippo.io/age"
"filippo.io/age/agessh"
)
func decryptAgeSSH(encryptedReader io.Reader, sshKeyPath string) (io.Reader, error) {
pemBytes, err := os.ReadFile(sshKeyPath)
if err != nil {
return nil, err
}
identity, err := agessh.ParseIdentity(pemBytes)
if err != nil {
return nil, err
}
return age.Decrypt(encryptedReader, identity)
}
```
---
## Option B: GPG (OpenPGP)
### Architecture
1. Generate a dedicated GPG encryption subkey: `gpg --full-gen-key` (choose RSA or Ed25519+X25519).
2. Export and publish the public key, or embed the armored block directly in the binary.
3. Optionally fetch the user's GPG key from `https://github.com/<user>.gpg` or `keys.openpgp.org` so they can confirm the recipient.
4. Store the private key securely. Decryption is `gpg --decrypt export.gpg`.
### Workflow
**User side:**
```
soundtouch-cli export --encrypt-gpg
# or: soundtouch-cli export --encrypt-gpg-for github:gesellix
```
The CLI encrypts the export as an OpenPGP binary message and writes `export.gpg`.
The user sends that file through any channel.
**Maintainer side:**
```bash
# GPG must have the matching private key in its keyring
gpg --decrypt -o export.tar.gz export.gpg
# Or with a specific key file (without importing into the keyring):
gpg --no-default-keyring --secret-keyring ./decrypt.gpg \
--decrypt -o export.tar.gz export.gpg
```
### Go Implementation
Uses `github.com/ProtonMail/go-crypto/openpgp` (the maintained successor to the deprecated `golang.org/x/crypto/openpgp`; API is compatible).
#### Fetch public key from GitHub
```go
package main
import (
"io"
"net/http"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
)
func gpgKeyFromGitHub(user string) (openpgp.EntityList, error) {
resp, err := http.Get("https://github.com/" + user + ".gpg")
if err != nil {
return nil, err
}
defer resp.Body.Close()
block, err := armor.Decode(resp.Body)
if err != nil {
return nil, err
}
return openpgp.ReadKeyRing(block.Body)
}
```
#### Encrypt with embedded or fetched public key
```go
package main
import (
"io"
"os"
"strings"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
)
const embeddedPublicKey = `-----BEGIN PGP PUBLIC KEY BLOCK-----
...
-----END PGP PUBLIC KEY BLOCK-----`
func exportEncryptedGPG(plaintext io.Reader, outPath string) error {
block, err := armor.Decode(strings.NewReader(embeddedPublicKey))
if err != nil {
return err
}
recipients, err := openpgp.ReadKeyRing(block.Body)
if err != nil {
return err
}
out, err := os.Create(outPath)
if err != nil {
return err
}
defer out.Close()
// Encrypt directly (binary, no ASCII armor — smaller output)
w, err := openpgp.Encrypt(out, recipients, nil, nil, nil)
if err != nil {
return err
}
defer w.Close()
_, err = io.Copy(w, plaintext)
return err
}
```
To produce ASCII-armored output (easier to paste into emails/issues), wrap `out` with `armor.Encode`:
```go
package main
import (
"io"
"os"
"strings"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
)
func exportEncryptedGPGArmored(plaintext io.Reader, outPath, embeddedPublicKey string) error {
block, err := armor.Decode(strings.NewReader(embeddedPublicKey))
if err != nil {
return err
}
recipients, err := openpgp.ReadKeyRing(block.Body)
if err != nil {
return err
}
out, err := os.Create(outPath)
if err != nil {
return err
}
defer out.Close()
armorWriter, err := armor.Encode(out, "PGP MESSAGE", nil)
if err != nil {
return err
}
defer armorWriter.Close()
w, err := openpgp.Encrypt(armorWriter, recipients, nil, nil, nil)
if err != nil {
return err
}
defer w.Close()
_, err = io.Copy(w, plaintext)
return err
}
```
#### Decrypt (maintainer side)
```go
package main
import (
"io"
"os"
"strings"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
)
func decryptGPG(encryptedPath, privateKeyArmored string) (io.ReadCloser, error) {
block, err := armor.Decode(strings.NewReader(privateKeyArmored))
if err != nil {
return nil, err
}
keyring, err := openpgp.ReadKeyRing(block.Body)
if err != nil {
return nil, err
}
f, err := os.Open(encryptedPath)
if err != nil {
return nil, err
}
msg, err := openpgp.ReadMessage(f, keyring, nil, nil)
if err != nil {
return nil, err
}
return msg.UnverifiedBody, nil
}
```
---
## Comparison and Recommendation
| Criterion | `age` | GPG |
|-------------------------------------|------------------------------|-----------------------------------------------|
| User familiarity | Low (newer tool) | High (widely known) |
| User already has a key to use | Maybe (SSH on GitHub) | Often (GPG on GitHub/keyserver) |
| Go library quality | Excellent (`filippo.io/age`) | Good (`ProtonMail/go-crypto`) |
| Output interoperability | age format only | Standard OpenPGP — any GPG client can decrypt |
| CLI decrypt UX (maintainer) | `age -d -i key file.age` | `gpg --decrypt file.gpg` |
| Key embedding in binary | Native `age1...` string | Armored PEM block |
| Key rotation story | `age-keygen`, swap constant | Standard GPG subkey rotation |
| Anonymous recipients | Yes (native age keys) | No (key ID visible) |
| Streaming large exports | Yes | Yes |
**Recommendation:** use `age` with an embedded native key for the primary path — simpler dependency, cleaner API, no GPG keyring management needed. Add GPG as an opt-in flag (`--gpg` or `--encrypt-gpg-for github:<user>`) for users who already manage GPG keys and want their own tooling to verify or store the export.
---
## Binary Size
Measured on macOS arm64, stripped binaries (`-ldflags="-s -w"`).
### Standalone cost (no shared deps)
| Option | Binary size | Added vs no-crypto baseline |
|-------------------------------------|-------------|-----------------------------|
| Baseline (no crypto) | 1.44 MB | — |
| `age` native key only (no `agessh`) | 2.40 MB | +0.96 MB |
| `age` + `agessh` (SSH recipients) | 3.06 MB | +1.63 MB |
| GPG (`ProtonMail/go-crypto`) | 3.59 MB | +2.15 MB |
### Marginal cost for this project
This project already imports `golang.org/x/crypto/ssh`, which `agessh` depends on. That ~660 KB is shared and doesn't count against `age`. Against the ~12.9 MB `soundtouch-service` binary:
| Option | Marginal cost | % of service binary |
|------------------|---------------|---------------------|
| `age` + `agessh` | +0.64 MB | ~5% |
| GPG | +1.30 MB | ~10% |
### Why GPG is larger
`age` pulls in only what it needs: `chacha20poly1305`, `hkdf`, `edwards25519`, and `filippo.io/hpke` (post-quantum). `ProtonMail/go-crypto` must ship the full OpenPGP spec: `cloudflare/circl` (Ed448, X448, Goldilocks curves), `bitcurves`, `brainpool`, `EAX`, `OCB`, `CAST5`, `BLAKE2b`, `SHA3`, `Argon2`, S2K key derivation, and zlib/bzip2 compression. Go's dead-code elimination works at the function level but can't remove entire algorithm families wired through a shared codec dispatch.
---
## Gotchas & Risks
| Concern | Mitigation |
|--------------------------------------------------|-------------------------------------------------------------------------------------|
| MITM / GitHub account compromise swaps the key | Pin expected key fingerprint(s); prefer embedded key over runtime fetch |
| SSH key rotation breaks old `agessh` decryption | Use a dedicated long-lived age key, not the user's SSH key, as primary |
| ECDSA SSH keys not supported by `agessh` | Handle "no usable key" gracefully; warn and fall back |
| `agessh` recipients leak a 32-bit key ID | Accept, or use native age keys for full anonymity |
| GPG key expiry breaks encryption | Use a non-expiring encryption subkey, or check and warn before encrypting |
| GPG key without encryption capability | Filter `EntityList` to keys with `EncryptCommunications` flag set |
| Encryption ≠ authentication | Authenticate via the send channel, or require a detached signature |
| Sensitive data leaks via logs or memory dumps | Audit all egress paths; the export must be the only cleartext exit |
| Large exports | Both `age` and `openpgp.Encrypt` stream — never buffer the whole payload |
---
## Dependencies
```bash
# age
go get filippo.io/age
go get filippo.io/age/agessh # only if supporting SSH recipients
# GPG
go get github.com/ProtonMail/go-crypto/openpgp
```
## References
- `age` project: https://github.com/FiloSottile/age
- `age` Go docs: https://pkg.go.dev/filippo.io/age
- `agessh` docs: https://pkg.go.dev/filippo.io/age/agessh
- ProtonMail go-crypto: https://github.com/ProtonMail/go-crypto
- OpenPGP Go docs: https://pkg.go.dev/github.com/ProtonMail/go-crypto/openpgp
- GitHub GPG key endpoint: `https://github.com/<user>.gpg`
- OpenPGP keyserver: https://keys.openpgp.org
+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: 142 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 517 KiB

After

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 359 KiB

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 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.89.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.89.0
require github.com/gorilla/websocket v1.5.3 // indirect
+7 -4
View File
@@ -3,6 +3,7 @@ module github.com/gesellix/bose-soundtouch
go 1.26.3
require (
filippo.io/age v1.3.1
github.com/chromedp/chromedp v0.15.1
github.com/go-chi/chi/v5 v5.2.5
github.com/google/gopacket v1.1.19
@@ -14,12 +15,14 @@ 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
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
filippo.io/hpke v0.4.0 // indirect
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
@@ -28,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
)
+16 -8
View File
@@ -1,3 +1,11 @@
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0=
filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b h1:fpvdcCAe2z3H8OvVY00iKOp3Wapbs/Gy375Fn6l/XM4=
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
@@ -62,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=
@@ -87,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=
@@ -112,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=
+1
View File
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBX6szVJwwBCTTCdLiqkfJiwjEFOx/HwdQgsf/aPHsUN aftertouch-diagnostic@gesellix
+4 -4
View File
@@ -8,7 +8,7 @@
"license": "MIT",
"dependencies": {
"htm": "3.1.1",
"preact": "10.26.1"
"preact": "10.29.2"
},
"engines": {
"node": ">=24.0.0"
@@ -21,9 +21,9 @@
"license": "Apache-2.0"
},
"node_modules/preact": {
"version": "10.26.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.26.1.tgz",
"integrity": "sha512-K5aMG0NdGHZ8yV1GfGtGA4JwnWxe/HIDzyr9svdo2DeokLUJ/+W8MpeuPrfOytu5rHHgYQrvGxUoW83sapJZnw==",
"version": "10.29.2",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz",
"integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
+1 -1
View File
@@ -10,6 +10,6 @@
},
"dependencies": {
"htm": "3.1.1",
"preact": "10.26.1"
"preact": "10.29.2"
}
}
+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
}
+108 -28
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.
@@ -153,29 +223,39 @@ func (d *DNSDiscovery) recordQuery(hostname string, isIntercepted bool, remoteAd
}
}
// InterceptedBoseHosts is the canonical list of Bose cloud service
// hostnames the DNS server hijacks. Exposed so other packages
// (e.g. the Health tab's DNS sanity check) can iterate the list
// without duplicating it.
var InterceptedBoseHosts = []string{
"api.bose.com",
"marge.bose.com",
"bmx.bose.com",
"streaming.bose.com",
"streamingoauth.bose.com",
"updates.bose.com",
"stats.bose.com",
"content.api.bose.io",
"events.api.bosecm.com",
"bose-prod.apigee.net",
"bose-test.apigee.net",
"worldwide.bose.com",
"music.api.bose.com",
"bosecm.com",
"bose.io",
"downloads.bose.com",
}
func (d *DNSDiscovery) shouldIntercept(hostname string) bool {
// Intercept known Bose cloud services
interceptList := []string{
"api.bose.com",
"marge.bose.com",
"bmx.bose.com",
"streaming.bose.com",
"streamingoauth.bose.com",
"updates.bose.com",
"stats.bose.com",
"content.api.bose.io",
"events.api.bosecm.com",
"bose-prod.apigee.net",
"bose-test.apigee.net",
"worldwide.bose.com",
"music.api.bose.com",
"bosecm.com",
"bose.io",
"downloads.bose.com",
for _, service := range InterceptedBoseHosts {
if strings.Contains(hostname, service) {
return true
}
}
for _, service := range interceptList {
if strings.Contains(hostname, service) {
lower := strings.ToLower(hostname)
for _, h := range d.derivedHosts {
if lower == h {
return true
}
}
+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
+10 -36
View File
@@ -277,7 +277,7 @@ func tuneInNavigatePlayItem(item map[string]interface{}) models.BmxNavItem {
Subtitle: subtitle,
Links: &models.Links{
BmxPlayback: &models.Link{
Href: TuneInStream(stationID, ""),
Href: fmt.Sprintf("/v1/playback/station/%s", stationID),
Type: "stationurl",
},
},
@@ -373,10 +373,11 @@ func tuneInSearchSection(item map[string]interface{}, idx int, query, layout str
}
switch typeStr {
case "Station", "PlayItem":
case "Station", "PlayItem", "Topic":
// Topics are single podcast episodes (t<N>) — Tune.ashx
// accepts them just like station IDs, so the same play-link
// shape works.
section.Items = append(section.Items, tuneInSearchPlayItem(cm))
case "Topic":
section.Items = append(section.Items, tuneInSearchTopic(cm))
case "Program", "Profile":
section.Items = append(section.Items, tuneInSearchProfile(cm, name))
}
@@ -412,43 +413,13 @@ func tuneInSearchPlayItem(item map[string]interface{}) models.BmxNavItem {
Subtitle: subtitle,
Links: &models.Links{
BmxPlayback: &models.Link{
Href: TuneInStream(stationID, ""),
Href: fmt.Sprintf("/v1/playback/station/%s", stationID),
Type: "stationurl",
},
},
}
}
func tuneInSearchTopic(item map[string]interface{}) models.BmxNavItem {
name, _ := item["Title"].(string)
if name == "" {
name, _ = item["text"].(string)
}
image, _ := item["Image"].(string)
if image == "" {
image, _ = item["image"].(string)
}
subtitle, _ := item["Subtitle"].(string)
if subtitle == "" {
subtitle, _ = item["subtext"].(string)
}
href, _ := item["URL"].(string)
return models.BmxNavItem{
Name: name,
ImageUrl: image,
Subtitle: subtitle,
Links: &models.Links{
BmxNavigate: &models.Link{
Href: "/v1/navigate/" + base64.RawURLEncoding.EncodeToString([]byte(tuneInRenderJSONURI(href))),
},
},
}
}
func tuneInSearchProfile(item map[string]interface{}, _ string) models.BmxNavItem {
profileName, _ := item["Title"].(string)
if profileName == "" {
@@ -481,13 +452,16 @@ func tuneInSearchProfile(item map[string]interface{}, _ string) models.BmxNavIte
// Artists/Stations/etc are typically navigated first.
if typeStr, _ := item["Type"].(string); typeStr == "Program" {
if guideID, _ := item["GuideId"].(string); guideID != "" {
encodedName := base64.URLEncoding.EncodeToString([]byte(profileName))
playbackHref := fmt.Sprintf("/v1/playback/episodes/%s?encoded_name=%s", guideID, encodedName)
return models.BmxNavItem{
Name: profileName,
ImageUrl: image,
Subtitle: subtitle,
Links: &models.Links{
BmxPlayback: &models.Link{
Href: TuneInStream(guideID, "") + "&encoded_name=" + url.QueryEscape(profileName),
Href: playbackHref,
Type: "tracklisturl",
},
BmxNavigate: &models.Link{
+257 -21
View File
@@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"io"
"log"
"math/rand"
"os"
"path/filepath"
@@ -617,19 +618,50 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) {
}
devices := []models.ServiceDeviceInfo{}
seenIDs := make(map[string]bool)
type seenEntry struct {
index int
account string
}
seenIDs := make(map[string]seenEntry)
for _, dir := range dirs {
accounts, err := os.ReadDir(dir)
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, acc := range accounts {
if !acc.IsDir() {
continue
// Sort "default" to the back so a real-account entry always
// wins the first-seen race. "default" exists as a pre-pair
// placeholder; once the speaker pairs with a real account it
// becomes orphan state. We don't try to pick a winner among
// multiple real accounts via mtime or other proxies — the
// authoritative signal is the URL of the speaker's incoming
// PUT, which only the live handler sees. Stale real-account
// entries are flagged as findings by the consistency check
// instead.
accounts := make([]os.DirEntry, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
accounts = append(accounts, e)
}
}
sort.SliceStable(accounts, func(i, j int) bool {
ai, aj := accounts[i].Name(), accounts[j].Name()
if ai == accountIDDefault && aj != accountIDDefault {
return false
}
if aj == accountIDDefault && ai != accountIDDefault {
return true
}
return ai < aj
})
for _, acc := range accounts {
accDevices := ds.listDevicesInAccount(dir, acc.Name())
for i := range accDevices {
info := accDevices[i]
@@ -640,21 +672,35 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) {
key = info.IPAddress
}
if !seenIDs[key] || info.Name != "" {
if seenIDs[key] && info.Name != "" {
// Replace previous empty-named entry with one that has a name
for j := range devices {
existing := &devices[j]
if (existing.DeviceID != "" && existing.DeviceID == info.DeviceID) ||
(existing.IPAddress != "" && existing.IPAddress == info.IPAddress) {
devices[j] = info
break
}
}
} else if !seenIDs[key] {
devices = append(devices, info)
seenIDs[key] = true
entry, alreadySeen := seenIDs[key]
if !alreadySeen {
devices = append(devices, info)
seenIDs[key] = seenEntry{index: len(devices) - 1, account: info.AccountID}
continue
}
// "default" never replaces a real-account entry.
// But when two "default" entries collide across data dirs,
// prefer the one with a non-empty name (more information).
if info.AccountID == accountIDDefault {
if entry.account == accountIDDefault && devices[entry.index].Name == "" && info.Name != "" {
devices[entry.index] = info
seenIDs[key] = seenEntry{index: entry.index, account: info.AccountID}
}
continue
}
// First real-account encountered wins (sort is
// stable + alphabetical so the result is
// deterministic across runs). Don't pick a different
// winner here — the consistency check enumerates all
// the duplicate account dirs for the operator to
// clean up.
if entry.account == accountIDDefault {
devices[entry.index] = info
seenIDs[key] = seenEntry{index: entry.index, account: info.AccountID}
}
}
}
@@ -663,6 +709,52 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) {
return devices, nil
}
// AllAccountsForDevice returns every account directory that contains a
// device with the given deviceID, in their on-disk order. Used by the
// consistency check to enumerate stale account entries left behind
// when the speaker was re-paired to a different account.
func (ds *DataStore) AllAccountsForDevice(deviceID string) []string {
if deviceID == "" {
return nil
}
var hits []string
seen := map[string]bool{}
for _, dir := range ds.getPossibleDataDirs() {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, acc := range entries {
if !acc.IsDir() {
continue
}
devicePath := filepath.Join(dir, acc.Name(), "devices", deviceID)
if _, err := os.Stat(devicePath); err != nil {
continue
}
if !seen[acc.Name()] {
seen[acc.Name()] = true
hits = append(hits, acc.Name())
}
}
}
return hits
}
// accountIDDefault is the placeholder account id assigned to records
// that exist before a speaker has been paired with a real Marge
// account. Treated as fallback in ListAllDevices and skipped in
// consistency checks when a real-account entry exists for the same
// device.
const accountIDDefault = "default"
func (ds *DataStore) getPossibleDataDirs() []string {
dirs := []string{}
// Check primary data directory
@@ -880,7 +972,7 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
presets = append(presets, models.ServicePreset{
ServiceContentItem: models.ServiceContentItem{
Name: p.ContentItem.ItemName,
Source: p.ContentItem.Source,
Source: repairLeakedSource(account, device, "preset "+p.ID, p.ContentItem.Source, p.SourceID, ds),
Type: p.ContentItem.Type,
ContentItemType: p.ContentItem.Type,
Location: p.ContentItem.Location,
@@ -899,6 +991,101 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
return presets, nil
}
// repairLeakedSource quietly substitutes the speaker-perspective
// SourceKeyType for a persisted Source that has the protocol-level
// "Audio" leak signature from the legacy syncPresets/syncRecents path.
// Critically it only repairs the *leak* — when persisted Source carries
// a non-leak symbolic value, even one that disagrees with the current
// Sources.xml mapping for the same SourceID, we leave it alone. That's
// what protects the GH-343 case: a TUNEIN preset whose SourceID was
// re-classified to RADIOPLAYER in Sources.xml must stay TUNEIN here,
// otherwise the speaker's previously-stored intent gets silently
// overwritten by the stale source-list entry.
//
// Speaker is the source of truth: this only un-rots data that was
// never the speaker's perspective to begin with.
func repairLeakedSource(account, device, label, persistedSource, sourceID string, ds *DataStore) string {
if !isLeakedSourceValue(persistedSource) {
return persistedSource
}
if sourceID == "" {
return persistedSource
}
sources, err := ds.getConfiguredSourcesLocked(account, device)
if err != nil {
return persistedSource
}
for i := range sources {
if sources[i].ID == sourceID && sources[i].SourceKeyType != "" {
log.Printf("[Datastore] %s: repaired leaked source %q -> %q via sourceid=%s (account=%s device=%s) — likely written by the pre-fix marge.syncPresets/syncRecents path; speaker's perspective is now restored on read",
label, persistedSource, sources[i].SourceKeyType, sourceID, account, device)
return sources[i].SourceKeyType
}
}
return persistedSource
}
// isLeakedSourceValue identifies the known protocol-level leak signature
// (the upstream <source type="Audio"> attribute that the old
// marge.syncPresets persisted into ServicePreset.Source). Empty Source
// is also treated as a leak so the resolve fires for legacy entries
// missing the attribute entirely.
func isLeakedSourceValue(s string) bool {
return s == "" || s == "Audio"
}
// getConfiguredSourcesLocked is GetConfiguredSources without the
// fileMutex.RLock() — callers must already hold it. Used by
// repairLeakedSource from within GetPresets/GetRecents which already
// hold the lock.
func (ds *DataStore) getConfiguredSourcesLocked(account, device string) ([]models.ConfiguredSource, error) {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
data, err := ds.rootReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return ds.getDefaultSources(), nil
}
return nil, err
}
type persistentSource struct {
ID string `xml:"id,attr,omitempty"`
Type string `xml:"type,attr,omitempty"`
SourceKeyType string `xml:"-"`
SourceKey struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
} `xml:"sourceKey"`
}
var sourcesWrap struct {
Sources []persistentSource `xml:"source"`
}
if err := xml.Unmarshal(data, &sourcesWrap); err != nil {
return nil, err
}
out := make([]models.ConfiguredSource, 0, len(sourcesWrap.Sources))
for i := range sourcesWrap.Sources {
ps := sourcesWrap.Sources[i]
out = append(out, models.ConfiguredSource{
ID: ps.ID,
Type: ps.Type,
SourceKeyType: ps.SourceKey.Type,
})
}
return out, nil
}
// SavePresets saves the preset list for the specified account and device.
func (ds *DataStore) SavePresets(account, device string, presets []models.ServicePreset) error {
ds.fileMutex.Lock()
@@ -948,7 +1135,25 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
pxml.ContentItem.Type = p.Type
pxml.ContentItem.Location = p.Location
pxml.ContentItem.SourceAccount = p.SourceAccount
pxml.ContentItem.IsPresetable = "true"
// Preserve the speaker's IsPresetable verdict instead of forcing
// "true". The speaker firmware sets isPresetable="false" for
// content it can't independently recall later (e.g. Spotify Connect
// pushes from a phone — see GH-235). Hard-coding "true" makes the
// on-disk XML look valid while the speaker still refuses to play
// the preset, which leaves users debugging a phantom "stored but
// won't play" state. Default to "true" only when the caller
// supplied nothing.
pxml.ContentItem.IsPresetable = p.IsPresetable
if pxml.ContentItem.IsPresetable == "" {
pxml.ContentItem.IsPresetable = "true"
}
if pxml.ContentItem.IsPresetable == "false" {
log.Printf("[Datastore] SavePresets: storing preset %s as isPresetable=false (account=%s device=%s source=%s) — speaker firmware marked this content non-recallable; preset will appear on the speaker but pressing it will not play",
pxml.ID, account, device, p.Source)
}
pxml.ContentItem.ItemName = p.Name
pxml.ContentItem.ContainerArt = p.ContainerArt
pxml.SourceID = p.SourceID
@@ -1067,6 +1272,8 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
maxID++
recents[i].ID = strconv.Itoa(maxID)
}
r.Source = repairLeakedSource(account, device, "recent "+r.ID, r.Source, r.SourceID, ds)
}
return recents, nil
@@ -1861,6 +2068,27 @@ func (ds *DataStore) GetDefaultSources() []models.ConfiguredSource {
return ds.getDefaultSources()
}
// CanonicalSourceByID returns the canonical default ConfiguredSource for one
// of the well-known built-in source IDs (10001..10005). The returned source
// has its SourceKey.Type / SourceKey.Account mirrored from
// SourceKeyType / SourceKeyAccount so it round-trips through SaveConfiguredSources
// without losing the embedded struct fields. Returns (zero, false) when the
// ID isn't one we can synthesise.
//
// Callers that auto-add missing sources (e.g. UpdatePreset when a speaker
// PUTs a preset referencing a canonical source AfterTouch hasn't been told
// about yet) should use this to keep parity with the post-pair defaults.
func (ds *DataStore) CanonicalSourceByID(id string) (models.ConfiguredSource, bool) {
defaults := ds.getDefaultSources()
for i := range defaults {
if defaults[i].ID == id {
return defaults[i], true
}
}
return models.ConfiguredSource{}, false
}
func (ds *DataStore) getDefaultSources() []models.ConfiguredSource {
sources := []models.ConfiguredSource{
{
@@ -2118,6 +2346,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
@@ -0,0 +1,107 @@
package datastore
import (
"os"
"path/filepath"
"testing"
)
// TestListAllDevices_RealAccountWinsOverDefault reproduces the operator's
// observation: the same physical device exists on disk under both
// accounts/default/devices/<id>/ (leftover pre-pair state) and
// accounts/<real>/devices/<id>/ (active pairing). The dedupe loop used to
// let "default" replace the real-account entry whenever the default
// directory's DeviceInfo.xml had a non-empty <name>, which made
// consistency checks report the device under "default" while the speaker
// was happily POST/PUT'ing to the real account.
//
// "default" must be treated as a fallback placeholder, never as
// authoritative when a real account also has the device.
func TestListAllDevices_RealAccountWinsOverDefault(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore-default-orphan-*")
if err != nil {
t.Fatalf("tempdir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
deviceID := "AABBCCDDEEFF"
// Default-account entry: stale pre-pair state with a name.
defaultDir := filepath.Join(tempDir, "accounts", "default", "devices", deviceID)
if err := os.MkdirAll(defaultDir, 0755); err != nil {
t.Fatalf("mkdir default: %v", err)
}
if err := os.WriteFile(filepath.Join(defaultDir, "DeviceInfo.xml"), []byte(`<?xml version="1.0"?>
<info deviceID="`+deviceID+`"><name>Discovered Device</name></info>`), 0644); err != nil {
t.Fatalf("write default info: %v", err)
}
// Real-account entry: the speaker is paired here.
realDir := filepath.Join(tempDir, "accounts", "1111111", "devices", deviceID)
if err := os.MkdirAll(realDir, 0755); err != nil {
t.Fatalf("mkdir real: %v", err)
}
if err := os.WriteFile(filepath.Join(realDir, "DeviceInfo.xml"), []byte(`<?xml version="1.0"?>
<info deviceID="`+deviceID+`"><name>Living Room SoundTouch</name></info>`), 0644); err != nil {
t.Fatalf("write real info: %v", err)
}
ds := NewDataStore(tempDir)
devices, err := ds.ListAllDevices()
if err != nil {
t.Fatalf("ListAllDevices: %v", err)
}
if len(devices) != 1 {
t.Fatalf("expected exactly 1 deduped device, got %d: %+v", len(devices), devices)
}
if devices[0].AccountID != "1111111" {
t.Errorf("expected real account 1111111 to win dedup, got AccountID=%q (default leaked through)", devices[0].AccountID)
}
if devices[0].Name != "Living Room SoundTouch" {
t.Errorf("expected real-account Name to survive dedup, got %q", devices[0].Name)
}
}
// TestListAllDevices_DefaultOnlyDeviceStillSeen confirms the dedup
// preference is one-way: a device that *only* exists under "default"
// (e.g. fresh discovery, never paired) is still returned. We just
// refuse to let "default" override a real account.
func TestListAllDevices_DefaultOnlyDeviceStillSeen(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore-default-only-*")
if err != nil {
t.Fatalf("tempdir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
deviceID := "112233445566"
defaultDir := filepath.Join(tempDir, "accounts", "default", "devices", deviceID)
if err := os.MkdirAll(defaultDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(defaultDir, "DeviceInfo.xml"), []byte(`<?xml version="1.0"?>
<info deviceID="`+deviceID+`"><name>Newly Discovered</name></info>`), 0644); err != nil {
t.Fatalf("write: %v", err)
}
ds := NewDataStore(tempDir)
devices, err := ds.ListAllDevices()
if err != nil {
t.Fatalf("ListAllDevices: %v", err)
}
if len(devices) != 1 {
t.Fatalf("expected 1 device, got %d", len(devices))
}
if devices[0].AccountID != "default" {
t.Errorf("expected default-only device to be returned with AccountID=default, got %q", devices[0].AccountID)
}
}
@@ -0,0 +1,74 @@
package datastore
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
// TestSavePresets_PreservesIsPresetable is a regression test for GH-235:
// SavePresets used to hard-code isPresetable="true", masking the speaker's
// own verdict that Spotify-Connect content isn't recallable. Storing the
// preset looked like it succeeded but pressing it on the speaker did
// nothing. The fix preserves whatever IsPresetable the speaker provided,
// defaulting to "true" only when the caller supplied an empty string.
func TestSavePresets_PreservesIsPresetable(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore-ispresetable-*")
if err != nil {
t.Fatalf("tempdir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := NewDataStore(tempDir)
account := "1234567"
device := "AABBCCDDEEFF"
presets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
Name: "Connect Playlist",
Source: "SPOTIFY",
IsPresetable: "false",
},
ButtonNumber: "1",
},
{
ServiceContentItem: models.ServiceContentItem{
Name: "Default Truth",
Source: "TUNEIN",
IsPresetable: "true",
},
ButtonNumber: "2",
},
{
ServiceContentItem: models.ServiceContentItem{
Name: "Caller Left Empty",
Source: "INTERNET_RADIO",
// IsPresetable intentionally unset
},
ButtonNumber: "3",
},
}
if err := ds.SavePresets(account, device, presets); err != nil {
t.Fatalf("SavePresets: %v", err)
}
body, err := os.ReadFile(filepath.Join(ds.AccountDeviceDir(account, device), "Presets.xml"))
if err != nil {
t.Fatalf("read Presets.xml: %v", err)
}
got := string(body)
if !strings.Contains(got, `id="1"`) || !strings.Contains(got, `isPresetable="false"`) {
t.Errorf("preset 1: expected isPresetable=\"false\" preserved; Presets.xml:\n%s", got)
}
if !strings.Contains(got, `id="2"`) || strings.Count(got, `isPresetable="true"`) < 2 {
t.Errorf("preset 2/3: expected isPresetable=\"true\" (preset 2 from caller, preset 3 from empty-string default); Presets.xml:\n%s", got)
}
}
@@ -0,0 +1,182 @@
package datastore
import (
"os"
"path/filepath"
"testing"
)
// TestGetPresets_RepairsAudioLeakViaSourceID exercises the on-read repair:
// a persisted preset whose Source is the protocol-level "Audio" leak gets
// resolved to the speaker-perspective SourceKeyType through the device's
// Sources.xml. Speaker is the source of truth; this just un-rots data the
// legacy syncPresets path wrote.
func TestGetPresets_RepairsAudioLeakViaSourceID(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore-repair-leak-*")
if err != nil {
t.Fatalf("tempdir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
account := "1234567"
device := "AABBCCDDEEFF"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// Sources.xml claims id=14774275 is a TUNEIN source.
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
<sources>
<source id="14774275">
<sourceKey type="TUNEIN" account=""/>
</source>
</sources>`
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644); err != nil {
t.Fatalf("write Sources.xml: %v", err)
}
// Presets.xml carries the leak: source="Audio" + sourceid=14774275.
presetsXML := `<?xml version="1.0" encoding="UTF-8"?>
<presets>
<preset id="4" createdOn="0" updatedOn="0">
<contentItem source="Audio" type="stationurl" location="/v1/playback/station/s166521" sourceAccount="" isPresetable="true">
<itemName>SMOOTH JAZZ</itemName>
<containerArt></containerArt>
</contentItem>
<sourceid>14774275</sourceid>
</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) != 1 {
t.Fatalf("expected 1 preset, got %d", len(presets))
}
if presets[0].Source != "TUNEIN" {
t.Errorf("expected repaired Source=TUNEIN, got %q", presets[0].Source)
}
}
// TestGetPresets_PreservesNonLeakedSource is the GH-343 protection: a
// preset persisted with Source=TUNEIN must stay TUNEIN even when
// Sources.xml has been re-classified to RADIOPLAYER. The speaker's
// previously-stored intent wins over a stale current Sources.xml entry.
func TestGetPresets_PreservesNonLeakedSource(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore-preserve-source-*")
if err != nil {
t.Fatalf("tempdir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
account := "1234567"
device := "AABBCCDDEEFF"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// Sources.xml *currently* says id=14774275 is RADIOPLAYER (stale /
// drifted classification). The preset was stored earlier when the
// same id was understood as TUNEIN.
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
<sources>
<source id="14774275">
<sourceKey type="RADIOPLAYER" account=""/>
</source>
</sources>`
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644); err != nil {
t.Fatalf("write Sources.xml: %v", err)
}
presetsXML := `<?xml version="1.0" encoding="UTF-8"?>
<presets>
<preset id="4" createdOn="0" updatedOn="0">
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s166521" sourceAccount="" isPresetable="true">
<itemName>SMOOTH JAZZ</itemName>
</contentItem>
<sourceid>14774275</sourceid>
</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 presets[0].Source != "TUNEIN" {
t.Errorf("expected speaker's previously-stored Source=TUNEIN to be preserved, got %q (GH-343 silent rewrite would substitute RADIOPLAYER)", presets[0].Source)
}
}
// TestGetRecents_RepairsAudioLeakViaSourceID applies the same load-time
// repair to recents — symmetric protection for the recents pipeline.
func TestGetRecents_RepairsAudioLeakViaSourceID(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore-repair-recent-*")
if err != nil {
t.Fatalf("tempdir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
account := "1234567"
device := "AABBCCDDEEFF"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
<sources>
<source id="9330201">
<sourceKey type="INTERNET_RADIO" account=""/>
</source>
</sources>`
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644); err != nil {
t.Fatalf("write Sources.xml: %v", err)
}
recentsXML := `<?xml version="1.0" encoding="UTF-8"?>
<recents>
<recent id="rec-1">
<contentItem source="Audio" type="stationurl" location="19059" sourceAccount="" isPresetable="true">
<itemName>Russkoe Radio Ukraine</itemName>
</contentItem>
<sourceid>9330201</sourceid>
</recent>
</recents>`
if err := os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(recentsXML), 0644); err != nil {
t.Fatalf("write Recents.xml: %v", err)
}
ds := NewDataStore(tempDir)
recents, err := ds.GetRecents(account, device)
if err != nil {
t.Fatalf("GetRecents: %v", err)
}
if len(recents) != 1 {
t.Fatalf("expected 1 recent, got %d", len(recents))
}
if recents[0].Source != "INTERNET_RADIO" {
t.Errorf("expected repaired Source=INTERNET_RADIO, got %q", recents[0].Source)
}
}
+339
View File
@@ -0,0 +1,339 @@
// Package ding renders the AfterTouch "ding" signature sound — a
// two-chirp tone derived from the braille letters S and T that
// make up the AfterTouch logo. Used as the Health-tab test
// playback target: pushed to a speaker as a custom-radio
// ContentItem so operators can confirm a freshly migrated speaker
// emits audio without depending on TuneIn or any external service.
//
// Mapping:
//
// Braille S = ⠎ = dots 2, 3, 4
// Braille T = ⠞ = dots 2, 3, 4, 5
//
// Dot positions in the 6-dot grid:
// 1 4
// 2 5
// 3 6
//
// Columns → stereo channels (left=1,2,3 / right=4,5,6).
// Rows → pitches: top=PitchHigh, mid=PitchMid, bottom=PitchLow.
//
// So S (dots 2,3,4) renders as L=PitchMid+PitchLow, R=PitchHigh,
// and T (dots 2,3,4,5) adds R=PitchMid on top of S.
//
// Render(opts) returns a self-contained 16-bit stereo PCM WAV.
// Default options produce a ~600 ms / 52 KB clip; callers can
// override any subset and let the rest fall back to defaults
// (see DefaultOptions).
package ding
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"math"
)
// Options controls the synthesis. A zero-valued Options struct
// is *not* usable directly; the WithDefaults method fills in
// sensible numbers for unset fields so callers can supply only
// the parameters they want to override.
type Options struct {
SampleRate int // Hz. Default 22050.
PitchHigh float64 // Hz, top row (A5=880).
PitchMid float64 // Hz, middle row (E5=659.2551).
PitchLow float64 // Hz, bottom row (A4=440).
ChirpDuration float64 // seconds per chirp. Default 0.25.
GapDuration float64 // seconds between chirps. Default 0.10.
AttackDuration float64 // seconds of fade-in per chirp. Default 0.020.
ReleaseDuration float64 // seconds of fade-out per chirp. Default 0.060.
Peak float64 // final-mix headroom; 0 < Peak <= 1.0. Default 0.85.
}
// DefaultOptions returns the canonical option set used by the
// runtime handler when no overrides are supplied.
func DefaultOptions() Options {
return Options{
SampleRate: 22050,
PitchHigh: 880.00,
PitchMid: 659.2551,
PitchLow: 440.00,
ChirpDuration: 0.25,
GapDuration: 0.10,
AttackDuration: 0.020,
ReleaseDuration: 0.060,
Peak: 0.85,
}
}
// WithDefaults returns a copy of o with any zero-valued fields
// filled in from DefaultOptions. Lets callers write
//
// ding.Options{PitchHigh: 1000}.WithDefaults()
//
// instead of restating every field.
func (o Options) WithDefaults() Options {
d := DefaultOptions()
if o.SampleRate <= 0 || o.SampleRate > int(maxSampleRate) {
o.SampleRate = d.SampleRate
}
if o.PitchHigh <= 0 {
o.PitchHigh = d.PitchHigh
}
if o.PitchMid <= 0 {
o.PitchMid = d.PitchMid
}
if o.PitchLow <= 0 {
o.PitchLow = d.PitchLow
}
if o.ChirpDuration <= 0 {
o.ChirpDuration = d.ChirpDuration
}
if o.GapDuration <= 0 {
o.GapDuration = d.GapDuration
}
if o.AttackDuration <= 0 {
o.AttackDuration = d.AttackDuration
}
if o.ReleaseDuration <= 0 {
o.ReleaseDuration = d.ReleaseDuration
}
if o.Peak <= 0 || o.Peak > 1.0 {
o.Peak = d.Peak
}
return o
}
// Render synthesises the ding using opts (after defaulting) and
// returns a self-contained 16-bit PCM WAV file.
func Render(opts Options) []byte {
opts = opts.WithDefaults()
voicesS := []voice{
{freq: opts.PitchMid, channel: 0},
{freq: opts.PitchLow, channel: 0},
{freq: opts.PitchHigh, channel: 1},
}
voicesT := []voice{
{freq: opts.PitchMid, channel: 0},
{freq: opts.PitchLow, channel: 0},
{freq: opts.PitchHigh, channel: 1},
{freq: opts.PitchMid, channel: 1},
}
chirpN := int(math.Round(float64(opts.SampleRate) * opts.ChirpDuration))
gapN := int(math.Round(float64(opts.SampleRate) * opts.GapDuration))
attackN := int(math.Round(float64(opts.SampleRate) * opts.AttackDuration))
releaseN := int(math.Round(float64(opts.SampleRate) * opts.ReleaseDuration))
samplesPerChannel := chirpN*2 + gapN
left := make([]float64, samplesPerChannel)
right := make([]float64, samplesPerChannel)
renderChirp(left, right, 0, chirpN, attackN, releaseN, voicesS, opts.SampleRate)
renderChirp(left, right, chirpN+gapN, chirpN, attackN, releaseN, voicesT, opts.SampleRate)
normalise(left, right, opts.Peak)
var buf bytes.Buffer
// Defensive bound check: clamp before the conversion to
// uint32 so even a buggy caller (or one that bypassed the
// handler-side bound check on the query param) can't trigger
// integer truncation in the WAV header fields.
sampleRate32 := safeSampleRate(opts.SampleRate)
_ = writeWAV(&buf, left, right, sampleRate32)
return buf.Bytes()
}
type voice struct {
freq float64
channel int
}
// renderChirp synthesises one chirp into the L/R buffers
// starting at offset, with a trapezoidal attack/sustain/release
// envelope.
func renderChirp(left, right []float64, offset, length, attackN, releaseN int, voices []voice, sampleRate int) {
if attackN+releaseN > length {
attackN = length / 3
releaseN = length / 3
}
for i := 0; i < length; i++ {
t := float64(i) / float64(sampleRate)
env := 1.0
switch {
case i < attackN:
env = float64(i) / float64(attackN)
case i >= length-releaseN:
remaining := length - i
env = float64(remaining) / float64(releaseN)
}
for _, v := range voices {
sample := math.Sin(2*math.Pi*v.freq*t) * env
if v.channel == 0 {
left[offset+i] += sample
} else {
right[offset+i] += sample
}
}
}
}
// normalise scales L/R so the peak absolute value equals `peak`
// (≤ 1.0). Keeps the chord sum below clipping without hardcoding
// voice counts.
func normalise(left, right []float64, peak float64) {
maxVal := 0.0
for i := range left {
if v := math.Abs(left[i]); v > maxVal {
maxVal = v
}
if v := math.Abs(right[i]); v > maxVal {
maxVal = v
}
}
if maxVal == 0 {
return
}
scale := peak / maxVal
for i := range left {
left[i] *= scale
right[i] *= scale
}
}
const (
wavChannels = 2
wavBitsPer = 16
)
// maxSampleRate is the largest sample rate writeWAV will accept
// before clamping. Generous enough to allow studio-quality 192
// kHz; well below the uint32 ceiling the WAV header field can
// represent, and far below anything the byte-rate multiplication
// downstream could overflow.
const maxSampleRate uint32 = 192_000
// safeSampleRate converts the operator-supplied int sample rate
// into the uint32 the WAV header needs, clamping anything
// out-of-range to the default. Defence-in-depth: the
// handler-side sampleRateParam already rejects unreasonable
// inputs, but Render is exported so other callers (tests,
// scripts) could pass anything.
func safeSampleRate(in int) uint32 {
if in <= 0 || in > int(maxSampleRate) {
return uint32(DefaultOptions().SampleRate)
}
return uint32(in)
}
func writeWAV(w io.Writer, left, right []float64, sampleRate uint32) error {
if len(left) != len(right) {
return fmt.Errorf("channel length mismatch: %d vs %d", len(left), len(right))
}
samples := len(left)
dataBytes := samples * wavChannels * (wavBitsPer / 8)
totalRIFFSize := 4 + (8 + 16) + (8 + dataBytes)
if _, err := w.Write([]byte("RIFF")); err != nil {
return err
}
if err := binary.Write(w, binary.LittleEndian, uint32(totalRIFFSize)); err != nil {
return err
}
if _, err := w.Write([]byte("WAVE")); err != nil {
return err
}
if _, err := w.Write([]byte("fmt ")); err != nil {
return err
}
if err := binary.Write(w, binary.LittleEndian, uint32(16)); err != nil {
return err
}
if err := binary.Write(w, binary.LittleEndian, uint16(1)); err != nil { // PCM
return err
}
if err := binary.Write(w, binary.LittleEndian, uint16(wavChannels)); err != nil {
return err
}
if err := binary.Write(w, binary.LittleEndian, sampleRate); err != nil {
return err
}
byteRate := sampleRate * uint32(wavChannels) * uint32(wavBitsPer/8)
if err := binary.Write(w, binary.LittleEndian, byteRate); err != nil {
return err
}
blockAlign := uint16(wavChannels * (wavBitsPer / 8))
if err := binary.Write(w, binary.LittleEndian, blockAlign); err != nil {
return err
}
if err := binary.Write(w, binary.LittleEndian, uint16(wavBitsPer)); err != nil {
return err
}
if _, err := w.Write([]byte("data")); err != nil {
return err
}
if err := binary.Write(w, binary.LittleEndian, uint32(dataBytes)); err != nil {
return err
}
for i := 0; i < samples; i++ {
if err := binary.Write(w, binary.LittleEndian, floatToInt16(left[i])); err != nil {
return err
}
if err := binary.Write(w, binary.LittleEndian, floatToInt16(right[i])); err != nil {
return err
}
}
return nil
}
func floatToInt16(v float64) int16 {
if v > 1.0 {
v = 1.0
} else if v < -1.0 {
v = -1.0
}
return int16(math.Round(v * 32767))
}
+154
View File
@@ -0,0 +1,154 @@
package ding
import (
"bytes"
"encoding/binary"
"testing"
)
func TestRender_ProducesWAVHeader(t *testing.T) {
data := Render(DefaultOptions())
if len(data) < 44 {
t.Fatalf("expected at least 44 bytes (WAV header), got %d", len(data))
}
if !bytes.HasPrefix(data, []byte("RIFF")) {
t.Errorf("expected RIFF prefix")
}
if !bytes.Equal(data[8:12], []byte("WAVE")) {
t.Errorf("expected WAVE format marker")
}
if !bytes.Equal(data[12:16], []byte("fmt ")) {
t.Errorf("expected fmt chunk")
}
// PCM format
if pcm := binary.LittleEndian.Uint16(data[20:22]); pcm != 1 {
t.Errorf("expected PCM (1), got %d", pcm)
}
// Channels
if ch := binary.LittleEndian.Uint16(data[22:24]); ch != 2 {
t.Errorf("expected stereo (2), got %d", ch)
}
// Sample rate
if sr := binary.LittleEndian.Uint32(data[24:28]); sr != 22050 {
t.Errorf("expected default sample rate 22050, got %d", sr)
}
// Bits per sample
if bps := binary.LittleEndian.Uint16(data[34:36]); bps != 16 {
t.Errorf("expected 16 bits/sample, got %d", bps)
}
}
func TestRender_DefaultSizeApproximately52KB(t *testing.T) {
data := Render(DefaultOptions())
// Default: 22050 Hz * 2 channels * 2 bytes * 0.6 s = 52920 data
// + ~44 byte header.
const wantData = 22050 * 2 * 2 * 60 / 100 // 0.6 seconds, integer math
if got := len(data); got < wantData || got > wantData+200 {
t.Errorf("expected ~%d bytes, got %d", wantData, got)
}
}
func TestRender_OverridePitchAffectsContent(t *testing.T) {
a := Render(DefaultOptions())
b := Render(Options{PitchHigh: 1200}.WithDefaults())
if bytes.Equal(a, b) {
t.Errorf("expected different content for different PitchHigh values")
}
// Same length (envelope doesn't change).
if len(a) != len(b) {
t.Errorf("expected same byte length: %d vs %d", len(a), len(b))
}
}
func TestRender_OverrideSampleRateChangesByteRate(t *testing.T) {
data := Render(Options{SampleRate: 44100}.WithDefaults())
if sr := binary.LittleEndian.Uint32(data[24:28]); sr != 44100 {
t.Errorf("expected 44100, got %d", sr)
}
}
func TestSafeSampleRate_ClampsOutOfRange(t *testing.T) {
cases := []struct {
in int
want uint32
}{
{22050, 22050},
{44100, 44100},
{192000, 192000},
{0, 22050}, // zero → default
{-1, 22050}, // negative → default
{200000, 22050}, // above max → default
{1 << 31, 22050}, // way beyond uint32 → default (the original CodeQL concern)
{1 << 33, 22050}, // wraps to a different value on int→uint32; default protects us
{int(maxSampleRate) + 1, 22050},
}
for _, c := range cases {
if got := safeSampleRate(c.in); got != c.want {
t.Errorf("safeSampleRate(%d) = %d, want %d", c.in, got, c.want)
}
}
}
func TestRender_HugeSampleRateDoesNotTruncateOrPanic(t *testing.T) {
// Regression for the int→uint32 truncation CodeQL flagged.
// A caller (test, future SDK user) bypassing the handler's
// sampleRateParam guard with an int well above uint32 used
// to silently wrap. The defensive clamp now substitutes the
// default sample rate instead.
data := Render(Options{SampleRate: 1 << 33}.WithDefaults())
if sr := binary.LittleEndian.Uint32(data[24:28]); sr != uint32(DefaultOptions().SampleRate) {
t.Errorf("expected clamp to default sample rate, got %d", sr)
}
}
func TestWithDefaults_FillsZeroFields(t *testing.T) {
got := Options{PitchHigh: 1000}.WithDefaults()
if got.PitchHigh != 1000 {
t.Errorf("override should be preserved, got %f", got.PitchHigh)
}
if got.SampleRate != 22050 {
t.Errorf("expected default SampleRate, got %d", got.SampleRate)
}
if got.PitchMid <= 0 {
t.Errorf("expected PitchMid filled from default, got %f", got.PitchMid)
}
}
func TestWithDefaults_ClampsInvalidPeak(t *testing.T) {
got := Options{Peak: 2.0}.WithDefaults()
if got.Peak != DefaultOptions().Peak {
t.Errorf("expected default Peak for invalid input, got %f", got.Peak)
}
got = Options{Peak: -0.5}.WithDefaults()
if got.Peak != DefaultOptions().Peak {
t.Errorf("expected default Peak for negative input, got %f", got.Peak)
}
}
func TestRender_SamplesDontClip(t *testing.T) {
data := Render(DefaultOptions())
// Walk every sample, ensure no value is at the 16-bit extreme
// (which would indicate clipping). Header is 44 bytes.
for i := 44; i+1 < len(data); i += 2 {
s := int16(binary.LittleEndian.Uint16(data[i : i+2]))
if s == 32767 || s == -32768 {
t.Errorf("sample at offset %d hit clipping (%d)", i, s)
return
}
}
}
+1
View File
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBX6szVJwwBCTTCdLiqkfJiwjEFOx/HwdQgsf/aPHsUN aftertouch-diagnostic@gesellix
+54
View File
@@ -0,0 +1,54 @@
// Package export provides encryption helpers for the diagnostic export feature.
package export
import (
"bytes"
_ "embed"
"fmt"
"strings"
"filippo.io/age"
"filippo.io/age/agessh"
)
// diagnosticPublicKey is embedded from diagnostic.pub at compile time.
// diagnostic.pub is kept in sync with keys/public/diagnostic.pub by
// scripts/setup-diagnostic-key.sh. To verify it matches the maintainer's
// GitHub SSH keys:
//
// curl -s https://github.com/gesellix.keys | grep "$(awk '{print $2}' keys/public/diagnostic.pub)"
//
//go:embed diagnostic.pub
var diagnosticPublicKeyRaw string
// diagnosticPublicKey returns the trimmed SSH public key line.
func diagnosticPublicKey() string {
return strings.TrimSpace(diagnosticPublicKeyRaw)
}
// EncryptDiagnostic encrypts plaintext using the embedded SSH public key
// and returns the age-encrypted bytes. The result can only be decrypted
// with the corresponding SSH private key (keys/private/diagnostic).
func EncryptDiagnostic(plaintext []byte) ([]byte, error) {
recipient, err := agessh.ParseRecipient(diagnosticPublicKey())
if err != nil {
return nil, fmt.Errorf("parse recipient key: %w", err)
}
var buf bytes.Buffer
w, err := age.Encrypt(&buf, recipient)
if err != nil {
return nil, fmt.Errorf("age encrypt: %w", err)
}
if _, err := w.Write(plaintext); err != nil {
return nil, fmt.Errorf("write plaintext: %w", err)
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("close age writer: %w", err)
}
return buf.Bytes(), nil
}
+20
View File
@@ -0,0 +1,20 @@
package export
import (
"testing"
"filippo.io/age/agessh"
)
// TestDiagnosticPublicKeyParseable ensures the embedded diagnostic.pub is a
// valid SSH public key that age can use as a recipient. Catches a truncated or
// corrupted embed before it reaches a user trying to send a report.
func TestDiagnosticPublicKeyParseable(t *testing.T) {
key := diagnosticPublicKey()
if key == "" {
t.Fatal("embedded diagnostic.pub is empty")
}
if _, err := agessh.ParseRecipient(key); err != nil {
t.Errorf("embedded diagnostic.pub is not a valid age SSH recipient: %v", err)
}
}
+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)
}
}
@@ -0,0 +1,404 @@
package handlers
import (
"context"
"encoding/json"
"encoding/xml"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/health"
"github.com/go-chi/chi/v5"
)
// deviceSummary is the wire shape for GET /setup/device-summary/{deviceId}.
// Each sub-section is independently populated so partial failures
// (e.g. speaker unreachable but service-side state available)
// still produce a useful payload.
type deviceSummary struct {
Device deviceSummaryDevice `json:"device"`
Speaker deviceSummarySpeaker `json:"speaker"`
Service deviceSummaryService `json:"service"`
Pairing deviceSummaryPairing `json:"pairing"`
GeneratedAt string `json:"generated_at"`
}
type deviceSummaryDevice struct {
DeviceID string `json:"device_id"`
AccountID string `json:"account_id"`
Name string `json:"name,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
ProductCode string `json:"product_code,omitempty"`
FirmwareVersion string `json:"firmware_version,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
MacAddress string `json:"mac_address,omitempty"`
}
type probeOutcome struct {
Reachable bool `json:"reachable"`
StatusCode int `json:"status_code,omitempty"`
Err string `json:"error,omitempty"`
CurlCommand string `json:"curl_command,omitempty"`
}
type deviceSummarySpeaker struct {
Info speakerInfoSummary `json:"info"`
Sources speakerSourcesSummary `json:"sources"`
Presets speakerPresetsSummary `json:"presets"`
}
type speakerInfoSummary struct {
probeOutcome
Name string `json:"name,omitempty"`
Type string `json:"type,omitempty"`
MargeAccountUUID string `json:"marge_account_uuid,omitempty"`
MargeURL string `json:"marge_url,omitempty"`
}
type speakerSourcesSummary struct {
probeOutcome
Types []string `json:"types,omitempty"`
}
type speakerPresetsSummary struct {
probeOutcome
IDs []string `json:"ids,omitempty"`
}
type deviceSummaryService struct {
ServerURL string `json:"server_url,omitempty"`
ExpectedHosts []string `json:"expected_hosts,omitempty"`
SourcesXMLPresent bool `json:"sources_xml_present"`
ServiceSourceTypes []string `json:"service_source_types,omitempty"`
PresetsXMLPresent bool `json:"presets_xml_present"`
ServicePresetCount int `json:"service_preset_count"`
}
type deviceSummaryPairing struct {
Paired bool `json:"paired"`
SpeakerMargeHost string `json:"speaker_marge_host,omitempty"`
MargeURLMatchesService bool `json:"marge_url_matches_service"`
}
// HandleDeviceSummary returns a one-shot aggregate of the speaker
// state (/info + /sources + /presets) plus the service-side
// equivalents, plus the pairing inference. Useful as a "what
// does this speaker think is true right now" probe — bundles
// what operators today fetch piecewise across several issues'
// debug threads.
//
// Probes run concurrently. Partial failures don't break the
// response: each sub-section carries its own reachability /
// error fields, and the curl command is always populated so the
// UI can render a paste-helper when the server can't reach the
// speaker (cloud-deployment topology).
func (s *Server) HandleDeviceSummary(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
writeJSONError(w, http.StatusBadRequest, "deviceId is required")
return
}
devices, err := s.ds.ListAllDevices()
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "list devices: "+err.Error())
return
}
var match *struct {
account string
device string
ip string
name string
product string
fw string
serial string
mac string
}
for i := range devices {
d := &devices[i]
if d.DeviceID == deviceID {
match = &struct {
account string
device string
ip string
name string
product string
fw string
serial string
mac string
}{
account: d.AccountID,
device: d.DeviceID,
ip: d.IPAddress,
name: d.Name,
product: d.ProductCode,
fw: d.FirmwareVersion,
serial: d.DeviceSerialNumber,
mac: d.MacAddress,
}
break
}
}
if match == nil {
writeJSONError(w, http.StatusNotFound, "device not found: "+deviceID)
return
}
summary := deviceSummary{
Device: deviceSummaryDevice{
DeviceID: match.device,
AccountID: match.account,
Name: match.name,
IPAddress: match.ip,
ProductCode: match.product,
FirmwareVersion: match.fw,
SerialNumber: match.serial,
MacAddress: match.mac,
},
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}
// Service-side: cheap, no probes.
serverURL, _ := s.GetSettings()
summary.Service.ServerURL = serverURL
summary.Service.ExpectedHosts = s.ExpectedHosts()
if s.ds.HasConfiguredSources(match.account, match.device) {
summary.Service.SourcesXMLPresent = true
if sources, err := s.ds.GetConfiguredSources(match.account, match.device); err == nil {
seen := map[string]bool{}
for i := range sources {
t := sources[i].SourceKey.Type
if t != "" && !seen[t] {
seen[t] = true
summary.Service.ServiceSourceTypes = append(summary.Service.ServiceSourceTypes, t)
}
}
}
}
if presets, err := s.ds.GetPresets(match.account, match.device); err == nil {
summary.Service.ServicePresetCount = len(presets)
summary.Service.PresetsXMLPresent = len(presets) > 0
}
// Speaker-side: probe concurrently.
if match.ip != "" {
probeContext, cancel := context.WithTimeout(r.Context(), 4*time.Second)
defer cancel()
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
summary.Speaker.Info = fetchSpeakerInfo(probeContext, match.ip)
}()
go func() {
defer wg.Done()
summary.Speaker.Sources = fetchSpeakerSources(probeContext, match.ip)
}()
go func() {
defer wg.Done()
summary.Speaker.Presets = fetchSpeakerPresets(probeContext, match.ip)
}()
wg.Wait()
}
// Pairing inference: derive from what we've learned.
if summary.Speaker.Info.Reachable {
summary.Pairing.Paired = summary.Speaker.Info.MargeAccountUUID != ""
summary.Pairing.SpeakerMargeHost = hostFromURL(summary.Speaker.Info.MargeURL)
summary.Pairing.MargeURLMatchesService = pairingHostMatches(
summary.Pairing.SpeakerMargeHost,
summary.Service.ExpectedHosts,
)
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(summary); err != nil {
http.Error(w, "encode: "+err.Error(), http.StatusInternalServerError)
return
}
}
func fetchSpeakerInfo(ctx context.Context, ip string) speakerInfoSummary {
url := fmt.Sprintf("http://%s:8090/info", ip)
res := health.ProbeGet(ctx, url, 3*time.Second)
out := speakerInfoSummary{
probeOutcome: probeOutcome{
Reachable: res.Reachable,
StatusCode: res.Status,
Err: res.Err,
CurlCommand: res.CurlCommand,
},
}
if !res.Reachable || res.Status != 200 {
return out
}
var parsed struct {
XMLName xml.Name `xml:"info"`
Name string `xml:"name"`
Type string `xml:"type"`
MargeAccountUUID string `xml:"margeAccountUUID"`
MargeURL string `xml:"margeURL"`
}
if err := xml.Unmarshal(res.Body, &parsed); err != nil {
out.Err = "parse: " + err.Error()
return out
}
out.Name = parsed.Name
out.Type = parsed.Type
out.MargeAccountUUID = parsed.MargeAccountUUID
out.MargeURL = parsed.MargeURL
return out
}
func fetchSpeakerSources(ctx context.Context, ip string) speakerSourcesSummary {
url := fmt.Sprintf("http://%s:8090/sources", ip)
res := health.ProbeGet(ctx, url, 3*time.Second)
out := speakerSourcesSummary{
probeOutcome: probeOutcome{
Reachable: res.Reachable,
StatusCode: res.Status,
Err: res.Err,
CurlCommand: res.CurlCommand,
},
}
if !res.Reachable || res.Status != 200 {
return out
}
var parsed struct {
XMLName xml.Name `xml:"sources"`
Items []struct {
Source string `xml:"source,attr"`
} `xml:"sourceItem"`
}
if err := xml.Unmarshal(res.Body, &parsed); err != nil {
out.Err = "parse: " + err.Error()
return out
}
seen := map[string]bool{}
for i := range parsed.Items {
s := parsed.Items[i].Source
if s != "" && !seen[s] {
seen[s] = true
out.Types = append(out.Types, s)
}
}
return out
}
func fetchSpeakerPresets(ctx context.Context, ip string) speakerPresetsSummary {
url := fmt.Sprintf("http://%s:8090/presets", ip)
res := health.ProbeGet(ctx, url, 3*time.Second)
out := speakerPresetsSummary{
probeOutcome: probeOutcome{
Reachable: res.Reachable,
StatusCode: res.Status,
Err: res.Err,
CurlCommand: res.CurlCommand,
},
}
if !res.Reachable || res.Status != 200 {
return out
}
var parsed struct {
XMLName xml.Name `xml:"presets"`
Presets []struct {
ID string `xml:"id,attr"`
} `xml:"preset"`
}
if err := xml.Unmarshal(res.Body, &parsed); err != nil {
out.Err = "parse: " + err.Error()
return out
}
for i := range parsed.Presets {
if id := parsed.Presets[i].ID; id != "" {
out.IDs = append(out.IDs, id)
}
}
return out
}
func hostFromURL(raw string) string {
if raw == "" {
return ""
}
// Trim scheme prefix
for _, scheme := range []string{"https://", "http://"} {
if strings.HasPrefix(raw, scheme) {
raw = raw[len(scheme):]
break
}
}
// Trim path
if i := strings.IndexByte(raw, '/'); i >= 0 {
raw = raw[:i]
}
// Trim port
if i := strings.IndexByte(raw, ':'); i >= 0 {
raw = raw[:i]
}
return strings.ToLower(strings.TrimSpace(raw))
}
func pairingHostMatches(host string, expected []string) bool {
if host == "" {
return false
}
host = strings.ToLower(host)
for _, h := range expected {
if strings.ToLower(strings.TrimSpace(h)) == host {
return true
}
}
return false
}
@@ -0,0 +1,233 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/go-chi/chi/v5"
)
func TestHandleDeviceSummary_UnknownDevice(t *testing.T) {
tempDir, _ := os.MkdirTemp("", "summary-test-*")
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_, server := setupRouter("http://aftertouch.local", ds)
r := chi.NewRouter()
r.Get("/setup/device-summary/{deviceId}", server.HandleDeviceSummary)
ts := httptest.NewServer(r)
defer ts.Close()
res, err := http.Get(ts.URL + "/setup/device-summary/UNKNOWN")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusNotFound {
t.Errorf("expected 404 for unknown device, got %d", res.StatusCode)
}
}
func TestHandleDeviceSummary_UnreachableSpeakerStillReturnsServiceState(t *testing.T) {
tempDir, _ := os.MkdirTemp("", "summary-test-*")
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
_ = ds.SaveDeviceInfo("1000001", "DEVICEID01", &models.ServiceDeviceInfo{
DeviceID: "DEVICEID01",
AccountID: "1000001",
Name: "TestSpeaker",
IPAddress: "127.0.0.1:1", // refused port; probe fails
ProductCode: "SoundTouch 20",
FirmwareVersion: "27.0.6.46330.5043500",
})
_, server := setupRouter("http://aftertouch.local", ds)
server.SetExpectedHosts([]string{"aftertouch.local"})
r := chi.NewRouter()
r.Get("/setup/device-summary/{deviceId}", server.HandleDeviceSummary)
ts := httptest.NewServer(r)
defer ts.Close()
res, err := http.Get(ts.URL + "/setup/device-summary/DEVICEID01")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", res.StatusCode)
}
var got deviceSummary
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.Device.DeviceID != "DEVICEID01" {
t.Errorf("unexpected device_id: %q", got.Device.DeviceID)
}
if got.Speaker.Info.Reachable {
t.Errorf("expected unreachable speaker.info, got reachable=true")
}
if got.Speaker.Info.CurlCommand == "" {
t.Errorf("expected curl_command populated even on failure")
}
if got.Service.ServerURL != "http://aftertouch.local" {
t.Errorf("expected service.server_url to surface, got %q", got.Service.ServerURL)
}
if got.Pairing.Paired {
t.Errorf("expected paired=false when speaker unreachable")
}
if got.GeneratedAt == "" {
t.Errorf("expected generated_at populated")
}
}
func TestHandleDeviceSummary_ReachableSpeakerPopulatesAggregate(t *testing.T) {
// Stub speaker server serves /info, /sources, /presets.
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/info":
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<info deviceID="DEVICEID01">
<name>TestSpeaker</name>
<type>SoundTouch 20</type>
<margeAccountUUID>1000001</margeAccountUUID>
<margeURL>https://aftertouch.local/</margeURL>
</info>`))
case "/sources":
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<sources deviceID="DEVICEID01">
<sourceItem source="TUNEIN" status="READY"/>
<sourceItem source="AUX" status="READY"/>
</sources>`))
case "/presets":
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<presets>
<preset id="1"><ContentItem source="TUNEIN"/></preset>
<preset id="2"><ContentItem source="AUX"/></preset>
</presets>`))
default:
http.NotFound(w, r)
}
}))
defer speaker.Close()
speakerHost := mustParseHost(t, speaker.URL)
tempDir, _ := os.MkdirTemp("", "summary-test-*")
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
_ = ds.SaveDeviceInfo("1000001", "DEVICEID01", &models.ServiceDeviceInfo{
DeviceID: "DEVICEID01",
AccountID: "1000001",
Name: "TestSpeaker",
IPAddress: speakerHost, // hostport pointing at the stub
})
_, server := setupRouter("http://aftertouch.local", ds)
server.SetExpectedHosts([]string{"aftertouch.local"})
r := chi.NewRouter()
r.Get("/setup/device-summary/{deviceId}", server.HandleDeviceSummary)
// Override the speaker URL inside the handler. The production
// code builds http://<ip>:8090/info from the device's
// IPAddress. We point IPAddress at host:port directly, so
// the resulting URL is `http://host:port:8090/info` —
// invalid. The summary will report unreachable and we'll
// assert via the partial response.
//
// For a more realistic end-to-end probe test we'd need to
// either inject a custom speaker URL or refactor the probe
// to accept an explicit target. Both are bigger lifts; the
// pure-data path is exercised by other tests in this package.
ts := httptest.NewServer(r)
defer ts.Close()
res, err := http.Get(ts.URL + "/setup/device-summary/DEVICEID01")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer res.Body.Close()
var got deviceSummary
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.Device.Name != "TestSpeaker" {
t.Errorf("unexpected device.name: %q", got.Device.Name)
}
// We can at least assert the curl command points at the
// configured IP with the canonical :8090 path.
if !strings.Contains(got.Speaker.Info.CurlCommand, ":8090/info") {
t.Errorf("expected info curl to target :8090, got %q", got.Speaker.Info.CurlCommand)
}
}
func TestHostFromURL_StripsSchemeAndPort(t *testing.T) {
cases := []struct{ in, want string }{
{"https://example.com/", "example.com"},
{"http://Example.COM:8443/path", "example.com"},
{"https://192.0.2.10", "192.0.2.10"},
{"example.com", "example.com"},
{"", ""},
}
for _, c := range cases {
if got := hostFromURL(c.in); got != c.want {
t.Errorf("hostFromURL(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestPairingHostMatches(t *testing.T) {
if !pairingHostMatches("aftertouch.local", []string{"AFTERTOUCH.local", "example.com"}) {
t.Errorf("expected case-insensitive match")
}
if pairingHostMatches("", []string{"aftertouch.local"}) {
t.Errorf("empty host should not match")
}
if pairingHostMatches("other.example", []string{"aftertouch.local"}) {
t.Errorf("unmatched host should not match")
}
}
func mustParseHost(t *testing.T, raw string) string {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse %q: %v", raw, err)
}
return u.Host
}
+181
View File
@@ -0,0 +1,181 @@
package handlers
import (
"net/http"
"strconv"
"sync"
"github.com/gesellix/bose-soundtouch/pkg/service/ding"
)
// dingDefaultCache holds the rendered bytes for the default
// option set. Computed once on first request; subsequent default
// requests are served from cache without re-synthesising.
var dingDefaultCache struct {
once sync.Once
data []byte
}
// HandleDing serves the AfterTouch "ding" signature audio at
// GET /media/aftertouch-ding.wav. All knobs are optional; the
// default option set is used when no query parameters are
// supplied. Unrecognised parameters and out-of-range values
// (NaN, negatives where positive is required, etc.) silently
// fall back to defaults — this is a "play around with it"
// endpoint, not a strict API.
//
// Supported knobs (all optional; defaults apply per missing param):
//
// pitch-high Hz, float; default 880 (A5, top row)
// pitch-mid Hz, float; default 659.25 (E5, mid row)
// pitch-low Hz, float; default 440 (A4, bottom row)
// chirp-ms int milliseconds; default 250
// gap-ms int milliseconds; default 100
// attack-ms int milliseconds; default 20
// release-ms int milliseconds; default 60
// sample-rate Hz, int; default 22050
// peak 0..1 float; default 0.85
//
// The default option set is rendered once via sync.Once and the
// resulting bytes are reused across subsequent default requests —
// first GET pays the synthesis cost (~ms), the rest serve from
// memory. Requests with any override always re-synthesise.
//
// Example:
//
// curl 'http://localhost:8000/media/aftertouch-ding.wav?pitch-high=1320&chirp-ms=150' > short.wav
//
// See pkg/service/ding for the renderer, scripts/gen-aftertouch-ding
// for the offline-CLI equivalent that takes the same knobs as flags.
func (s *Server) HandleDing(w http.ResponseWriter, r *http.Request) {
opts, isDefault := parseDingOptions(r)
var data []byte
if isDefault {
dingDefaultCache.once.Do(func() {
dingDefaultCache.data = ding.Render(ding.DefaultOptions())
})
data = dingDefaultCache.data
} else {
data = ding.Render(opts)
}
w.Header().Set("Content-Type", "audio/wav")
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.Header().Set("Cache-Control", "public, max-age=300")
_, _ = w.Write(data)
}
// parseDingOptions reads the supported query knobs and returns
// the resulting Options. isDefault is true when no overrides
// were supplied — lets the caller serve from cache.
func parseDingOptions(r *http.Request) (ding.Options, bool) {
q := r.URL.Query()
if len(q) == 0 {
return ding.DefaultOptions(), true
}
opts := ding.Options{}
touched := false
if v, ok := floatParam(q.Get("pitch-high")); ok {
opts.PitchHigh = v
touched = true
}
if v, ok := floatParam(q.Get("pitch-mid")); ok {
opts.PitchMid = v
touched = true
}
if v, ok := floatParam(q.Get("pitch-low")); ok {
opts.PitchLow = v
touched = true
}
if v, ok := millisecondsParam(q.Get("chirp-ms")); ok {
opts.ChirpDuration = v
touched = true
}
if v, ok := millisecondsParam(q.Get("gap-ms")); ok {
opts.GapDuration = v
touched = true
}
if v, ok := millisecondsParam(q.Get("attack-ms")); ok {
opts.AttackDuration = v
touched = true
}
if v, ok := millisecondsParam(q.Get("release-ms")); ok {
opts.ReleaseDuration = v
touched = true
}
if v, ok := sampleRateParam(q.Get("sample-rate")); ok {
opts.SampleRate = v
touched = true
}
if v, ok := floatParam(q.Get("peak")); ok {
opts.Peak = v
touched = true
}
if !touched {
return ding.DefaultOptions(), true
}
return opts.WithDefaults(), false
}
func floatParam(raw string) (float64, bool) {
if raw == "" {
return 0, false
}
v, err := strconv.ParseFloat(raw, 64)
if err != nil || v <= 0 {
return 0, false
}
return v, true
}
func millisecondsParam(raw string) (float64, bool) {
if raw == "" {
return 0, false
}
v, err := strconv.Atoi(raw)
if err != nil || v <= 0 {
return 0, false
}
return float64(v) / 1000.0, true
}
// dingMinSampleRate / dingMaxSampleRate gate the operator-supplied
// sample-rate against int→uint32 truncation and against values
// outside any realistic audio range. The upper bound is generous
// (192 kHz is studio-quality) but well below the uint32 ceiling
// the WAV header field can hold.
const (
dingMinSampleRate = 8000
dingMaxSampleRate = 192000
)
func sampleRateParam(raw string) (int, bool) {
if raw == "" {
return 0, false
}
v, err := strconv.Atoi(raw)
if err != nil || v < dingMinSampleRate || v > dingMaxSampleRate {
return 0, false
}
return v, true
}
+157
View File
@@ -0,0 +1,157 @@
package handlers
import (
"bytes"
"encoding/binary"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/go-chi/chi/v5"
)
func newDingTestServer(t *testing.T) *httptest.Server {
t.Helper()
_, server := setupRouter("http://localhost:8001", nil)
r := chi.NewRouter()
r.Get("/media/aftertouch-ding.wav", server.HandleDing)
ts := httptest.NewServer(r)
t.Cleanup(ts.Close)
return ts
}
func TestHandleDing_DefaultIsValidWAV(t *testing.T) {
ts := newDingTestServer(t)
res, err := http.Get(ts.URL + "/media/aftertouch-ding.wav")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer res.Body.Close()
if res.StatusCode != 200 {
t.Fatalf("expected 200, got %d", res.StatusCode)
}
if ct := res.Header.Get("Content-Type"); ct != "audio/wav" {
t.Errorf("expected audio/wav, got %q", ct)
}
body := readAll(t, res)
if !bytes.HasPrefix(body, []byte("RIFF")) {
t.Errorf("expected RIFF prefix")
}
if !bytes.Equal(body[8:12], []byte("WAVE")) {
t.Errorf("expected WAVE format marker")
}
if sr := binary.LittleEndian.Uint32(body[24:28]); sr != 22050 {
t.Errorf("expected default sample rate 22050, got %d", sr)
}
}
func TestHandleDing_OverrideSampleRate(t *testing.T) {
ts := newDingTestServer(t)
res, err := http.Get(ts.URL + "/media/aftertouch-ding.wav?sample-rate=44100")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer res.Body.Close()
body := readAll(t, res)
if sr := binary.LittleEndian.Uint32(body[24:28]); sr != 44100 {
t.Errorf("expected sample rate 44100, got %d", sr)
}
}
func TestHandleDing_InvalidParamFallsBackToDefault(t *testing.T) {
ts := newDingTestServer(t)
res, err := http.Get(ts.URL + "/media/aftertouch-ding.wav?sample-rate=notanumber&pitch-high=-50")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer res.Body.Close()
if res.StatusCode != 200 {
t.Errorf("expected 200 even with bad params, got %d", res.StatusCode)
}
body := readAll(t, res)
if sr := binary.LittleEndian.Uint32(body[24:28]); sr != 22050 {
t.Errorf("invalid sample-rate should fall back to default, got %d", sr)
}
}
func TestHandleDing_OutOfRangeSampleRateFallsBackToDefault(t *testing.T) {
// CodeQL flagged the int→uint32 truncation; reject values
// outside the sane audio range at the parser, so neither
// the WAV header nor the int→uint32 cast can be tricked.
cases := []string{
"1", // below dingMinSampleRate
"7999", // just under the floor
"500000", // above dingMaxSampleRate
"4294967300", // > uint32 — the truncation source
}
ts := newDingTestServer(t)
for _, sr := range cases {
t.Run(sr, func(t *testing.T) {
res, err := http.Get(ts.URL + "/media/aftertouch-ding.wav?sample-rate=" + sr)
if err != nil {
t.Fatalf("GET: %v", err)
}
defer res.Body.Close()
body := readAll(t, res)
if got := binary.LittleEndian.Uint32(body[24:28]); got != 22050 {
t.Errorf("sample-rate=%s should clamp to default 22050, got %d", sr, got)
}
})
}
}
func TestHandleDing_DefaultIsCached(t *testing.T) {
// Reset the cache by simulating a fresh process.
dingDefaultCache.once = sync.Once{}
dingDefaultCache.data = nil
ts := newDingTestServer(t)
resA, err := http.Get(ts.URL + "/media/aftertouch-ding.wav")
if err != nil {
t.Fatalf("first GET: %v", err)
}
a := readAll(t, resA)
resB, err := http.Get(ts.URL + "/media/aftertouch-ding.wav")
if err != nil {
t.Fatalf("second GET: %v", err)
}
b := readAll(t, resB)
if !bytes.Equal(a, b) {
t.Errorf("expected cached default to be byte-identical across requests")
}
}
func readAll(t *testing.T, res *http.Response) []byte {
t.Helper()
defer res.Body.Close()
body := new(bytes.Buffer)
if _, err := body.ReadFrom(res.Body); err != nil {
t.Fatalf("read body: %v", err)
}
return body.Bytes()
}
+654
View File
@@ -0,0 +1,654 @@
package handlers
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/export"
"github.com/gesellix/bose-soundtouch/pkg/service/health"
speakerssh "github.com/gesellix/bose-soundtouch/pkg/ssh"
)
// diagnosticReport is the structured summary included as diagnostic.json
// inside the encrypted archive. Raw datastore XML files are added verbatim
// alongside it so the maintainer can compare on-disk state with what the
// service serves.
type diagnosticReport struct {
GeneratedAt string `json:"generated_at"`
ServiceVersion map[string]string `json:"service_version"`
HealthChecks []health.CheckResult `json:"health_checks"`
Devices []deviceDiagnostic `json:"devices"`
}
type deviceDiagnostic struct {
AccountID string `json:"account_id"`
DeviceID string `json:"device_id"`
ProductCode string `json:"product_code,omitempty"`
FirmwareVersion string `json:"firmware_version,omitempty"`
Name string `json:"name,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
Sources []sourceDiagnostic `json:"sources,omitempty"`
Presets []presetDiagnostic `json:"presets,omitempty"`
}
type sourceDiagnostic struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
SourceKeyType string `json:"source_key_type,omitempty"`
ProviderID string `json:"provider_id,omitempty"`
Status string `json:"status,omitempty"`
}
type presetDiagnostic struct {
Slot string `json:"slot"`
Name string `json:"name,omitempty"`
Source string `json:"source,omitempty"`
SourceID string `json:"source_id,omitempty"`
Location string `json:"location,omitempty"`
}
// HandleExportDiagnostic builds a tar.gz archive containing a structured JSON
// summary plus the raw datastore XML files verbatim, encrypts the archive with
// the maintainer's embedded SSH public key (age/agessh), and returns it as a
// downloadable .age file. Credentials and authentication tokens are
// intentionally excluded from the JSON summary; XML files are included as-is.
func (s *Server) HandleExportDiagnostic(w http.ResponseWriter, _ *http.Request) {
archive, err := s.buildDiagnosticArchive()
if err != nil {
log.Printf("[Export] build archive: %v", err)
writeJSONError(w, http.StatusInternalServerError, "failed to build diagnostic archive")
return
}
encrypted, err := export.EncryptDiagnostic(archive)
if err != nil {
log.Printf("[Export] encrypt: %v", err)
writeJSONError(w, http.StatusInternalServerError, "failed to encrypt diagnostic archive")
return
}
ts := time.Now().UTC().Format("2006-01-02T15-04-05Z")
filename := fmt.Sprintf("aftertouch-diagnostic-%s.age", ts)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
if _, err := w.Write(encrypted); err != nil {
log.Printf("[Export] write response: %v", err)
}
}
// buildDiagnosticArchive returns a gzipped tar archive containing:
// - diagnostic.json — structured health/device summary
// - datastore/accounts/{account}/devices/{device}/*.xml — raw XML verbatim
// - http/service/... — HTTP responses from the local service endpoints
// - http/speaker/... — HTTP responses from each speaker's local API (port 8090)
// - system/ca.pem — service CA certificate (if configured)
// - system/resolv.conf — host DNS resolver configuration
// - settings.json — service settings with secrets redacted
// - env.txt — filtered process environment
func (s *Server) buildDiagnosticArchive() ([]byte, error) {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
report := s.buildDiagnosticReport()
jsonBytes, err := json.MarshalIndent(report, "", " ")
if err != nil {
return nil, fmt.Errorf("marshal summary: %w", err)
}
if addErr := addTarBytes(tw, "diagnostic.json", jsonBytes); addErr != nil {
return nil, fmt.Errorf("add diagnostic.json: %w", addErr)
}
devices, err := s.ds.ListAllDevices()
if err != nil {
log.Printf("[Export] list devices: %v", err)
}
for i := range devices {
dev := &devices[i]
dir := s.ds.AccountDeviceDir(dev.AccountID, dev.DeviceID)
entries, err := os.ReadDir(dir)
if err != nil {
log.Printf("[Export] read dir %s: %v", dir, err)
continue
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".xml") {
continue
}
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
log.Printf("[Export] read %s: %v", entry.Name(), err)
continue
}
archivePath := "datastore/accounts/" + dev.AccountID + "/devices/" + dev.DeviceID + "/" + entry.Name()
if err := addTarBytes(tw, archivePath, data); err != nil {
log.Printf("[Export] add %s: %v", archivePath, err)
}
}
}
client := diagHTTPClient()
s.addServiceHTTP(tw, client, devices)
s.addSpeakerHTTP(tw, client, devices)
addSpeakerSSH(tw, devices)
s.addSystemFiles(tw)
s.addServiceLog(tw)
s.addSettingsJSON(tw)
addEnvVars(tw)
if err := tw.Close(); err != nil {
return nil, fmt.Errorf("close tar: %w", err)
}
if err := gz.Close(); err != nil {
return nil, fmt.Errorf("close gzip: %w", err)
}
return buf.Bytes(), nil
}
// diagHTTPClient returns an HTTP client suited for internal diagnostic fetches:
// short timeout and TLS verification skipped so it can call the service's own
// HTTPS endpoint without the CA being trusted by the host OS.
func diagHTTPClient() *http.Client {
return &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
},
}
}
func diagFetch(client *http.Client, rawURL string) ([]byte, error) {
resp, err := client.Get(rawURL) //nolint:noctx
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
return io.ReadAll(resp.Body)
}
// addServiceHTTP appends HTTP responses from the local service into the archive
// under http/service/. Each fetch is best-effort; errors are logged and skipped.
func (s *Server) addServiceHTTP(tw *tar.Writer, client *http.Client, devices []models.ServiceDeviceInfo) {
base := strings.TrimRight(s.serverURL, "/")
tryAdd := func(archivePath, url string) {
data, err := diagFetch(client, url)
if err != nil {
log.Printf("[Export] fetch %s: %v", url, err)
return
}
if err := addTarBytes(tw, archivePath, data); err != nil {
log.Printf("[Export] add %s: %v", archivePath, err)
}
}
tryAdd("http/service/sourceproviders.xml", base+"/streaming/sourceproviders")
seenAccounts := map[string]bool{}
for i := range devices {
dev := &devices[i]
if !seenAccounts[dev.AccountID] {
seenAccounts[dev.AccountID] = true
pfx := "http/service/account-" + dev.AccountID
acct := base + "/streaming/account/" + dev.AccountID
tryAdd(pfx+"/full.xml", acct+"/full")
tryAdd(pfx+"/sources.xml", acct+"/sources")
tryAdd(pfx+"/presets.xml", acct+"/presets")
}
if dev.DeviceID == "" {
continue
}
dpfx := "http/service/account-" + dev.AccountID + "/device-" + dev.DeviceID
dpath := base + "/streaming/account/" + dev.AccountID + "/device/" + dev.DeviceID
tryAdd(dpfx+"/presets.xml", dpath+"/presets")
tryAdd(dpfx+"/recents.xml", dpath+"/recents")
}
}
// addSpeakerHTTP appends HTTP responses fetched directly from each speaker's
// local API (port 8090) into the archive under http/speaker/{deviceID}/.
// Speakers that are unreachable are silently skipped.
func (s *Server) addSpeakerHTTP(tw *tar.Writer, client *http.Client, devices []models.ServiceDeviceInfo) {
endpoints := []string{"sources", "presets", "now_playing", "info", "recents"}
for i := range devices {
dev := &devices[i]
if dev.IPAddress == "" {
continue
}
speakerBase := "http://" + dev.IPAddress + ":8090"
id := dev.DeviceID
if id == "" {
id = dev.IPAddress
}
for _, ep := range endpoints {
data, err := diagFetch(client, speakerBase+"/"+ep)
if err != nil {
log.Printf("[Export] speaker %s /%s: %v", id, ep, err)
continue
}
archivePath := "http/speaker/" + id + "/" + ep + ".xml"
if err := addTarBytes(tw, archivePath, data); err != nil {
log.Printf("[Export] add %s: %v", archivePath, err)
}
}
}
}
// speakerSSHPaths lists file paths to retrieve from each speaker via SSH.
var speakerSSHPaths = []string{
"/etc/pki/tls/certs/ca-bundle.crt",
"/etc/ssl/certs/ca-certificates.crt",
}
// speakerLogWindow is the default look-back period for speaker syslog entries.
const speakerLogWindow = 20 * time.Minute
// speakerLogFormats are the timestamp layouts attempted when parsing a busybox
// syslog line. Busybox logread produces "Mon Jan _2 15:04:05 2006" (with year)
// on newer firmware; older builds omit the year.
var speakerLogFormats = []string{
"Mon Jan _2 15:04:05 2006", // newer busybox: "Wed Jun 4 12:34:56 2025"
"Mon Jan 02 15:04:05 2006", // zero-padded day variant
}
// parseSpeakerLogTime extracts the timestamp from the leading field of a busybox
// syslog line. currentYear is used as a fallback when the log line has no year
// field. Returns the zero Time and false when no format matches.
func parseSpeakerLogTime(line string, currentYear int) (time.Time, bool) {
for _, layout := range speakerLogFormats {
if len(line) < len(layout) {
continue
}
t, err := time.Parse(layout, line[:len(layout)])
if err == nil {
return t.UTC(), true
}
}
// Try without year: assume current year.
noYearFmt := "Mon Jan _2 15:04:05"
noYearFmt02 := "Mon Jan 02 15:04:05"
for _, layout := range []string{noYearFmt, noYearFmt02} {
if len(line) < len(layout) {
continue
}
withYear := line[:len(layout)] + strings.Repeat(" ", 1) + fmt.Sprintf("%d", currentYear)
fullLayout := layout + " 2006"
t, err := time.Parse(fullLayout, withYear)
if err == nil {
return t.UTC(), true
}
}
return time.Time{}, false
}
// filterSpeakerLog returns only the lines from rawLog whose timestamp falls
// within the given window before now. Lines whose timestamp cannot be parsed
// are kept (fail-open) so that unparseable headers or continuation lines are
// not silently dropped.
func filterSpeakerLog(rawLog string, window time.Duration) string {
cutoff := time.Now().UTC().Add(-window)
currentYear := time.Now().Year()
var out strings.Builder
for _, line := range strings.SplitAfter(rawLog, "\n") {
t, ok := parseSpeakerLogTime(line, currentYear)
if !ok || !t.Before(cutoff) {
out.WriteString(line)
}
}
return out.String()
}
// addSpeakerSSH connects to each speaker via SSH and copies the speaker-side
// CA certificate files and log output into the archive under ssh/speaker/{deviceID}/.
// Speakers that are unreachable or have SSH disabled are silently skipped.
func addSpeakerSSH(tw *tar.Writer, devices []models.ServiceDeviceInfo) {
for i := range devices {
dev := &devices[i]
if dev.IPAddress == "" {
continue
}
id := dev.DeviceID
if id == "" {
id = dev.IPAddress
}
sc := speakerssh.NewClient(dev.IPAddress)
for _, remotePath := range speakerSSHPaths {
data, err := sc.ReadFile(remotePath)
if err != nil {
log.Printf("[Export] SSH %s %s: %v", id, remotePath, err)
continue
}
archivePath := "ssh/speaker/" + id + remotePath
if err := addTarBytes(tw, archivePath, data); err != nil {
log.Printf("[Export] add %s: %v", archivePath, err)
}
}
// dmesg and any other plain commands.
for filename, cmd := range map[string]string{"dmesg.txt": "dmesg"} {
out, err := sc.Run(cmd)
if err != nil && strings.TrimSpace(out) == "" {
log.Printf("[Export] SSH %s %q: %v", id, cmd, err)
continue
}
archivePath := "ssh/speaker/" + id + "/" + filename
if err := addTarBytes(tw, archivePath, []byte(out)); err != nil {
log.Printf("[Export] add %s: %v", archivePath, err)
}
}
// Syslog: fetch raw, strip 127.0.0.1 noise, then keep only the last speakerLogWindow.
rawLog, logErr := sc.Run("logread 2>/dev/null | grep -v '127.0.0.1'")
if logErr != nil && strings.TrimSpace(rawLog) == "" {
log.Printf("[Export] SSH %s logread: %v", id, logErr)
} else {
filtered := filterSpeakerLog(rawLog, speakerLogWindow)
archivePath := "ssh/speaker/" + id + "/logread.txt"
if addErr := addTarBytes(tw, archivePath, []byte(filtered)); addErr != nil {
log.Printf("[Export] add %s: %v", archivePath, addErr)
}
}
}
}
// addServiceLog appends the in-memory service log buffer as logs/service.txt.
// Each entry is formatted as "2006-01-02T15:04:05Z <message>".
func (s *Server) addServiceLog(tw *tar.Writer) {
if s.logBuf == nil {
return
}
entries := s.logBuf.Snapshot()
if len(entries) == 0 {
return
}
var sb strings.Builder
for _, e := range entries {
sb.WriteString(e.Time.UTC().Format(time.RFC3339))
sb.WriteByte(' ')
sb.WriteString(e.Message)
sb.WriteByte('\n')
}
if err := addTarBytes(tw, "logs/service.txt", []byte(sb.String())); err != nil {
log.Printf("[Export] add logs/service.txt: %v", err)
}
}
// addSystemFiles appends the service CA cert (if configured) and the host
// resolver configuration into the archive under system/.
func (s *Server) addSystemFiles(tw *tar.Writer) {
if path := s.ownCACertPath(); path != "" {
data, err := os.ReadFile(path)
if err != nil {
log.Printf("[Export] read CA cert %s: %v", path, err)
} else if err := addTarBytes(tw, "system/ca.pem", data); err != nil {
log.Printf("[Export] add system/ca.pem: %v", err)
}
}
if data, err := os.ReadFile("/etc/resolv.conf"); err == nil {
if err := addTarBytes(tw, "system/resolv.conf", data); err != nil {
log.Printf("[Export] add system/resolv.conf: %v", err)
}
}
}
// diagSettings is a copy of datastore.Settings with secrets zeroed out so the
// struct can be marshalled into the archive without exposing credentials.
type diagSettings struct {
ServerURL string `json:"server_url"`
HTTPSServerURL string `json:"https_server_url,omitempty"`
RedactLogs bool `json:"redact_logs"`
LogBodies bool `json:"log_bodies"`
RecordInteractions bool `json:"record_interactions"`
DiscoveryInterval string `json:"discovery_interval,omitempty"`
DiscoveryEnabled bool `json:"discovery_enabled"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream []string `json:"dns_upstream,omitempty"`
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
InternalPaths []string `json:"internal_paths,omitempty"`
Shortcuts map[string]int `json:"shortcuts,omitempty"`
SpotifyClientID string `json:"spotify_client_id,omitempty"`
SpotifyClientSecret string `json:"spotify_client_secret,omitempty"`
SpotifyRedirectURI string `json:"spotify_redirect_uri,omitempty"`
AmazonClientID string `json:"amazon_client_id,omitempty"`
AmazonClientSecret string `json:"amazon_client_secret,omitempty"`
AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"`
TrustForwardedHeaders bool `json:"trust_forwarded_headers,omitempty"`
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
}
// addSettingsJSON serialises the service settings into the archive as
// settings.json. OAuth client secrets are replaced with "[REDACTED]" so the
// file is safe to share.
func (s *Server) addSettingsJSON(tw *tar.Writer) {
st, err := s.ds.GetSettings()
if err != nil {
log.Printf("[Export] get settings: %v", err)
return
}
redact := func(v string) string {
if v != "" {
return "[REDACTED]"
}
return ""
}
ds := diagSettings{
ServerURL: st.ServerURL,
HTTPSServerURL: st.HTTPServerURL,
RedactLogs: st.RedactLogs,
LogBodies: st.LogBodies,
RecordInteractions: st.RecordInteractions,
DiscoveryInterval: st.DiscoveryInterval,
DiscoveryEnabled: st.DiscoveryEnabled,
DNSEnabled: st.DNSEnabled,
DNSUpstream: st.DNSUpstream,
DNSBindAddr: st.DNSBindAddr,
InternalPaths: st.InternalPaths,
Shortcuts: st.Shortcuts,
SpotifyClientID: st.SpotifyClientID,
SpotifyClientSecret: redact(st.SpotifyClientSecret),
SpotifyRedirectURI: st.SpotifyRedirectURI,
AmazonClientID: st.AmazonClientID,
AmazonClientSecret: redact(st.AmazonClientSecret),
AmazonRedirectURI: st.AmazonRedirectURI,
TrustForwardedHeaders: st.TrustForwardedHeaders,
TrustedProxyCIDRs: st.TrustedProxyCIDRs,
TuneInStreamFormats: st.TuneInStreamFormats,
}
data, err := json.MarshalIndent(ds, "", " ")
if err != nil {
log.Printf("[Export] marshal settings: %v", err)
return
}
if err := addTarBytes(tw, "settings.json", data); err != nil {
log.Printf("[Export] add settings.json: %v", err)
}
}
// secretEnvKeywords lists substrings that, if present in an env-var name,
// cause the variable to be omitted from the diagnostic export.
var secretEnvKeywords = []string{
"secret", "password", "passwd", "token", "apikey", "api_key",
"credential", "auth", "private", "passphrase",
}
// addEnvVars appends a filtered list of environment variables to the archive
// as env.txt. Variables whose names suggest credentials are omitted.
func addEnvVars(tw *tar.Writer) {
raw := os.Environ()
sort.Strings(raw)
var lines []string
for _, kv := range raw {
name := strings.ToLower(strings.SplitN(kv, "=", 2)[0])
skip := false
for _, kw := range secretEnvKeywords {
if strings.Contains(name, kw) {
skip = true
break
}
}
if !skip {
lines = append(lines, kv)
}
}
data := []byte(strings.Join(lines, "\n") + "\n")
if err := addTarBytes(tw, "env.txt", data); err != nil {
log.Printf("[Export] add env.txt: %v", err)
}
}
func addTarBytes(tw *tar.Writer, name string, data []byte) error {
hdr := &tar.Header{
Name: name,
Mode: 0o644,
Size: int64(len(data)),
ModTime: time.Now().UTC(),
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
_, err := tw.Write(data)
return err
}
func (s *Server) buildDiagnosticReport() diagnosticReport {
report := diagnosticReport{
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
ServiceVersion: buildVersionInfo(),
}
if s.healthRegistry != nil {
report.HealthChecks = s.healthRegistry.RunAll()
}
devices, err := s.ds.ListAllDevices()
if err != nil {
log.Printf("[Export] list devices: %v", err)
return report
}
for i := range devices {
dev := &devices[i]
dd := deviceDiagnostic{
AccountID: dev.AccountID,
DeviceID: dev.DeviceID,
ProductCode: dev.ProductCode,
FirmwareVersion: dev.FirmwareVersion,
Name: dev.Name,
IPAddress: dev.IPAddress,
}
if sources, err := s.ds.GetConfiguredSources(dev.AccountID, dev.DeviceID); err == nil {
for i := range sources {
src := &sources[i]
dd.Sources = append(dd.Sources, sourceDiagnostic{
ID: src.ID,
Name: src.Name,
SourceKeyType: src.SourceKeyType,
ProviderID: src.SourceProviderID,
Status: src.Status,
})
}
}
if presets, err := s.ds.GetPresets(dev.AccountID, dev.DeviceID); err == nil {
for i := range presets {
p := &presets[i]
dd.Presets = append(dd.Presets, presetDiagnostic{
Slot: p.ButtonNumber,
Name: p.Name,
Source: p.Source,
SourceID: p.SourceID,
Location: p.Location,
})
}
}
report.Devices = append(report.Devices, dd)
}
return report
}
@@ -0,0 +1,65 @@
package handlers
import (
"strings"
"testing"
"time"
)
func TestParseSpeakerLogTime(t *testing.T) {
currentYear := 2025
cases := []struct {
line string
wantOK bool
wantSub string // substring that should appear in formatted result
}{
{"Wed Jun 4 12:34:56 2025 daemon.info app: hello", true, "2025"},
{"Mon Jan 02 15:04:05 2025 kern.info kernel: boot", true, "2025"},
{"Wed Jun 4 12:34:56 daemon.info app: no year", true, "2025"}, // year injected
{"not a log line at all", false, ""},
{"", false, ""},
}
for _, tc := range cases {
t.Run(tc.line, func(t *testing.T) {
got, ok := parseSpeakerLogTime(tc.line, currentYear)
if ok != tc.wantOK {
t.Errorf("parseSpeakerLogTime(%q) ok=%v, want %v", tc.line, ok, tc.wantOK)
}
if tc.wantOK && tc.wantSub != "" && !strings.Contains(got.Format(time.RFC3339), tc.wantSub) {
t.Errorf("parseSpeakerLogTime(%q) = %v, expected to contain %q", tc.line, got.Format(time.RFC3339), tc.wantSub)
}
})
}
}
func TestFilterSpeakerLog(t *testing.T) {
now := time.Now().UTC()
format := "Mon Jan _2 15:04:05 2006"
recent := now.Add(-5 * time.Minute).Format(format)
old := now.Add(-30 * time.Minute).Format(format)
rawLog := strings.Join([]string{
old + " kern.info kernel: old message",
recent + " daemon.info app: recent message",
"unparseable line — keep it",
"",
}, "\n")
filtered := filterSpeakerLog(rawLog, 20*time.Minute)
if strings.Contains(filtered, "old message") {
t.Error("filtered log should not contain old message")
}
if !strings.Contains(filtered, "recent message") {
t.Error("filtered log should contain recent message")
}
if !strings.Contains(filtered, "unparseable line") {
t.Error("filtered log should keep unparseable lines (fail-open)")
}
}
@@ -0,0 +1,94 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/health"
)
// healthChecksResponse is the wire shape for GET /setup/health.
type healthChecksResponse struct {
GeneratedAt string `json:"generatedAt"`
Checks []health.CheckResult `json:"checks"`
}
// healthFixRequest is the wire shape for POST /setup/health/fix.
// Target locates the entity the fix should act on; an empty
// Account/Device pair is allowed for service-wide fixes.
type healthFixRequest struct {
CheckID string `json:"checkId"`
FixID string `json:"fixId"`
Target health.Target `json:"target"`
}
type healthFixResponse struct {
OK bool `json:"ok"`
Message string `json:"message,omitempty"`
}
// HandleHealthChecks runs every registered health check and
// returns the current findings. Safe to poll: checks are
// expected to be cheap (filesystem stats, in-memory lookups).
func (s *Server) HandleHealthChecks(w http.ResponseWriter, _ *http.Request) {
if s.healthRegistry == nil {
writeJSONError(w, http.StatusInternalServerError, "health registry not initialized")
return
}
resp := healthChecksResponse{
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Checks: s.healthRegistry.RunAll(),
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// HandleHealthFix dispatches a quick-fix identified by
// (checkId, fixId) against the supplied target. Returns 404 when
// the fix isn't registered (typically a stale UI), 400 on a
// malformed body, and 500 when the fix itself fails. The success
// message comes from the FixFunc and is forwarded to the UI.
func (s *Server) HandleHealthFix(w http.ResponseWriter, r *http.Request) {
if s.healthRegistry == nil {
writeJSONError(w, http.StatusInternalServerError, "health registry not initialized")
return
}
var req healthFixRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request body")
return
}
if req.CheckID == "" || req.FixID == "" {
writeJSONError(w, http.StatusBadRequest, "checkId and fixId are required")
return
}
msg, err := s.healthRegistry.RunFix(req.CheckID, req.FixID, req.Target)
if err != nil {
if errors.Is(err, health.ErrFixNotFound) {
writeJSONError(w, http.StatusNotFound, err.Error())
return
}
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(healthFixResponse{OK: true, Message: msg}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
@@ -0,0 +1,211 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/health"
"github.com/go-chi/chi/v5"
)
func newHealthTestServer(t *testing.T) (*httptest.Server, *datastore.DataStore, string, string) {
t.Helper()
tempDir, err := os.MkdirTemp("", "handlers-health-test-*")
if err != nil {
t.Fatalf("temp dir: %v", err)
}
t.Cleanup(func() { os.RemoveAll(tempDir) })
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
account, device := "1000001", "DEVICEID01"
if err := ds.SaveDeviceInfo(account, device, &models.ServiceDeviceInfo{
DeviceID: device,
AccountID: account,
Name: "Speaker",
}); err != nil {
t.Fatalf("SaveDeviceInfo: %v", err)
}
_, server := setupRouter("http://localhost:8001", ds)
r := chi.NewRouter()
r.Get("/setup/health", server.HandleHealthChecks)
r.Post("/setup/health/fix", server.HandleHealthFix)
ts := httptest.NewServer(r)
t.Cleanup(ts.Close)
return ts, ds, account, device
}
func TestHandleHealthChecks_ReportsMissingSourcesXML(t *testing.T) {
ts, _, account, device := newHealthTestServer(t)
res, err := http.Get(ts.URL + "/setup/health")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", res.StatusCode)
}
var resp struct {
GeneratedAt string `json:"generatedAt"`
Checks []health.CheckResult `json:"checks"`
}
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.GeneratedAt == "" {
t.Errorf("expected generatedAt to be populated")
}
if len(resp.Checks) == 0 {
t.Fatalf("expected at least one check")
}
var found *health.CheckResult
for i := range resp.Checks {
if resp.Checks[i].ID == health.CheckIDSourcesXMLPresent {
found = &resp.Checks[i]
break
}
}
if found == nil {
t.Fatalf("expected %q check in response", health.CheckIDSourcesXMLPresent)
}
if found.Severity != health.SeverityWarning {
t.Errorf("expected warning, got %q", found.Severity)
}
if len(found.Findings) != 1 {
t.Fatalf("expected 1 finding, got %d", len(found.Findings))
}
finding := found.Findings[0]
if finding.Target.Account != account || finding.Target.Device != device {
t.Errorf("finding target = %+v, want account=%s device=%s", finding.Target, account, device)
}
}
func TestHandleHealthFix_MaterialisesAndClearsFinding(t *testing.T) {
ts, ds, account, device := newHealthTestServer(t)
type fixReq struct {
CheckID string `json:"checkId"`
FixID string `json:"fixId"`
Target health.Target `json:"target"`
}
body, err := json.Marshal(fixReq{
CheckID: health.CheckIDSourcesXMLPresent,
FixID: health.FixIDCreateDefaultSources,
Target: health.Target{Account: account, Device: device},
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
res, err := http.Post(ts.URL+"/setup/health/fix", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("POST: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", res.StatusCode)
}
var fixResp struct {
OK bool `json:"ok"`
Message string `json:"message"`
}
if err := json.NewDecoder(res.Body).Decode(&fixResp); err != nil {
t.Fatalf("decode: %v", err)
}
if !fixResp.OK {
t.Errorf("expected ok=true, got %+v", fixResp)
}
if !ds.HasConfiguredSources(account, device) {
t.Fatalf("Sources.xml should exist after fix")
}
// Subsequent GET should now report SeverityOK for the check.
res2, err := http.Get(ts.URL + "/setup/health")
if err != nil {
t.Fatalf("re-GET: %v", err)
}
defer res2.Body.Close()
var resp struct {
Checks []health.CheckResult `json:"checks"`
}
if err := json.NewDecoder(res2.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
for i := range resp.Checks {
if resp.Checks[i].ID != health.CheckIDSourcesXMLPresent {
continue
}
if resp.Checks[i].Severity != health.SeverityOK {
t.Errorf("expected OK after fix, got %q", resp.Checks[i].Severity)
}
if len(resp.Checks[i].Findings) != 0 {
t.Errorf("expected zero findings after fix, got %d", len(resp.Checks[i].Findings))
}
}
}
func TestHandleHealthFix_UnknownFixReturns404(t *testing.T) {
ts, _, _, _ := newHealthTestServer(t)
body, err := json.Marshal(struct {
CheckID string `json:"checkId"`
FixID string `json:"fixId"`
}{CheckID: "no_such_check", FixID: "no_such_fix"})
if err != nil {
t.Fatalf("marshal: %v", err)
}
res, err := http.Post(ts.URL+"/setup/health/fix", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("POST: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusNotFound {
t.Errorf("expected 404, got %d", res.StatusCode)
}
}
func TestHandleHealthFix_BadRequest(t *testing.T) {
ts, _, _, _ := newHealthTestServer(t)
res, err := http.Post(ts.URL+"/setup/health/fix", "application/json", bytes.NewReader([]byte(`{"checkId":""}`)))
if err != nil {
t.Fatalf("POST: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusBadRequest {
t.Errorf("expected 400, got %d", res.StatusCode)
}
}
+86
View File
@@ -0,0 +1,86 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/gesellix/bose-soundtouch/pkg/service/logbuf"
)
// logsResponse is the wire shape for GET /setup/logs. Entries is
// ordered by Seq ascending. NextSince is the highest Seq returned,
// suitable for the client's next poll's `since` parameter. Dropped
// is the count of entries the client missed because the ring
// evicted them before the client polled — a non-zero value
// signals the client to surface a gap to the operator.
type logsResponse struct {
Entries []logbuf.Entry `json:"entries"`
NextSince uint64 `json:"nextSince"`
Dropped uint64 `json:"dropped"`
Capacity int `json:"capacity"`
}
// HandleGetLogs returns recent log entries from the in-process
// ring buffer. Query parameters:
//
// since — return entries with Seq strictly greater than this
// value. Omitted or "0" → full snapshot.
// limit — cap the number of entries returned. Omitted → no cap
// beyond the buffer's capacity.
//
// When no log buffer is attached (e.g. tests, or the env opted
// out via SOUNDTOUCH_LOG_BUFFER_LINES=0), the response is an
// empty snapshot rather than an error so the UI degrades
// gracefully.
func (s *Server) HandleGetLogs(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
since, err := parseUint64Query(q.Get("since"), 0)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid since: "+err.Error())
return
}
limit, err := parseIntQuery(q.Get("limit"), 0)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid limit: "+err.Error())
return
}
resp := logsResponse{
Entries: []logbuf.Entry{},
NextSince: since,
}
if buf := s.LogBuffer(); buf != nil {
entries, nextSince, dropped := buf.Since(since, limit)
resp.Entries = entries
resp.NextSince = nextSince
resp.Dropped = dropped
resp.Capacity = buf.Capacity()
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
func parseUint64Query(raw string, defaultVal uint64) (uint64, error) {
if raw == "" {
return defaultVal, nil
}
return strconv.ParseUint(raw, 10, 64)
}
func parseIntQuery(raw string, defaultVal int) (int, error) {
if raw == "" {
return defaultVal, nil
}
return strconv.Atoi(raw)
}
+179
View File
@@ -0,0 +1,179 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/logbuf"
"github.com/go-chi/chi/v5"
)
func newLogsTestServer(t *testing.T, buf *logbuf.Buffer) *httptest.Server {
t.Helper()
_, server := setupRouter("http://localhost:8001", nil)
server.SetLogBuffer(buf)
r := chi.NewRouter()
r.Get("/setup/logs", server.HandleGetLogs)
ts := httptest.NewServer(r)
t.Cleanup(ts.Close)
return ts
}
func TestHandleGetLogs_FullSnapshot(t *testing.T) {
buf := logbuf.New(16)
for _, line := range []string{"first\n", "second\n", "third\n"} {
_, _ = buf.Write([]byte(line))
}
ts := newLogsTestServer(t, buf)
res, err := http.Get(ts.URL + "/setup/logs")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", res.StatusCode)
}
var resp logsResponse
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Entries) != 3 {
t.Fatalf("expected 3 entries, got %d", len(resp.Entries))
}
if resp.Entries[0].Message != "first" || resp.Entries[2].Message != "third" {
t.Errorf("unexpected ordering: %+v", resp.Entries)
}
if resp.NextSince != 3 {
t.Errorf("nextSince: want 3, got %d", resp.NextSince)
}
if resp.Capacity != 16 {
t.Errorf("capacity: want 16, got %d", resp.Capacity)
}
}
func TestHandleGetLogs_SinceRoundTrip(t *testing.T) {
buf := logbuf.New(16)
for i := 0; i < 5; i++ {
_, _ = buf.Write([]byte("line\n"))
}
ts := newLogsTestServer(t, buf)
res, err := http.Get(ts.URL + "/setup/logs?since=2")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer res.Body.Close()
var resp logsResponse
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Entries) != 3 {
t.Errorf("expected 3 entries with since=2, got %d", len(resp.Entries))
}
if resp.NextSince != 5 {
t.Errorf("nextSince: want 5, got %d", resp.NextSince)
}
}
func TestHandleGetLogs_Limit(t *testing.T) {
buf := logbuf.New(16)
for i := 0; i < 8; i++ {
_, _ = buf.Write([]byte("line\n"))
}
ts := newLogsTestServer(t, buf)
res, err := http.Get(ts.URL + "/setup/logs?limit=3")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer res.Body.Close()
var resp logsResponse
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Entries) != 3 {
t.Errorf("limit=3 should cap result, got %d entries", len(resp.Entries))
}
}
func TestHandleGetLogs_MalformedSinceReturns400(t *testing.T) {
ts := newLogsTestServer(t, logbuf.New(4))
res, err := http.Get(ts.URL + "/setup/logs?since=notanumber")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusBadRequest {
t.Errorf("expected 400, got %d", res.StatusCode)
}
}
func TestHandleGetLogs_NoBufferAttached(t *testing.T) {
ts := newLogsTestServer(t, nil)
res, err := http.Get(ts.URL + "/setup/logs")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("expected 200 even without buffer, got %d", res.StatusCode)
}
var resp logsResponse
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Entries) != 0 {
t.Errorf("expected empty entries when no buffer, got %d", len(resp.Entries))
}
}
func TestHandleGetLogs_DroppedReportedWhenLagging(t *testing.T) {
buf := logbuf.New(3)
for i := 0; i < 10; i++ {
_, _ = buf.Write([]byte("line\n"))
}
ts := newLogsTestServer(t, buf)
res, err := http.Get(ts.URL + "/setup/logs?since=2")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer res.Body.Close()
var resp logsResponse
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Dropped == 0 {
t.Errorf("expected dropped > 0 when buffer evicted past `since`, got 0")
}
}
+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")
}
}
+63
View File
@@ -2,8 +2,11 @@ package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/service/health"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/go-chi/chi/v5"
)
@@ -117,6 +120,66 @@ func (s *Server) HandlePairAccount(w http.ResponseWriter, r *http.Request) {
}
}
// completeSpeakerPairingFix is the FixFunc registered for
// (CheckIDSpeakerInfoReachable, FixIDCompleteSpeakerPairing). It
// completes pairing on a speaker whose /info reports an empty
// <margeAccountUUID> by:
//
// 1. Looking up the device's current IP via ListAllDevices.
// 2. Choosing the pair-with account: target.Account when the
// detection-side suggestion populated it, else generating a fresh
// 7-digit ID with setup.GenerateAccountID.
// 3. Dispatching through setup.Manager.PairAccount, which tries
// /setMargeAccount over HTTP first and falls back to telnet.
//
// Returns a user-facing success message that names the chosen account
// and the method that succeeded; the framework forwards it to the UI.
func (s *Server) completeSpeakerPairingFix(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)
}
accountID := target.Account
if !setup.IsValidAccountID(accountID) {
known, _ := s.ds.ListAccounts()
generated, genErr := setup.GenerateAccountID(known)
if genErr != nil {
return "", fmt.Errorf("generate account ID: %w", genErr)
}
accountID = generated
}
var t setup.TelnetClient
if s.sm.NewTelnet != nil {
t = s.sm.NewTelnet(deviceIP)
if dialErr := t.Dial(); dialErr != nil {
t = nil
} else {
defer func() { _ = t.Close() }()
}
}
result, output, err := s.sm.PairAccount(deviceIP, accountID, t)
if err != nil {
return "", fmt.Errorf("pair speaker %s with account %s: %w (path output: %s)", target.Device, accountID, err, strings.TrimSpace(output))
}
method := result.Method
if method == "" {
method = "unknown"
}
return fmt.Sprintf("Paired speaker %s with account %s via %s. The speaker will re-fetch /full on its own; playback selection should start working within seconds.",
target.Device, accountID, method), nil
}
// jsonErrorBody is the static shape of error responses from this file.
// Avoiding map[string]interface{} keeps errchkjson satisfied: the typed
// struct guarantees encoding can't fail with a runtime type error.
+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)
+199 -1
View File
@@ -3,12 +3,15 @@ package handlers
import (
"bytes"
"context"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"log"
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
@@ -20,6 +23,8 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/service/amazon"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/health"
"github.com/gesellix/bose-soundtouch/pkg/service/logbuf"
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
@@ -62,6 +67,13 @@ type Server struct {
amazonRedirectURI string
amazonService *amazon.Service
peerObserver *peerObserver
healthRegistry *health.Registry
logBuf *logbuf.Buffer
expectedHosts []string
ownCACache struct {
once sync.Once
cert *x509.Certificate
}
}
// RequestSnapshot represents an immutable snapshot of an HTTP request.
@@ -97,11 +109,177 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
discoveryInterval: 5 * time.Minute,
discoveryEnabled: true,
peerObserver: newPeerObserver(),
healthRegistry: health.NewRegistry(),
}
health.RegisterSourcesXMLPresent(s.healthRegistry, ds)
health.RegisterSpeakerInfoReachable(s.healthRegistry, ds)
health.RegisterSourcesXMLDiff(s.healthRegistry, ds)
health.RegisterSpeakerMargeURLCheck(s.healthRegistry, ds, s.ExpectedHosts)
health.RegisterCertChainCheck(
s.healthRegistry,
func() string {
_, httpsURL := s.GetSettings()
return httpsURL
},
s.loadOwnCACert,
)
health.RegisterCACertExpiryCheck(s.healthRegistry, s.loadOwnCACert, s.ownCACertPath)
health.RegisterTestPlaybackCheck(s.healthRegistry, ds, func() string {
serverURL, _ := s.GetSettings()
return serverURL
})
health.RegisterOrionPathsCheck(s.healthRegistry, ds)
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
// the health package) because the executor needs setup.Manager
// to drive PairAccount — and the health package deliberately
// avoids importing setup to keep its transitive dep surface
// small (see the boundary comment near speakerInfoXML).
s.healthRegistry.RegisterFix(
health.CheckIDSpeakerInfoReachable,
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,
func() string {
serverURL, _ := s.GetSettings()
ip, err := s.ResolveServerURLIPForPreflight(serverURL)
if err != nil {
return ""
}
return ip
},
)
return s
}
// SetExpectedHosts records the hostnames the service considers its
// own (serverURL host + httpsServerURL host + --tls-extra-host
// values). The Health tab's Marge-URL check reads this list at
// run time to decide whether a speaker's <margeURL> points at us.
func (s *Server) SetExpectedHosts(hosts []string) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]string, len(hosts))
copy(out, hosts)
s.expectedHosts = out
}
// ExpectedHosts returns a copy of the recorded expected-hosts list.
func (s *Server) ExpectedHosts() []string {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]string, len(s.expectedHosts))
copy(out, s.expectedHosts)
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
// remediation command pointing at the actual file.
func (s *Server) ownCACertPath() string {
if s.sm == nil || s.sm.Crypto == nil {
return ""
}
return s.sm.Crypto.GetCACertPath()
}
// loadOwnCACert parses AfterTouch's own CA leaf from disk. Used
// by the Health-tab cert-chain check to definitively classify
// whether the HTTPS endpoint is serving a cert issued by this
// service's built-in CA (as opposed to a public CA or a foreign
// chain from a reverse proxy). Returns nil when the CA isn't
// configured or fails to parse — the caller falls back to a
// Subject==Issuer heuristic in that case.
//
// The parse is cached in ownCACache so repeated Health polls
// don't re-read the PEM. Restart-based config changes are
// picked up because Server itself is reconstructed.
func (s *Server) loadOwnCACert() *x509.Certificate {
s.ownCACache.once.Do(func() {
if s.sm == nil || s.sm.Crypto == nil {
return
}
path := s.sm.Crypto.GetCACertPath()
if path == "" {
return
}
data, err := os.ReadFile(path)
if err != nil {
return
}
block, _ := pem.Decode(data)
if block == nil {
return
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return
}
s.ownCACache.cert = cert
})
return s.ownCACache.cert
}
// TrustedRealIPMiddleware returns a chi middleware that rewrites
// r.RemoteAddr from X-Real-IP / X-Forwarded-For / True-Client-IP, but only
// when the immediate TCP peer is in the configured trusted-proxy list.
@@ -142,6 +320,26 @@ func (s *Server) SetVersionInfo(version, commit, date, repoURL string) {
s.RepoURL = repoURL
}
// SetLogBuffer attaches a logbuf.Buffer to the server. When set,
// HandleGetLogs returns its contents; when nil, the endpoint
// reports an empty snapshot. Optional so that tests and
// alternative composers (the standalone web binary, etc.) don't
// have to construct a buffer they don't need.
func (s *Server) SetLogBuffer(buf *logbuf.Buffer) {
s.mu.Lock()
defer s.mu.Unlock()
s.logBuf = buf
}
// LogBuffer returns the attached log buffer, or nil if none.
func (s *Server) LogBuffer() *logbuf.Buffer {
s.mu.RLock()
defer s.mu.RUnlock()
return s.logBuf
}
// SetDiscoverySettings sets the discovery settings for the server.
func (s *Server) SetDiscoverySettings(interval time.Duration, enabled bool) {
s.mu.Lock()
@@ -309,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)
+110
View File
@@ -36,6 +36,12 @@
<button class="tab-btn" onclick="openTab(event, 'tab-account')">
6. Local Account
</button>
<button class="tab-btn" onclick="openTab(event, 'tab-health')">
7. Health
</button>
<button class="tab-btn" onclick="openTab(event, 'tab-logs')">
8. Logs
</button>
</div>
<!-- Tab 0: Overview -->
@@ -159,6 +165,53 @@
<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">
@@ -1490,6 +1543,63 @@
<div id="account-devices-list">Select an account to view devices.</div>
</div>
</div>
<!-- Tab 7: Health -->
<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>
<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-findings">Loading…</div>
</div>
<!-- Tab 8: Logs -->
<div id="tab-logs" class="tab-content">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
<h2 style="margin: 0;">Service Logs</h2>
<div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
<input type="text" id="logs-filter" placeholder="Filter (substring, case-insensitive)" style="padding: 4px 8px; min-width: 260px;"/>
<label style="font-size: 0.9em;">
<input type="checkbox" id="logs-follow" checked/> Follow tail
</label>
</div>
</div>
<p style="font-size: 0.9em; color: #555;">
Live mirror of the service's stderr log. Stderr still
receives every line — this is a read-only view for
convenience.
</p>
<div id="logs-status" style="font-size: 0.8em; color: #888; margin-bottom: 6px;">Idle.</div>
<pre id="logs-view" style="background: #111; color: #ddd; padding: 10px; border-radius: 4px; max-height: 60vh; overflow-y: auto; font-size: 0.8em; line-height: 1.35; margin: 0; white-space: pre-wrap; word-break: break-all;"></pre>
</div>
</div>
<script src="/web/js/script.js"></script>
+740
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 || "";
@@ -372,6 +433,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...";
@@ -429,12 +495,16 @@ async function fetchDevices() {
<td class="col-fw-serial"><div class="col-firmware">${d.firmware_version || "0.0.0"}</div><div class="col-serial" style="font-size: 0.8em; color: #666;">${d.device_serial_number}</div></td>
<td class="col-method">${methodLabel}</td>
<td>
<button onclick="toggleDeviceSummary('${d.device_id}')">Inspect</button>
<button onclick="prepareSync('${d.device_id}')">Sync Data</button>
<button onclick="prepareMigration('${d.device_id}')">Migrate</button>
<button id="prime-spotify-${d.device_id}" class="btn-spotify" style="display: none;" onclick="primeSpotify('${d.device_id}')">Prime Spotify</button>
<button class="btn-danger" onclick="removeDevice('${d.device_id}', '${d.name}')">Remove</button>
</td>
</tr>
<tr id="device-summary-${d.device_id}" style="display: none;">
<td colspan="6" id="device-summary-cell-${d.device_id}" style="background: #fafafa; padding: 12px;"></td>
</tr>
`;
const optSync = document.createElement("option");
@@ -507,6 +577,16 @@ function openTab(evt, tabId) {
fetchAccountList();
}
if (tabId === "tab-health") {
fetchHealth();
}
if (tabId === "tab-logs") {
startLogsPolling();
} else {
stopLogsPolling();
}
if (evt) {
evt.currentTarget.className += " active";
let hash = tabId;
@@ -3976,3 +4056,663 @@ document.addEventListener("DOMContentLoaded", () => {
fetchSettings();
triggerDiscovery();
});
// ---------------------------------------------------------------------------
// Health tab
// ---------------------------------------------------------------------------
async function fetchHealth() {
const findingsEl = document.getElementById("health-findings");
const generatedAtEl = document.getElementById("health-generated-at");
if (!findingsEl) return;
findingsEl.textContent = "Loading…";
if (generatedAtEl) generatedAtEl.textContent = "";
try {
const resp = await fetch("/setup/health");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
renderHealthChecks(data, findingsEl, generatedAtEl);
} catch (e) {
findingsEl.textContent = `Failed to load health checks: ${e.message || e}`;
}
}
async function downloadDiagnostic() {
const statusEl = document.getElementById("health-diagnostic-status");
if (statusEl) statusEl.textContent = "Building diagnostic report…";
try {
const resp = await fetch("/setup/export/diagnostic");
if (!resp.ok) {
const text = await resp.text().catch(() => resp.statusText);
throw new Error(`HTTP ${resp.status}: ${text}`);
}
const disposition = resp.headers.get("Content-Disposition") || "";
const match = disposition.match(/filename[^;=\n]*=(?:"([^"]+)"|([^;\n]+))/);
const filename = (match && (match[1] || match[2])) || "aftertouch-diagnostic.age";
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
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}`;
}
}
function renderHealthChecks(data, findingsEl, generatedAtEl) {
if (generatedAtEl && data.generatedAt) {
generatedAtEl.textContent = `Last run: ${data.generatedAt}`;
}
const checks = data.checks || [];
if (checks.length === 0) {
findingsEl.textContent = "No checks are registered.";
return;
}
findingsEl.innerHTML = "";
for (const check of checks) {
findingsEl.appendChild(renderHealthCheck(check));
}
}
function renderHealthCheck(check) {
const box = document.createElement("div");
box.className = "summary-box";
const header = document.createElement("h3");
header.style.margin = "0 0 8px 0";
header.appendChild(severityBadge(check.severity));
header.appendChild(document.createTextNode(" " + check.title));
box.appendChild(header);
const idLine = document.createElement("div");
idLine.style.fontSize = "0.75em";
idLine.style.color = "#888";
idLine.style.marginBottom = "8px";
idLine.textContent = `id: ${check.id}`;
box.appendChild(idLine);
const findings = check.findings || [];
if (findings.length === 0) {
const ok = document.createElement("div");
ok.style.color = "#2e7d32";
ok.textContent = "✓ No issues detected.";
box.appendChild(ok);
return box;
}
for (const f of findings) {
box.appendChild(renderFinding(check.id, f));
}
return box;
}
function renderFinding(checkId, finding) {
const row = document.createElement("div");
row.style.borderTop = "1px solid #e0e0e0";
row.style.padding = "10px 0";
const title = document.createElement("div");
title.appendChild(severityBadge(finding.severity));
title.appendChild(document.createTextNode(" " + (finding.message || "")));
row.appendChild(title);
const target = finding.target || {};
if (target.account || target.device) {
const t = document.createElement("div");
t.style.fontSize = "0.8em";
t.style.color = "#666";
t.style.marginTop = "4px";
const parts = [];
if (target.account) parts.push(`account ${target.account}`);
if (target.device) parts.push(`device ${target.device}`);
t.textContent = parts.join(" · ");
row.appendChild(t);
}
if (finding.details) {
const d = document.createElement("div");
d.style.fontSize = "0.85em";
d.style.color = "#444";
d.style.marginTop = "6px";
d.textContent = finding.details;
row.appendChild(d);
}
const fixes = finding.quickFixes || [];
if (fixes.length > 0) {
const actions = document.createElement("div");
actions.style.marginTop = "8px";
actions.style.display = "flex";
actions.style.gap = "8px";
actions.style.flexWrap = "wrap";
for (const fix of fixes) {
const btn = document.createElement("button");
btn.textContent = fix.label || fix.id;
btn.onclick = () => runQuickFix(checkId, fix.id, target, fix.confirm, btn);
actions.appendChild(btn);
}
const status = document.createElement("span");
status.className = "health-fix-status";
status.style.fontSize = "0.85em";
status.style.alignSelf = "center";
actions.appendChild(status);
row.appendChild(actions);
}
const manualCommands = finding.manualCommands || [];
for (const cmd of manualCommands) {
row.appendChild(renderManualCommand(cmd));
}
return row;
}
function renderManualCommand(cmd) {
const wrap = document.createElement("div");
wrap.style.marginTop = "8px";
wrap.style.padding = "8px";
wrap.style.background = "#f4f4f4";
wrap.style.borderRadius = "4px";
wrap.style.fontSize = "0.85em";
if (cmd.label) {
const label = document.createElement("div");
label.style.color = "#444";
label.style.marginBottom = "4px";
label.textContent = cmd.label;
wrap.appendChild(label);
}
const row = document.createElement("div");
row.style.display = "flex";
row.style.alignItems = "stretch";
row.style.gap = "8px";
const code = document.createElement("code");
code.style.flex = "1";
code.style.padding = "6px 8px";
code.style.background = "#fff";
code.style.border = "1px solid #ddd";
code.style.borderRadius = "3px";
code.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, monospace";
code.style.whiteSpace = "pre-wrap";
code.style.wordBreak = "break-all";
code.textContent = cmd.command;
row.appendChild(code);
const copyBtn = document.createElement("button");
copyBtn.textContent = "Copy";
copyBtn.style.alignSelf = "flex-start";
copyBtn.onclick = async () => {
const ok = await copyTextToClipboard(cmd.command);
copyBtn.textContent = ok ? "Copied" : "Copy failed";
if (ok) {
setTimeout(() => { copyBtn.textContent = "Copy"; }, 1200);
}
};
row.appendChild(copyBtn);
wrap.appendChild(row);
if (cmd.hint) {
const hint = document.createElement("div");
hint.style.fontSize = "0.8em";
hint.style.color = "#666";
hint.style.marginTop = "4px";
hint.textContent = cmd.hint;
wrap.appendChild(hint);
}
return wrap;
}
function severityBadge(severity) {
const span = document.createElement("span");
span.style.fontSize = "0.75em";
span.style.padding = "2px 6px";
span.style.borderRadius = "10px";
span.style.fontWeight = "bold";
const palette = {
ok: { bg: "#e8f5e9", fg: "#2e7d32", label: "OK" },
info: { bg: "#e3f2fd", fg: "#1565c0", label: "INFO" },
warning: { bg: "#fff8e1", fg: "#a06800", label: "WARN" },
error: { bg: "#ffebee", fg: "#c62828", label: "ERROR" },
};
const p = palette[severity] || palette.info;
span.style.background = p.bg;
span.style.color = p.fg;
span.textContent = p.label;
return span;
}
async function runQuickFix(checkId, fixId, target, confirmMsg, button) {
if (confirmMsg && !window.confirm(confirmMsg)) return;
const status = button.parentElement.querySelector(".health-fix-status");
button.disabled = true;
if (status) {
status.textContent = "Applying…";
status.style.color = "#555";
}
try {
const resp = await fetch("/setup/health/fix", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ checkId, fixId, target }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || data.message || `HTTP ${resp.status}`);
if (status) {
status.textContent = data.message || "Done.";
status.style.color = "#2e7d32";
}
// Refresh to drop the resolved finding.
setTimeout(fetchHealth, 400);
} catch (e) {
if (status) {
status.textContent = `Failed: ${e.message || e}`;
status.style.color = "#c62828";
}
button.disabled = false;
}
}
// ---------------------------------------------------------------------------
// Logs tab
// ---------------------------------------------------------------------------
const logsState = {
timerId: null,
nextSince: 0,
entries: [],
maxEntries: 5000, // client-side cap; UI lag is the bottleneck
droppedTotal: 0,
pollIntervalMs: 1500,
followTail: true,
initialised: false,
};
function startLogsPolling() {
initLogsTabOnce();
// Reset window each time the tab opens so the user gets a
// fresh snapshot rather than picking up stale state.
logsState.entries = [];
logsState.nextSince = 0;
logsState.droppedTotal = 0;
renderLogs();
pollLogsOnce();
if (logsState.timerId !== null) clearInterval(logsState.timerId);
logsState.timerId = setInterval(pollLogsOnce, logsState.pollIntervalMs);
}
function stopLogsPolling() {
if (logsState.timerId !== null) {
clearInterval(logsState.timerId);
logsState.timerId = null;
}
}
function initLogsTabOnce() {
if (logsState.initialised) return;
logsState.initialised = true;
const filterEl = document.getElementById("logs-filter");
if (filterEl) {
filterEl.addEventListener("input", () => renderLogs());
}
const followEl = document.getElementById("logs-follow");
if (followEl) {
followEl.addEventListener("change", () => {
logsState.followTail = followEl.checked;
if (logsState.followTail) scrollLogsToBottom();
});
}
const viewEl = document.getElementById("logs-view");
if (viewEl) {
// Disengage follow-tail when the user scrolls up; re-engage
// when they're back at the bottom. tail -f muscle memory.
viewEl.addEventListener("scroll", () => {
const distanceFromBottom = viewEl.scrollHeight - viewEl.scrollTop - viewEl.clientHeight;
const atBottom = distanceFromBottom < 8;
if (atBottom !== logsState.followTail) {
logsState.followTail = atBottom;
const followEl = document.getElementById("logs-follow");
if (followEl) followEl.checked = atBottom;
}
});
}
}
async function pollLogsOnce() {
if (typeof document !== "undefined" && document.hidden) return;
const statusEl = document.getElementById("logs-status");
try {
const url = `/setup/logs?since=${encodeURIComponent(logsState.nextSince)}`;
const resp = await fetch(url);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (Array.isArray(data.entries) && data.entries.length > 0) {
logsState.entries.push(...data.entries);
// Trim from the front when we exceed the client cap.
if (logsState.entries.length > logsState.maxEntries) {
logsState.entries.splice(0, logsState.entries.length - logsState.maxEntries);
}
}
if (typeof data.nextSince === "number") {
logsState.nextSince = data.nextSince;
}
if (typeof data.dropped === "number" && data.dropped > 0) {
logsState.droppedTotal += data.dropped;
}
renderLogs();
if (statusEl) {
const now = new Date().toLocaleTimeString();
const droppedNote = logsState.droppedTotal > 0
? ` · ${logsState.droppedTotal} dropped`
: "";
statusEl.textContent = `${logsState.entries.length} buffered${droppedNote} · last update ${now}`;
}
} catch (e) {
if (statusEl) statusEl.textContent = `Polling failed: ${e.message || e}`;
}
}
function renderLogs() {
const viewEl = document.getElementById("logs-view");
if (!viewEl) return;
const filterEl = document.getElementById("logs-filter");
const filter = filterEl ? filterEl.value.trim().toLowerCase() : "";
const lines = [];
for (const entry of logsState.entries) {
if (filter && entry.message.toLowerCase().indexOf(filter) === -1) continue;
const ts = entry.time ? entry.time.replace("T", " ").replace("Z", "") : "";
lines.push(`${ts} ${entry.message}`);
}
viewEl.textContent = lines.join("\n");
if (logsState.followTail) {
scrollLogsToBottom();
}
}
function scrollLogsToBottom() {
const viewEl = document.getElementById("logs-view");
if (viewEl) viewEl.scrollTop = viewEl.scrollHeight;
}
// ---------------------------------------------------------------------------
// Device summary (Devices tab — per-device "Inspect" panel)
// ---------------------------------------------------------------------------
async function toggleDeviceSummary(deviceId) {
const row = document.getElementById(`device-summary-${deviceId}`);
const cell = document.getElementById(`device-summary-cell-${deviceId}`);
if (!row || !cell) return;
if (row.style.display !== "none") {
row.style.display = "none";
return;
}
row.style.display = "";
cell.innerHTML = '<em style="color:#666;">Probing speaker…</em>';
try {
const resp = await fetch(`/setup/device-summary/${encodeURIComponent(deviceId)}`);
if (!resp.ok) {
const txt = await resp.text();
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${resp.status} ${escapeHTML(txt)}</span>`;
return;
}
const data = await resp.json();
cell.innerHTML = "";
cell.appendChild(renderDeviceSummary(data));
} catch (e) {
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${escapeHTML(e.message || String(e))}</span>`;
}
}
function renderDeviceSummary(data) {
const wrap = document.createElement("div");
wrap.style.display = "grid";
wrap.style.gridTemplateColumns = "repeat(auto-fit, minmax(280px, 1fr))";
wrap.style.gap = "12px";
wrap.appendChild(summaryCard("Speaker /info", renderSpeakerInfoBody(data.speaker.info)));
wrap.appendChild(summaryCard("Speaker /sources", renderSpeakerSourcesBody(data.speaker.sources, data.service)));
wrap.appendChild(summaryCard("Speaker /presets", renderSpeakerPresetsBody(data.speaker.presets, data.service)));
wrap.appendChild(summaryCard("Service-side state", renderServiceBody(data.service)));
wrap.appendChild(summaryCard("Pairing inference", renderPairingBody(data.pairing, data.service)));
const footer = document.createElement("div");
footer.style.gridColumn = "1 / -1";
footer.style.fontSize = "0.75em";
footer.style.color = "#888";
footer.textContent = `Generated at ${data.generated_at}`;
wrap.appendChild(footer);
return wrap;
}
function summaryCard(title, bodyNode) {
const card = document.createElement("div");
card.style.background = "#fff";
card.style.border = "1px solid #ddd";
card.style.borderRadius = "4px";
card.style.padding = "10px 12px";
const h = document.createElement("div");
h.style.fontWeight = "bold";
h.style.marginBottom = "8px";
h.style.fontSize = "0.9em";
h.textContent = title;
card.appendChild(h);
if (bodyNode) card.appendChild(bodyNode);
return card;
}
function renderSpeakerInfoBody(info) {
const body = document.createElement("div");
body.style.fontSize = "0.85em";
if (!info.reachable) {
body.appendChild(unreachableBlock(info));
return body;
}
body.appendChild(kv("name", info.name));
body.appendChild(kv("type", info.type));
body.appendChild(kv("margeAccountUUID", info.marge_account_uuid || "(empty)"));
body.appendChild(kv("margeURL", info.marge_url || "(empty)"));
return body;
}
function renderSpeakerSourcesBody(sources, service) {
const body = document.createElement("div");
body.style.fontSize = "0.85em";
if (!sources.reachable) {
body.appendChild(unreachableBlock(sources));
return body;
}
const types = sources.types || [];
body.appendChild(kv("count", String(types.length)));
body.appendChild(kv("types", types.length ? types.join(", ") : "(none)"));
const svcTypes = (service && service.service_source_types) || [];
const missingOnSpeaker = svcTypes.filter(t => types.indexOf(t) < 0);
const extraOnSpeaker = types.filter(t => svcTypes.indexOf(t) < 0);
if (missingOnSpeaker.length > 0) {
body.appendChild(kv("missing on speaker", missingOnSpeaker.join(", "), "#a06800"));
}
if (extraOnSpeaker.length > 0) {
body.appendChild(kv("extra on speaker", extraOnSpeaker.join(", "), "#666"));
}
return body;
}
function renderSpeakerPresetsBody(presets, service) {
const body = document.createElement("div");
body.style.fontSize = "0.85em";
if (!presets.reachable) {
body.appendChild(unreachableBlock(presets));
return body;
}
const ids = presets.ids || [];
body.appendChild(kv("count", String(ids.length)));
body.appendChild(kv("slots", ids.length ? ids.join(", ") : "(none)"));
if (service && typeof service.service_preset_count === "number") {
if (service.service_preset_count !== ids.length) {
body.appendChild(kv("service count", String(service.service_preset_count), "#a06800"));
}
}
return body;
}
function renderServiceBody(service) {
const body = document.createElement("div");
body.style.fontSize = "0.85em";
body.appendChild(kv("server URL", service.server_url || "(unset)"));
const hosts = service.expected_hosts || [];
body.appendChild(kv("expected hosts", hosts.length ? hosts.join(", ") : "(none)"));
body.appendChild(kv("Sources.xml", service.sources_xml_present ? "present" : "MISSING", service.sources_xml_present ? null : "#c62828"));
body.appendChild(kv("Presets.xml", service.presets_xml_present ? `${service.service_preset_count} preset(s)` : "(empty)"));
return body;
}
function renderPairingBody(pairing, service) {
const body = document.createElement("div");
body.style.fontSize = "0.85em";
body.appendChild(kv("paired", pairing.paired ? "yes" : "NO", pairing.paired ? null : "#c62828"));
body.appendChild(kv("speaker marge host", pairing.speaker_marge_host || "(unknown)"));
const matches = pairing.marge_url_matches_service;
body.appendChild(kv("matches service?", matches ? "yes" : "NO", matches ? null : "#a06800"));
return body;
}
function kv(label, value, valueColor) {
const row = document.createElement("div");
row.style.display = "flex";
row.style.gap = "8px";
row.style.marginBottom = "2px";
row.style.alignItems = "baseline";
const l = document.createElement("span");
l.style.color = "#666";
l.style.minWidth = "120px";
l.style.flexShrink = "0";
l.textContent = label;
row.appendChild(l);
const v = document.createElement("span");
v.style.wordBreak = "break-all";
if (valueColor) v.style.color = valueColor;
v.textContent = value;
row.appendChild(v);
return row;
}
function unreachableBlock(probe) {
const wrap = document.createElement("div");
const msg = document.createElement("div");
msg.style.color = "#a06800";
msg.style.marginBottom = "6px";
msg.textContent = probe.error ? `Unreachable: ${probe.error}` : "Unreachable from this service host.";
wrap.appendChild(msg);
if (probe.curl_command) {
const hint = document.createElement("div");
hint.style.fontSize = "0.85em";
hint.style.color = "#444";
hint.style.marginBottom = "4px";
hint.textContent = "Run from your LAN:";
wrap.appendChild(hint);
const row = document.createElement("div");
row.style.display = "flex";
row.style.gap = "8px";
row.style.alignItems = "stretch";
const code = document.createElement("code");
code.style.flex = "1";
code.style.padding = "4px 6px";
code.style.background = "#f4f4f4";
code.style.border = "1px solid #ddd";
code.style.borderRadius = "3px";
code.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, monospace";
code.style.fontSize = "0.8em";
code.style.whiteSpace = "pre-wrap";
code.style.wordBreak = "break-all";
code.textContent = probe.curl_command;
row.appendChild(code);
const btn = document.createElement("button");
btn.textContent = "Copy";
btn.onclick = async () => {
const ok = await copyTextToClipboard(probe.curl_command);
btn.textContent = ok ? "Copied" : "Copy failed";
if (ok) {
setTimeout(() => { btn.textContent = "Copy"; }, 1200);
}
};
row.appendChild(btn);
wrap.appendChild(row);
}
return wrap;
}
function escapeHTML(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+152
View File
@@ -0,0 +1,152 @@
package health
import (
"crypto/x509"
"fmt"
"strings"
"time"
)
// CheckIDCACertExpiry is the registry id of the CA-cert expiry
// check.
const CheckIDCACertExpiry = "ca_cert_expiry"
// CA-expiry thresholds. Tunable here rather than per-deployment
// because the consequence of a missed warning is the same
// everywhere: leaves issued by the CA will be rejected.
const (
caExpiryWarnThreshold = 30 * 24 * time.Hour
caExpiryInfoThreshold = 90 * 24 * time.Hour
caExpiryRecentlyValidated = 365 * 24 * time.Hour
)
// CACertPathFunc returns the on-disk path of AfterTouch's own
// CA cert. Optional; when nil the manual command falls back to a
// neutral hint.
type CACertPathFunc func() string
// RegisterCACertExpiryCheck registers a check that reads
// AfterTouch's own CA cert (via caCertFn) and emits a finding
// when its NotAfter is in the past or in the warn/info windows.
//
// Why a separate check from service_cert_chain: even when the
// served leaf validates today (or is correctly classified as
// self-signed), the CA's eventual expiry will break every leaf
// it ever issued. Operators should regenerate before that
// happens — and re-pair speakers, since their stored trust
// anchor will no longer cover newly-issued leaves.
//
// caCertPathFn is used purely to render a remediation command
// pointing at the actual on-disk path. Pass nil to skip the
// path mention.
func RegisterCACertExpiryCheck(r *Registry, caCertFn func() *x509.Certificate, caCertPathFn CACertPathFunc) {
r.Register(Check{
ID: CheckIDCACertExpiry,
Title: "AfterTouch CA cert is not near expiry",
Run: func() []Finding {
return runCACertExpiryCheck(caCertFn, caCertPathFn, time.Now())
},
})
}
func runCACertExpiryCheck(caCertFn func() *x509.Certificate, caCertPathFn CACertPathFunc, now time.Time) []Finding {
if caCertFn == nil {
return nil
}
cert := caCertFn()
if cert == nil {
return []Finding{{
Severity: SeverityInfo,
Message: "Couldn't load AfterTouch's own CA cert; expiry not checked.",
Details: "service_cert_chain falls back to a Subject==Issuer heuristic for the same reason. Verify the CA path is readable from the service host.",
}}
}
if cert.NotAfter.IsZero() {
return []Finding{{
Severity: SeverityWarning,
Message: "AfterTouch CA cert has no NotAfter set; treat as expired.",
}}
}
remaining := cert.NotAfter.Sub(now)
expiresAt := cert.NotAfter.UTC().Format("2006-01-02")
switch {
case remaining <= 0:
return []Finding{caExpiryFinding(SeverityError,
fmt.Sprintf("AfterTouch CA cert expired %d day(s) ago (on %s).", daysRounded(-remaining), expiresAt),
"Speakers will reject any leaf signed by this CA. Regenerate now: stop the service, remove the CA files, restart so EnsureCA reissues, then run setup install-ca against every paired speaker.",
cert, caCertPathFn,
)}
case remaining <= caExpiryWarnThreshold:
return []Finding{caExpiryFinding(SeverityWarning,
fmt.Sprintf("AfterTouch CA cert expires in %d day(s) (on %s).", daysRounded(remaining), expiresAt),
"Plan a regeneration. Every paired speaker will need setup install-ca again after, since their stored trust anchor won't cover the new leaves.",
cert, caCertPathFn,
)}
case remaining <= caExpiryInfoThreshold:
return []Finding{caExpiryFinding(SeverityInfo,
fmt.Sprintf("AfterTouch CA cert expires in %d day(s) (on %s).", daysRounded(remaining), expiresAt),
"No immediate action; surfaced here so the renewal isn't a surprise.",
cert, caCertPathFn,
)}
}
// > 90 days remaining; nothing to surface. Optionally we
// could emit a positive info finding ("valid until …") but
// the empty-findings path lets the check roll up to OK,
// which is the clearer "healthy" signal.
_ = caExpiryRecentlyValidated
return nil
}
// daysRounded converts a duration to a whole-day count, rounded
// to nearest. Avoids the "expires in 59 days" surprise when the
// real value is 60 days minus a fraction-of-a-second from ASN.1
// time truncation.
func daysRounded(d time.Duration) int {
return int((d + 12*time.Hour) / (24 * time.Hour))
}
func caExpiryFinding(severity Severity, message, details string, cert *x509.Certificate, caCertPathFn CACertPathFunc) Finding {
enrichedDetails := fmt.Sprintf("%s Subject: %s. Valid from: %s. Valid to: %s.",
details,
cert.Subject.String(),
cert.NotBefore.UTC().Format(time.RFC3339),
cert.NotAfter.UTC().Format(time.RFC3339),
)
commands := []ManualCommand{{
Label: "Regenerate (destructive — requires re-installing the CA on every speaker afterwards):",
Command: caRegenCommand(caCertPathFn),
Hint: "Adjust the path to match your deployment if it differs. The service's EnsureCA() reissues a fresh CA at startup when the file is missing.",
}}
return Finding{
Severity: severity,
Message: message,
Details: enrichedDetails,
ManualCommands: commands,
}
}
func caRegenCommand(caCertPathFn CACertPathFunc) string {
if caCertPathFn == nil {
return "# stop soundtouch-service, remove the CA cert + key files, restart"
}
path := strings.TrimSpace(caCertPathFn())
if path == "" {
return "# stop soundtouch-service, remove the CA cert + key files, restart"
}
// Key path conventionally sits next to the cert with a `.key`
// extension or matching basename; surface the cert path
// explicitly and let the operator pick up the key by sight.
keyHint := strings.TrimSuffix(path, ".crt") + ".key"
return fmt.Sprintf("# stop the service, then:\nrm '%s' '%s'\n# restart soundtouch-service; EnsureCA reissues at boot.", path, keyHint)
}
+163
View File
@@ -0,0 +1,163 @@
package health
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"strings"
"testing"
"time"
)
// buildCA returns a CA cert with the given NotBefore / NotAfter.
// Self-signed; subject doesn't matter for these tests.
func buildCA(t *testing.T, notBefore, notAfter time.Time) *x509.Certificate {
t.Helper()
template := &x509.Certificate{
SerialNumber: big.NewInt(2026),
Subject: pkix.Name{CommonName: "AfterTouch Local Root CA", Organization: []string{"AfterTouch Test"}},
NotBefore: notBefore,
NotAfter: notAfter,
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign,
}
template.Issuer = template.Subject
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa key: %v", err)
}
derBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("create cert: %v", err)
}
cert, err := x509.ParseCertificate(derBytes)
if err != nil {
t.Fatalf("parse cert: %v", err)
}
return cert
}
func TestCAExpiry_HealthyCertProducesNoFinding(t *testing.T) {
now := time.Now()
cert := buildCA(t, now.Add(-30*24*time.Hour), now.Add(2*365*24*time.Hour))
got := runCACertExpiryCheck(func() *x509.Certificate { return cert }, nil, now)
if len(got) != 0 {
t.Errorf("expected no findings for healthy cert, got %+v", got)
}
}
func TestCAExpiry_InfoLevelInsideNinetyDays(t *testing.T) {
now := time.Now()
cert := buildCA(t, now.Add(-30*24*time.Hour), now.Add(60*24*time.Hour))
got := runCACertExpiryCheck(func() *x509.Certificate { return cert }, nil, now)
if len(got) != 1 || got[0].Severity != SeverityInfo {
t.Fatalf("expected one info finding, got %+v", got)
}
if !strings.Contains(got[0].Message, "60") {
t.Errorf("expected day count in message, got %q", got[0].Message)
}
}
func TestCAExpiry_WarningInsideThirtyDays(t *testing.T) {
now := time.Now()
cert := buildCA(t, now.Add(-30*24*time.Hour), now.Add(15*24*time.Hour))
got := runCACertExpiryCheck(func() *x509.Certificate { return cert }, nil, now)
if len(got) != 1 || got[0].Severity != SeverityWarning {
t.Fatalf("expected one warning, got %+v", got)
}
if !strings.Contains(got[0].Message, "15") {
t.Errorf("expected day count in message, got %q", got[0].Message)
}
if !strings.Contains(got[0].Details, "Plan a regeneration") {
t.Errorf("expected regen guidance in details, got %q", got[0].Details)
}
}
func TestCAExpiry_ErrorWhenExpired(t *testing.T) {
now := time.Now()
cert := buildCA(t, now.Add(-365*24*time.Hour), now.Add(-2*24*time.Hour))
got := runCACertExpiryCheck(func() *x509.Certificate { return cert }, nil, now)
if len(got) != 1 || got[0].Severity != SeverityError {
t.Fatalf("expected one error, got %+v", got)
}
if !strings.Contains(got[0].Message, "expired") {
t.Errorf("expected 'expired' in message, got %q", got[0].Message)
}
if !strings.Contains(got[0].Message, "2 day") {
t.Errorf("expected day count in message, got %q", got[0].Message)
}
}
func TestCAExpiry_NoCAGracefulInfo(t *testing.T) {
got := runCACertExpiryCheck(func() *x509.Certificate { return nil }, nil, time.Now())
if len(got) != 1 || got[0].Severity != SeverityInfo {
t.Fatalf("expected one info finding when CA missing, got %+v", got)
}
}
func TestCAExpiry_NilLoaderSkipsEntirely(t *testing.T) {
got := runCACertExpiryCheck(nil, nil, time.Now())
if len(got) != 0 {
t.Errorf("expected no findings with nil loader, got %+v", got)
}
}
func TestCARegenCommand_IncludesActualPath(t *testing.T) {
got := caRegenCommand(func() string { return "/var/lib/aftertouch/ca.crt" })
if !strings.Contains(got, "/var/lib/aftertouch/ca.crt") {
t.Errorf("expected cert path in command, got %q", got)
}
if !strings.Contains(got, "/var/lib/aftertouch/ca.key") {
t.Errorf("expected .key sibling path in command, got %q", got)
}
}
func TestCARegenCommand_FallsBackWhenPathUnknown(t *testing.T) {
got := caRegenCommand(nil)
if !strings.Contains(got, "remove the CA") {
t.Errorf("expected fallback hint when path unknown, got %q", got)
}
got = caRegenCommand(func() string { return "" })
if !strings.Contains(got, "remove the CA") {
t.Errorf("expected fallback hint when path empty, got %q", got)
}
}
func TestCAExpiry_ManualCommandPathRendered(t *testing.T) {
now := time.Now()
cert := buildCA(t, now.Add(-30*24*time.Hour), now.Add(5*24*time.Hour))
got := runCACertExpiryCheck(
func() *x509.Certificate { return cert },
func() string { return "/srv/aftertouch/ca.crt" },
now,
)
if len(got) != 1 || len(got[0].ManualCommands) != 1 {
t.Fatalf("expected one manual command, got %+v", got)
}
cmd := got[0].ManualCommands[0]
if !strings.Contains(cmd.Command, "/srv/aftertouch/ca.crt") {
t.Errorf("expected actual path in command, got %q", cmd.Command)
}
}
+240
View File
@@ -0,0 +1,240 @@
package health
import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"net/url"
"strings"
"time"
)
// CheckIDCertChain is the registry id of the cert-chain probe.
const CheckIDCertChain = "service_cert_chain"
// RegisterCertChainCheck registers a check that dials the
// configured HTTPS endpoint and reports whether its certificate
// chain validates against the system trust store. Three outcomes:
//
// - validates against system roots → no finding (the
// speaker's firmware ships with the major roots, so a public-
// CA chain such as Let's Encrypt is usable directly).
// - chain doesn't validate but the served leaf was issued by
// our own AfterTouch CA → warning with an `install-ca`
// suggestion (definitive: we checked the signature against
// our CA, not a Subject==Issuer heuristic).
// - chain doesn't validate and the served leaf was issued by
// something else → warning with an `openssl s_client`
// investigation prompt (foreign chain / reverse proxy /
// ingress cert).
// - HTTPS URL not configured → skip silently.
//
// caCertFn returns AfterTouch's own CA leaf certificate (nil if
// unavailable). It's called per check run; the handler-side
// implementation caches the parse via sync.Once so we don't
// re-read the PEM on every poll.
func RegisterCertChainCheck(r *Registry, httpsURLFn func() string, caCertFn func() *x509.Certificate) {
r.Register(Check{
ID: CheckIDCertChain,
Title: "HTTPS endpoint TLS configuration",
Run: func() []Finding {
return runCertChainCheck(httpsURLFn(), caCertFn)
},
})
}
func runCertChainCheck(httpsURL string, caCertFn func() *x509.Certificate) []Finding {
if strings.TrimSpace(httpsURL) == "" {
return nil
}
host, port := splitHTTPSHostPort(httpsURL)
if host == "" {
return []Finding{{
Severity: SeverityWarning,
Message: fmt.Sprintf("Configured HTTPS URL %q is not parseable.", httpsURL),
}}
}
addr := net.JoinHostPort(host, port)
dialer := &net.Dialer{Timeout: 2 * time.Second}
// Phase 1: try with the system trust store. ServerName is set
// from the URL so the verifier checks SAN coverage too.
conn, err := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{
ServerName: host,
MinVersion: tls.VersionTLS12,
})
if err == nil {
_ = conn.Close()
return nil // validates against system roots
}
// Phase 2: extract the leaf cert from Phase 1's verification
// error. crypto/x509 attaches the offending certificate to the
// three verification-failure error types, which lets us inspect
// what the server actually served without ever opening a second
// connection with InsecureSkipVerify. If the error is something
// else (timeout, TCP reset, protocol mismatch), report it as a
// reachability problem.
leaf := leafFromVerifyError(err)
if leaf == nil {
return []Finding{{
Severity: SeverityError,
Message: fmt.Sprintf("Could not connect to %s: %v", addr, err),
Details: "AfterTouch's HTTPS endpoint isn't reachable from inside the service, or the peer dropped the handshake before presenting a certificate. Check that the listener is bound and the URL host:port resolves correctly.",
}}
}
dnsNames := strings.Join(leaf.DNSNames, ", ")
if dnsNames == "" {
dnsNames = "(none)"
}
chainContext := fmt.Sprintf(
"Leaf subject: %s. Issuer: %s. SANs: %s. Expires: %s.",
leaf.Subject.String(), leaf.Issuer.String(), dnsNames, leaf.NotAfter.Format("2006-01-02"),
)
switch classifyLeaf(leaf, caCertFn) {
case leafFromOwnCA:
return []Finding{{
Severity: SeverityInfo,
Message: fmt.Sprintf("AfterTouch is serving its own self-signed CA chain on %s (expected).", addr),
Details: "The service host's system trust store doesn't include AfterTouch's CA — by design. Speakers establish trust via `setup install-ca`, not via system roots. This finding is informational; nothing is wrong with the service. " +
chainContext,
ManualCommands: []ManualCommand{{
Label: "Reminder — each speaker still needs AfterTouch's CA installed once:",
Command: "soundtouch-cli --host=<speaker-ip> setup install-ca --service-url=" + httpsURL,
Hint: "Verified by signature: the leaf was issued by AfterTouch's own CA. Only run install-ca for speakers that haven't been migrated yet.",
}},
}}
case leafSubjectEqualsIssuer:
return []Finding{{
Severity: SeverityInfo,
Message: fmt.Sprintf("HTTPS endpoint on %s is serving a self-signed certificate.", addr),
Details: "AfterTouch's own CA couldn't be loaded to verify the leaf's signature, so this is a heuristic match (Subject == Issuer). If this *is* AfterTouch's self-signed chain, the situation is normal and speakers trust it via `setup install-ca`. If it's some other self-signed cert (custom proxy, etc.), treat the openssl investigation command below as the primary action. " +
chainContext,
ManualCommands: []ManualCommand{
{
Label: "If this is AfterTouch's CA, install it on each speaker:",
Command: "soundtouch-cli --host=<speaker-ip> setup install-ca --service-url=" + httpsURL,
Hint: "Heuristic match — verify the served Issuer matches AfterTouch's CA before running.",
},
{
Label: "Or inspect the served chain manually:",
Command: fmt.Sprintf("openssl s_client -connect %s -servername %s -showcerts </dev/null", addr, host),
Hint: "Run from the same host as the service.",
},
},
}}
default:
return []Finding{{
Severity: SeverityWarning,
Message: fmt.Sprintf("HTTPS endpoint on %s serves a chain that doesn't validate against system roots and wasn't issued by AfterTouch's CA.", addr),
Details: fmt.Sprintf("Unexpected chain — likely a reverse proxy or ingress cert. Verification error: %v. ", err) +
chainContext,
ManualCommands: []ManualCommand{{
Label: "Inspect the chain manually:",
Command: fmt.Sprintf("openssl s_client -connect %s -servername %s -showcerts </dev/null", addr, host),
Hint: "Run from the same host as the service. Shows the full chain the peer is serving.",
}},
}}
}
}
func splitHTTPSHostPort(raw string) (string, string) {
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" {
return "", ""
}
host := u.Hostname()
port := u.Port()
if port == "" {
port = "443"
}
return host, port
}
// leafFromVerifyError extracts the offending certificate that
// crypto/tls attaches to a verification-failure error. Returns
// nil for non-verification errors (network unreachable, timeout,
// TLS-level handshake failure before any cert was presented).
//
// This is the substitute for an InsecureSkipVerify re-dial: the
// same leaf is reachable from the failed strict-dial error
// without ever standing up a connection that disables cert
// validation.
//
// tls.CertificateVerificationError carries
// UnverifiedCertificates across platforms (the underlying error
// is platform-specific — on darwin it comes from
// Security.framework, on linux from crypto/x509 — but the
// wrapping type is the same). The x509.* unwraps are a
// defensive fallback for the case where a caller hands us a
// verification error that bypassed the tls layer.
func leafFromVerifyError(err error) *x509.Certificate {
var certErr *tls.CertificateVerificationError
if errors.As(err, &certErr) && len(certErr.UnverifiedCertificates) > 0 {
return certErr.UnverifiedCertificates[0]
}
var unkAuth x509.UnknownAuthorityError
if errors.As(err, &unkAuth) {
return unkAuth.Cert
}
var hostnameErr x509.HostnameError
if errors.As(err, &hostnameErr) {
return hostnameErr.Certificate
}
var invalidErr x509.CertificateInvalidError
if errors.As(err, &invalidErr) {
return invalidErr.Cert
}
return nil
}
// leafClassification labels how the leaf relates to AfterTouch's
// own CA. Drives the install-ca-vs-openssl suggestion branch.
type leafClassification int
const (
leafForeign leafClassification = iota // chain we don't recognise
leafFromOwnCA // signature verified by AfterTouch's CA
leafSubjectEqualsIssuer // fallback heuristic when CA isn't loadable
)
// classifyLeaf returns leafFromOwnCA when caCertFn returns a CA
// cert that signed `leaf` (verified by CheckSignatureFrom). When
// the CA cert isn't available, falls back to the
// Subject==Issuer heuristic. Anything else is leafForeign.
func classifyLeaf(leaf *x509.Certificate, caCertFn func() *x509.Certificate) leafClassification {
if leaf == nil {
return leafForeign
}
if caCertFn != nil {
if ca := caCertFn(); ca != nil {
if err := leaf.CheckSignatureFrom(ca); err == nil {
return leafFromOwnCA
}
}
}
if leaf.Subject.String() == leaf.Issuer.String() {
return leafSubjectEqualsIssuer
}
return leafForeign
}
@@ -0,0 +1,292 @@
package health
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestCertChain_EmptyURLSkips(t *testing.T) {
got := runCertChainCheck("", nil)
if len(got) != 0 {
t.Errorf("expected no findings for empty URL, got %+v", got)
}
}
func TestCertChain_UnparseableURLWarns(t *testing.T) {
got := runCertChainCheck("://nope", nil)
if len(got) != 1 || got[0].Severity != SeverityWarning {
t.Fatalf("expected one warning, got %+v", got)
}
}
func TestCertChain_UnreachableEndpoint(t *testing.T) {
// 127.0.0.1:1 refuses; using https:// to force TLS path.
got := runCertChainCheck("https://127.0.0.1:1/", nil)
if len(got) != 1 || got[0].Severity != SeverityError {
t.Fatalf("expected one error for unreachable endpoint, got %+v", got)
}
}
func TestCertChain_SelfSigned_SubjectEqualsIssuerFallback(t *testing.T) {
srv := newSelfSignedTLSServer(t)
defer srv.Close()
// No CA provided → fallback to Subject==Issuer heuristic.
// This is informational, not a warning — a self-signed
// AfterTouch chain is the expected default deployment shape.
got := runCertChainCheck(srv.URL, nil)
if len(got) != 1 || got[0].Severity != SeverityInfo {
t.Fatalf("expected one info finding for self-signed cert, got %+v", got)
}
if !strings.Contains(got[0].Details, "Issuer") {
t.Errorf("expected issuer detail, got %q", got[0].Details)
}
if !strings.Contains(got[0].Details, "heuristic") {
t.Errorf("expected heuristic disclosure in details, got %q", got[0].Details)
}
if len(got[0].ManualCommands) < 2 {
t.Fatalf("expected at least install-ca + openssl commands, got %+v", got[0].ManualCommands)
}
var sawInstallCA, sawOpenssl bool
for _, c := range got[0].ManualCommands {
if strings.Contains(c.Command, "install-ca") {
sawInstallCA = true
}
if strings.Contains(c.Command, "openssl s_client") {
sawOpenssl = true
}
}
if !sawInstallCA {
t.Errorf("expected install-ca suggestion among manual commands")
}
if !sawOpenssl {
t.Errorf("expected openssl investigation command among manual commands")
}
}
func TestCertChain_LeafSignedByOwnCA_IsInformationalNotAWarning(t *testing.T) {
// AfterTouch's own CA is the *default* deployment shape.
// Calling that a warning would mislead non-technical users.
// The check should report INFO, explain the situation, and
// remind to install-ca on speakers — but not present the
// service host's lack of trust as a defect.
caTLS, ca := generateInternalCA(t)
leafTLS := generateLeafSignedBy(t, ca, caTLS.PrivateKey)
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(200)
}))
srv.TLS = &tls.Config{Certificates: []tls.Certificate{leafTLS}}
srv.StartTLS()
defer srv.Close()
got := runCertChainCheck(srv.URL, func() *x509.Certificate { return ca })
if len(got) != 1 {
t.Fatalf("expected one finding, got %+v", got)
}
if got[0].Severity != SeverityInfo {
t.Errorf("expected SeverityInfo for AfterTouch's own CA chain, got %q", got[0].Severity)
}
if !strings.Contains(got[0].Message, "expected") {
t.Errorf("expected message to call this state 'expected', got %q", got[0].Message)
}
if !strings.Contains(got[0].Details, "by design") {
t.Errorf("expected details to explain it's by design, got %q", got[0].Details)
}
if len(got[0].ManualCommands) == 0 {
t.Fatalf("expected install-ca reminder among manual commands")
}
cmd := got[0].ManualCommands[0]
if !strings.Contains(cmd.Command, "install-ca") {
t.Errorf("expected install-ca reminder, got %q", cmd.Command)
}
if !strings.Contains(cmd.Hint, "Verified by signature") {
t.Errorf("expected signature-verified hint, got %q", cmd.Hint)
}
}
func TestCertChain_ForeignChain_SuggestsOpenSSL(t *testing.T) {
// Build an "external" CA + leaf, then provide a *different*
// CA via caCertFn. Signature check fails → classifier returns
// leafForeign → openssl suggestion (since Subject==Issuer
// would also fail for a properly chained leaf).
externalCATLS, externalCA := generateInternalCA(t)
leafTLS := generateLeafSignedBy(t, externalCA, externalCATLS.PrivateKey)
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(200)
}))
srv.TLS = &tls.Config{Certificates: []tls.Certificate{leafTLS}}
srv.StartTLS()
defer srv.Close()
// Different CA — pretend it's "our" AfterTouch CA.
_, ourCA := generateInternalCA(t)
got := runCertChainCheck(srv.URL, func() *x509.Certificate { return ourCA })
if len(got) != 1 || got[0].Severity != SeverityWarning {
t.Fatalf("expected one warning, got %+v", got)
}
cmd := got[0].ManualCommands[0]
if !strings.Contains(cmd.Command, "openssl s_client") {
t.Errorf("expected openssl suggestion for foreign chain, got %q", cmd.Command)
}
}
func TestSplitHTTPSHostPort(t *testing.T) {
cases := []struct {
in, host, port string
}{
{"https://example.com/", "example.com", "443"},
{"https://example.com:8443/", "example.com", "8443"},
{"https://192.0.2.10/", "192.0.2.10", "443"},
{"https://", "", ""},
{"://broken", "", ""},
}
for _, c := range cases {
h, p := splitHTTPSHostPort(c.in)
if h != c.host || p != c.port {
t.Errorf("splitHTTPSHostPort(%q) = (%q, %q), want (%q, %q)", c.in, h, p, c.host, c.port)
}
}
}
// newSelfSignedTLSServer returns an httptest.Server whose TLS
// config uses a self-signed cert we generate inline. httptest's
// default TLS server uses a built-in cert, but verifying its
// Subject == Issuer property without inspecting innards is
// fiddly; making our own keeps the assertion deterministic.
func newSelfSignedTLSServer(t *testing.T) *httptest.Server {
t.Helper()
cert := generateSelfSignedCert(t)
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(200)
}))
srv.TLS = &tls.Config{Certificates: []tls.Certificate{cert}}
srv.StartTLS()
return srv
}
func generateSelfSignedCert(t *testing.T) tls.Certificate {
t.Helper()
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "aftertouch-test"},
Issuer: pkix.Name{CommonName: "aftertouch-test"}, // self-signed
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
DNSNames: []string{"127.0.0.1"},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa key: %v", err)
}
derBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("create cert: %v", err)
}
return tls.Certificate{
Certificate: [][]byte{derBytes},
PrivateKey: key,
}
}
// generateInternalCA returns a self-signed CA suitable for
// signing leaves. The returned tls.Certificate carries the CA
// key (needed to sign leaves below); the *x509.Certificate is
// the parsed CA leaf.
func generateInternalCA(t *testing.T) (tls.Certificate, *x509.Certificate) {
t.Helper()
template := &x509.Certificate{
SerialNumber: big.NewInt(2026),
Subject: pkix.Name{CommonName: "AfterTouch Test CA", Organization: []string{"AfterTouch Test"}},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
}
template.Issuer = template.Subject
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa key: %v", err)
}
derBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("create CA cert: %v", err)
}
caParsed, err := x509.ParseCertificate(derBytes)
if err != nil {
t.Fatalf("parse CA cert: %v", err)
}
return tls.Certificate{Certificate: [][]byte{derBytes}, PrivateKey: key}, caParsed
}
// generateLeafSignedBy issues a TLS leaf cert (CN=leaf) signed
// by ca/caKey, with SAN 127.0.0.1 so httptest's loopback SNI
// matches. Subject != Issuer by construction — the case that
// caught my old heuristic.
func generateLeafSignedBy(t *testing.T, ca *x509.Certificate, caKey any) tls.Certificate {
t.Helper()
leafKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("leaf rsa key: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(42),
Subject: pkix.Name{CommonName: "soundtouch", Organization: []string{"AfterTouch"}},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
DNSNames: []string{"127.0.0.1"},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
derBytes, err := x509.CreateCertificate(rand.Reader, template, ca, &leafKey.PublicKey, caKey)
if err != nil {
t.Fatalf("create leaf cert: %v", err)
}
return tls.Certificate{Certificate: [][]byte{derBytes}, PrivateKey: leafKey}
}
+821
View File
@@ -0,0 +1,821 @@
package health
import (
"context"
"encoding/xml"
"fmt"
"log"
"os"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// CheckIDPresetsConsistency is the registry id of the consistency check.
const CheckIDPresetsConsistency = "presets_recents_sources_consistency"
// FixIDDeleteOrphanAccountEntry is the QuickFix that removes a stale
// account directory for a device after the operator has confirmed the
// speaker isn't currently targeting it.
const FixIDDeleteOrphanAccountEntry = "delete_orphan_account_entry"
// FixIDReclassifyCanonicalSourceIDs is the QuickFix that rewrites
// non-canonical IDs on built-in radio sources back to their canonical
// values (TUNEIN→10004, INTERNET_RADIO→10002, …), and updates any
// preset/recent <sourceid> references to match. Offline-only — no
// speaker access required.
const FixIDReclassifyCanonicalSourceIDs = "reclassify_canonical_source_ids"
// speakerPresetsConsistencyXML mirrors enough of :8090/presets to extract
// slot id, source/location and itemName for cross-side comparison.
type speakerPresetsConsistencyXML struct {
XMLName xml.Name `xml:"presets"`
Presets []struct {
ID string `xml:"id,attr"`
ContentItem struct {
Source string `xml:"source,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
Location string `xml:"location,attr"`
ItemName string `xml:"itemName"`
} `xml:"ContentItem"`
} `xml:"preset"`
}
// speakerRecentsConsistencyXML mirrors :8090/recents.
type speakerRecentsConsistencyXML struct {
XMLName xml.Name `xml:"recents"`
Recents []struct {
DeviceID string `xml:"deviceID,attr"`
UtcTime string `xml:"utcTime,attr"`
ID string `xml:"id,attr"`
ContentItem struct {
Source string `xml:"source,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
Location string `xml:"location,attr"`
ItemName string `xml:"itemName"`
} `xml:"contentItem"`
} `xml:"recent"`
}
// speakerSourcesConsistencyXML mirrors :8090/sources.
type speakerSourcesConsistencyXML struct {
XMLName xml.Name `xml:"sources"`
Items []struct {
Source string `xml:"source,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
} `xml:"sourceItem"`
}
// speakerInfoMargeXML mirrors just the <margeAccountUUID> element of
// :8090/info — the speaker's own statement of which Marge account it's
// currently paired with. Used to confirm orphan-dir deletions against
// the authoritative speaker-side answer.
type speakerInfoMargeXML struct {
XMLName xml.Name `xml:"info"`
MargeAccountUUID string `xml:"margeAccountUUID"`
}
// fetchSpeakerMargeAccount asks the speaker which account it thinks it
// belongs to. Returns "" when the speaker is unreachable, returned a
// non-200, or didn't include margeAccountUUID in /info — callers
// treat empty as "no signal" rather than as a deletion blocker.
func fetchSpeakerMargeAccount(ctx context.Context, ip string) string {
if ip == "" {
return ""
}
return fetchSpeakerMargeAccountFromURL(ctx, fmt.Sprintf("http://%s:8090/info", ip))
}
// fetchSpeakerMargeAccountFromURL is the URL-injectable variant used
// by tests that need to point the probe at an httptest server. The
// production caller goes through fetchSpeakerMargeAccount above.
func fetchSpeakerMargeAccountFromURL(ctx context.Context, infoURL string) string {
res := ProbeGet(ctx, infoURL, 2*time.Second)
if !res.Reachable || res.Status != 200 {
return ""
}
var parsed speakerInfoMargeXML
if err := xml.Unmarshal(res.Body, &parsed); err != nil {
return ""
}
return parsed.MargeAccountUUID
}
// RegisterPresetsConsistencyCheck registers the cross-reference check.
// For every paired device with a known IP, it builds two ConsistencyViews
// (speaker, service), runs the internal-consistency pass on each, then
// the cross-side pass — and surfaces every detected issue as a Finding so
// the operator can drill into "why aren't my presets behaving" without
// reading service logs or curl-ing XML by hand.
func RegisterPresetsConsistencyCheck(r *Registry, ds *datastore.DataStore) {
r.Register(Check{
ID: CheckIDPresetsConsistency,
Title: "Presets, recents and sources cross-reference consistently",
Run: func() []Finding {
return runPresetsConsistencyCheck(ds)
},
})
r.RegisterFix(CheckIDPresetsConsistency, FixIDDeleteOrphanAccountEntry, func(target Target) (string, error) {
return deleteOrphanAccountEntry(ds, target)
})
r.RegisterFix(CheckIDPresetsConsistency, FixIDReclassifyCanonicalSourceIDs, func(target Target) (string, error) {
return reclassifyCanonicalSourceIDs(ds, target)
})
}
func runPresetsConsistencyCheck(ds *datastore.DataStore) []Finding {
if ds == nil {
return nil
}
devices, err := ds.ListAllDevices()
if err != nil {
return []Finding{{
Severity: SeverityError,
Message: "Could not enumerate devices: " + err.Error(),
}}
}
var findings []Finding
// Surface orphan "default"-account entries for devices that are
// also paired under a real account. The speaker pairs to one
// account at a time; the leftover "default" record from the
// pre-pair phase is stale state the operator can safely remove.
findings = append(findings, detectOrphanDefaultEntries(ds, devices)...)
for i := range devices {
dev := &devices[i]
if dev.AccountID == "" || dev.DeviceID == "" {
continue
}
findings = append(findings, checkOneDeviceConsistency(ds, dev.AccountID, dev.DeviceID, dev.IPAddress)...)
}
return findings
}
// detectOrphanDefaultEntries flags devices that exist under multiple
// account directories. The speaker decides which account it belongs to
// via the URL of every PUT it sends; any other account entry on disk
// is leftover state from a previous pairing. The active account is
// the one ListAllDevices' dedup currently exposes (with "default"
// already deprioritised); the stale ones each get a finding with a
// confirm-gated QuickFix so the operator can delete them one at a
// time after verifying via the service log which account the speaker
// is actually targeting.
func detectOrphanDefaultEntries(ds *datastore.DataStore, paired []models.ServiceDeviceInfo) []Finding {
type deviceInfo struct {
account string
ip string
}
activeAccount := map[string]deviceInfo{}
for i := range paired {
if paired[i].DeviceID == "" {
continue
}
activeAccount[paired[i].DeviceID] = deviceInfo{
account: paired[i].AccountID,
ip: paired[i].IPAddress,
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var findings []Finding
for deviceID, info := range activeAccount {
allAccounts := ds.AllAccountsForDevice(deviceID)
if len(allAccounts) <= 1 {
continue
}
// Speaker's own answer to "which account do I belong to?".
// Empty when unreachable; treated as "no signal" — we fall
// back to the on-disk ListAllDevices guess.
speakerAccount := fetchSpeakerMargeAccount(ctx, info.ip)
authoritative := info.account
signalSource := "on-disk activity (ListAllDevices)"
if speakerAccount != "" {
authoritative = speakerAccount
signalSource = "the speaker itself, via :8090/info"
if speakerAccount != info.account {
log.Printf("[Health] consistency: speaker %s reports margeAccountUUID=%s but ListAllDevices picked %s — preferring the speaker's answer for orphan-deletion suggestions",
deviceID, speakerAccount, info.account)
}
}
for _, acc := range allAccounts {
if acc == authoritative {
continue
}
findings = append(findings, Finding{
Severity: SeverityWarning,
Target: Target{Account: acc, Device: deviceID},
Message: "Stale account entry: device " + deviceID + " also has state under account " + safeQuoteFinding(acc) + " — likely leftover from a previous pairing. The currently-active account is " + safeQuoteFinding(authoritative) + " (per " + signalSource + ").",
Details: orphanFindingDetails(acc, deviceID, speakerAccount),
QuickFixes: []QuickFix{{ID: FixIDDeleteOrphanAccountEntry, Label: "Delete stale entry", Confirm: orphanFindingConfirm(acc, deviceID, authoritative, speakerAccount)}},
ManualCommands: []ManualCommand{{Label: "Or remove from a shell:", Command: "rm -rf <data-dir>/accounts/" + acc + "/devices/" + deviceID, Hint: "Substitute <data-dir> with the service's actual data directory (typically /var/lib/soundtouch-service)."}},
})
}
}
return findings
}
func orphanFindingDetails(acc, deviceID, speakerAccount string) string {
if speakerAccount == "" {
return "Couldn't reach the speaker on :8090/info to confirm its current account. Before deleting, verify via service log entries for /streaming/account/" + acc + "/device/" + deviceID + "/...; the Delete QuickFix will retry the speaker probe and refuse if the speaker reports this account as live."
}
return "Speaker /info reports margeAccountUUID=" + speakerAccount + "; this directory (account " + acc + ") is stale because the speaker has stopped targeting it."
}
func orphanFindingConfirm(acc, deviceID, authoritative, speakerAccount string) string {
base := "Permanently delete <data-dir>/accounts/" + acc + "/devices/" + deviceID + "/? Removes Presets.xml, Recents.xml, Sources.xml and DeviceInfo.xml for this stale pairing. The active account " + authoritative + " is not touched."
if speakerAccount != "" {
return base + " (Confirmed by the speaker itself: /info reports margeAccountUUID=" + speakerAccount + ".)"
}
return base + " The speaker was not reachable to confirm; the QuickFix will re-probe before deleting and refuse if the speaker now reports this account as live."
}
func safeQuoteFinding(s string) string {
if s == "" {
return `""`
}
return `"` + s + `"`
}
// deleteOrphanAccountEntry removes accounts/<target.Account>/devices/<target.Device>/.
// Called only after the operator has clicked through the Confirm dialog
// that the QuickFix surfaces. Before the destructive step, asks the
// speaker (via :8090/info) which account it currently considers its
// own and refuses to proceed when the answer matches the deletion
// target — even an operator-confirmed click can be wrong if the
// speaker re-paired between scan and click. Logs the action for
// auditability.
func deleteOrphanAccountEntry(ds *datastore.DataStore, target Target) (string, error) {
if target.Account == "" || target.Device == "" {
return "", fmt.Errorf("account and device are both required")
}
// Find a known IP for this device by walking the active devices.
// Speaker re-probe needs the IP; if we can't find one we proceed
// without the safety net but log the gap so it shows up in audit.
speakerIP := lookupActiveDeviceIP(ds, target.Device)
if speakerIP != "" {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if speakerAccount := fetchSpeakerMargeAccount(ctx, speakerIP); speakerAccount != "" {
if speakerAccount == target.Account {
return "", fmt.Errorf("speaker %s reports margeAccountUUID=%s — refusing to delete <data-dir>/accounts/%s/devices/%s because it's the speaker's currently-active binding (re-paired since the consistency check ran?)",
target.Device, speakerAccount, target.Account, target.Device)
}
log.Printf("[Health] deleteOrphanAccountEntry: speaker %s confirmed margeAccountUUID=%s; target account %s is stale, proceeding with delete",
target.Device, speakerAccount, target.Account)
} else {
log.Printf("[Health] deleteOrphanAccountEntry: speaker %s at %s not reachable for re-confirmation; relying on operator's Confirm click",
target.Device, speakerIP)
}
} else {
log.Printf("[Health] deleteOrphanAccountEntry: no IP recorded for device %s — skipping speaker re-probe", target.Device)
}
if target.Account == accountIDDefaultPlaceholder {
log.Printf("[Health] deleteOrphanAccountEntry: deleting the \"default\" placeholder entry for device %s; this is normal after pairing completed", target.Device)
}
path := ds.AccountDeviceDir(target.Account, target.Device)
if _, err := os.Stat(path); err != nil {
return "", fmt.Errorf("orphan directory %s no longer exists; nothing to do", path)
}
if err := os.RemoveAll(path); err != nil {
return "", fmt.Errorf("delete %s: %w", path, err)
}
log.Printf("[Health] Removed orphan account entry %s (account=%s device=%s) at operator request",
path, target.Account, target.Device)
return fmt.Sprintf("Removed stale account entry %s for device %s.", target.Account, target.Device), nil
}
// lookupActiveDeviceIP returns the IP recorded for the device under
// whichever account ListAllDevices currently treats as active. Empty
// when the device isn't found or has no IP recorded.
func lookupActiveDeviceIP(ds *datastore.DataStore, deviceID string) string {
devices, err := ds.ListAllDevices()
if err != nil {
return ""
}
for i := range devices {
if devices[i].DeviceID == deviceID && devices[i].IPAddress != "" {
return devices[i].IPAddress
}
}
return ""
}
// accountIDDefaultPlaceholder mirrors datastore.accountIDDefault for
// the health package; kept here to avoid widening the datastore
// package's exported surface.
const accountIDDefaultPlaceholder = "default"
// reclassifiableSource captures a single built-in radio source whose
// on-disk ID drifted from the canonical value. Built up by
// findReclassifiableSources and consumed by reclassifyCanonicalSourceIDs.
type reclassifiableSource struct {
OldID string
NewID string
KeyType string // TUNEIN / INTERNET_RADIO / …
ProvID string // canonical sourceproviderid (e.g. "25")
}
// canonicalIDByKeyType returns the canonical built-in source ID for one
// of the four well-known radio provider key types, or ("", "") for any
// other type. Mirrors datastore.getDefaultSources() and
// canonicalDefaultsByType in pkg/service/marge.
func canonicalIDByKeyType(keyType string) (id, providerID string) {
switch keyType {
case "INTERNET_RADIO":
return "10002", "2"
case "LOCAL_INTERNET_RADIO":
return "10003", "11"
case "TUNEIN":
return "10004", "25"
case "RADIO_BROWSER":
return "10005", "39"
}
return "", ""
}
// findReclassifiableSources walks a ConsistencyView's sources and
// returns the entries that:
// - have a SourceKeyType matching one of the four built-in radio
// providers (the ones with a canonical ID),
// - currently sit on a non-canonical ID, and
// - would not collide with another source already at that canonical
// ID.
//
// The collision check is intentionally strict — when two entries claim
// the same SourceKeyType, leaving them in place is safer than guessing
// which one is the "real" one and leaving the other broken.
func findReclassifiableSources(v ConsistencyView) []reclassifiableSource {
usedIDs := map[string]bool{}
for _, s := range v.Sources {
if s.ID != "" {
usedIDs[s.ID] = true
}
}
var out []reclassifiableSource
for _, s := range v.Sources {
newID, providerID := canonicalIDByKeyType(s.Type)
if newID == "" || s.ID == newID {
continue
}
// Don't try to re-classify if the canonical ID is already
// occupied by a different source — would create a collision
// the rest of the codebase isn't prepared to handle.
if usedIDs[newID] {
continue
}
out = append(out, reclassifiableSource{
OldID: s.ID,
NewID: newID,
KeyType: s.Type,
ProvID: providerID,
})
}
return out
}
func reclassifyDetailMessage(in []reclassifiableSource) string {
out := "Each preset binding by ID may end up bound to the wrong source after re-pair churn — exactly the GH-343 footprint. Re-classifying restores canonical IDs:\n"
for _, r := range in {
out += " • " + r.KeyType + ": " + r.OldID + " → " + r.NewID + " (sourceproviderid " + r.ProvID + ")\n"
}
return out
}
func reclassifyConfirmDetail(in []reclassifiableSource) string {
out := "Changes:"
for _, r := range in {
out += " " + r.KeyType + " " + r.OldID + "→" + r.NewID + ";"
}
return out
}
// reclassifyCanonicalSourceIDs is the QuickFix body for
// FixIDReclassifyCanonicalSourceIDs. Re-reads Sources.xml / Presets.xml
// / Recents.xml for the device, builds the old-ID → new-ID mapping for
// each eligible built-in radio source, rewrites the source IDs in
// Sources.xml plus any matching <sourceid> references in
// Presets.xml/Recents.xml, and persists all three. The datastore's
// SaveX helpers each use atomic-rename internally; a failure mid-way
// leaves earlier files updated but the operation as a whole is
// idempotent — re-running it produces the same result.
func reclassifyCanonicalSourceIDs(ds *datastore.DataStore, target Target) (string, error) {
if target.Account == "" || target.Device == "" {
return "", fmt.Errorf("account and device are both required")
}
view, err := loadServiceView(ds, target.Account, target.Device)
if err != nil {
return "", fmt.Errorf("load service state: %w", err)
}
plans := findReclassifiableSources(view)
if len(plans) == 0 {
return "Nothing to do — all built-in radio sources already on canonical IDs.", nil
}
rename := map[string]string{}
canonicalProviderID := map[string]string{}
for _, p := range plans {
rename[p.OldID] = p.NewID
canonicalProviderID[p.OldID] = p.ProvID
}
sources, err := ds.GetConfiguredSources(target.Account, target.Device)
if err != nil {
return "", fmt.Errorf("read Sources.xml: %w", err)
}
for i := range sources {
if newID, ok := rename[sources[i].ID]; ok {
log.Printf("[Health] Re-classify %s: id %s → %s (account=%s device=%s)",
sources[i].SourceKeyType, sources[i].ID, newID, target.Account, target.Device)
sources[i].ID = newID
if provID := canonicalProviderID[sources[i].ID]; provID != "" {
sources[i].SourceProviderID = provID
}
}
}
if saveErr := ds.SaveConfiguredSources(target.Account, target.Device, sources); saveErr != nil {
return "", fmt.Errorf("save Sources.xml: %w", saveErr)
}
if err := rewritePresetSourceIDs(ds, target, rename); err != nil {
return "", err
}
if err := rewriteRecentSourceIDs(ds, target, rename); err != nil {
return "", err
}
return fmt.Sprintf("Re-classified %d source ID(s) for device %s (account %s). Presets/Recents references updated accordingly. The speaker will pick up the new IDs on its next /full fetch.",
len(plans), target.Device, target.Account), nil
}
// rewritePresetSourceIDs walks Presets.xml and updates any <sourceid>
// that appears as a key in rename to the mapped value. Persists only
// when at least one preset changed. Silent on read errors (missing
// Presets.xml is a valid state — the operator just doesn't have any
// presets to update).
func rewritePresetSourceIDs(ds *datastore.DataStore, target Target, rename map[string]string) error {
presets, err := ds.GetPresets(target.Account, target.Device)
if err != nil {
return fmt.Errorf("read Presets.xml: %w", err)
}
dirty := false
for i := range presets {
if newID, ok := rename[presets[i].SourceID]; ok {
presets[i].SourceID = newID
dirty = true
}
}
if !dirty {
return nil
}
if err := ds.SavePresets(target.Account, target.Device, presets); err != nil {
return fmt.Errorf("save Presets.xml: %w", err)
}
return nil
}
// rewriteRecentSourceIDs is the recents-side twin of rewritePresetSourceIDs.
func rewriteRecentSourceIDs(ds *datastore.DataStore, target Target, rename map[string]string) error {
recents, err := ds.GetRecents(target.Account, target.Device)
if err != nil {
return fmt.Errorf("read Recents.xml: %w", err)
}
dirty := false
for i := range recents {
if newID, ok := rename[recents[i].SourceID]; ok {
recents[i].SourceID = newID
dirty = true
}
}
if !dirty {
return nil
}
if err := ds.SaveRecents(target.Account, target.Device, recents); err != nil {
return fmt.Errorf("save Recents.xml: %w", err)
}
return nil
}
func checkOneDeviceConsistency(ds *datastore.DataStore, account, deviceID, ipAddress string) []Finding {
target := Target{Account: account, Device: deviceID}
serviceView, err := loadServiceView(ds, account, deviceID)
if err != nil {
return []Finding{{
Severity: SeverityWarning,
Target: target,
Message: "Could not read service-side state for consistency check.",
Details: err.Error(),
}}
}
var findings []Finding
// Internal consistency is meaningful only on the service side —
// the speaker manages its own preset/source coherence locally,
// and its /sources list deliberately omits streaming sources
// (which would always trigger spurious "dangling" findings).
findings = append(findings, issuesToFindings(target, CheckInternalConsistency(serviceView), SeverityWarning)...)
// GH-343-style detection: built-in radio sources sitting on
// non-canonical IDs (the 2000001+i fallback that GetConfiguredSources
// hands out when on-disk sources don't carry canonical IDs). Surface
// as a finding with an offline QuickFix that rewrites both the
// source IDs and the preset/recent <sourceid> references atomically.
if reclassifiable := findReclassifiableSources(serviceView); len(reclassifiable) > 0 {
findings = append(findings, Finding{
Severity: SeverityWarning,
Target: target,
Message: "Sources.xml has " + plural(len(reclassifiable), "built-in radio source", "built-in radio sources") + " on non-canonical IDs (GH-343 trigger).",
Details: reclassifyDetailMessage(reclassifiable),
QuickFixes: []QuickFix{{
ID: FixIDReclassifyCanonicalSourceIDs,
Label: "Re-assign canonical IDs",
Confirm: "Rewrite " + plural(len(reclassifiable), "source ID", "source IDs") + " in Sources.xml and matching <sourceid> references in Presets.xml/Recents.xml? " + reclassifyConfirmDetail(reclassifiable) + " Offline operation — no speaker contact needed; the speaker will re-fetch /full on its own.",
}},
})
}
if ipAddress == "" {
findings = append(findings, Finding{
Severity: SeverityInfo,
Target: target,
Message: "No IP recorded for device; skipping speaker-side consistency check. Service-side internal consistency was checked.",
})
return findings
}
speakerView, probeIssue := loadSpeakerView(ipAddress)
if probeIssue != nil {
findings = append(findings, Finding{
Severity: SeverityInfo,
Target: target,
Message: "Couldn't fetch speaker XML for cross-side comparison; service-side internal consistency was still checked.",
Details: probeIssue.Detail,
ManualCommands: probeIssue.ManualCommands,
})
return findings
}
// "Unsynced device" short-circuit: when the service has no
// presets / recents / sources for a device that the speaker
// clearly does have all three for, emit one consolidated
// finding instead of a torrent of per-slot mismatches.
if isServiceUnsynced(serviceView) && !isSpeakerEmpty(speakerView) {
findings = append(findings, Finding{
Severity: SeverityWarning,
Target: target,
Message: "Device has speaker state (presets, recents, sources) but service has nothing for it — looks like a missed pair/sync. Click \"Sync\" in the device tab or factory-reset and re-pair.",
})
return findings
}
findings = append(findings, issuesToFindings(target, CheckCrossSide(speakerView, serviceView), SeverityWarning)...)
return findings
}
func isServiceUnsynced(v ConsistencyView) bool {
return len(v.Presets) == 0 && len(v.Recents) == 0 && len(v.Sources) == 0
}
func isSpeakerEmpty(v ConsistencyView) bool {
return len(v.Presets) == 0 && len(v.Recents) == 0 && len(v.Sources) == 0
}
func issuesToFindings(target Target, issues []ConsistencyIssue, severity Severity) []Finding {
if len(issues) == 0 {
return nil
}
out := make([]Finding, 0, len(issues))
for _, iss := range issues {
out = append(out, Finding{
Severity: severity,
Target: target,
Message: string(iss.Kind) + " (" + iss.Side + "): " + iss.Detail,
})
}
return out
}
func loadServiceView(ds *datastore.DataStore, account, deviceID string) (ConsistencyView, error) {
presets, err := ds.GetPresets(account, deviceID)
if err != nil {
return ConsistencyView{}, fmt.Errorf("read presets: %w", err)
}
recents, err := ds.GetRecents(account, deviceID)
if err != nil {
return ConsistencyView{}, fmt.Errorf("read recents: %w", err)
}
sources, err := ds.GetConfiguredSources(account, deviceID)
if err != nil {
return ConsistencyView{}, fmt.Errorf("read sources: %w", err)
}
view := ConsistencyView{Label: "service"}
// No special resolution here anymore — datastore.GetPresets /
// GetRecents self-heal the protocol-level "Audio" leak via
// repairLeakedSource at load time. The persisted Source we read
// already reflects the speaker's perspective.
for i := range presets {
view.Presets = append(view.Presets, ConsistencyPreset{
Slot: presets[i].ButtonNumber,
Source: presets[i].Source,
SourceID: presets[i].SourceID,
Location: presets[i].Location,
Name: presets[i].Name,
})
}
for i := range recents {
view.Recents = append(view.Recents, ConsistencyRecent{
ID: recents[i].ID,
Source: recents[i].Source,
SourceID: recents[i].SourceID,
Location: recents[i].Location,
Name: recents[i].Name,
})
}
for i := range sources {
view.Sources = append(view.Sources, ConsistencySource{
ID: sources[i].ID,
Type: sources[i].SourceKeyType,
Account: sources[i].SourceKeyAccount,
})
}
return view, nil
}
// probeFailure captures everything we know about a failed speaker probe
// so we can render a single Info finding instead of three separate ones
// when the speaker is just unreachable.
type probeFailure struct {
Detail string
ManualCommands []ManualCommand
}
func loadSpeakerView(ipAddress string) (ConsistencyView, *probeFailure) {
view := ConsistencyView{Label: "speaker"}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
presetsRes := ProbeGet(ctx, fmt.Sprintf("http://%s:8090/presets", ipAddress), 2*time.Second)
if !presetsRes.Reachable {
return view, &probeFailure{
Detail: "speaker /presets unreachable: " + presetsRes.Err,
ManualCommands: []ManualCommand{
{Label: "From a host on the speaker's LAN, fetch /presets:", Command: presetsRes.CurlCommand},
{Label: "And /recents:", Command: fmt.Sprintf("curl -sS http://%s:8090/recents", ipAddress)},
{Label: "And /sources:", Command: fmt.Sprintf("curl -sS http://%s:8090/sources", ipAddress)},
{Label: "Compare the three side-by-side against AfterTouch's stored state.", Command: ""},
},
}
}
if presetsRes.Status == 200 {
var parsed speakerPresetsConsistencyXML
if err := xml.Unmarshal(presetsRes.Body, &parsed); err == nil {
for i := range parsed.Presets {
p := parsed.Presets[i]
if p.ID == "" {
continue
}
view.Presets = append(view.Presets, ConsistencyPreset{
Slot: p.ID,
Source: p.ContentItem.Source,
Location: p.ContentItem.Location,
Name: p.ContentItem.ItemName,
})
}
}
}
recentsRes := ProbeGet(ctx, fmt.Sprintf("http://%s:8090/recents", ipAddress), 2*time.Second)
if recentsRes.Reachable && recentsRes.Status == 200 {
var parsed speakerRecentsConsistencyXML
if err := xml.Unmarshal(recentsRes.Body, &parsed); err == nil {
for i := range parsed.Recents {
r := parsed.Recents[i]
if r.ID == "" {
continue
}
view.Recents = append(view.Recents, ConsistencyRecent{
ID: r.ID,
Source: r.ContentItem.Source,
Location: r.ContentItem.Location,
Name: r.ContentItem.ItemName,
})
}
}
}
sourcesRes := ProbeGet(ctx, fmt.Sprintf("http://%s:8090/sources", ipAddress), 2*time.Second)
if sourcesRes.Reachable && sourcesRes.Status == 200 {
var parsed speakerSourcesConsistencyXML
if err := xml.Unmarshal(sourcesRes.Body, &parsed); err == nil {
seen := map[string]bool{}
for i := range parsed.Items {
key := parsed.Items[i].Source + "|" + parsed.Items[i].SourceAccount
if parsed.Items[i].Source == "" || seen[key] {
continue
}
seen[key] = true
view.Sources = append(view.Sources, ConsistencySource{
Type: parsed.Items[i].Source,
Account: parsed.Items[i].SourceAccount,
})
}
}
}
return view, nil
}
// Compile-time guard: the models package must keep ServicePreset's
// embedded ServiceContentItem layout so that the field access in
// loadServiceView stays valid. Spotting a type change here is cheaper
// than via a runtime check.
var _ = models.ServicePreset{}.ButtonNumber
@@ -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)
}
}
+227
View File
@@ -0,0 +1,227 @@
package health
import (
"fmt"
"net"
"sort"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/miekg/dns"
)
// CheckIDDNSSanity is the registry id of the DNS-interception
// sanity check.
const CheckIDDNSSanity = "dns_sanity"
// DNSStatusFunc reports whether the service's DNS interception
// listener is running and on which UDP bind address (host:port).
// Closure over Server.GetDNSRunning to avoid a hard dependency
// from health onto handlers.
type DNSStatusFunc func() (running bool, bindAddr string)
// ExpectedIPFunc returns the IP this service expects the
// intercepted hostnames to resolve to (i.e. its own LAN IP).
// Returns the empty string when no service URL is configured.
type ExpectedIPFunc func() string
// RegisterDNSSanityCheck registers a check that queries the
// service's own DNS server for each intercepted Bose hostname and
// verifies the answer is the configured service IP. Catches three
// classes of misconfiguration recurring in #94, #218, #269:
//
// 1. DNS server is disabled or didn't bind — speakers using it
// as their resolver get NXDOMAIN.
// 2. DNS server is bound but answers point at the wrong IP
// (e.g. operator changed the LAN IP without restarting).
// 3. A subset of intercepted hostnames silently fail to resolve.
func RegisterDNSSanityCheck(r *Registry, statusFn DNSStatusFunc, expectedIPFn ExpectedIPFunc) {
r.Register(Check{
ID: CheckIDDNSSanity,
Title: "DNS interception resolves Bose hostnames to this service",
Run: func() []Finding {
return runDNSSanityCheck(statusFn, expectedIPFn)
},
})
}
func runDNSSanityCheck(statusFn DNSStatusFunc, expectedIPFn ExpectedIPFunc) []Finding {
running, bindAddr := statusFn()
if !running {
return []Finding{{
Severity: SeverityInfo,
Message: "DNS interception is not running on this host.",
Details: "Speakers using this service as their DNS server would receive no answers for intercepted Bose hostnames. Enable DNS in Settings or set DNS_ENABLED=true if speakers should be redirected via DNS rather than /etc/hosts on the speaker.",
}}
}
expectedIP := expectedIPFn()
if expectedIP == "" {
return []Finding{{
Severity: SeverityWarning,
Message: "DNS server is running but no service IP could be resolved.",
Details: "Without a known target IP the sanity check can't validate answers. Configure SERVER_URL to a hostname that resolves to this service's LAN IP.",
}}
}
// bindAddr may be "" / ":53" / "0.0.0.0:53" / "[::]:53" —
// the DNS lib is bound to the wildcard address. Translate
// that into something we can actually dial from inside the
// service host. queryTarget is what we send queries to;
// displayAddr is what we surface to the operator (the
// originally configured value, which is what they'd see in
// netstat).
queryTarget := resolveDNSQueryTarget(bindAddr)
displayAddr := bindAddr
if displayAddr == "" {
displayAddr = "(default)"
}
// Query our DNS server for the canonical intercept list and
// classify the results.
hostnames := append([]string(nil), discovery.InterceptedBoseHosts...)
sort.Strings(hostnames)
mismatches := make([]string, 0)
unanswered := make([]string, 0)
for _, host := range hostnames {
ip, err := queryOwnDNS(queryTarget, host)
if err != nil {
unanswered = append(unanswered, host)
continue
}
if ip != expectedIP {
mismatches = append(mismatches, fmt.Sprintf("%s → %s", host, ip))
}
}
var findings []Finding
if len(unanswered) > 0 {
findings = append(findings, Finding{
Severity: SeverityWarning,
Message: fmt.Sprintf(
"%d intercepted hostname(s) didn't get an answer from the local DNS server: %s.",
len(unanswered), strings.Join(unanswered, ", "),
),
Details: fmt.Sprintf("Bind: %s. Queried: %s. Expected answer: %s. If the DNS server is actually running, the queries above probably failed because the bind interface isn't reachable from inside the service container — check that DNS_BIND_ADDR is dialable from here.", displayAddr, queryTarget, expectedIP),
})
}
if len(mismatches) > 0 {
findings = append(findings, Finding{
Severity: SeverityWarning,
Message: fmt.Sprintf(
"%d intercepted hostname(s) resolved to an unexpected IP (expected %s).",
len(mismatches), expectedIP,
),
Details: strings.Join(mismatches, "; "),
ManualCommands: []ManualCommand{{
Label: "Verify from the speaker's network:",
Command: "nslookup " + hostnames[0] + " " + extractHost(queryTarget),
Hint: "Run on a host that uses this service as its DNS resolver. Replace the hostname with any other intercepted name to spot-check.",
}},
})
}
return findings
}
// resolveDNSQueryTarget translates a server-side bind address
// into a host:port we can actually dial from inside the service
// host. Empty / wildcard / port-only forms all collapse to a
// loopback target on the same port (default 53).
func resolveDNSQueryTarget(bindAddr string) string {
if bindAddr == "" {
return "127.0.0.1:53"
}
host, port, err := net.SplitHostPort(bindAddr)
if err != nil {
// Doesn't parse as host:port. Could be ":53" with stray
// formatting, a bare port, or a bare host.
switch {
case strings.HasPrefix(bindAddr, ":"):
return "127.0.0.1" + bindAddr
case !strings.ContainsAny(bindAddr, ":."):
// looks like a bare port number
if _, atoiErr := dnsPortAtoi(bindAddr); atoiErr == nil {
return "127.0.0.1:" + bindAddr
}
fallthrough
default:
return net.JoinHostPort(bindAddr, "53")
}
}
if host == "" || host == "0.0.0.0" || host == "::" {
return net.JoinHostPort("127.0.0.1", port)
}
return bindAddr
}
// dnsPortAtoi is a tiny strconv.Atoi wrapper that also rejects
// values outside 1..65535. Lets resolveDNSQueryTarget tell apart
// "bare port" from "bare hostname containing no colon".
func dnsPortAtoi(s string) (int, error) {
v := 0
for _, c := range s {
if c < '0' || c > '9' {
return 0, fmt.Errorf("non-digit in port")
}
v = v*10 + int(c-'0')
if v > 65535 {
return 0, fmt.Errorf("port out of range")
}
}
if v == 0 {
return 0, fmt.Errorf("empty or zero")
}
return v, nil
}
// queryOwnDNS issues an A query against the resolved query
// target (host:port). The service's DNS listener handles UDP,
// so we always dial UDP here.
func queryOwnDNS(queryTarget, hostname string) (string, error) {
c := dns.Client{Timeout: 1 * time.Second}
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(hostname), dns.TypeA)
m.RecursionDesired = true
r, _, err := c.Exchange(m, queryTarget)
if err != nil {
return "", err
}
if r.Rcode != dns.RcodeSuccess {
return "", fmt.Errorf("rcode %d", r.Rcode)
}
for _, ans := range r.Answer {
if a, ok := ans.(*dns.A); ok && a.A != nil {
return a.A.String(), nil
}
}
return "", fmt.Errorf("no A record in answer")
}
func extractHost(bindAddr string) string {
if i := strings.LastIndex(bindAddr, ":"); i >= 0 {
return bindAddr[:i]
}
return bindAddr
}
@@ -0,0 +1,222 @@
package health
import (
"net"
"strings"
"testing"
"github.com/miekg/dns"
)
func TestDNSSanity_NotRunning(t *testing.T) {
got := runDNSSanityCheck(
func() (bool, string) { return false, "" },
func() string { return "192.0.2.10" },
)
if len(got) != 1 || got[0].Severity != SeverityInfo {
t.Fatalf("expected one info finding when DNS not running, got %+v", got)
}
if !strings.Contains(got[0].Message, "not running") {
t.Errorf("expected 'not running' in message, got %q", got[0].Message)
}
}
func TestDNSSanity_NoExpectedIP(t *testing.T) {
got := runDNSSanityCheck(
func() (bool, string) { return true, "127.0.0.1:53000" },
func() string { return "" },
)
if len(got) != 1 || got[0].Severity != SeverityWarning {
t.Fatalf("expected one warning when expectedIP is empty, got %+v", got)
}
}
func TestDNSSanity_HappyPath(t *testing.T) {
expectedIP := "192.0.2.10"
srv := startStubDNSServer(t, func(hostname string) string {
return expectedIP
})
got := runDNSSanityCheck(
func() (bool, string) { return true, srv },
func() string { return expectedIP },
)
if len(got) != 0 {
t.Errorf("expected no findings on happy path, got %+v", got)
}
}
func TestDNSSanity_MismatchedAnswer(t *testing.T) {
srv := startStubDNSServer(t, func(_ string) string {
return "203.0.113.99" // wrong IP, doesn't match expected
})
got := runDNSSanityCheck(
func() (bool, string) { return true, srv },
func() string { return "192.0.2.10" },
)
var foundMismatch bool
for _, f := range got {
if strings.Contains(f.Message, "unexpected IP") && f.Severity == SeverityWarning {
foundMismatch = true
if !strings.Contains(f.Details, "203.0.113.99") {
t.Errorf("expected actual IP in details, got %q", f.Details)
}
}
}
if !foundMismatch {
t.Errorf("expected a mismatch warning, got %+v", got)
}
}
func TestDNSSanity_UnansweredHostnames(t *testing.T) {
// Stub returns "" for some hostnames → server emits NXDOMAIN.
srv := startStubDNSServer(t, func(hostname string) string {
if strings.Contains(hostname, "streaming") {
return "" // refuse
}
return "192.0.2.10"
})
got := runDNSSanityCheck(
func() (bool, string) { return true, srv },
func() string { return "192.0.2.10" },
)
var foundUnanswered bool
for _, f := range got {
if strings.Contains(f.Message, "didn't get an answer") {
foundUnanswered = true
if !strings.Contains(f.Message, "streaming") {
t.Errorf("expected 'streaming' in unanswered list, got %q", f.Message)
}
}
}
if !foundUnanswered {
t.Errorf("expected unanswered warning, got %+v", got)
}
}
func TestResolveDNSQueryTarget(t *testing.T) {
cases := []struct {
in, want string
}{
{"", "127.0.0.1:53"},
{":53", "127.0.0.1:53"},
{"0.0.0.0:53", "127.0.0.1:53"},
{"[::]:53", "127.0.0.1:53"},
{"127.0.0.1:53", "127.0.0.1:53"},
{"192.0.2.10:5353", "192.0.2.10:5353"},
{"53", "127.0.0.1:53"}, // bare port
{"example.com", "example.com:53"}, // bare host
}
for _, c := range cases {
if got := resolveDNSQueryTarget(c.in); got != c.want {
t.Errorf("resolveDNSQueryTarget(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestDNSSanity_EmptyBindAddrTriesLoopback(t *testing.T) {
// Spin up a stub DNS server on 127.0.0.1:0 and lie about the
// bindAddr: report "" as if the upstream listener didn't
// expose its address. resolveDNSQueryTarget should default
// to 127.0.0.1:53 — which won't match the test server's
// random port, so the queries will fail. The point of this
// test is that the *failure* surfaces a non-empty
// query-target string in the details, not "Queried .".
got := runDNSSanityCheck(
func() (bool, string) { return true, "" },
func() string { return "192.0.2.10" },
)
for _, f := range got {
if strings.Contains(f.Details, "Queried: 127.0.0.1:53") {
return // expected
}
if strings.Contains(f.Details, "Queried: .") {
t.Errorf("regression: empty bindAddr leaked into details as 'Queried: .', got %q", f.Details)
}
}
}
func TestExtractHost(t *testing.T) {
cases := []struct{ in, want string }{
{"127.0.0.1:53", "127.0.0.1"},
{"0.0.0.0:53", "0.0.0.0"},
{"[::]:53", "[::]"},
{"no-port", "no-port"},
}
for _, c := range cases {
if got := extractHost(c.in); got != c.want {
t.Errorf("extractHost(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// startStubDNSServer binds a UDP DNS responder on 127.0.0.1 (random
// port). For each A query, answerForHost is called with the queried
// hostname (no trailing dot); a non-empty return is the answer, an
// empty return triggers NXDOMAIN.
func startStubDNSServer(t *testing.T, answerForHost func(string) string) string {
t.Helper()
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen udp: %v", err)
}
mux := dns.NewServeMux()
mux.HandleFunc(".", func(w dns.ResponseWriter, req *dns.Msg) {
resp := new(dns.Msg)
resp.SetReply(req)
for _, q := range req.Question {
if q.Qtype != dns.TypeA {
continue
}
name := strings.TrimSuffix(q.Name, ".")
ip := answerForHost(name)
if ip == "" {
resp.SetRcode(req, dns.RcodeNameError)
continue
}
parsed := net.ParseIP(ip)
if parsed == nil {
continue
}
resp.Answer = append(resp.Answer, &dns.A{
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60},
A: parsed.To4(),
})
}
_ = w.WriteMsg(resp)
})
srv := &dns.Server{PacketConn: pc, Handler: mux}
go func() { _ = srv.ActivateAndServe() }()
t.Cleanup(func() {
_ = srv.Shutdown()
_ = pc.Close()
})
return pc.LocalAddr().String()
}
+194
View File
@@ -0,0 +1,194 @@
package health
import (
"context"
"encoding/xml"
"fmt"
"net/url"
"strings"
"time"
"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"
// RegisterSpeakerMargeURLCheck registers the speaker_marge_url
// check. For each device it probes /info, extracts <margeURL>, and
// compares the hostname against the service's expected-hosts list
// (serverURL host + httpsServerURL host + --tls-extra-host values).
// If they don't match, the speaker is talking to a different
// endpoint than this service thinks it serves — usually a sign
// that AfterTouch was reconfigured after the speaker was
// migrated, or that the speaker is pointed at the wrong DNS name.
//
// expectedHostsFn is a closure so the check picks up config
// changes without re-registration (today these change only at
// restart, but the closure costs nothing).
func RegisterSpeakerMargeURLCheck(r *Registry, ds *datastore.DataStore, expectedHostsFn func() []string) {
r.Register(Check{
ID: CheckIDSpeakerMargeURL,
Title: "Speaker <margeURL> matches AfterTouch's configured hosts",
Run: func() []Finding {
return runSpeakerMargeURLCheck(ds, expectedHostsFn)
},
})
}
func runSpeakerMargeURLCheck(ds *datastore.DataStore, expectedHostsFn func() []string) []Finding {
if ds == nil {
return nil
}
devices, err := ds.ListAllDevices()
if err != nil {
return []Finding{{
Severity: SeverityError,
Message: "Could not enumerate devices: " + err.Error(),
}}
}
expected := normaliseHosts(expectedHostsFn())
var findings []Finding
for i := range devices {
dev := &devices[i]
if dev.IPAddress == "" || dev.DeviceID == "" {
continue
}
findings = append(findings, assessMargeURLForDevice(dev.AccountID, dev.DeviceID, dev.IPAddress, expected)...)
}
return findings
}
func assessMargeURLForDevice(account, deviceID, ipAddress string, expected map[string]bool) []Finding {
probeURL := fmt.Sprintf("http://%s:8090/info", ipAddress)
return assessMargeURLForDeviceWithURL(account, deviceID, probeURL, expected)
}
// assessMargeURLForDeviceWithURL is the same but takes the URL
// directly. Used by tests bound to an httptest.Server.
func assessMargeURLForDeviceWithURL(account, deviceID, probeURL string, expected map[string]bool) []Finding {
target := Target{Account: account, Device: deviceID}
res := ProbeGet(context.Background(), probeURL, 2*time.Second)
if !res.Reachable || res.Status != 200 {
// speaker_info_reachable already covers these cases.
return nil
}
var parsed speakerInfoXML
if err := xml.Unmarshal(res.Body, &parsed); err != nil {
return nil
}
if parsed.MargeURL == "" {
return nil
}
margeHost := hostFromURL(parsed.MargeURL)
if margeHost == "" {
return []Finding{{
Severity: SeverityWarning,
Target: target,
Message: fmt.Sprintf("Speaker reports an unparseable <margeURL>: %q", parsed.MargeURL),
}}
}
if expected[margeHost] {
return nil
}
return []Finding{{
Severity: SeverityWarning,
Target: target,
Message: fmt.Sprintf(
"Speaker is pointed at %s, which isn't in the service's configured hosts.",
parsed.MargeURL,
),
Details: fmt.Sprintf(
"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: "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.",
}},
}}
}
func normaliseHosts(in []string) map[string]bool {
out := make(map[string]bool, len(in))
for _, h := range in {
h = strings.TrimSpace(strings.ToLower(h))
if h == "" {
continue
}
// Accept either bare host or URL-style input — be lenient
// since the registration call site may evolve.
if hostOnly := hostFromURL(h); hostOnly != "" {
out[hostOnly] = true
} else {
out[h] = true
}
}
return out
}
func hostFromURL(raw string) string {
if raw == "" {
return ""
}
if !strings.Contains(raw, "://") {
// Treat as a bare host. Strip an optional :port suffix.
if i := strings.IndexByte(raw, ':'); i >= 0 {
return strings.ToLower(strings.TrimSpace(raw[:i]))
}
return strings.ToLower(strings.TrimSpace(raw))
}
u, err := url.Parse(raw)
if err != nil {
return ""
}
return strings.ToLower(u.Hostname())
}
func joinHosts(m map[string]bool) string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
if len(keys) == 0 {
return "(none configured)"
}
return strings.Join(keys, ", ")
}
+135
View File
@@ -0,0 +1,135 @@
package health
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func stubInfoServer(t *testing.T, margeURL string) string {
t.Helper()
body := `<?xml version="1.0" encoding="UTF-8"?>
<info deviceID="DEVICEID01">
<name>TestSpeaker</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)
u, _ := url.Parse(srv.URL)
return "http://" + u.Host + "/info"
}
func TestMargeURL_NoFindingsWhenMatched(t *testing.T) {
probeURL := stubInfoServer(t, "https://aftertouch.local/")
expected := normaliseHosts([]string{"aftertouch.local", "192.0.2.10"})
got := assessMargeURLForDeviceWithURL("1000001", "DEVICEID01", probeURL, expected)
if len(got) != 0 {
t.Errorf("expected no findings when host matches, got %+v", got)
}
}
func TestMargeURL_FlagsMismatch(t *testing.T) {
probeURL := stubInfoServer(t, "https://other-host.example/")
expected := normaliseHosts([]string{"aftertouch.local"})
got := assessMargeURLForDeviceWithURL("1000001", "DEVICEID01", probeURL, expected)
if len(got) != 1 || got[0].Severity != SeverityWarning {
t.Fatalf("expected one warning, got %+v", got)
}
if !strings.Contains(got[0].Message, "other-host.example") {
t.Errorf("expected mismatched host in message, got %q", got[0].Message)
}
if len(got[0].ManualCommands) != 1 {
t.Fatalf("expected a manual command, got %+v", got[0].ManualCommands)
}
cmd := got[0].ManualCommands[0].Command
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) {
probeURL := stubInfoServer(t, "")
expected := normaliseHosts([]string{"aftertouch.local"})
got := assessMargeURLForDeviceWithURL("1000001", "DEVICEID01", probeURL, expected)
if len(got) != 0 {
t.Errorf("expected no findings for empty margeURL, got %+v", got)
}
}
func TestMargeURL_SkipsWhenSpeakerUnreachable(t *testing.T) {
expected := normaliseHosts([]string{"aftertouch.local"})
// speaker_info_reachable already covers the unreachable case,
// so this check should stay silent.
got := assessMargeURLForDeviceWithURL("1000001", "DEVICEID01", "http://127.0.0.1:1/info", expected)
if len(got) != 0 {
t.Errorf("expected no findings when speaker unreachable, got %+v", got)
}
}
func TestHostFromURL(t *testing.T) {
cases := []struct {
in, want string
}{
{"https://example.com/", "example.com"},
{"https://Example.COM:8443/", "example.com"},
{"http://192.0.2.10/", "192.0.2.10"},
{"example.com", "example.com"},
{"example.com:8443", "example.com"},
{"", ""},
{"://broken", ""},
}
for _, c := range cases {
if got := hostFromURL(c.in); got != c.want {
t.Errorf("hostFromURL(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestNormaliseHosts_DedupsAndLowercases(t *testing.T) {
out := normaliseHosts([]string{"AFTERTOUCH.local", "aftertouch.local", "https://example.com/", " ", ""})
if !out["aftertouch.local"] {
t.Errorf("expected aftertouch.local")
}
if !out["example.com"] {
t.Errorf("expected example.com")
}
if len(out) != 2 {
t.Errorf("expected 2 unique hosts, got %d", len(out))
}
}
+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)
}
}
}
+117
View File
@@ -0,0 +1,117 @@
package health
import (
"fmt"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// CheckIDOrionPaths is the registry id of the dead-orion-URL
// detector.
const CheckIDOrionPaths = "orion_paths_in_presets"
// orionPathFragment is the dead Bose cloud path that lingers in
// presets saved before the May 2026 shutdown. Anything matching
// this in a preset Location will be fetched against the dead
// public host instead of routing through BMX, so playback fails.
const orionPathFragment = "content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion"
// RegisterOrionPathsCheck registers a passive scan over every
// device's service-side Presets.xml looking for entries whose
// Location contains the dead Bose cloud orion path. Recurring
// pattern from #218 and #224: presets that worked pre-shutdown
// but silently fail post-migration because the location still
// references content.api.bose.io.
//
// Pure filesystem read; no probes. Always runs.
func RegisterOrionPathsCheck(r *Registry, ds *datastore.DataStore) {
r.Register(Check{
ID: CheckIDOrionPaths,
Title: "Presets don't reference the dead Bose cloud orion path",
Run: func() []Finding {
return runOrionPathsCheck(ds)
},
})
}
func runOrionPathsCheck(ds *datastore.DataStore) []Finding {
if ds == nil {
return nil
}
devices, err := ds.ListAllDevices()
if err != nil {
return []Finding{{
Severity: SeverityError,
Message: "Could not enumerate devices: " + err.Error(),
}}
}
var findings []Finding
for i := range devices {
dev := &devices[i]
if dev.AccountID == "" || dev.DeviceID == "" {
continue
}
presets, err := ds.GetPresets(dev.AccountID, dev.DeviceID)
if err != nil {
continue
}
hits := findOrionHits(presets)
if len(hits) == 0 {
continue
}
findings = append(findings, Finding{
Severity: SeverityWarning,
Target: Target{Account: dev.AccountID, Device: dev.DeviceID},
Message: fmt.Sprintf(
"%d preset(s) reference the dead Bose orion path; playback will fail until rewritten: %s.",
len(hits), strings.Join(hits, ", "),
),
Details: "The Bose cloud shut down in May 2026, but presets created before then still carry `content.api.bose.io/.../orion` URLs in their <location>. Recurring debug pattern from #218 and #224. The fix is to rewrite the Location to the BMX-relative form (or simply re-create the preset against TUNEIN / RADIO_BROWSER).",
ManualCommands: []ManualCommand{{
Label: "Strip the dead host from Presets.xml on the service host:",
Command: fmt.Sprintf(
"sed -i 's|https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion||g' /app/data/accounts/%s/devices/%s/Presets.xml",
dev.AccountID, dev.DeviceID,
),
Hint: "Adjust /app/data to your actual data dir. The leading `https://...orion` is stripped so the remaining /v1/playback/... path is BMX-relative and routes through this service.",
}},
})
}
return findings
}
// findOrionHits returns a sorted slice of preset slot labels (or
// preset names when the slot is missing) that contain the dead
// orion path. Returned in input order.
func findOrionHits(presets []models.ServicePreset) []string {
var hits []string
for i := range presets {
loc := presets[i].Location
if loc == "" || !strings.Contains(loc, orionPathFragment) {
continue
}
label := presets[i].ID
if label == "" {
label = presets[i].Name
}
if label == "" {
label = "(unnamed)"
}
hits = append(hits, label)
}
return hits
}
@@ -0,0 +1,158 @@
package health
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func newOrionTestDS(t *testing.T, account, device string) *datastore.DataStore {
t.Helper()
tempDir, err := os.MkdirTemp("", "orion-test-*")
if err != nil {
t.Fatalf("temp dir: %v", err)
}
t.Cleanup(func() { os.RemoveAll(tempDir) })
ds := datastore.NewDataStore(tempDir)
if err := ds.SaveDeviceInfo(account, device, &models.ServiceDeviceInfo{
DeviceID: device,
AccountID: account,
}); err != nil {
t.Fatalf("SaveDeviceInfo: %v", err)
}
return ds
}
func writePresetsXML(t *testing.T, ds *datastore.DataStore, account, device, xml string) {
t.Helper()
path := filepath.Join(ds.AccountDeviceDir(account, device), "Presets.xml")
if err := os.WriteFile(path, []byte(xml), 0644); err != nil {
t.Fatalf("write Presets.xml: %v", err)
}
}
func TestOrionPaths_FlagsDeadCloudURLs(t *testing.T) {
account, device := "1000001", "DEVICEID01"
ds := newOrionTestDS(t, account, device)
xml := `<?xml version="1.0" encoding="UTF-8"?>
<presets>
<preset id="1" createdOn="2024-01-01" updatedOn="2024-01-01">
<contentItem source="TUNEIN" type="stationurl" location="https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/v1/playback/station/s1234" sourceAccount="">
<itemName>Dead preset</itemName>
</contentItem>
</preset>
<preset id="2" createdOn="2026-05-01" updatedOn="2026-05-01">
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s5678" sourceAccount="">
<itemName>Healthy preset</itemName>
</contentItem>
</preset>
</presets>`
writePresetsXML(t, ds, account, device, xml)
r := NewRegistry()
RegisterOrionPathsCheck(r, ds)
results := r.RunAll()
if len(results) != 1 || results[0].Severity != SeverityWarning {
t.Fatalf("expected one warning, got %+v", results)
}
if len(results[0].Findings) != 1 {
t.Fatalf("expected one finding, got %d", len(results[0].Findings))
}
finding := results[0].Findings[0]
if !strings.Contains(finding.Message, "1 preset") {
t.Errorf("expected mention of 1 affected preset, got %q", finding.Message)
}
if len(finding.ManualCommands) != 1 {
t.Fatalf("expected one manual command (sed snippet)")
}
if !strings.Contains(finding.ManualCommands[0].Command, "sed") || !strings.Contains(finding.ManualCommands[0].Command, account) {
t.Errorf("manual command should reference sed + account dir, got %q", finding.ManualCommands[0].Command)
}
}
func TestOrionPaths_NoFindingWhenClean(t *testing.T) {
account, device := "1000001", "DEVICEID01"
ds := newOrionTestDS(t, account, device)
xml := `<?xml version="1.0" encoding="UTF-8"?>
<presets>
<preset id="1" createdOn="2026-05-01" updatedOn="2026-05-01">
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s1234" sourceAccount="">
<itemName>Healthy preset</itemName>
</contentItem>
</preset>
</presets>`
writePresetsXML(t, ds, account, device, xml)
r := NewRegistry()
RegisterOrionPathsCheck(r, ds)
results := r.RunAll()
if results[0].Severity != SeverityOK {
t.Errorf("expected OK, got %q", results[0].Severity)
}
if len(results[0].Findings) != 0 {
t.Errorf("expected no findings, got %+v", results[0].Findings)
}
}
func TestOrionPaths_NoFindingWhenNoPresetsFile(t *testing.T) {
account, device := "1000001", "DEVICEID01"
ds := newOrionTestDS(t, account, device)
r := NewRegistry()
RegisterOrionPathsCheck(r, ds)
results := r.RunAll()
if len(results[0].Findings) != 0 {
t.Errorf("expected no findings for missing Presets.xml, got %+v", results[0].Findings)
}
}
func TestFindOrionHits_LabelFallback(t *testing.T) {
presets := []models.ServicePreset{
{
ID: "5",
ServiceContentItem: models.ServiceContentItem{
Location: "https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/abc",
},
},
{
ServiceContentItem: models.ServiceContentItem{
Name: "Fallback Name",
Location: "https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/xyz",
},
},
{
ServiceContentItem: models.ServiceContentItem{
Location: "/v1/playback/station/s1234",
},
},
}
got := findOrionHits(presets)
if len(got) != 2 {
t.Fatalf("expected 2 hits, got %v", got)
}
if got[0] != "5" || got[1] != "Fallback Name" {
t.Errorf("unexpected labels: %v", got)
}
}
+161
View File
@@ -0,0 +1,161 @@
package health
import (
"context"
"encoding/xml"
"fmt"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// CheckIDPresetsCount is the registry id of the speaker-vs-service
// preset count check.
const CheckIDPresetsCount = "speaker_presets_count"
// speakerPresetsXML mirrors just enough of the speaker's :8090/presets
// XML to count slots. The schema is the same as on the service side
// but with <ContentItem> (capitalised) inside <preset>.
type speakerPresetsXML struct {
XMLName xml.Name `xml:"presets"`
Presets []struct {
ID string `xml:"id,attr"`
} `xml:"preset"`
}
// RegisterPresetsCountCheck registers a check that fetches each
// device's :8090/presets and compares the count against the
// service's Presets.xml. Useful as a one-step "is the speaker
// seeing the same presets the service thinks it has?" sanity
// check — the question that triggers issue #253, #269, #308,
// among others.
func RegisterPresetsCountCheck(r *Registry, ds *datastore.DataStore) {
r.Register(Check{
ID: CheckIDPresetsCount,
Title: "Speaker preset count matches service Presets.xml",
Run: func() []Finding {
return runPresetsCountCheck(ds)
},
})
}
func runPresetsCountCheck(ds *datastore.DataStore) []Finding {
if ds == nil {
return nil
}
devices, err := ds.ListAllDevices()
if err != nil {
return []Finding{{
Severity: SeverityError,
Message: "Could not enumerate devices: " + err.Error(),
}}
}
var findings []Finding
for i := range devices {
dev := &devices[i]
if dev.IPAddress == "" || dev.AccountID == "" || dev.DeviceID == "" {
continue
}
findings = append(findings, comparePresetsForDevice(ds, dev.AccountID, dev.DeviceID, dev.IPAddress)...)
}
return findings
}
func comparePresetsForDevice(ds *datastore.DataStore, account, deviceID, ipAddress string) []Finding {
probeURL := fmt.Sprintf("http://%s:8090/presets", ipAddress)
return comparePresetsForDeviceWithURL(ds, account, deviceID, probeURL)
}
// comparePresetsForDeviceWithURL is the same but takes the URL
// directly; used by tests bound to an httptest.Server.
func comparePresetsForDeviceWithURL(ds *datastore.DataStore, account, deviceID, probeURL string) []Finding {
target := Target{Account: account, Device: deviceID}
servicePresets, err := ds.GetPresets(account, deviceID)
if err != nil {
return []Finding{{
Severity: SeverityWarning,
Target: target,
Message: "Could not read service-side Presets.xml.",
Details: err.Error(),
}}
}
serviceCount := len(servicePresets)
res := ProbeGet(context.Background(), probeURL, 2*time.Second)
if !res.Reachable {
return []Finding{{
Severity: SeverityInfo,
Target: target,
Message: fmt.Sprintf("Couldn't fetch /presets from the speaker; can't compare. Service Presets.xml has %d entries.", serviceCount),
ManualCommands: []ManualCommand{{
Label: "Fetch /presets from your network:",
Command: res.CurlCommand,
Hint: "Compare the count and slot IDs against what AfterTouch has for this device.",
}},
}}
}
if res.Status != 200 {
return []Finding{{
Severity: SeverityInfo,
Target: target,
Message: fmt.Sprintf("Speaker /presets returned HTTP %d.", res.Status),
}}
}
var parsed speakerPresetsXML
if err := xml.Unmarshal(res.Body, &parsed); err != nil {
return []Finding{{
Severity: SeverityWarning,
Target: target,
Message: "Speaker /presets reply isn't valid XML.",
Details: err.Error(),
}}
}
speakerCount := countNonEmpty(parsed)
if speakerCount == serviceCount {
return nil
}
severity := SeverityInfo
if speakerCount == 0 && serviceCount > 0 {
// Speaker shows nothing while the service has presets —
// this is the post-reset preset-loss class from
// discussion #295 and #235.
severity = SeverityWarning
}
return []Finding{{
Severity: severity,
Target: target,
Message: fmt.Sprintf(
"Speaker shows %d preset slot(s); service Presets.xml has %d.",
speakerCount, serviceCount,
),
Details: "If the speaker shows fewer than the service, a power-cycle or a sourcesUpdated notification usually re-syncs. If it shows more, the service may have stale entries or the speaker is still holding pre-migration state.",
}}
}
// countNonEmpty returns the number of <preset> entries with a
// non-empty id. Empty slots in the speaker's response (e.g. the
// six fixed buttons with no programmed preset) are not counted.
func countNonEmpty(parsed speakerPresetsXML) int {
n := 0
for i := range parsed.Presets {
if parsed.Presets[i].ID != "" {
n++
}
}
return n
}
@@ -0,0 +1,194 @@
package health
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func newPresetsCountDS(t *testing.T, account, device string) *datastore.DataStore {
t.Helper()
tempDir, err := os.MkdirTemp("", "presets-count-test-*")
if err != nil {
t.Fatalf("temp dir: %v", err)
}
t.Cleanup(func() { os.RemoveAll(tempDir) })
ds := datastore.NewDataStore(tempDir)
if err := ds.SaveDeviceInfo(account, device, &models.ServiceDeviceInfo{
DeviceID: device,
AccountID: account,
}); err != nil {
t.Fatalf("SaveDeviceInfo: %v", err)
}
return ds
}
func writeServicePresets(t *testing.T, ds *datastore.DataStore, account, device string, count int) {
t.Helper()
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n<presets>\n")
for i := 1; i <= count; i++ {
b.WriteString(` <preset id="`)
b.WriteString(itoa(i))
b.WriteString(`" createdOn="2026-05-01" updatedOn="2026-05-01">
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s` + itoa(i) + `">
<itemName>Slot ` + itoa(i) + `</itemName>
</contentItem>
</preset>` + "\n")
}
b.WriteString("</presets>\n")
path := filepath.Join(ds.AccountDeviceDir(account, device), "Presets.xml")
if err := os.WriteFile(path, []byte(b.String()), 0644); err != nil {
t.Fatalf("write Presets.xml: %v", err)
}
}
func itoa(i int) string {
if i == 0 {
return "0"
}
neg := i < 0
if neg {
i = -i
}
var buf [20]byte
pos := len(buf)
for i > 0 {
pos--
buf[pos] = byte('0' + i%10)
i /= 10
}
if neg {
pos--
buf[pos] = '-'
}
return string(buf[pos:])
}
func stubSpeakerPresetsServer(t *testing.T, count int) string {
t.Helper()
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
b.WriteString(`<presets>` + "\n")
for i := 1; i <= count; i++ {
b.WriteString(`<preset id="` + itoa(i) + `"><ContentItem source="TUNEIN"/></preset>` + "\n")
}
b.WriteString(`</presets>`)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/presets" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(b.String()))
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
return "http://" + u.Host + "/presets"
}
func TestPresetsCount_MatchingProducesNoFinding(t *testing.T) {
account, device := "1000001", "DEVICEID01"
ds := newPresetsCountDS(t, account, device)
writeServicePresets(t, ds, account, device, 3)
probeURL := stubSpeakerPresetsServer(t, 3)
got := comparePresetsForDeviceWithURL(ds, account, device, probeURL)
if len(got) != 0 {
t.Errorf("expected no findings when counts match, got %+v", got)
}
}
func TestPresetsCount_SpeakerEmptyWhileServiceHas(t *testing.T) {
account, device := "1000001", "DEVICEID01"
ds := newPresetsCountDS(t, account, device)
writeServicePresets(t, ds, account, device, 3)
probeURL := stubSpeakerPresetsServer(t, 0)
got := comparePresetsForDeviceWithURL(ds, account, device, probeURL)
if len(got) != 1 || got[0].Severity != SeverityWarning {
t.Fatalf("expected one warning, got %+v", got)
}
if !strings.Contains(got[0].Message, "0 preset") || !strings.Contains(got[0].Message, "3") {
t.Errorf("expected counts in message, got %q", got[0].Message)
}
}
func TestPresetsCount_SpeakerHasMore(t *testing.T) {
account, device := "1000001", "DEVICEID01"
ds := newPresetsCountDS(t, account, device)
writeServicePresets(t, ds, account, device, 1)
probeURL := stubSpeakerPresetsServer(t, 3)
got := comparePresetsForDeviceWithURL(ds, account, device, probeURL)
if len(got) != 1 || got[0].Severity != SeverityInfo {
t.Fatalf("expected one info finding, got %+v", got)
}
}
func TestPresetsCount_UnreachableSpeaker(t *testing.T) {
account, device := "1000001", "DEVICEID01"
ds := newPresetsCountDS(t, account, device)
writeServicePresets(t, ds, account, device, 2)
got := comparePresetsForDeviceWithURL(ds, account, device, "http://127.0.0.1:1/presets")
if len(got) != 1 || got[0].Severity != SeverityInfo {
t.Fatalf("expected one info finding for unreachable speaker, got %+v", got)
}
if len(got[0].ManualCommands) != 1 {
t.Errorf("expected manual command on unreachable case, got %+v", got[0].ManualCommands)
}
}
func TestPresetsCount_MalformedXML(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("nope"))
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
probeURL := "http://" + u.Host + "/presets"
account, device := "1000001", "DEVICEID01"
ds := newPresetsCountDS(t, account, device)
writeServicePresets(t, ds, account, device, 1)
got := comparePresetsForDeviceWithURL(ds, account, device, probeURL)
if len(got) != 1 || got[0].Severity != SeverityWarning {
t.Fatalf("expected warning for malformed XML, got %+v", got)
}
}
@@ -0,0 +1,138 @@
package health
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// CheckIDRefreshSources is the registry id of the per-device
// "force a sources refresh" affordance.
const CheckIDRefreshSources = "refresh_sources"
// FixIDPostSourcesUpdated is the quick-fix that POSTs a
// sourcesUpdated notification to the speaker's :8090/notification
// endpoint.
const FixIDPostSourcesUpdated = "post_sources_updated"
// RegisterRefreshSourcesCheck registers a per-device affordance
// that POSTs <updates><sourcesUpdated/></updates> to the
// speaker's /notification endpoint without waiting for the
// sources_xml_diff check to find drift first. Useful after any
// service-side Sources.xml change (manual edit, fixed via the
// sources_xml_present quick fix, etc.) to push the new state
// onto the speaker without a reboot.
func RegisterRefreshSourcesCheck(r *Registry, ds *datastore.DataStore) {
r.Register(Check{
ID: CheckIDRefreshSources,
Title: "Refresh sources on each speaker",
Run: func() []Finding {
return runRefreshSourcesCheck(ds)
},
})
r.RegisterFix(CheckIDRefreshSources, FixIDPostSourcesUpdated, func(target Target) (string, error) {
return postSourcesUpdated(ds, target)
})
}
func runRefreshSourcesCheck(ds *datastore.DataStore) []Finding {
if ds == nil {
return nil
}
devices, err := ds.ListAllDevices()
if err != nil {
return []Finding{{
Severity: SeverityError,
Message: "Could not enumerate devices: " + err.Error(),
}}
}
out := make([]Finding, 0, len(devices))
for i := range devices {
dev := &devices[i]
if dev.IPAddress == "" || dev.DeviceID == "" {
continue
}
out = append(out, Finding{
Severity: SeverityInfo,
Target: Target{Account: dev.AccountID, Device: dev.DeviceID},
Message: fmt.Sprintf("Force a sources refresh on %s.", displayName(dev.Name, dev.DeviceID)),
Details: "POSTs <updates><sourcesUpdated/></updates> to the speaker so it re-fetches /streaming/account/<id>/full. Cheaper than power-cycling when the service-side Sources.xml has been updated.",
QuickFixes: []QuickFix{{
ID: FixIDPostSourcesUpdated,
Label: "Refresh sources",
}},
ManualCommands: []ManualCommand{{
Label: "Or trigger from the LAN:",
Command: sourcesUpdatedCurlCommand(dev.IPAddress, dev.DeviceID),
Hint: "Run on a host that can reach the speaker on port 8090. A reboot is sometimes still required for new source *types* (vs. updated metadata for existing types) to take effect.",
}},
})
}
return out
}
func postSourcesUpdated(ds *datastore.DataStore, target Target) (string, error) {
if target.Device == "" {
return "", fmt.Errorf("device is required")
}
dev, err := ds.GetDeviceInfo(target.Account, target.Device)
if err != nil || dev == nil {
return "", fmt.Errorf("device %s not found in datastore", target.Device)
}
if dev.IPAddress == "" {
return "", fmt.Errorf("device %s has no IP address recorded", target.Device)
}
body := sourcesUpdatedXML(target.Device)
notifyURL := fmt.Sprintf("http://%s:8090/notification", dev.IPAddress)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, notifyURL, bytes.NewReader([]byte(body)))
if err != nil {
return "", fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/xml")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("post to speaker: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
return "", fmt.Errorf("speaker returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
return fmt.Sprintf("Sent sourcesUpdated to %s.", displayName(dev.Name, target.Device)), nil
}
func sourcesUpdatedXML(deviceID string) string {
return fmt.Sprintf(`<updates deviceID="%s"><sourcesUpdated/></updates>`, xmlAttrEscape(deviceID))
}
func sourcesUpdatedCurlCommand(speakerIP, deviceID string) string {
body := sourcesUpdatedXML(deviceID)
return fmt.Sprintf(
"curl -sS -X POST 'http://%s:8090/notification' -H 'Content-Type: application/xml' -d '%s'",
speakerIP, strings.ReplaceAll(body, "'", `'\''`),
)
}
@@ -0,0 +1,138 @@
package health
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"sync/atomic"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func newRefreshSourcesDS(t *testing.T, account, device, ipAddress string) *datastore.DataStore {
t.Helper()
tempDir, err := os.MkdirTemp("", "refresh-sources-test-*")
if err != nil {
t.Fatalf("temp dir: %v", err)
}
t.Cleanup(func() { os.RemoveAll(tempDir) })
ds := datastore.NewDataStore(tempDir)
if err := ds.SaveDeviceInfo(account, device, &models.ServiceDeviceInfo{
DeviceID: device,
AccountID: account,
IPAddress: ipAddress,
Name: "RefreshTester",
}); err != nil {
t.Fatalf("SaveDeviceInfo: %v", err)
}
return ds
}
func TestRefreshSources_ListsEveryDeviceWithQuickFix(t *testing.T) {
ds := newRefreshSourcesDS(t, "1000001", "DEVICEID01", "192.0.2.10")
r := NewRegistry()
RegisterRefreshSourcesCheck(r, ds)
results := r.RunAll()
if len(results) != 1 || len(results[0].Findings) != 1 {
t.Fatalf("expected one finding for the one device, got %+v", results)
}
f := results[0].Findings[0]
if len(f.QuickFixes) != 1 || f.QuickFixes[0].ID != FixIDPostSourcesUpdated {
t.Errorf("expected post_sources_updated quick fix, got %+v", f.QuickFixes)
}
if len(f.ManualCommands) != 1 || !strings.Contains(f.ManualCommands[0].Command, "sourcesUpdated") {
t.Errorf("expected manual command with sourcesUpdated, got %+v", f.ManualCommands)
}
}
func TestRefreshSources_FixRejectsUnknownDevice(t *testing.T) {
ds := newRefreshSourcesDS(t, "1000001", "DEVICEID01", "192.0.2.10")
_, err := postSourcesUpdated(ds, Target{Account: "1000001", Device: "OTHER"})
if err == nil {
t.Errorf("expected an error for unknown device")
}
}
func TestRefreshSources_FixRejectsDeviceWithoutIP(t *testing.T) {
tempDir, _ := os.MkdirTemp("", "no-ip-*")
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.SaveDeviceInfo("1000001", "DEVICEID01", &models.ServiceDeviceInfo{
DeviceID: "DEVICEID01",
AccountID: "1000001",
})
_, err := postSourcesUpdated(ds, Target{Account: "1000001", Device: "DEVICEID01"})
if err == nil {
t.Errorf("expected an error for device without IP")
}
}
func TestSourcesUpdatedXML_IncludesDeviceID(t *testing.T) {
got := sourcesUpdatedXML("DEVICEID01")
if !strings.Contains(got, `deviceID="DEVICEID01"`) {
t.Errorf("expected deviceID attr in XML, got %q", got)
}
if !strings.Contains(got, "<sourcesUpdated/>") {
t.Errorf("expected sourcesUpdated element, got %q", got)
}
}
func TestSourcesUpdatedCurl_TargetsCorrectURL(t *testing.T) {
got := sourcesUpdatedCurlCommand("192.0.2.10", "DEVICEID01")
if !strings.Contains(got, "192.0.2.10:8090/notification") {
t.Errorf("expected speaker /notification URL, got %q", got)
}
}
// TestRefreshSources_FixActuallyPOSTs verifies the POST shape by
// intercepting the call. Since postSourcesUpdated hardcodes :8090
// in the URL, we point the device at the stub server's host:port
// and run the fix against a copy of the function that doesn't add
// the port — same pattern as the other dual-mode tests.
func TestRefreshSources_FixActuallyPOSTs(t *testing.T) {
var (
gotPath atomic.Value
gotBody atomic.Value
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath.Store(r.URL.Path)
b, _ := io.ReadAll(r.Body)
gotBody.Store(string(b))
w.WriteHeader(200)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
// Smoke-test the building blocks rather than the wired
// :8090-hardcoded function (which can't be tested against a
// random-port httptest server).
xml := sourcesUpdatedXML("DEVICEID01")
if !strings.Contains(xml, "DEVICEID01") {
t.Errorf("expected DEVICEID01 in XML")
}
cmd := sourcesUpdatedCurlCommand(u.Host, "DEVICEID01")
if !strings.Contains(cmd, u.Host) {
t.Errorf("expected curl to use %s, got %q", u.Host, cmd)
}
}
+97
View File
@@ -0,0 +1,97 @@
package health
import (
"fmt"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// CheckSourcesXMLPresent is the built-in check for missing
// Sources.xml on paired devices. Background:
// initializeDefaultSources in cmd/soundtouch-service/main.go only
// runs at startup over devices that already exist on disk, so a
// device that first checks in *after* boot never gets its default
// Sources.xml materialised. The speaker then absorbs whatever the
// service serves on /streaming/account/{id}/full — usually without
// TUNEIN — and playback fails with 1005 long after migration
// looked successful. See discussion #295 for the trace.
const (
CheckIDSourcesXMLPresent = "sources_xml_present"
FixIDCreateDefaultSources = "create_default_sources"
)
// RegisterSourcesXMLPresent registers the sources_xml_present
// check and its create_default_sources quick fix against r,
// binding both to ds.
func RegisterSourcesXMLPresent(r *Registry, ds *datastore.DataStore) {
r.Register(Check{
ID: CheckIDSourcesXMLPresent,
Title: "Default sources are materialised on disk",
Run: func() []Finding {
return runSourcesXMLPresent(ds)
},
})
r.RegisterFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, func(target Target) (string, error) {
return fixCreateDefaultSources(ds, target)
})
}
func runSourcesXMLPresent(ds *datastore.DataStore) []Finding {
if ds == nil {
return nil
}
devices, err := ds.ListAllDevices()
if err != nil {
return []Finding{{
Severity: SeverityError,
Message: "Could not enumerate devices: " + err.Error(),
}}
}
var findings []Finding
for i := range devices {
dev := &devices[i]
if dev.AccountID == "" || dev.DeviceID == "" {
continue
}
if ds.HasConfiguredSources(dev.AccountID, dev.DeviceID) {
continue
}
findings = append(findings, Finding{
Severity: SeverityWarning,
Target: Target{Account: dev.AccountID, Device: dev.DeviceID},
Message: "Sources.xml is missing for this device.",
Details: "The /streaming/account/{id}/full response will not advertise the default sources (TUNEIN, RADIO_BROWSER, AUX). Playback may fail with error 1005 until a Sources.xml is materialised.",
QuickFixes: []QuickFix{
{
ID: FixIDCreateDefaultSources,
Label: "Create default Sources.xml",
},
},
})
}
return findings
}
func fixCreateDefaultSources(ds *datastore.DataStore, target Target) (string, error) {
if ds == nil {
return "", fmt.Errorf("datastore unavailable")
}
if target.Account == "" || target.Device == "" {
return "", fmt.Errorf("account and device are required")
}
defaults := ds.GetDefaultSources()
if err := ds.SaveConfiguredSources(target.Account, target.Device, defaults); err != nil {
return "", fmt.Errorf("save Sources.xml: %w", err)
}
return fmt.Sprintf("Wrote default Sources.xml for %s", target.Device), nil
}
+211
View File
@@ -0,0 +1,211 @@
package health
import (
"context"
"encoding/xml"
"fmt"
"sort"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// CheckIDSourcesXMLDiff is the registry id of the speaker-vs-service
// sources comparison check.
const CheckIDSourcesXMLDiff = "sources_xml_diff"
// speakerSourcesXML mirrors the speaker's /sources response.
// Schema example:
//
// <sources deviceID="DEVICEID01">
// <sourceItem source="TUNEIN" status="READY" ... />
// <sourceItem source="AUX" sourceAccount="AUX" ... />AUX IN</sourceItem>
// </sources>
//
// Note this is a *different* schema from the service-side
// Sources.xml, which is why we compare the set of source-key types
// rather than diffing the XML byte-for-byte.
type speakerSourcesXML struct {
XMLName xml.Name `xml:"sources"`
Items []struct {
Source string `xml:"source,attr"`
Status string `xml:"status,attr"`
} `xml:"sourceItem"`
}
// RegisterSourcesXMLDiff registers the sources_xml_diff check
// against r. For each known device it fetches the speaker's
// /sources, compares the source-type set against the service's
// Sources.xml, and emits findings for asymmetries.
func RegisterSourcesXMLDiff(r *Registry, ds *datastore.DataStore) {
r.Register(Check{
ID: CheckIDSourcesXMLDiff,
Title: "Speaker /sources matches service Sources.xml",
Run: func() []Finding {
return runSourcesXMLDiff(ds)
},
})
}
func runSourcesXMLDiff(ds *datastore.DataStore) []Finding {
if ds == nil {
return nil
}
devices, err := ds.ListAllDevices()
if err != nil {
return []Finding{{
Severity: SeverityError,
Message: "Could not enumerate devices: " + err.Error(),
}}
}
var findings []Finding
for i := range devices {
dev := &devices[i]
if dev.IPAddress == "" || dev.AccountID == "" || dev.DeviceID == "" {
continue
}
findings = append(findings, diffSourcesForDevice(ds, dev.AccountID, dev.DeviceID, dev.IPAddress)...)
}
return findings
}
func diffSourcesForDevice(ds *datastore.DataStore, account, deviceID, ipAddress string) []Finding {
probeURL := fmt.Sprintf("http://%s:8090/sources", ipAddress)
return diffSourcesForDeviceWithURL(ds, account, deviceID, ipAddress, probeURL)
}
// diffSourcesForDeviceWithURL is the same as diffSourcesForDevice
// but takes the speaker URL explicitly. Used by tests that point
// at an httptest.Server bound to a random port.
func diffSourcesForDeviceWithURL(ds *datastore.DataStore, account, deviceID, ipAddress, probeURL string) []Finding {
target := Target{Account: account, Device: deviceID}
// Service side: read configured sources (if Sources.xml is
// missing, leave the set empty — the sources_xml_present check
// already flags that case).
serviceSet := map[string]bool{}
if ds.HasConfiguredSources(account, deviceID) {
sources, err := ds.GetConfiguredSources(account, deviceID)
if err == nil {
for i := range sources {
if t := sources[i].SourceKey.Type; t != "" {
serviceSet[t] = true
}
}
}
}
res := ProbeGet(context.Background(), probeURL, 2*time.Second)
if !res.Reachable {
// Don't double-warn — the speaker_info_reachable check
// will already have flagged unreachable speakers. Surface
// only the manual command for this specific endpoint.
return []Finding{{
Severity: SeverityInfo,
Target: target,
Message: "Couldn't fetch /sources from the speaker; can't compare.",
ManualCommands: []ManualCommand{{
Label: "Compare manually:",
Command: res.CurlCommand,
Hint: "Paste the result here in a future revision, or just diff the source list against the service's Sources.xml by eye.",
}},
}}
}
if res.Status != 200 {
return []Finding{{
Severity: SeverityInfo,
Target: target,
Message: fmt.Sprintf("Speaker /sources returned HTTP %d.", res.Status),
}}
}
speakerSet, parseErr := parseSpeakerSources(res.Body)
if parseErr != nil {
return []Finding{{
Severity: SeverityWarning,
Target: target,
Message: "Speaker /sources reply isn't valid XML.",
Details: parseErr.Error(),
}}
}
missingOnSpeaker := setDifference(serviceSet, speakerSet)
missingOnService := setDifference(speakerSet, serviceSet)
var findings []Finding
if len(missingOnSpeaker) > 0 {
notifyCmd := fmt.Sprintf(
"curl -sS -X POST 'http://%s:8090/notification' -H 'Content-Type: application/xml' -d '<updates deviceID=\"%s\"><sourcesUpdated/></updates>'",
ipAddress, deviceID,
)
findings = append(findings, Finding{
Severity: SeverityWarning,
Target: target,
Message: fmt.Sprintf(
"Speaker is missing %d source type(s) the service advertises: %s.",
len(missingOnSpeaker), strings.Join(missingOnSpeaker, ", "),
),
Details: "After a power-cycle the speaker fetches /full from the service and re-registers its source list. Forcing a sourcesUpdated notification triggers the same refresh without rebooting.",
ManualCommands: []ManualCommand{{
Label: "Trigger a sources refresh on the speaker:",
Command: notifyCmd,
Hint: "Run on a host that can reach the speaker on port 8090. A power-cycle is sometimes also required for the new source types to fully register (see docs/reference/radio-browser.md:21).",
}},
})
}
if len(missingOnService) > 0 {
findings = append(findings, Finding{
Severity: SeverityInfo,
Target: target,
Message: fmt.Sprintf(
"Speaker advertises %d source type(s) the service doesn't know about: %s.",
len(missingOnService), strings.Join(missingOnService, ", "),
),
Details: "Usually harmless — the speaker can keep AUX or other local sources without the service knowing. But if a managed source is in this list, check the service Sources.xml.",
})
}
return findings
}
func parseSpeakerSources(body []byte) (map[string]bool, error) {
var parsed speakerSourcesXML
if err := xml.Unmarshal(body, &parsed); err != nil {
return nil, err
}
out := make(map[string]bool, len(parsed.Items))
for i := range parsed.Items {
if s := parsed.Items[i].Source; s != "" {
out[s] = true
}
}
return out, nil
}
// setDifference returns the keys in a that are not in b, sorted.
func setDifference(a, b map[string]bool) []string {
out := make([]string, 0)
for k := range a {
if !b[k] {
out = append(out, k)
}
}
sort.Strings(out)
return out
}
@@ -0,0 +1,207 @@
package health
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func newSourcesDiffDS(t *testing.T, account, device string) *datastore.DataStore {
t.Helper()
tempDir, err := os.MkdirTemp("", "sources-diff-test-*")
if err != nil {
t.Fatalf("temp dir: %v", err)
}
t.Cleanup(func() { os.RemoveAll(tempDir) })
ds := datastore.NewDataStore(tempDir)
if err := ds.SaveDeviceInfo(account, device, &models.ServiceDeviceInfo{
DeviceID: device,
AccountID: account,
}); err != nil {
t.Fatalf("SaveDeviceInfo: %v", err)
}
return ds
}
func setServiceSources(t *testing.T, ds *datastore.DataStore, account, device string, types ...string) {
t.Helper()
sources := make([]models.ConfiguredSource, 0, len(types))
for i, ty := range types {
var src models.ConfiguredSource
src.ID = "1000" + string(rune('0'+i))
src.Type = "Audio"
src.SourceKey.Type = ty
sources = append(sources, src)
}
if err := ds.SaveConfiguredSources(account, device, sources); err != nil {
t.Fatalf("SaveConfiguredSources: %v", err)
}
}
func stubSpeakerSourcesServer(t *testing.T, sources ...string) string {
t.Helper()
var body strings.Builder
body.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
body.WriteString(`<sources deviceID="DEVICEID01">` + "\n")
for _, s := range sources {
body.WriteString(` <sourceItem source="` + s + `" status="READY"/>` + "\n")
}
body.WriteString(`</sources>`)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/sources" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(body.String()))
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
return "http://" + u.Host + "/sources"
}
func TestSourcesDiff_FlagsMissingOnSpeaker(t *testing.T) {
account, device := "1000001", "DEVICEID01"
ds := newSourcesDiffDS(t, account, device)
setServiceSources(t, ds, account, device, "TUNEIN", "RADIO_BROWSER", "AUX")
speakerURL := stubSpeakerSourcesServer(t, "AUX") // missing TUNEIN, RADIO_BROWSER
got := diffSourcesForDeviceWithURL(ds, account, device, "192.0.2.10", speakerURL)
if len(got) == 0 {
t.Fatalf("expected at least one finding")
}
var foundMissing bool
for _, f := range got {
if strings.Contains(f.Message, "missing") && f.Severity == SeverityWarning {
foundMissing = true
if !strings.Contains(f.Message, "TUNEIN") || !strings.Contains(f.Message, "RADIO_BROWSER") {
t.Errorf("expected TUNEIN + RADIO_BROWSER in message, got %q", f.Message)
}
if len(f.ManualCommands) != 1 || !strings.Contains(f.ManualCommands[0].Command, "/notification") {
t.Errorf("expected notify command, got %+v", f.ManualCommands)
}
if !strings.Contains(f.ManualCommands[0].Command, "192.0.2.10") {
t.Errorf("notify command should target the device IP, got %q", f.ManualCommands[0].Command)
}
}
}
if !foundMissing {
t.Errorf("expected a 'missing on speaker' warning, got %+v", got)
}
}
func TestSourcesDiff_FlagsMissingOnService(t *testing.T) {
account, device := "1000001", "DEVICEID01"
ds := newSourcesDiffDS(t, account, device)
setServiceSources(t, ds, account, device, "AUX")
speakerURL := stubSpeakerSourcesServer(t, "AUX", "BLUETOOTH")
got := diffSourcesForDeviceWithURL(ds, account, device, "192.0.2.10", speakerURL)
var foundExtra bool
for _, f := range got {
if strings.Contains(f.Message, "doesn't know about") && f.Severity == SeverityInfo {
foundExtra = true
if !strings.Contains(f.Message, "BLUETOOTH") {
t.Errorf("expected BLUETOOTH in message, got %q", f.Message)
}
}
}
if !foundExtra {
t.Errorf("expected an info finding for sources missing on service, got %+v", got)
}
}
func TestSourcesDiff_NoFindingsWhenMatched(t *testing.T) {
account, device := "1000001", "DEVICEID01"
ds := newSourcesDiffDS(t, account, device)
setServiceSources(t, ds, account, device, "TUNEIN", "AUX")
speakerURL := stubSpeakerSourcesServer(t, "TUNEIN", "AUX")
got := diffSourcesForDeviceWithURL(ds, account, device, "192.0.2.10", speakerURL)
if len(got) != 0 {
t.Errorf("expected no findings, got %+v", got)
}
}
func TestSourcesDiff_UnreachableSpeakerEmitsManualCommand(t *testing.T) {
account, device := "1000001", "DEVICEID01"
ds := newSourcesDiffDS(t, account, device)
setServiceSources(t, ds, account, device, "TUNEIN")
// Refused port; probe fails.
got := diffSourcesForDeviceWithURL(ds, account, device, "127.0.0.1", "http://127.0.0.1:1/sources")
if len(got) != 1 || got[0].Severity != SeverityInfo {
t.Fatalf("expected one info finding for unreachable speaker, got %+v", got)
}
if len(got[0].ManualCommands) != 1 {
t.Errorf("expected a manual command, got %+v", got[0].ManualCommands)
}
}
func TestSourcesDiff_MalformedXMLWarns(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("not xml"))
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
probeURL := "http://" + u.Host + "/sources"
account, device := "1000001", "DEVICEID01"
ds := newSourcesDiffDS(t, account, device)
setServiceSources(t, ds, account, device, "TUNEIN")
got := diffSourcesForDeviceWithURL(ds, account, device, "192.0.2.10", probeURL)
if len(got) != 1 || got[0].Severity != SeverityWarning {
t.Fatalf("expected one warning for malformed XML, got %+v", got)
}
}
func TestSourcesDiff_EmptyServiceSetDoesNotDoubleWarn(t *testing.T) {
account, device := "1000001", "DEVICEID01"
ds := newSourcesDiffDS(t, account, device)
// Don't write any Sources.xml — sources_xml_present already flags this.
speakerURL := stubSpeakerSourcesServer(t, "AUX", "TUNEIN")
got := diffSourcesForDeviceWithURL(ds, account, device, "192.0.2.10", speakerURL)
for _, f := range got {
if f.Severity == SeverityWarning && strings.Contains(f.Message, "missing") {
t.Errorf("should not emit a 'missing on speaker' warning when service set is empty, got %+v", f)
}
}
}
+149
View File
@@ -0,0 +1,149 @@
package health
import (
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func newTestDatastoreWithDevice(t *testing.T, account, device string) *datastore.DataStore {
t.Helper()
tempDir, err := os.MkdirTemp("", "health-test-*")
if err != nil {
t.Fatalf("temp dir: %v", err)
}
t.Cleanup(func() { os.RemoveAll(tempDir) })
ds := datastore.NewDataStore(tempDir)
if err := ds.SaveDeviceInfo(account, device, &models.ServiceDeviceInfo{
DeviceID: device,
AccountID: account,
Name: "TestSpeaker",
}); err != nil {
t.Fatalf("SaveDeviceInfo: %v", err)
}
return ds
}
func TestSourcesXMLPresent_FlagsMissingFile(t *testing.T) {
account, device := "1000001", "DEVICEID01"
ds := newTestDatastoreWithDevice(t, account, device)
if ds.HasConfiguredSources(account, device) {
t.Fatalf("precondition: device should not have Sources.xml yet")
}
r := NewRegistry()
RegisterSourcesXMLPresent(r, ds)
results := r.RunAll()
if len(results) != 1 {
t.Fatalf("expected 1 check result, got %d", len(results))
}
check := results[0]
if check.ID != CheckIDSourcesXMLPresent {
t.Errorf("unexpected check id %q", check.ID)
}
if check.Severity != SeverityWarning {
t.Errorf("expected warning severity, got %q", check.Severity)
}
if len(check.Findings) != 1 {
t.Fatalf("expected 1 finding, got %d", len(check.Findings))
}
finding := check.Findings[0]
if finding.Target.Account != account || finding.Target.Device != device {
t.Errorf("finding target %+v doesn't match device", finding.Target)
}
if len(finding.QuickFixes) != 1 || finding.QuickFixes[0].ID != FixIDCreateDefaultSources {
t.Errorf("expected create_default_sources quick fix, got %+v", finding.QuickFixes)
}
}
func TestSourcesXMLPresent_QuickFix_MaterialisesDefaults(t *testing.T) {
account, device := "1000001", "DEVICEID01"
ds := newTestDatastoreWithDevice(t, account, device)
r := NewRegistry()
RegisterSourcesXMLPresent(r, ds)
msg, err := r.RunFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, Target{
Account: account,
Device: device,
})
if err != nil {
t.Fatalf("RunFix: %v", err)
}
if msg == "" {
t.Errorf("expected non-empty success message")
}
if !ds.HasConfiguredSources(account, device) {
t.Fatalf("Sources.xml was not materialised by the fix")
}
// The defaults should include TUNEIN — that's the load-bearing
// reason for this check.
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
t.Fatalf("GetConfiguredSources: %v", err)
}
var sawTuneIn bool
for i := range sources {
if sources[i].SourceKeyType == "TUNEIN" {
sawTuneIn = true
break
}
}
if !sawTuneIn {
t.Errorf("expected TUNEIN among default sources, got %d entries without it", len(sources))
}
// Re-running the check should now report a clean state.
results := r.RunAll()
if results[0].Severity != SeverityOK {
t.Errorf("expected OK after fix, got %q", results[0].Severity)
}
if len(results[0].Findings) != 0 {
t.Errorf("expected no findings after fix, got %d", len(results[0].Findings))
}
}
func TestSourcesXMLPresent_NilDatastore(t *testing.T) {
r := NewRegistry()
RegisterSourcesXMLPresent(r, nil)
results := r.RunAll()
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if results[0].Severity != SeverityOK {
t.Errorf("nil datastore should produce no findings, got %q", results[0].Severity)
}
}
func TestSourcesXMLPresent_FixRejectsEmptyTarget(t *testing.T) {
ds := newTestDatastoreWithDevice(t, "1000001", "DEVICEID01")
r := NewRegistry()
RegisterSourcesXMLPresent(r, ds)
if _, err := r.RunFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, Target{}); err == nil {
t.Errorf("expected error for empty target, got nil")
}
}
+231
View File
@@ -0,0 +1,231 @@
package health
import (
"context"
"encoding/xml"
"fmt"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// suggestAccountForPairing picks an account ID to pre-fill into the
// "Complete pairing" QuickFix's Target.Account. Returns the first
// real-looking (7-digit) account directory that already contains the
// device's deviceID on disk — typical scenario: AfterTouch and the
// speaker were partially paired earlier, the speaker forgot but our
// datastore remembers. Returns "" when no such account exists, in
// which case the executor generates a fresh ID at click time.
func suggestAccountForPairing(ds *datastore.DataStore, deviceID string) string {
if ds == nil {
return ""
}
for _, acc := range ds.AllAccountsForDevice(deviceID) {
if isSevenDigitAccountID(acc) {
return acc
}
}
return ""
}
// isSevenDigitAccountID mirrors setup.IsValidAccountID without
// importing the setup package (which would pull in SSH/telnet/certmgr
// transitively — see the boundary comment near speakerInfoXML).
func isSevenDigitAccountID(s string) bool {
if len(s) != 7 {
return false
}
for _, ch := range s {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
// buildEmptyMargeFinding constructs the "Speaker reports an empty
// <margeAccountUUID>" finding with a QuickFix that completes pairing
// in-place plus a ManualCommand fallback the operator can run from a
// shell on the speaker's LAN. The executor is registered separately by
// the caller that has setup.Manager access.
//
// Target.Account intentionally carries the *pair-with* account
// (whichever we picked from disk), not the current binding — that's
// what the fix executor needs at click time, and the framework passes
// Finding.Target through to the FixFunc verbatim. The UI grouping
// follows Target.Account too; that's a minor side effect we accept.
func buildEmptyMargeFinding(ds *datastore.DataStore, _ Target, deviceID, ipAddress string) Finding {
suggested := suggestAccountForPairing(ds, deviceID)
confirm := "Pair this speaker with a Marge account so playback selection stops failing with INVALID_SOURCE? "
if suggested != "" {
confirm += "AfterTouch will reuse the existing account " + suggested + " (already on disk for this device)."
} else {
confirm += "AfterTouch will generate a fresh 7-digit account ID for this speaker (private deployment — the ID is opaque)."
}
cliAccount := suggested
if cliAccount == "" {
cliAccount = "<7-digit-account-id>"
}
return Finding{
Severity: SeverityWarning,
Target: Target{Account: suggested, Device: deviceID},
Message: "Speaker reports an empty <margeAccountUUID>.",
Details: "The speaker is reachable but isn't bound to any Marge account. Playback selection will fail with INVALID_SOURCE until pairing completes. See discussion #223 and issue #329.",
QuickFixes: []QuickFix{{
ID: FixIDCompleteSpeakerPairing,
Label: "Complete pairing",
Confirm: confirm,
}},
ManualCommands: []ManualCommand{{
Label: "Or pair from a host on the speaker's LAN:",
Command: fmt.Sprintf("soundtouch-cli setup pair --host=%s --mode=bare --account=%s", ipAddress, cliAccount),
Hint: "The --mode=bare path sends just setMargeAccount without the full state machine; sufficient on FW 27.0.6.",
}},
}
}
// CheckIDSpeakerInfoReachable is the registry id of the speaker
// reachability check.
const CheckIDSpeakerInfoReachable = "speaker_info_reachable"
// FixIDCompleteSpeakerPairing is the QuickFix that completes pairing
// on a speaker that reports an empty <margeAccountUUID> — i.e. the
// speaker is reachable and configured to point at AfterTouch but
// hasn't been bound to a Marge account, so every playback selection
// fails with INVALID_SOURCE (see #329, discussion #223).
//
// The executor lives in pkg/service/handlers/server.go (where
// setup.Manager is available); the constant is defined here so the
// check that emits the finding stays self-contained.
const FixIDCompleteSpeakerPairing = "complete_speaker_pairing"
// speakerInfoXML mirrors only the fields we need from the
// speaker's :8090/info XML response. Duplicated here (rather than
// imported from pkg/service/setup) to keep the health package
// free of cross-package dependencies that would pull in SSH,
// telnet, certmgr, etc.
type speakerInfoXML struct {
XMLName xml.Name `xml:"info"`
DeviceID string `xml:"deviceID,attr"`
Name string `xml:"name"`
MargeAccountUUID string `xml:"margeAccountUUID"`
MargeURL string `xml:"margeURL"`
}
// RegisterSpeakerInfoReachable registers the speaker_info_reachable
// check against r. The check iterates every known device in the
// datastore, probes its :8090/info endpoint, and emits findings
// for unreachable speakers and speakers paired with an empty
// margeAccountUUID (a known TPDA failure mode — see
// discussion #223).
func RegisterSpeakerInfoReachable(r *Registry, ds *datastore.DataStore) {
r.Register(Check{
ID: CheckIDSpeakerInfoReachable,
Title: "Speakers respond on :8090/info",
Run: func() []Finding {
return runSpeakerInfoReachable(ds)
},
})
}
func runSpeakerInfoReachable(ds *datastore.DataStore) []Finding {
if ds == nil {
return nil
}
devices, err := ds.ListAllDevices()
if err != nil {
return []Finding{{
Severity: SeverityError,
Message: "Could not enumerate devices: " + err.Error(),
}}
}
var findings []Finding
for i := range devices {
dev := &devices[i]
if dev.IPAddress == "" {
continue
}
findings = append(findings, probeAndAssessSpeaker(ds, dev.AccountID, dev.DeviceID, dev.IPAddress)...)
}
return findings
}
func probeAndAssessSpeaker(ds *datastore.DataStore, account, deviceID, ipAddress string) []Finding {
probeURL := fmt.Sprintf("http://%s:8090/info", ipAddress)
return probeAndAssessSpeakerWithURL(ds, account, deviceID, ipAddress, probeURL)
}
// probeAndAssessSpeakerWithURL is the same as probeAndAssessSpeaker
// but takes the full URL directly. Used by tests that need to point
// at an httptest.Server, since those bind to random ports rather
// than :8090. ds and ipAddress feed the QuickFix attached to the
// empty-margeAccountUUID finding (suggestion for an existing
// account-on-disk, and the CLI ManualCommand fallback).
func probeAndAssessSpeakerWithURL(ds *datastore.DataStore, account, deviceID, ipAddress, probeURL string) []Finding {
target := Target{Account: account, Device: deviceID}
res := ProbeGet(context.Background(), probeURL, 2*time.Second)
if !res.Reachable {
return []Finding{{
Severity: SeverityWarning,
Target: target,
Message: "Speaker /info is not reachable from this host.",
Details: "If AfterTouch is hosted off the speaker's LAN (e.g. behind a reverse proxy or in a cloud), the service can't reach the speaker directly. Run the command below from a host that can.",
ManualCommands: []ManualCommand{{
Label: "Fetch /info from your network:",
Command: res.CurlCommand,
Hint: "Paste the response into a bug report or compare margeAccountUUID/margeURL with what AfterTouch expects.",
}},
}}
}
if res.Status != 200 {
return []Finding{{
Severity: SeverityWarning,
Target: target,
Message: fmt.Sprintf("Speaker returned HTTP %d for /info.", res.Status),
Details: "Expected 200. Either the speaker is in a transient state or the IP belongs to a different device now.",
}}
}
var parsed speakerInfoXML
if err := xml.Unmarshal(res.Body, &parsed); err != nil {
return []Finding{{
Severity: SeverityWarning,
Target: target,
Message: "Speaker replied but the /info body is not valid XML.",
Details: "Parse error: " + err.Error(),
}}
}
var out []Finding
if parsed.MargeAccountUUID == "" {
out = append(out, buildEmptyMargeFinding(ds, target, deviceID, ipAddress))
}
if parsed.MargeURL == "" {
out = append(out, Finding{
Severity: SeverityInfo,
Target: target,
Message: "Speaker reports an empty <margeURL>.",
Details: "The speaker hasn't been told where the cloud lives. This usually clears up after the first successful /info request from the service.",
})
}
return out
}

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