Fixes CodeQL go/log-injection alerts in the final batch of packages.
New logutil.go helpers: pkg/client, pkg/testutils/amazon,
pkg/testutils/spotify, cmd/soundtouch-service, cmd/soundtouch-web,
cmd/dummy-speaker, cmd/mdns-scanner.
pkg/discovery/logger.go: added sanitizeLog and a nil-safe
remoteAddrString helper to the existing file (alongside logVerbose).
Call sites wrapped across 11 files — device IDs, source types,
hostnames, IPs, interface names, URLs, service names, HTTP method/form
values, WebSocket URLs and payloads, TLS SNI names, remote addresses.
No behaviour change. golangci-lint and make check pass.
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>
#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>
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>
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>
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>
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>
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>
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>
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.
Mirrors the .md/.txt sweep across all tracked _test.go, testdata XML,
and .http integration files. Test files are self-contained (producer
+ assertion in the same file), so the matched-pair swap stays green
under `go test ./...`.
Mapping applied:
192.168.178.[0-9]+ → 192.0.2.[same]
192.168.1.[0-9]+ → 192.0.2.[same]
Sound Machinechen → Living Room SoundTouch
A Sound Machine → Kitchen SoundTouch
A81B6A536A98 + case/separator variants → AABBCCDDEEFF (etc.)
A81B6A849D99 → AABBCCDDEE01
A81B6A849D88 → AABBCCDDEE03
A81B6A536A09 → AABBCCDDEE04
884AEAEEBD27 → AABBCCDDEE02
3230304 → 1000001
9569497 → 1000002
Two semantic fixes alongside the bulk swap:
- pkg/service/zeroconf/zeroconf_test.go: the "private 192" and
"strips query" cases pin acceptance of RFC-1918 192.168/16. They
must use a real 192.168 value; doc-range IPs would (correctly) be
rejected by validateZcBaseURL. Switched to 192.168.10.10 — generic
enough not to match any home LAN default, real enough for the
validator. Added a comment explaining why this single test still
carries a 192.168 literal.
- pkg/service/setup/setup_test.go: TestTestDNSRedirection mocks the
device's `od -An -tu1` byte output, which is space-separated
octets ("192 168 1 100"). My sed only matched the dot-separated
form, so the mock was returning the old IP while the test
assertions had moved to the doc range. Updated to " 192 0 2 100".
go build ./... clean. go test ./... clean (only TestDocsConsistency
remains failing, which is a pre-existing/untracked-file issue).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the proxy/mirror removal there is no proxy left in the service,
but the parallel partial-update endpoint /setup/proxy-settings stuck
around with its legacy name. It serves a legitimate purpose distinct
from the bulk /setup/settings POST: the three checkboxes
(Redact / Log Bodies / Record) use onchange-triggered live save,
while /setup/settings drives a Save-button form for dozens of fields.
Folding the two endpoints together would either lose the live-toggle
UX or send half-edited draft form data on every toggle, so the
partial-update endpoint earns its keep — it just needed the right
name.
Renamed symbols (no behaviour change):
Go handler funcs:
HandleGetProxySettings → HandleGetLoggingSettings
HandleUpdateProxySettings → HandleUpdateLoggingSettings
GetProxySettings → GetLoggingSettings
Route:
/setup/proxy-settings → /setup/logging-settings
JS:
fetchProxySettings() → fetchLoggingSettings()
updateProxySettings() → updateLoggingSettings()
HTML element IDs (cosmetic, kept consistent):
proxy-redact / proxy-log-body / proxy-record
→ logging-redact / logging-log-body / logging-record
HTML heading:
"Proxy Logging:" → "Logging:"
JSON payload shapes (request + response keys) are UNCHANGED: the
endpoint still emits / accepts {"redact", "log_body", "record"}.
Persisted Settings on disk are UNCHANGED. CLI flags are UNCHANGED.
Server struct fields redactLogs / logBodies / recordEnabled
(renamed earlier this session) are UNCHANGED.
testdata/router_routes.txt regenerated. go build clean. go test
./... clean except pre-existing TestDocsConsistency (untracked-file
issue, unrelated). golangci-lint 0 issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The new HandleSiriusXMLiveAdapter and HandleSiriusXMLiveAdapterSubpath
routes were registered via r.HandleFunc (every HTTP method) at the top
level in main.go. The router-shape golden file gets one entry per
(method, path) pair, so SiriusXM adds 14 lines across CONNECT / DELETE
/ GET / HEAD / OPTIONS / PATCH / POST / PUT / TRACE.
Pure regeneration — no behaviour change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bmx_services.json advertises SIRIUSXM_EVEREST at
`{BMX_SERVER}/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter`
and bmx_services_availability.json lists it as available, so speakers
that try SiriusXM hit that path. Without a route we 404'd silently
and the call was invisible in our logs.
- HandleSiriusXMLiveAdapter at the bare base URL returns the
SIRIUSXM_EVEREST service descriptor (selected by id.name from
bmx_services.json, with {BMX_SERVER}/{MEDIA_SERVER} substitution).
Mirrors deborahgu/soundcork main.py:805 in shape.
- HandleSiriusXMLiveAdapterSubpath catches every sub-path advertised
by the descriptor's _links (/availability, /token, /navigate,
/logout) plus the playback URLs the speaker discovers via navigate.
Logs the request with method+path+UA+Authorization+RawQuery, then
404s — giving the next implementation pass concrete data about
what the speaker actually asks for.
Two helpers added to handlers_bmx.go (shared with any future
BMX-segment stub):
- extractBMXService(json, name) — find a service entry by id.name.
- (*Server).applyBMXTemplate(content) — {BMX_SERVER}/{MEDIA_SERVER}
substitution, identical to what HandleBMXRegistry does inline.
Routes registered next to Orion at the top level — same convention
(no /bmx/ prefix) because bmx_services.json advertises baseUrl without
that prefix and speakers reach the path verbatim under either
migration mode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /favicon.ico route was redirecting r.URL.Path to
"/media/favicon-braille.svg" and calling HandleMedia. HandleMedia
strips "/media" and serves from the embedded static/media/ subtree —
which does not contain a favicon. The actual asset lives under the
embedded web/img/ subtree (see the `web/img/favicon-braille*` embed
directive in handlers_media.go).
Repoint to "/web/img/favicon-braille.svg" + HandleWeb. http.FileServer
inside HandleWeb finds the file at its native embed path and serves
it with the right Content-Type.
Pre-existing bug exposed by Stockholm because that frontend triggers
a /favicon.ico request from every loaded page; without this fix the
browser fills the console with a 404 on every Stockholm view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
setupRouter gained a *stockholm.Handler parameter on this branch, but
the test left over from the previous signature still called it with
one argument, breaking `go vet ./...`. Pass nil — Stockholm is opt-in
and not exercised in this test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements pkg/service/stockholm with bridge (appSend/runQueue), HTTP
proxy, static serving, config URL rewriting, native state persistence,
and device discovery. Mounts under a configurable base path (/stockholm
by default) with correct http.StripPrefix routing and apiBase-prefixed
bridge API routes matching the patched JS window.__stockholmBase calls.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Issue #285's first fix (5f31616) registered the rename PUT inside a
chi subrouter at `/streaming/account/{account}/device`, alongside the
existing POST handlers. A *second* subrouter was already declared at
`/streaming/account/{account}/device/{device}` for the per-device
sub-resources (presets, recent, group, …). chi's radix tree treats
those two registrations as overlapping prefixes and at request time
prefers the more-specific `/device/{device}` subrouter — which had
no root-level method handlers. A PUT to /device/X fell through to
the [UNHANDLED] catch-all, got proxied to streaming.bose.com, came
back as 401 from CloudFront. Speakers retried in a loop.
The handlers-package regression test passed because the test router
in `pkg/service/handlers/main_test.go` is flatter (one subrouter for
device, no `/device/{device}` nested block). The route snapshot
test passed because `chi.Walk` enumerates each subrouter's
registrations independently — it doesn't simulate how the radix tree
will resolve a runtime request when subrouters overlap.
Reproduced against the actual production setupRouter in
TestPUTRenameRoutesToLocalHandler (new in router_test.go). Before
this commit: 404 / [UNHANDLED] / 401 proxy. After: 200 from
HandleMargeUpdateDevice.
Fix: collapse the two subrouters into one. All `/device` routes —
the POST/PUT/DELETE on the device resource itself plus the GET/POST
sub-resources — share a single `r.Route("/device", ...)` block with
explicit `/{device}/...` paths inside. No radix-tree ambiguity.
Knock-on: the `r.Delete("/device/{device}", server.HandleMargeRemoveDevice)`
that lived at the outer `/account/{account}` level moves into the
unified `/device` subrouter for symmetry. Its prior placement was
also being shadowed by the radix overlap, which is why the route
snapshot's first regeneration after this fix grew by exactly one
DELETE line — that route was never resolvable at runtime under the
old structure either.
Refs #285.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes issue #285. When the user renames an ST10 via the Bose App or
via `soundtouch-cli name set`, the speaker fires:
PUT http://<aftertouch>:8000/streaming/account/{accountID}/device/{deviceID}
Content-Type: application/xml
<device deviceid="…"><name>NEW</name><macaddress>…</macaddress></device>
The router only had POST registered for that path; PUT fell through
to chi's default handling and the speaker observed HTTP 502 (captured
verbatim in _/i285/Rename.log:38: "SimpleURLFetcher: retry needed,
Curl 0, http 502, retries remaining 0"). The speaker's SimpleURLFetcher
retried the PUT on a 15-second timer, the Bose App showed the rename
spinning indefinitely, and the device's display name never updated on
the AfterTouch side.
Implementation reuses marge.AddDeviceToAccount, which is already an
upsert via ds.SaveDeviceInfo — there's no semantic difference between
"add" and "update" at the persistence layer. The new handler
HandleMargeUpdateDevice differs from HandleMargeAddDevice only in the
HTTP envelope:
- 200 OK (not 201 Created — this is an update, not a fresh resource)
- no Location header (the resource already lives at the URL the
speaker is PUT-ing to)
- deviceID in the body must match the URL's {device} segment;
mismatch is a 400 rather than a silent re-key
Registered as `r.Put("/{device}", server.HandleMargeUpdateDevice)`
inside the existing `/streaming/account/{account}/device/` route
group in both cmd/soundtouch-service/main.go and the handlers-package
test router. Router-routes snapshot regenerated.
Test coverage in pkg/service/handlers/issue285_regression_test.go:
- TestIssue285_RenamePutAcceptedAndPersisted seeds the datastore
with a device under its original name, replays the literal log
payload from _/i285/Rename.log:36 against the real router, and
asserts 200 OK + new name in response body + new name persisted
on disk. testdata/issue285/rename_request.xml is the captured
payload byte-for-byte (accountID 3981561, deviceID 884AEAEEBD27,
rename to "Wohnzimmer SB" — same as the reporter).
- TestIssue285_RenamePutRejectsMismatchedDeviceID pins the safety
check: body deviceid != URL {device} → 400.
Closes#285.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The BMX registry advertises orion at
`{BMX_SERVER}/core02/svc-bmx-adapter-orion/prod/orion` — no `/bmx/`
prefix. That matches the upstream Bose capture in
pkg/service/handlers/static/bmx_services_ustream.json. But our router
nested both orion routes inside the `/bmx/` chi group, so the speaker
asked `/core02/.../prod/orion/token` and our service routed
`/bmx/core02/.../prod/orion/token` — pure path mismatch. The legacy
preset URLs in issue #218 (LOCAL_INTERNET_RADIO presets pointing at
`https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/station?data=...`)
also dead-ended for the same reason.
Three changes:
- Move `POST /core02/svc-bmx-adapter-orion/prod/orion/token` from the
`/bmx/` group to top level so it matches what the registry hands the
speaker.
- Add the missing `GET /core02/svc-bmx-adapter-orion/prod/orion/station`
that takes `data` as a query string. The handler reuses
bmx.PlayCustomStream — base64-decode the JSON blob (streamUrl/
imageUrl/name) and rewrap it into the standard BmxPlaybackResponse
shape, exactly the way soundcork's reference impl handles it
(soundcork main.py:786, bmx.py:720). No auth check on this endpoint:
`data` is the speaker's own preset payload, there's nothing
privileged to gate, and the upstream behaviour treats it the same way.
- Drop the local-invention `POST /bmx/orion/v1/playback/station/{data}`
route. Nothing advertised it, nothing real-world called it, and
keeping it as a "convenience alias" would have left a misleading
duplicate next to the canonical path.
TuneIn's `/bmx/tunein/...` routes stay where they are — TuneIn's
upstream baseUrl genuinely is `{BMX_SERVER}/bmx/tunein`, so the chi
group prefix is correct for that one.
Router snapshot regenerated; TestOrionPlayback flipped from
POST `/bmx/orion/v1/playback/station/{data}` to GET
`/core02/...station?data=...` (no auth header); the orion entry in
TestBMXUnauthorized's table is removed (the endpoint isn't authed
anymore, by design).
Refs #218.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Speakers connect to Bose hostnames over implicit HTTPS (:443) while
AfterTouch's listener defaults to :8443. Without iptables / setcap /
reverse-proxy in front, the speaker side sees Curl 7 / connection
refused and AfterTouch's HTTP log stays silent — a recurring source
of confusion (see #214, #269).
Add a server-side probe (Check443Reachability) that dials both
localhost:443 and the DNS-resolved LAN IP on :443. Run it once at
service startup with a 2s timeout and emit a [WARN] log with the
exact iptables/setcap commands keyed to the configured listener port.
Expose the result via GET /setup/settings (with a shorter inline
timeout) so the web UI renders a ✅/❌ line next to Target Domain
and a complementary browser-side fetch probe — the browser sits on
the LAN exactly where speakers do, and timing-to-error distinguishes
TCP refused from TLS handshake started even with an untrusted CA.
Both the startup WARN and the UI row are gated on dns_enabled,
since :443 only matters for the DNS migration path; SDK-override
migration uses the port from the configured URL.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SoundTouch 10 firmware 27.x posts the addGroup payload to the Marge
URL with a trailing slash ("/streaming/account/<id>/group/") when the
master is forming a stereo pair. AfterTouch only registered the no-
slash form, so chi returned 404, the master's MargeClient retried
every 15 s, the slave kept connecting to the master's audio transport
but was rejected with "Group STP NOT FOUND" because the master never
finished AddingMaster, and the group eventually reverted -- the symptom
reported in #252.
Register POST /group/ alongside POST /group in both Marge route trees
(the /marge/streaming/... mount and the bare /streaming/... mount that
serves direct device traffic). The GET device-group routes already had
both forms; this brings the POST in line.
Add TestMargeAddGroup_FromSpeakerCapture, which replays the exact
request captured live from BirdyBA's master log: URL with trailing
slash, Authorization Bearer header, vendor Content-Type, and the
minimal XML body (no <senderIPAddress>, no per-role <ipAddress>, no
<status>, no numeric group id). The test failed with 404 before this
change and now returns 201 Created with the proper Location header,
pinning the exact wire contract so future refactors fail loudly.
Refs #252
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TestPrintRoutes compares the live router against
testdata/router_routes.txt; the deletion commit (ba69fc0) changed the
route set but didn't regenerate the golden file. Drops
/probe/{token}[/*] and /setup/telnet-probe/{deviceId}; adds
/setup/peer-probe/{deviceId}.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hard-deletes everything marked DEPRECATED in the previous commit:
Files:
- pkg/service/setup/telnet_probe.go
- pkg/service/setup/telnet_probe_test.go
- pkg/service/handlers/handlers_telnet_probe.go
- pkg/service/handlers/probe_registry.go
- pkg/service/handlers/probe_registry_test.go
Edits:
- Server.probes field + initialization (server.go).
- Routes /probe/{token}, /probe/{token}/*, and
/setup/telnet-probe/{deviceId} (main.go).
- checkTelnetRoundTrip() in script.js.
The passive observer (peer_probe.go + handlers_peer_probe.go) is now
the only reachability check for migrated speakers; unmigrated/partial
states surface a skip row pointing at the Apply + reboot cycle, as
documented in TELNET-MIGRATION-METHOD.md §9.8.
isCommandNotFound and parseGetpdoConfig remain — they are used by
telnet_migration, telnet_preflight, marge_pairing, and
preflight_crosscheck.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The swUpdate daemon caches its target URL at boot and ignores live
`sys configuration` writes, so the active flip in
RunTelnetRoundTripProbe never reaches the running daemon — confirmed
empirically on a fully-migrated speaker (FW 27.0.6) where both the
runtime and persistence layers were flipped and the device still
dialed the previously-cached `/updates/soundtouch` URL plus
DNS-intercepted `/streaming/software/update/account/*`. The probe URL
was never observed.
Marks DEPRECATED:
- pkg/service/setup/telnet_probe.go: ProbeRegistrar,
TelnetProbeResult, generateProbeToken, RunTelnetRoundTripProbe.
- pkg/service/handlers/handlers_telnet_probe.go: HandleTelnetProbe,
HandleProbeInbound, telnetProbeTimeout, telnetProbeResponse.
- pkg/service/handlers/probe_registry.go: probeRegistry.
- Server.probes field.
- /probe/{token}[/*] and /setup/telnet-probe/{deviceId} routes.
Adds §9.8 to docs/analysis/TELNET-MIGRATION-METHOD.md documenting the
daemon-cache finding, the diagnostic that confirmed it, the passive
observer replacement, the pre-flight branch on migration state, and
the canonical telnet flow (Apply config → reboot → passive
validation). All code symbols remain in place this commit; the
follow-up commit performs the hard delete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RunPeerReachabilityProbe is the post-migration replacement for the
active swUpdateUrl round-trip: register the device IP with the
in-process observer, nudge :8090/swUpdateCheck, and wait for any
inbound from that IP. No device-state mutation. Any inbound counts
as proof — on a migrated speaker, DNS interception routes the
daemon's outbounds through this service regardless of which URL it
resolved internally, so reachability reduces to "did the device
dial us at all."
PeerHit and the abstract observer interface live in setup alongside
the probe logic; handlers.peerObserver implements the interface and
the existing observer files now import from setup.
Route: POST /setup/peer-probe/{deviceId}. Timeout: 30s, surfaced as
result.ElapsedMs so the budget can be tuned from real data. The
pre-flight orchestrator gains the branch in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an in-process observer that records device->service requests by
source IP. PeerObserverMiddleware fires on every inbound after RealIP
trust and Recoverer; the registry exposes Register/Signal/Forget keyed
on the device IP with a buffered one-shot delivery.
No callers yet — this is the substrate for the passive reachability
probe that replaces the broken active swUpdateUrl round-trip on
migrated speakers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fills the SSH-less gap the curl-from-device test leaves in the
pre-flight panel: instead of skipping connectivity verification on
USB-unlock-refusing speakers, we drive a round-trip from the device
itself using only telnet:17000 and the device's own :8090 API.
Sequence (Manager.RunTelnetRoundTripProbe):
1. telnet `getpdo CurrentSystemConfiguration` — capture the
speaker's current swUpdateUrl so we can restore it.
2. Generate a random hex token; register a one-shot signal
channel under it via the new probeRegistry on Server.
3. telnet `sys configuration swUpdateUrl <targetURL>/probe/<token>`
— runtime layer only, no envswitch boseurls set, so the
persistence layer keeps the original and a reboot heals the
device naturally if our restore step fails.
4. HTTP GET :8090/swUpdateCheck — the cleanest :8090 endpoint
that triggers exactly one outbound to the configured
swUpdateUrl. Read-only on the cloud side, doesn't depend on
margeAccountUUID, doesn't start an actual update.
5. Wait on the registered channel up to telnetProbeTimeout (6s).
6. telnet `sys configuration swUpdateUrl <original>` — restore
in a deferred call so it runs even on the failure path.
New /probe/{token}[/*] catch-all on the root router signals the
matching channel when the speaker's outbound lands; the response is
a minimal `<swUpdateIndex/>` so the device's swUpdateCheck doesn't
choke on a missing structure. The {token}/* sub-path is registered
because some firmware appends a path component to the configured
swUpdateUrl.
POST /setup/telnet-probe/{deviceId}?target_url=… exposes the
orchestrator as a single REST call returning {ok, result: {reached,
restored, original_url, probe_url, elapsed_ms, logs}, error?}.
Tests cover: happy path with channel signalled by the fake registrar
when the :8090 trigger fires, timeout when no inbound arrives,
abort when getpdo doesn't expose swUpdateUrl, abort when the
firmware rejects sys configuration, dial failure, invalid target URL.
Frontend wiring (visible pre-flight panel) lands in the next
commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire up X-Real-IP / X-Forwarded-For / True-Client-IP support for
deployments fronted by a reverse proxy, while staying safe on flat-LAN
deployments where a malicious speaker could spoof those headers
directly.
Two new fields on `datastore.Settings`:
* TrustForwardedHeaders (bool, default false) — opt-in switch.
* TrustedProxyCIDRs ([]string, default `["127.0.0.0/8", "::1/128"]`)
— only requests whose immediate TCP peer falls in one of these
blocks may have their source IP rewritten from forwarded headers.
Loopback default matches the documented same-host nginx layout in
docs/guides/HTTPS-SETUP.md.
New middleware in `pkg/service/handlers/middleware_realip.go`:
* TrustedRealIP wraps `chi/middleware.RealIP` with a trusted-peer
gate. When the immediate TCP peer is in the allowlist, chi's
parsing handles the actual header → IP rewrite. When it isn't
(e.g. a speaker sending forwarded headers itself), we ignore the
headers and r.RemoteAddr stays as-is.
* ParseTrustedProxyCIDRs converts string CIDRs into *net.IPNet,
applying the loopback default on empty input and erroring loudly
on invalid entries.
Server.TrustedRealIPMiddleware() returns the middleware (or nil) by
reading the live settings; the router setup in
cmd/soundtouch-service/main.go installs it as the very first
middleware so SnapshotMiddleware and downstream handlers see the
correct r.RemoteAddr.
HandleMargePowerOn now prefers r.RemoteAddr over the body's
self-reported `<IPAddress>` for outbound credential push:
* The body field is treated as a hint only — a malicious LAN speaker
could set it to any value; using it for outbound HTTP requests is
the SSRF surface the previous zeroconf hardening was guarding
against from the sink side. Fixing it at the source as well closes
the gap entirely.
* When body IP and TCP source disagree, a log line names both and
the device ID so the discrepancy is investigable.
* RemoteAddr is unparseable → fall back to the body so we don't
silently drop the priming.
docs/guides/HTTPS-SETUP.md gains a follow-up note next to the existing
nginx snippet explaining the new flag, the loopback-only default, and
the explicit warning against enabling the flag on a flat-LAN
deployment without a real proxy.
Eleven test cases in middleware_realip_test.go lock in the gate
behaviour: trusted peers honoured for X-Real-IP / X-Forwarded-For /
no-headers / IPv6, untrusted peers' headers ignored, garbage values
rejected, ParseTrustedProxyCIDRs covers default / override / invalid.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an SSH-free third migration path that drives the SoundTouch device's
diagnostic shell on TCP port 17000, plus a hardened replacement for the
fragile /setMargeAccount HTTP pairing call.
* `pkg/telnet` — new reusable, dependency-free client (sibling of `pkg/ssh`)
with deadline-driven Dial / Probe / SendCommand / Close. Mock-server tests
cover happy path, command-not-found, mid-stream close, and the wedged-device
read-timeout scenario.
* `setup.MigrationMethodTelnet` — runs `sys configuration` for all four URLs
plus the parallel `envswitch boseurls set` persistence layer that otherwise
wins on reboot, then verifies with `getpdo CurrentSystemConfiguration`.
Aborts on the first non-OK response so configuration is never half-written.
No SSH backup or rw pre-flight (the path is SSH-free by design).
* `setup.PairAccount` — probes :8090/supportedURLs first, time-bounds
POST /setMargeAccount aggressively (5s connect / 12s total) to avoid the
hangs reported in #236, and falls back to `envswitch accountid set <id>`
over telnet when the HTTP endpoint is missing or wedged. Returns a
PairAccountResult breadcrumb so the UI can show which path actually
succeeded.
* `setup.Reboot(deviceIP, method)` — gains a RebootMethod selector;
RebootMethodSSH stays the default (preserving prior behavior),
RebootMethodTelnet sends `sys reboot` over a fresh telnet session and
treats the inevitable socket-close as success.
* New endpoints on `/setup`:
- GET /account-id-suggestions/{deviceId} — returns the device's current
margeAccountUUID (from :8090/info) plus known account IDs from the
datastore, so the UI can offer reuse.
- POST /pair-account/{deviceId}?account_id=NNNNNNN — invokes PairAccount;
the existing reboot endpoint reads ?method=ssh|telnet from the query
string.
* Helpers `IsValidAccountID` (exactly 7 digits) and `GenerateAccountID`
(crypto/rand, retries on collision against a known-IDs list).
Documentation in docs/analysis/TELNET-MIGRATION-METHOD.md is updated to match
the implementation: bare-URL convention for `soundtouch-service`, no automatic
`sys reboot` (user-initiated via the existing button with a method selector),
and the realised package layout. The /etc/hosts method is intentionally not
exposed in the new flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Why: Operators need to control device discovery from the command line
without touching the persisted settings file, and a zero discovery
interval should be unambiguously off rather than running an
immediate-fire scan loop.
- Add --discovery-enabled BoolFlag (default true, env DISCOVERY_ENABLED)
and thread it through serviceConfig, applyPersistedSettings, and
createDefaultSettings so CLI/env can seed initial state and persisted
settings still take precedence on subsequent runs.
- HandleUpdateSettings now forces discoveryEnabled=false whenever the
resulting discoveryInterval is zero.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
domains[0] was non-deterministic (Go map iteration) and could resolve to
any domain in the list including Bose-owned domains. Adds CommonName field
to CertificateManager, defaulting to "localhost", set to the device hostname
at startup. All Bose domains remain in the SAN where clients actually look.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On constrained hardware (e.g. ARMv7), RSA key generation can block
startup for minutes. HTTP now starts immediately; HTTPS is brought up
in a background goroutine once cert generation completes. A log message
informs the user that HTTPS will be available shortly after startup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Registers HandleAlexaCertificate on POST /alexa/certificate. The handler
logs the device MAC from the request body and returns 501 Not
Implemented with a JSON error explaining that AWS IoT integration is
required to provision Alexa device certificates.
Adds voice.api.bose.io to both /etc/hosts domain lists in setup.go (DNS
intercept was already covered by the bose.io wildcard entry in dns.go).
Relates to https://github.com/gesellix/Bose-SoundTouch/discussions/84
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Endpoints:
- POST /streaming/music/musicprovider/{id}/trial/is_eligible (reuses
is_eligible handler)
- POST /bmx/tunein/v1/favorite/{stationID} with datastore persistence
(SaveTuneInFavorite)
- DELETE /bmx/tunein/v1/favorite/{stationID} (DeleteTuneInFavorite)
- POST /bmx/core02/svc-bmx-adapter-orion/prod/orion/token (anonymous
Orion token)
- GET /bmx-icons/* serving embedded static/media assets (media.bose.io)
- GET /ced/* serving embedded firmware index, release notes, and 10
app-help XMLs (downloads.bose.com)
Add media.bose.io and downloads.bose.com to DNS redirect lists (setup.go
both domain slices, dns.go shouldIntercept list, main.go getDomains
map). Document implemented endpoints in
tests/interactions_20260502_missing_external.md; mark rows 0246–0247 as
self/☑ in the interactions table.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add SpotifyClientID/Secret/RedirectURI and AmazonClientID/Secret/RedirectURI
fields to datastore.Settings for persistent storage
- Server: add amazonClientID/Secret/RedirectURI fields, SetAmazonConfig,
GetSpotifyConfig/GetAmazonConfig, ReinitSpotifyService/ReinitAmazonService,
and applyMusicServiceCredentials (called under lock from HandleUpdateSettings)
- GET /setup/settings: expose credential fields; mask secrets as "***" when set
- POST /setup/settings: apply credential updates and reinitialize services live
- applyPersistedSettings: fill in music credentials from settings.json when not
set via CLI/env (CLI takes precedence)
- Settings tab: replace read-only Spotify status with editable Client ID / Secret /
Redirect URI inputs for both Spotify and Amazon; save via existing Save button
- script.js: populate and collect the six new fields in fetchSettings/updateSettings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Groups (stereo pairs of ST10 speakers) were read-only — the GET endpoint
always returned an empty <group/>. Add POST /account/{account}/group,
POST /account/{account}/group/{groupId}, and DELETE
/account/{account}/group/{groupId} with datastore persistence, matching
the API shape observed in soundcork. The GET endpoint now reads live
group state from the datastore.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>