48 Commits
Author SHA1 Message Date
Tobias Gesellchen b0b8b9a475 feat(health): offer a sourcesUpdated pull alongside the presets push
Adds a second QuickFix to the speaker_presets_count warning, reusing
the existing postSourcesUpdated fix (checks_refresh_sources.go). It
nudges the speaker to re-fetch /full, which is confirmed (both from
marge.AccountFullToXML and a genuine captured Bose-cloud response) to
carry presets alongside sources.

Whether firmware actually re-applies /full's preset section back onto
its own local table is unconfirmed — issue253_regression_test.go
already flags that exact link as untested. So this is offered as a
free, non-destructive thing to try first, with the guaranteed
restore_presets_to_speaker push as the fallback. Gives both directions
(pull-style nudge, direct push) rather than only the one.

Refs #614
2026-08-15 14:39:49 +02:00
Tobias Gesellchen 0f452357e2 feat(health): add "Restore presets to speaker" QuickFix
When the speaker shows 0 preset slots while the service's Presets.xml
has entries (the #614 pattern), replays each stored preset onto the
speaker via :8090/storePreset (client.StorePreset), one slot at a
time. Doesn't require a reboot and doesn't need the content playing
first, unlike a physical preset-button save.

Sync only ever reads from the speaker; this is the missing write
direction, and lets a reporter try recovering presets without
re-entering all 6 by hand.

Refs #614
2026-08-15 14:39:49 +02:00
Tobias Gesellchen bcb819dccd fix(health): correct preset-count guidance, drop wrong citation
The speaker_presets_count check told users a power-cycle "usually
re-syncs" missing presets. #614 shows a power-cycle is itself one of
the two reported triggers for the speaker wiping its own presets, so
that advice was actively harmful for this failure mode.

Also fixes the comment's citation: it claimed this was a known pattern
from discussion #295 and #235, but neither actually discusses preset
loss (#295 is a cloud-hosting question, #235 a closed Spotify
preset-save bug). That reference was wrong from the original commit
(7d46ae2); #614 is the first confirmed instance.

Refs #614
2026-08-15 14:39:49 +02:00
Tobias Gesellchen 4bbcd4d178 feat(health): add admin_area_auth_available check
Sixth piece of #419. Visibility-only nudge, same spirit as
mgmt_default_credentials: surfaces on the Health tab that the admin-area
gate exists and is unset, for operators who dismissed the announcement
banner or never saw it on an older release. Does not gate anything.

Refs #419
2026-08-08 23:49:57 +02:00
4a76805df4 feat(health): flag speakers whose runtime bmxRegistryUrl is still on the Bose cloud (#577)
## What

Adds a new `runtime_bmx_url_stale` health check to `soundtouch-service`.
For each reachable speaker it reads the **runtime** `bmxRegistryUrl`
(from the on-device `SoundTouchSdkPrivateCfg.xml` via SSH, or `getpdo
CurrentSystemConfiguration` over telnet) and warns when it still points
at the shut-down Bose cloud, offering a copy-paste re-migrate command.

## Why

Radio source types (TUNEIN / RADIO_BROWSER / LOCAL_INTERNET_RADIO) are
delivered to the speaker through the BMX registry. A speaker whose
runtime `bmxRegistryUrl` still names the Bose cloud can never mount
them, even though the service's own `/sources` listing is correct. The
existing `sources_xml_diff` check reports the *symptom* ("missing 3
source types"); this check reports the *per-device cause*, so an
operator sees exactly which speakers still need re-migrating.

This is the recurring "radio missing after migration" theme (relates to
#549, #547, #546, #493). In the #549 diagnostic, 6 of 9 speakers had
never actually been migrated (all four runtime URLs still on
`content.api.bose.io` / `streaming.bose.com`) while the service itself
looked healthy; this check would have surfaced that per device
immediately.

## False-positive guard

Under a DNS-based migration (AfterTouch acting as the speaker's DNS
server) a cloud URL is legitimate: the redirect happens at the DNS
layer, not by rewriting the on-device URL. So the check stays silent
while the service's own DNS interception is running (`GetDNSRunning`).
The router-DNS variant (the LAN's DNS points at AfterTouch without our
DNS server running) cannot be detected here, so it is called out as a
known exception in the finding text rather than suppressed.

## Changes

- `pkg/service/health/checks_runtime_bmx_url.go` (+ unit test): the
check, following the injected-closure pattern of `checks_marge_url.go`.
`assessRuntimeBmxURL` is the pure, testable core; `isBoseCloudHost` does
a domain-suffix match on the Bose cloud domains.
- `pkg/service/handlers/handlers_export.go`: a lightweight
`readSpeakerBmxRegistryURL(ip)` reader (SSH then telnet), reusing the
existing export imports. The diagnostic export path itself is unchanged.
- `pkg/service/handlers/server.go`: registers the check, wiring
`GetDNSRunning` as the guard.

The health package stays free of the SSH / `setup` imports (the reader
lives in the handlers layer), matching the existing dependency boundary.

## Testing

- `go test ./pkg/service/health/` green (new tests: cloud URL warns;
AfterTouch URL and empty URL do not; `isBoseCloudHost` matrix).
- `go vet` and `golangci-lint` clean on both packages; `go build
./cmd/soundtouch-service/` succeeds.

Not tied to a single issue to close; it complements the #549 / #547 /
#546 / #493 cluster as a diagnostic aid.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 21:11:10 +02:00
bb9bce440e fix(admin): surface silent Management API auth failures, add link-state health checks (#585)
## Summary
- #269's stuck Spotify presets traced to the account link never
completing, invisible because the admin UI silently swallowed 401s from
`/api/mgmt/*` instead of prompting for a retry. The browser's own Basic
Auth caching already works correctly here (verified live); the bug was
purely missing feedback.
- Adds two Health-tab checks: Spotify configured but no account linked,
and Management API credentials still at the published default.

Refs #269, #419.

## Test plan
- [x] `make check` (fmt, vet, unit tests) clean
- [x] `make lint` clean
- [x] Live-tested end to end with a headless Chrome (chromedp) against a
local build: confirmed the new failure-path messages render correctly
for `fetchSpotifyStatus`, `fetchAccountList`, and `linkSpotify`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 20:24:57 +02:00
Tobias GesellchenandClaude Opus 4.8 433a779998 fix(health): detect advertised-HTTPS-URL / listener port mismatch (#355)
Follow-up to the previous commit. The cert-chain check dialed the
advertised HTTPS URL, whose port defaults to 443 when the URL omits it
(splitHTTPSHostPort). The advertised URL comes from
--https-server-url / HTTPS_SERVER_URL / the settings file and is not
editable in the web UI, so when it lost its port it silently pointed the
check (and speakers) at 443 while the real listener was on 8443 — the
exact "port 443" complaint in issue #355.

Thread the actual HTTPS listener port into the check (new
Server.SetHTTPSListenAddr, wired from config.httpsAddr). When the dial
fails and the advertised port differs from the listener port, emit a
mismatch-specific warning that names both ports and offers the corrected
HTTPS_SERVER_URL, while still deferring to reverse-proxy setups. A
reachable endpoint never reaches this branch.

Reproduced locally on a clean data dir: seeding a port-less
https_server_url with the listener on 8443 previously errored on
:443; it now warns with both ports and the fix. Regression tests added.

refs #355

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 17:45:41 +02:00
Tobias GesellchenandClaude Opus 4.8 9d69b61779 fix(health): don't error when the advertised HTTPS URL is unreachable from the service
The `service_cert_chain` check ("HTTPS endpoint TLS configuration") dials
the service's own configured HTTPS URL. When that dial fails before any
certificate is presented (connection refused, timeout, handshake reset),
it reported a hard red error.

But from inside the service we can't distinguish "the endpoint is down"
from "the advertised HTTPS URL simply isn't reachable from here" — and
the latter is a normal, healthy deployment: TLS terminated by a reverse
proxy in front of AfterTouch, or a Docker-published port / LAN-only
hostname that the container itself can't dial. In those setups the red
error is a false alarm (issue #355: reporter runs HTTP 8080 / HTTPS 8443
and noted "in my configuration that is expected").

Downgrade that specific case (no cert presented) to a warning, reword it
to name the expected reverse-proxy / unreachable-advertised-URL case, and
add an `openssl s_client` command to verify the endpoint from a client
that actually reaches the advertised URL. Cert-classification outcomes
(own-CA info, foreign-chain warning) are unchanged.

Reproduced locally on a clean data dir before/after: custom ports and
localhost/127.0.0.1 already returned INFO; only the unreachable-URL case
produced the error, which now returns a warning.

refs #355

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 17:45:41 +02:00
Tobias GesellchenandClaude Opus 4.8 a033ec077f fix(health): reword sources_xml_diff title to an expectation (refs #493)
The check title asserted "Speaker /sources matches service Sources.xml",
but the row renders as a warning when they differ, so "matches" plus a
warning read as a contradiction (reported in #493). Reword to "should
match" so the title states the expectation; the per-finding messages and
severities already convey whether it holds and what the differences are.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:10:25 +02:00
Tobias GesellchenandClaude Opus 4.8 1fff2c07a9 feat(health): show device name and IP on per-device findings
Per-device health findings previously labelled the device by account
and device IDs only (e.g. "account 3230304 · device 08DF1F0BA325"),
which is hard to place at a glance. Add display-only Name and IP fields
to health.Target and fill them centrally via EnrichTargets after the
checks run, so individual checks don't each have to look up the device
record. Both the live health endpoint and the diagnostic export go
through the new Server.runHealthChecks helper, and the Health tab renders
the friendly name first, then account/device IDs, then IP.

Fixes match on Account+Device only, so the new fields don't affect
quick-fix dispatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:10:18 +02:00
Tobias GesellchenandClaude Opus 4.8 18f3eabd67 docs(api,health): note /speaker play_info needs DNS interception (refs #517)
play_info notifications make the speaker validate the app_key via
GET /v1/auth against a hardcoded Bose host; without DNS interception that
call can't resolve and /speaker times out with ALLEGROWEBSERVER_TIMEOUT
(1046). Document the requirement on POST /speaker (plus the no-DNS
LOCAL_INTERNET_RADIO alternative), and have the "Test DNS path" health
check mention that TTS/play_info depends on the same DNS path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:58:19 +02:00
Tobias GesellchenandClaude Opus 4.8 9d8e8f4858 docs(troubleshooting): radio sources not activating after in-place migration (refs #521)
After an in-place migration the firmware sometimes does not activate the
radio source types (LOCAL_INTERNET_RADIO, TUNEIN, RADIO_BROWSER) even
though the entries are present in the device's own Sources.xml; a reboot
and a sourcesUpdated notification do not help. The root cause is not yet
understood, so this documents the user-confirmed workaround (factory
reset + re-migrate) rather than changing migration behaviour:

- New troubleshooting section with a stable anchor, linked from the
  sources_xml_diff health check and the Radio Browser reference.
- Capture the speaker's on-device /mnt/nv/BoseApp-Persistence/1/Sources.xml
  in the diagnostic export (when SSH is available), so a future report
  taken before a factory reset carries the evidence to pin down the cause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:34:09 +02:00
Tobias GesellchenandClaude Opus 4.8 21742cfbf6 refactor(health): probe /api/setup/version in the server-URL reachability check (refs #451)
Move the internal self-reachability probe onto the new /api/setup/version path
(updating the doc comment and the unit test accordingly). No behavior change
(the legacy path still works); keeps our own code off the soon-to-be-legacy
/setup/* surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:57:54 +02:00
Tobias GesellchenandClaude Opus 4.8 3b3cec7e94 feat(health): add speaker_clock check with set_clock quick-fix
A speaker with a wrong clock fails TLS to any HTTPS host because the
certificate appears not-yet-valid or expired (the CURL ErrorCode 60 seen
in #345, where the failing speaker had a wrong clock, the only one of
several speakers that was off, with a failing NTP sync; these speakers
default to the year 2000 at boot until NTP succeeds). Nothing surfaced
this before.

The check reads each speaker's /clockTime and compares its UTC epoch to
the service's epoch. Using the epoch (ClockTime.GetUTC, not GetTime) keeps
the comparison timezone-independent. Tiers: under 60s no finding; 60s-5m
info; 5m-24h warning; 24h-or-more, or a time outside the year 2000..2100
plausibility window, error. Findings note a stale or missing NTP sync.

A set_clock quick-fix on the warning and error findings pushes the current
time to the speaker via POST /clockTime (client.SetClockTime). That call is
plain HTTP on :8090, so it works regardless of the speaker's wrong clock or
TLS state. It is a band-aid: if NTP is still failing the clock drifts again
and resets on reboot, so the confirm dialog and success message point at
restoring time sync as the durable fix. An SSH set-clock fallback is left
for later since the HTTP path is confirmed on firmware 27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:29:15 +02:00
Tobias GesellchenandClaude Opus 4.8 c466246dee feat(health): add on-demand DNS-path diagnostics for the #345 speaker-DNS escape
When a speaker resolves the firmware-hardcoded content.api.bose.io through
the operator's own DNS instead of AfterTouch, TuneIn/BMX content requests
escape AfterTouch and fail (CURL 60, or a dead-cloud 404), so the speaker
reports INVALID_SOURCE. The existing dns_sanity check only probes AfterTouch's
own answering side over loopback, so it passes even when no speaker uses
AfterTouch as its resolver. This adds a speaker-side, on-demand check.

dns_speaker_usage:
- pkg/discovery/dns.go tracks distinct non-loopback clients that query an
  intercepted Bose hostname (interceptClients set, populated in recordQuery,
  exposed via InterceptClientIPs()). Loopback is excluded so dns_sanity's own
  probes don't register.
- The check lists each unconfirmed speaker as an info finding with a "Test DNS
  path" quick-fix. It never emits a standing warning, so it does not
  false-positive after a restart (the querier set is in-memory and starts empty).

Active probe (the "Test DNS path" quick-fix; also POST /setup/health/dns-path-probe):
- Sends a /speaker notification carrying a per-probe nonce as the app_key. To
  accept it the speaker must resolve audionotification.api.bosecm.com
  (intercepted) and call back GET /v1/auth with that nonce; the callback
  arriving is direct proof the speaker resolves Bose hosts through AfterTouch.
- HandleSpeakerAuth returns 403 for a matching nonce so the speaker refuses the
  notification (silent, no audio, confirmed on hardware); any other key still
  gets 200 so real TTS is untouched. Reuses resolveTTSHost for SSRF-safe
  targeting; the nonce is never logged. Registered without refresh so the probe
  result stays visible in the Health tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:29:15 +02:00
Tobias GesellchenandClaude Sonnet 4.6 04f7388051 fix(health): skip fetchHealth re-render for non-resolving quick fixes
Add a refresh policy to the fix registry so the UI can avoid the
unnecessary "Loading…" flash when a quick fix does not change any
check state.

- Registry stores fixEntry{fn, refresh} instead of bare FixFunc.
- RegisterFix (existing callers) keeps refresh=true: resolved
  findings disappear from the list after the fix runs.
- New RegisterFixNoRefresh sets refresh=false: used for persistent
  operator affordances whose success leaves the finding unchanged.
- RunFix now returns (string, bool, error); the bool propagates to
  the healthFixResponse JSON as "refresh".
- play_ding registered via RegisterFixNoRefresh — pressing it never
  resolves the finding, so no re-fetch is needed.
- runQuickFix in script.js gates setTimeout(fetchHealth, 400) on
  data.refresh !== false; absent or true keeps the existing behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:20:58 +02:00
Tobias GesellchenandClaude Sonnet 4.6 118e3fc4a0 feat(health): add speaker_ca_bundle integrity check
Two per-device checks run against each speaker's CA bundle via a
single SSH probe round-trip:

  (1) Every PEM block from ca-bundle.crt.original (the factory backup
      written by TrustCACertFromBytes on first CA injection) must be
      present in the live ca-bundle.crt. A missing block means the
      original trust store was truncated, which would break external
      HTTPS (Spotify, Amazon, firmware updates).

  (2) The AfterTouch CA sentinel (# AfterTouch) must be present in
      the live bundle. Without it the speaker rejects AfterTouch's
      TLS cert and migration is effectively inactive.

Both findings carry a QuickFix:
  - FixIDRestoreAndInjectCA: cp .original → live bundle over SSH,
    then TrustCACert to re-inject the AfterTouch CA.
  - FixIDInjectCACert: TrustCACert only (original certs intact).

Graceful degradation:
  - SSH unavailable → SeverityInfo, no fix offered.
  - .original absent (device never had install-ca run) → SeverityWarning,
    suggest install-ca; check (2) still runs.

Infrastructure changes:
  - ssh_probe.go: add ca-bundle.crt.original to probeFilePaths (free
    in the existing single-round-trip batch).
  - setup.go: export ProbeCABundles and RestoreCABundleFromOriginal so
    the handlers package can use them without exposing speakerProbe.
  - Fix executors live in handlers (need setup.Manager) per the
    established boundary used by completeSpeakerPairingFix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 01:25:56 +02:00
Tobias GesellchenandClaude Sonnet 4.6 417c0223dd feat(health): add server_url self-reachability check + log actual listen port
The most common misconfiguration on install-on-speaker setups is an
HTTP server URL that omits the port (e.g. http://192.0.2.1 instead of
http://192.0.2.1:8000). Port 80 is occupied by the Bose firmware's
PtsServer, so AfterTouch binds its default port 8000 — but the
margeURL pushed to speakers still resolves to port 80 and hits
PtsServer instead of AfterTouch. Marge calls are silently dropped,
sources are never registered, and TuneIn playback fails with error
1005 (UNKNOWN_SOURCE_ERROR). See issue #319.

Changes:
- pkg/service/health/checks_server_url.go — new health check
  (server_url_reachable) that probes GET {serverURL}/setup/version from
  inside the service; emits SeverityWarning with remediation steps when
  the endpoint is not reachable or returns non-200.
- pkg/service/handlers/server.go — register the new check in NewServer.
- cmd/soundtouch-service/main.go — replace http.ListenAndServe with an
  explicit net.Listen so the true effective port is logged before TLS
  starts. Both HTTP and HTTPS log lines now show the listener's actual
  bound address alongside the configured server URL:
    Go service listening on 0.0.0.0:8000 (server URL: http://192.0.2.1)
  Previously only the server URL was logged, creating the false
  impression that AfterTouch had bound that URL's implicit port.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Real bugs fixed:

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

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

Noise removed:

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Opus 4.7 ce3b0e582a fix(health): drop InsecureSkipVerify from cert-chain probe
CodeQL alert 147 flagged the Phase-2 re-dial with
InsecureSkipVerify=true, used to read the served leaf after
Phase 1's strict verification failed.

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

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

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

Rework the severity matrix:

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

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

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

Three thresholds against the loaded CA's NotAfter:

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

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

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

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

resolveDNSQueryTarget now translates:

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 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