Commit Graph
76 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Sonnet 4.6 3d5f8717d0 sec5a: sanitize log-injection in pkg/service/handlers
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>
2026-05-24 16:20:25 +02:00
Tobias GesellchenandClaude Sonnet 4.6 251221cafa fix(handlers): remove stale account entry when MoveDevice target dir exists
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>
2026-05-24 10:23:38 +02:00
Marcin Mennemann 44ad0e5928 code style: linting 2026-05-24 09:52:02 +02:00
Marcin Mennemann 65b142881a replace copy-and-delete migration with atomic MoveDevice 2026-05-24 09:52:02 +02:00
Marcin Mennemann 7a268a0372 fix: removing stale devices from datastore 2026-05-24 09:52:02 +02:00
Tobias GesellchenandClaude Sonnet 4.6 9172072601 feat: add source removal — health check, API endpoint, and CLI commands
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>
2026-05-23 22:45:15 +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 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 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 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 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 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 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 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 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 f8108b0dd9 refactor: rename /setup/proxy-settings → /setup/logging-settings
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>
2026-05-17 21:53:33 +02:00
Tobias GesellchenandClaude Opus 4.7 1da654c9b9 refactor(handlers): rename proxy-era leftovers to match public names
After the proxy/mirror removal, two internal Server fields kept their
historical "proxy" prefix even though no proxy code exists anymore:

- s.proxyRedact   still controls recorder.Redact for sensitive-header
                  scrubbing (server.go:393)
- s.proxyLogBody  still controls the [UNHANDLED] body preview in the
                  catch-all (handlers_catchall.go:14)

Both names misled — they read as proxy-related. Renamed to match the
public-facing names that have been used all along: the CLI flags are
--redact-logs / --log-bodies, the persisted Settings fields are
RedactLogs / LogBodies, and the JSON keys are redact_logs / log_bodies.

  proxyRedact  → redactLogs
  proxyLogBody → logBodies

Also renamed the file that now contains only HandleNotFound:

  pkg/service/handlers/handlers_proxy.go      → handlers_catchall.go
  pkg/service/handlers/handlers_proxy_test.go → handlers_catchall_test.go

git mv preserves history. NewServer's positional parameter list is
unchanged at the call site (cmd/soundtouch-service/main.go:391).

go build ./... clean. go test ./... clean except the pre-existing
TestDocsConsistency (unrelated). golangci-lint run ./... 0 issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:53:33 +02:00
Marcin Mennemann 2747d95a8f remove: proxy forwarding to Bose upstream 2026-05-17 21:53:33 +02:00
Marcin Mennemann 0f0a96c0ce remove: mirror middleware and parity comparison with Bose cloud 2026-05-17 21:53:33 +02:00
Tobias GesellchenandClaude Opus 4.7 b0d7e8aae2 feat(spotify): wire preset storage end-to-end via server-centric priming (#302)
storePreset on the speaker was failing with "AddPreset - failed due to
invalid SourceID" because the watchdog priming path only pushed ZeroConf
credentials and never registered a SPOTIFY ConfiguredSource in marge.

PrimeDeviceWithSpotify now:
- resolves the device's paired account via live :8090/info
(margeAccountUUID), falling back to ServiceDeviceInfo.AccountID — same
order as setup.populateDeviceInfo;
- writes a SPOTIFY ConfiguredSource under that account (providerID=15,
BoseSecret as credential), mirroring bridgeSpotifyToMarge;
- POSTs `<updates><sourcesUpdated/></updates>` so the speaker re-fetches
its on-device Sources.xml from marge.

Also introduce zeroconf.ErrAddUserNoOp for the narrow firmware quirk
(404 + empty body on ?action=addUser when activeUser already matches).
Recognised only on that exact pattern; real 4xx/5xx still surface loudly
with full response details. Same treatment applied to Amazon priming.

Docs:
- new docs/concepts/spotify-overview.md anchors the topic (mental model,
streamingoauth.bose.com DNS gotcha, token lifecycle, clientId notes,
troubleshooting table);
- spotify-oauth.md drops the removed install-primer endpoint and the
on-device boot-primer install sections, adds /mgmt/spotify/prime;
- spotify-priming-strategy.md and MUSIC-SERVICES.md link to the
overview.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:47:57 +02:00
Tobias GesellchenandClaude Opus 4.7 3727ae6f0f feat(service): pre-flight :443 reachability check with UI surfacing
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>
2026-05-15 11:03:12 +02:00
Tobias GesellchenandClaude Opus 4.7 ab65dceb9a feat(service): validate server_url and surface resolved DNS intercept IP
Refuse to start the DNS server and reject Settings updates whose
server_url does not resolve to a routable IP. Without this, a
misconfigured hostname caused the DNS server to answer every intercepted
Bose hostname with `CNAME .`, leaving speakers unable to reach the
service while everything looked healthy. The Settings page now displays
the resolved intercept IP (or the resolve error) next to "Target
Domain", so misconfigurations are visible up front instead of buried in
the DNS log.

Refs #269

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:49:23 +02:00
Tobias GesellchenandClaude Opus 4.7 62dd53777d remove(service): delete deprecated telnet round-trip probe
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>
2026-05-11 20:31:24 +02:00
Tobias GesellchenandClaude Opus 4.7 f0de4864b6 deprecate(service): mark active telnet round-trip probe for removal
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>
2026-05-11 20:31:24 +02:00
Tobias GesellchenandClaude Opus 4.7 dc924e351c feat(service): add peer observer registry and middleware
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>
2026-05-11 20:31:24 +02:00
Tobias GesellchenandClaude Opus 4.7 09c8b916ae feat(setup,handlers): SSH-less reachability via telnet round-trip probe
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>
2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 f951fc92df feat(handlers): proxy-aware RemoteAddr via opt-in TrustForwardedHeaders
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>
2026-05-10 14:40:15 +02:00
Tobias GesellchenandClaude Sonnet 4.6 147a1a8490 feat: add Spotify and Amazon credential fields to Settings UI
- 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>
2026-04-29 20:30:06 +02:00
Tobias GesellchenandClaude Sonnet 4.6 cb037df831 feat: add Amazon Music management handlers and CLI wiring
- Add HandleMgmtAmazonInit/Callback/Confirm/Accounts/Token/PrimeDeviceAmazon
- Add bridgeAmazonToMarge (AmazonSecret JSON envelope, Marge registration, speaker notification with OAuth/sync/legacy fallbacks)
- Add PrimeDeviceWithAmazon and pushAmazonTokenToDevice to Server
- Wire --amazon-client-id/secret/redirect-uri/token-url/profile-url CLI flags
- Initialize Amazon service on startup alongside Spotify
- Register /mgmt/amazon/* routes (callback unauthenticated, rest Basic Auth)
- Update router_routes.txt snapshot with 6 new Amazon routes
- Fix errchkjson lint: use typed amazon.Account in test fixtures
- Fix gocyclo lint: extract initMusicServices helper from main action

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:30:06 +02:00
Tobias GesellchenandClaude Sonnet 4.6 f1c2b7a53f feat: implement HandleBoseAmazonToken and wire amazonService into Server
- Add GetAccountByRefreshToken to amazon.Service — the speaker sends
  the bare Atzr| refresh token (extracted from AmazonSecret JSON), not
  a surrogate, so lookup must match against Account.RefreshToken
- Add amazonService field, SetAmazonService and IsAmazonConfigured to
  Server (step 5 essentials required by the handler)
- Replace HandleBoseAmazonToken 501 stub with full implementation:
  lookup by refresh token → RefreshAccessToken; fallback to
  GetFreshToken; fallback to HandleBoseProxy if no service configured;
  scope intentionally omitted from response
- Add handler tests covering the by-refresh-token path (mock LWA
  server), the default-account path, and the no-service fallback
- Unlock assertions in post_oauth_token_amazon.http integration test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:30:06 +02:00
Tobias Gesellchen c6fbc45be5 lint 2026-04-28 17:57:46 +02:00
Tobias GesellchenandClaude Sonnet 4.6 968312aa39 Implement Spotify Connect ZeroConf DH blob encryption (#192)
Replace the simplified tokenType=accesstoken push with the full Spotify
Connect ZeroConf protocol: GET getInfo to fetch the speaker's 768-bit DH
public key, derive AES-128-CTR + HMAC-SHA1 keys from the shared secret,
and POST an encrypted LoginCredentials protobuf blob. Speakers that
receive a proper blob can self-refresh their Spotify session
independently, eliminating the need for periodic re-priming on token
expiry. Falls back to the raw token approach automatically when getInfo
fails, preserving compatibility with older firmware.

SHA1 is mandated by the Spotify Connect ZeroConf protocol spec for DH key derivation. This cannot be changed without breaking protocol compatibility.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:34:05 +02:00
Tobias Gesellchen 174d087b8e Do not duplicate existing sources with default sources 2026-04-23 22:08:54 +02:00
Tobias Gesellchen aa5a25b382 Enhance version-info (#170) 2026-04-17 22:16:47 +02:00
aa7b2c28ab feat: improve Bose SoundTouch parity, Spotify integration, and data reliability (#138)
feat: improve Bose SoundTouch parity, Spotify integration, and data
reliability

- Update XML marshaling for ServicePreset and ServiceRecent to match
Bose parity requirements.
- Add support for adding music sources via
`/streaming/account/{account}/source`.
- Implement HandleBoseAccountToken for Spotify OAuth code exchange and
token persistence.
- Implement atomic file writes in the datastore to prevent data
corruption.
- Add startup logic to initialize default sources for existing devices.
- Expand test coverage with new parity regression and Spotify
integration tests.

---------

Co-authored-by: Junie <junie@jetbrains.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-04-01 21:55:52 +02:00
Tobias GesellchenandJunie 9f7cb81b45 Implement skip mirror endpoints to reduce false positives in parity checks (#126)
Added 'Skip Mirror Endpoints' setting to allow specific requests like
`/oauth/device/*/music/musicprovider/15/token/cs3` to be handled
exclusively locally, even when mirroring is enabled. Updated
MirrorMiddleware to check against the skip list before performing
mirroring or parity logic. Exposed the setting via the Web UI Settings
tab and the CLI. Updated relevant tests to accommodate the configuration
changes.

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-22 10:53:36 +01:00
Tobias GesellchenandJunie 37eb23fc36 Merge existing device info in SaveDeviceInfo to preserve name on power-on
Co-authored-by: Junie <junie@jetbrains.com>
2026-03-17 22:59:20 +01:00
Tobias Gesellchen 6ee0fc8115 Remove Soundcork fallback 2026-02-26 22:26:35 +01:00
Tobias Gesellchen 6211e34050 Improve parity with upstream Bose services 2026-02-26 21:08:14 +01:00
Tobias Gesellchen 53184a6bca reduce log noise 2026-02-24 22:20:13 +01:00
Tobias Gesellchen a1d0213f92 refactor: reorganize device directories to use true deviceId from /info endpoint
- Replace serial number-based directory structure with deviceId from device /info
- Extract migration logic to handle transition from old to new directory structure
- Fix directory resolution bug that prevented proper migration to deviceId-based paths
- Ensure all device data (Presets.xml, Sources.xml, Recents.xml) preserved during transition
- Add configurable migration with --migration-enabled and --migration-dry-run flags
- Update DeviceInfo.xml to reflect authoritative deviceId from device's /info endpoint
- Directory structure now: /devices/{deviceId}/ instead of /devices/{serialNumber}/

This aligns the directory structure with the device's self-declared identity
and ensures data consistency with the device's /info endpoint.
2026-02-24 21:45:40 +01:00