The handler used chi.URLParam("account") directly without the
validatePathID guard present on every other account-parameter handler
in the file. CodeQL traced the raw URL param through
marge.ProviderSettingsToXML into the response body (go/reflected-xss,
alert 75).
Add the standard two-line guard identical to HandleMargeAddDevice,
HandleMargeUpdateDevice, and the rest of the family.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three links in pkg/service/handlers/web/index.html still pointed to
the old Jekyll URL structure (/guides/FOO.html). The docs site moved
to Hugo+Hextra; correct URLs now include /docs/ and drop the .html
extension in favour of a trailing slash.
MIGRATION-SAFETY.html → docs/guides/MIGRATION-SAFETY/
SURVIVAL-GUIDE.html → docs/guides/SURVIVAL-GUIDE/
CLI-REFERENCE.html → docs/guides/CLI-REFERENCE/
The GitHub blob links in script.js and the hostname-resolution warning
in index.html point to source Markdown files and remain valid.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes CodeQL go/log-injection alerts in the handlers package.
Adds pkg/service/handlers/logutil.go with a package-private
sanitizeLog helper that strips \n and \r from strings before they
reach log call sites. Values from speakers, HTTP requests, and
external APIs (device IDs, account IDs, IP addresses, speaker names,
OAuth user IDs/emails, station IDs, URL paths, user-agent strings)
may contain attacker-controlled newlines.
Wraps all external-data string arguments across 12 files:
handlers_account_mgmt.go, handlers_alexa.go, handlers_bmx_orion.go,
handlers_bmx_siriusxm.go, handlers_bmx_tunein.go, handlers_catchall.go,
handlers_export.go, handlers_marge.go, handlers_mgmt.go,
handlers_oauth.go, origin_middleware.go, server.go.
No behaviour change — purely a logging concern. make check passes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The static-analysis CI job runs 'staticcheck ./...' directly.
Standalone staticcheck uses //lint:ignore directives, not the
//nolint comments that golangci-lint reads.
SA1008 (non-canonical header key) on three ETag lines:
handlers_etag_test.go:228, :270
mac_mapping_integration_test.go:226
ETag must stay non-canonical — Bose speakers reject 'Etag'.
Existing //nolint:canonicalheader / //nolint:staticcheck comments
remain for golangci-lint; //lint:ignore SA1008 is added for the
standalone staticcheck invocation.
U1000 (unused function) on writeBMXUnauthorized in handlers_bmx.go:
The auth gate is temporarily disabled; the helper is kept as a
restore point. //lint:ignore U1000 replaces //nolint:unused because
golangci-lint's staticcheck runner also honours //lint:ignore,
making //nolint:unused redundant (nolintlint would complain).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace docs/_config.yml + docs/SUMMARY.md with Hugo + Hextra theme.
Move all content into docs/content/, images into docs/static/images/.
Update docs_consistency_test.go to check Hugo front matter instead of
SUMMARY.md inclusion. Update CI workflow and screenshot script paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When handleDiscoveredDevice calls MoveDevice and the target device
directory already exists (pre-existing duplicate state), os.Rename
fails with ENOTEMPTY/EEXIST leaving the stale source account entry
on disk. Because SaveDeviceInfo has just written fresh data under
accountID, it is safe to unconditionally remove the stale source
entry afterward — RemoveDevice returns nil when the path is already
gone (successful rename), so this is a no-op in the happy path and
a cleanup in the failure path.
Adds TestHandleDiscoveredDevice_CrossAccountMigration_TargetExists
which seeds a device under two real accounts (old sorts alphabetically
first so findExistingDeviceInfoByDeviceID picks it as storedAccount),
triggers discovery with the new account as MargeAccountUUID, and
asserts that after the cycle only the new account entry exists.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Exercises the branch in handleDiscoveredDevice where a device's live
MargeAccountUUID differs from its stored account. The test:
- seeds a device + presets under 'default'
- mocks /info to report a different margeAccountUUID ('8637922')
- calls handleDiscoveredDevice
- asserts the device is now stored under the new account with the live name
- asserts the old 'default' entry is gone
- asserts presets survived the MoveDevice rename
- asserts ListAllDevices returns exactly one entry (no duplicates)
Closes the server-level gap noted during PR #348 review.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Health check (checks_stale_internet_radio.go): detects stub INTERNET_RADIO
sources (empty credentials) left on devices initialised before the stub was
removed from the default source list. Quick-fix removes by ID; skips any
INTERNET_RADIO source that has real credentials.
Datastore: DeleteSourceByID and DeleteSourceByType (uniqueness-guarded).
API: DELETE /setup/sources/{account}/{device}/{sourceID}
CLI — two new commands:
soundtouch-cli cloud source remove --service-url ... --account ... --device ... [--id 10002 | --type INTERNET_RADIO]
Talks to AfterTouch (service side). --type resolves to canonical ID
locally; fails for unknown types.
soundtouch-cli source notify-updated --host <speaker-ip>
Talks to the speaker directly. Fetches device ID from /info, then
POSTs sourcesUpdated to :8090/notification so the speaker re-fetches
its source list immediately.
CloudCommonFlags (--service-url / AFTERTOUCH_URL) mirrors CommonFlags
(--host) for AfterTouch-facing command groups.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TuneIn's profiles API caps initial results at ~10 per container (Stations,
Shows, etc.) and exposes a Pivots.More.Url cursor for the remainder. This
change wires that cursor through the stack so users can load additional
results without leaving the search view.
- tuneInSearchSection now extracts Pivots.More.Url as bmx_next when
itemToken is present; absent for containers already at their limit
- TuneInSearchNext fetches the cursor URL, which returns a flat Items[]
(not nested containers), and maps Station/Program/Topic items using
the existing play/profile builders
- New GET /v1/search/next and /api/tunein/search/next endpoints with
matching handlers in both service paths
- TuneInBrowser: flat items state replaced with per-section sections
state; each section shows a header label and a Load more button when
a cursor is available; browse/navigate mode is unaffected
Relates to #336.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When `discovery_enabled` is false the page no longer fires a discovery
scan on load. Both DOMContentLoaded handlers now await fetchSettings()
and gate triggerDiscovery() on the returned flag — default true keeps
existing behaviour for installations that never touched the setting.
Also renames the UI label from "Enable Automated Discovery" to
"Enable Periodic Discovery" to make clear the checkbox controls the
background timer, not the manual trigger button or IP-entry form.
Relates to #269
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- Refactors BMX service to support multiple radio providers
- Adds RadioBrowser.com API integration with search and browse
- Splits TuneIn logic into separate module for better organization
- Adds new web UI components for radio station discovery
- Includes new SVG icons for RadioBrowser branding
skip history.pushState when the hash already matches, so popstate -> selectMigrationDevice -> showSummary no longer pushes a duplicate entry that traps browser-Back in an oscillation between identical `#tab-migration?<id>` entries.