99 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Opus 4.8 788a6ced93 fix(setup): re-install the CA when it was regenerated, not just label-matched (refs #517)
checkCACertTrusted matched only the static "# AfterTouch" label in the
device's trust bundle. After the service CA was regenerated (e.g. a
recreated container with a fresh/empty data dir), the stale label was
still present, so the migration wrongly reported the speaker as already
trusting the new CA and skipped re-installing it, leaving the speaker
unable to validate TLS to the service.

When the service CA is available, compare the actual cert payload and
re-install on mismatch; fall back to the label only when the CA can't be
read (CLI callers without Crypto). Adds regression tests for the
stale-label and no-Crypto cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:58:19 +02:00
Tobias GesellchenandClaude Opus 4.8 9331e63b2d feat(setup): add enable-ssh --full-config for devices where sshd never starts (#515)
The default `setup enable-ssh` injects the remote_services/sshd payload
only via `envswitch boseurls set` and relies on the speaker re-reading its
boseurls (~60s) without a reboot. On the SoundTouch Portable (Series I,
FW 27.0.6.46330.5043500) and some CineMate 520 units the device accepts and
persists that injection (getpdo confirms) but sshd never comes up, so :22
stays "Connection refused".

@Henri-be got root on the ST Portable by typing a different sequence by hand
over telnet :17000: the injection rides `sys configuration margeServerUrl`
(the runtime layer) as well as `envswitch`, all four URL keys are written,
and the device is rebooted so it re-parses the config at boot.

Add an opt-in `--full-config` flag that replicates that exact sequence
(EnableSSHViaTelnetFullConfig + telnet reboot via the existing
RebootMethodTelnet). The default single-envswitch path is unchanged, so the
field-confirmed flow on the Wireless Link Adapter and CineMate 520 `lisa`
variant does not regress. Docs (TELNET-COMMAND-REFERENCE, DEVICE-LOGGING)
document both paths and which device models/firmware need `--full-config`.

The flag automation is candidate behaviour awaiting reporter confirmation:
the manual sequence is confirmed on the ST Portable, the flag is not yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:23:15 +02:00
Tobias GesellchenandClaude Opus 4.8 fa158bc498 feat(cli/setup): friendlier enable-ssh timeout + re-sync boseurls after XML migration (refs #471)
Two follow-ups from the #471 field reports on the BETA `setup enable-ssh`:

1. enable-ssh: when sshd (:22) does not come up within the wait window, this is
   no longer treated as a hard error. On some devices (e.g. the Wireless Link
   Adapter) the envswitch injection is accepted but sshd only starts after the
   speaker restarts. The command now prints a warning with power-cycle + retry
   guidance (and the exact ssh command), deliberately leaves the injected
   boseurls in place so a restart re-triggers the unlock, and exits cleanly
   instead of failing.

2. XML migration: re-apply the boseurls over telnet at the end of migrateViaXML
   so the runtime layer reported by `getpdo CurrentSystemConfiguration` matches
   the persisted SoundTouchSdkPrivateCfg.xml. After enable-ssh bootstraps SSH,
   that runtime layer still points at the placeholder (https://aftertouch.invalid),
   so the preflight cross-check keeps warning that margeServerUrl/swUpdateUrl
   differ between transports until a reboot. The re-apply reconciles it now.
   Best-effort: if telnet is unavailable (e.g. port 17000 was closed via
   --close-17000), a reboot still reconciles the layers, so it only logs a note
   and never fails the migration.

Tests cover the re-apply command and its best-effort (non-fatal) behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:36:21 +02:00
Tobias GesellchenandClaude Opus 4.8 6c8a50c049 feat(cli): opt-in hardening for setup enable-ssh (--close-17000, --authorized-key) (refs #471)
Adds the #471 "secure" steps as opt-in flags on `setup enable-ssh`, off by
default (per the decision that closing 17000 must be opt-in):

- --close-17000: blocks port 17000 from the LAN. Manager.Close17000 remounts /
  read-write, persists an idempotent iptables rule in
  /etc/init.d/Firewalls/update_iptables (keyed on a marker), and applies it
  immediately; loopback access is kept.
- --authorized-key <pubkey>: Manager.InstallAuthorizedKey writes the key to
  /home/root/.ssh/authorized_keys so root SSH no longer relies on the
  empty-password login.

Both run over the SSH the enable step just opened. Default output reminds the
user that 17000 is left open and how to close it. Unit tests cover the
firewall command sequence and the key upload path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:11:19 +02:00
Tobias GesellchenandClaude Opus 4.8 b7009a50eb feat(cli): setup enable-ssh — bootstrap SSH via the port-17000 envswitch trick (refs #471)
Adds `soundtouch-cli setup enable-ssh`, the first iteration of foob61451's #471:
turn on SSH on a speaker that has no prior SSH access and without a USB
recovery stick, then fall into the migration / CA-install flow we already have.

Mechanism (new Manager methods, reusing the existing telnet :17000 client):
- EnableSSHViaTelnet sends `envswitch boseurls set "<url>;touch
  /tmp/remote_services;/etc/init.d/sshd start" "<url>/update"`. The injected
  shell commands run when the speaker next parses its boseurls (~60s), starting
  sshd. The URL is only the vehicle for the injection — it does NOT need a live
  server, so this works before any AfterTouch service exists.
- WaitForSSHPort polls :22 until sshd is up.
- ResetBoseURLs restores a clean marge URL afterwards.
- Persistence reuses the existing EnsureRemoteServices (writes the marker over
  the now-open SSH so it survives reboot).

CLI flow: inject → wait for :22 → reset clean URLs → persist. `--service-url`
is optional (placeholder used otherwise; set real URLs later via migration).
Securing/closing port 17000 is deliberately OPT-IN and not done here. Unit
tests pin the exact injected/reset command strings and the double-quote guard.

This lands in -cli first (cheapest to iterate); the future soundtouch-app can
reuse the same Manager methods.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:11:19 +02:00
Tobias GesellchenandClaude Opus 4.8 d73ce7b559 fix(setup): bound speaker HTTP GETs so offline devices fail fast (refs #451)
Manager.HTTPGet defaulted to http.Get, which uses http.DefaultClient with
no timeout. An offline speaker therefore hung the caller for the OS-level
TCP timeout (~30 s). The admin device list refreshes every device's live
/info on each load (updateDeviceInfo per row), so a handful of offline
speakers each held a request for 30 s. Server-side those run concurrently
and never blocked other routes, but the browser's ~6-connections-per-origin
limit got saturated by the long-held /info requests, which made the whole
admin page (and navigating away from it) feel stuck.

Give HTTPGet a 5 s timeout (liveDeviceHTTPTimeout): ample for a healthy
speaker on the LAN, quick to fail a dead one. Applies to the /info,
/presets, /recents, /sources, inspect, and peer-probe GETs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:08:26 +02:00
Tobias GesellchenandClaude Opus 4.8 7e0573032c chore(sanitize): remove real device ID and personal LAN IPs from tracked files
Per the repo's no-real-data rule (CLAUDE.md), scrub committed files only (the
gitignored _/ local captures are left as-is):

- Real Bose-OUI device ID 08DF1F0BA325 -> placeholder AABBCCDDEE0A across 4 docs
  and 8 Go test files (consistent 1:1 rename; affected packages tested green).
- Personal/topology LAN IPs -> RFC-5737: the lab runbook's AP subnet
  192.168.10.x -> 198.51.100.x (192.0.2.x is already used contrastively there)
  and 192.168.100.1 -> 203.0.113.1; illustrative example IPs in
  ANONYMIZATION-SUMMARY / spotify-overview / TROUBLESHOOTING -> 192.0.2.x.
- Kept factual RFC-1918 range citations (10.0.0.0/8 trusted-proxy example,
  192.168.0.0/16 "all private subnets") since they name the ranges themselves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:24 +02:00
Tobias GesellchenandClaude Opus 4.8 382c68d2b6 fix(setup): seed audionotification host(s) into /etc/hosts for /speaker TTS
soundcork#104 confirms speakers validate the /speaker audio-notification
app_key against audionotification.api.bosecm.com (100 calls/day on real
Bose). Our /v1/auth shim accepts it, but a host-seeded migration only
worked if the speaker resolved that host to us. DNS interception already
covers it (bosecm.com substring), but the /etc/hosts migration domain
list did not — so the speaker method would fail on hosts-based setups.

Seed both audionotification.api.bosecm.com and the dev variant
(audionotificationdev.api.bosecm.com; firmware may use either) into the
migration /etc/hosts lists, and update the mock fixtures/docs accordingly.
/v1/auth is path-based, so it already answers regardless of which host the
speaker thinks it is calling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias GesellchenandClaude Opus 4.8 83c0e999bc feat(service): capture speaker redirect config in diagnostic export
The diagnostic export captured the symptom of #345 (a TuneIn select
escaping to the dead Bose Apigee gateway → BMX_HTTP_ERROR 4501 →
INVALID_SOURCE) but none of the data that decides where a speaker sends
its marge/BMX/streaming traffic, so we couldn't tell whether the request
was ever redirected to AfterTouch.

Collect that per speaker:
- New collectSpeakerRedirectConfig prefers the on-device
  SoundTouchSdkPrivateCfg.xml over SSH (archives raw + parses
  marge/stats/swUpdate/bmxRegistry URLs), and falls back to
  `getpdo CurrentSystemConfiguration` over telnet when SSH is
  unavailable — the same channel the telnet migration uses. Parsed URLs
  and provenance land in diagnostic.json as redirect_config: source
  (ssh|telnet|none), ssh_reachable, and inferred_migration_method
  (telnet when only telnet answered, since xml/hosts/resolv all need SSH).
- Pull redirection-relevant files over SSH: /etc/hosts(.original),
  /etc/resolv.conf, the resolv-method hook, /mnt/nv/remote_services, and
  the pre-migration .original backups (CA bundle and the URL config).
- Dump the speaker firewall (iptables-save; ip6tables-save is empty on
  FW 27.0.6 but harmless) to catch self-inflicted DROP rules (cf. #354).

Export ParseGetpdoConfig from pkg/service/setup and add a test pinning
the field-name contract the export depends on.

Diagnostic-collection only; does not change migration or playback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 12:56:45 +02:00
Tobias GesellchenandClaude Opus 4.8 f26176fad4 fix(marge): never persist or serve sources without a resolvable provider id
Root cause of #334's INVALID_SOURCE: a speaker reports device-local slots
(STORED_MUSIC_MEDIA_RENDERER, UPNP) in /sources; AfterTouch imports them
verbatim and re-serves them in /full. PrepareConfiguredSource fills
sourceproviderid only for types in constants.StaticProviders, so these go
out with an empty <sourceproviderid> — a required protobuf field — and the
speaker rejects them as INVALID_SOURCE, which then re-syncs back into the
datastore.

Fix, keyed on the principle (no hardcoded denylist in production):
- HasResolvableProviderID(s): true if the source already carries a provider
  id, or its source-key type resolves via StaticProviders.
- Serve-side guard in getAccountSources: drop any source whose resolved
  sourceproviderid is still empty (generalises the existing AUX/#195 skip).
  Heals already-polluted datastores on the next /full, no resync needed.
- Import-side filter in syncConfiguredSources (marge) and both branches of
  syncSources (setup): drop unresolvable sources before persisting, stopping
  future pollution and the re-import loop.

Tests: reproduction converted to regression test
(TestI334FullOmitsSourcesWithoutProviderID) seeded from a sanitised real
#334 /sources capture; explicit servable/non-servable tables in
TestHasResolvableProviderID. Two pre-existing fixtures that relied on
sources with no provider id were given valid ones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 23:33:19 +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 2722e2383c fix(lint): sec6/sec7 post-pass — static.go Close + remove unused sanitizeErr
- stockholm/static.go: wrap deferred root.Close() in func(){}() to
  silence errcheck; change 'rel = rel + ...' to 'rel += ...' (gocritic).

- Remove sanitizeErr from four logutil files where no call site exists
  (cmd/soundtouch-cli, cmd/websocket-demo, pkg/discovery, pkg/service/setup).
  The log-injection fixes in those packages used sanitizeLog on string
  arguments rather than sanitizeErr on error values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 11:28:26 +02:00
Tobias GesellchenandClaude Sonnet 4.6 0e9445af47 fix(security): sec7 — log-injection sweep, sanitizeErr helper
~30 remaining go/log-injection alerts share a common pattern: other
positional args in a log call are wrapped in sanitizeLog() but the
trailing 'err' value (via "%v") is not. CodeQL traces taint through
error chains back to the log.Printf call site itself.

Add sanitizeErr(err error) string to every affected package's
logutil.go (strips newlines from err.Error(), returns "<nil>" when
nil). Three packages had no logutil.go yet; new files added for
cmd/soundtouch-cli, cmd/websocket-demo, and examples.

Call-site changes (replace "%v, err" with "%s, sanitizeErr(err)" and
wrap any other unsanitised args in sanitizeLog):

pkg/client:
  - websocket.go:42   DefaultLogger.Printf now pre-formats and sanitises
                       the entire message (all variadic args sanitised)
  - websocket.go:445  err → sanitizeErr(err)

pkg/service/handlers:
  - handlers_account_mgmt.go:44   err
  - handlers_bmx_tunein.go:324,336 err (stationID already sanitised)
  - handlers_marge.go:288,510      err (deviceID/account already done)
  - handlers_mgmt.go:409,436,720  err
  - handlers_setup.go:1345        session + err
  - server.go:500                  bind
  - server.go:504,863,944,1029,   err (deviceIP/accountID already done)
    1164,1174

pkg/service/marge:
  - marge.go:1469,1923  saveErr / err

pkg/service/setup:
  - setup.go:1417,2316,2462  fmt.Printf — deviceIP / hostsContent / ip

pkg/service/stockholm:
  - proxy.go:117  effectiveTarget.String() + err

pkg/service/zeroconf:
  - zeroconf.go:312  err

pkg/service/proxy:
  - recorder.go:403  err (task.path already sanitised)

pkg/service/datastore:
  - datastore.go:940  werr (device already sanitised)

pkg/discovery:
  - dns.go:72   strings.Join(derived)
  - dns.go:503  d.upstreamDNS (fmt.Sprint of []string)

cmd/soundtouch-cli:
  - cmd_events.go:571  VerboseLogger.Printf — pre-format + sanitise
  - common.go:335      PrintError message

cmd/websocket-demo:
  - main.go:576   VerboseLogger.Printf — pre-format + sanitise

examples:
  - recording-filename-demo.go:79  err

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 11:28:26 +02:00
Tobias GesellchenandClaude Sonnet 4.6 bc52dd3067 sec5c: sanitize log-injection in pkg/service/proxy and pkg/service/setup
Fixes CodeQL go/log-injection alerts in the proxy and setup packages.

Adds logutil.go with a package-private sanitizeLog helper to each package.

pkg/service/proxy/proxy.go (2 call sites):
- LogRequest: r.URL.String(), bodyStr
- LogResponse: r.Request.URL.String(), bodyStr

pkg/service/proxy/recorder.go (1 call site):
- save: task.path (derived from external URL path segments)

pkg/service/setup/setup.go (7 call sites):
- SyncDeviceData: deviceIP, info.Name, info.DeviceID, info.SerialNumber
- syncPresets: deviceIP
- notifySpeakerSourcesUpdated: deviceIP

No behaviour change. golangci-lint and make check pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:43:22 +02:00
Tobias Gesellchen ff61f0ca65 lint 2026-05-22 18:58:57 +02:00
Tobias GesellchenandClaude Sonnet 4.6 b0df8ba963 fix(setup): correct false-positive migration detection and plan command errors
- isXMLMigrated and isResolvConfMigrated now guard against empty hostname
  (Go's strings.Contains(s, "") is always true, causing any speaker to
  appear migrated when --service-url has a malformed single-slash scheme)
- renderPlanSteps message no longer claims "and paired" when --include-pair=false
- validateServiceURL rejects malformed service URLs early with a hint
  (e.g. "did you mean https://soundtouch.fritz.box?")
- Generated plan-step commands move --host before the subcommand name
  (urfave/cli/v2 requires global flags before the first subcommand token)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:58:57 +02:00
Tobias GesellchenandClaude Opus 4.7 feadc478d5 test: sweep example data in test files to RFC-5737 + placeholders
Mirrors the .md/.txt sweep across all tracked _test.go, testdata XML,
and .http integration files. Test files are self-contained (producer
+ assertion in the same file), so the matched-pair swap stays green
under `go test ./...`.

Mapping applied:
  192.168.178.[0-9]+   → 192.0.2.[same]
  192.168.1.[0-9]+     → 192.0.2.[same]
  Sound Machinechen    → Living Room SoundTouch
  A Sound Machine      → Kitchen SoundTouch
  A81B6A536A98 + case/separator variants → AABBCCDDEEFF (etc.)
  A81B6A849D99         → AABBCCDDEE01
  A81B6A849D88         → AABBCCDDEE03
  A81B6A536A09         → AABBCCDDEE04
  884AEAEEBD27         → AABBCCDDEE02
  3230304              → 1000001
  9569497              → 1000002

Two semantic fixes alongside the bulk swap:

- pkg/service/zeroconf/zeroconf_test.go: the "private 192" and
  "strips query" cases pin acceptance of RFC-1918 192.168/16. They
  must use a real 192.168 value; doc-range IPs would (correctly) be
  rejected by validateZcBaseURL. Switched to 192.168.10.10 — generic
  enough not to match any home LAN default, real enough for the
  validator. Added a comment explaining why this single test still
  carries a 192.168 literal.

- pkg/service/setup/setup_test.go: TestTestDNSRedirection mocks the
  device's `od -An -tu1` byte output, which is space-separated
  octets ("192 168 1 100"). My sed only matched the dot-separated
  form, so the mock was returning the old IP while the test
  assertions had moved to the doc range. Updated to " 192 0 2 100".

go build ./... clean. go test ./... clean (only TestDocsConsistency
remains failing, which is a pre-existing/untracked-file issue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:05:13 +02:00
Marcin MennemannandTobias Gesellchen 0f0a96c0ce remove: mirror middleware and parity comparison with Bose cloud 2026-05-17 21:53:33 +02:00
Tobias GesellchenandClaude Opus 4.7 c668c732df fix(#308): handle placeholder presets without panicking
The ST10's /presets response after a factory reset emits self-closing
<preset/> entries with no ContentItem child. cmd/soundtouch-cli's
getPresets() handled the missing ContentItem in GetDisplayName() but
then dereferenced preset.ContentItem.Source on the next line, panicking
with "invalid memory address or nil pointer dereference" the moment the
loop reached the first empty entry.

A second placeholder shape was observed on healthy devices that were
never reset: <preset id="0"><ContentItem source="INVALID_SOURCE"
isPresetable="true"/></preset>. ContentItem is non-nil here, so the
previous "ContentItem != nil" guard at other call sites still let
these placeholders through into listings and into the AfterTouch
datastore.

Fix shape:

  pkg/models/presets.go - extend Preset.IsEmpty() to recognise both
  shapes (ContentItem == nil, OR Source == "" / "INVALID_SOURCE").
  HasPresets, GetEmptyPresetSlots and GetUsedPresetSlots become honest
  about which slots actually carry playable content.

  cmd/soundtouch-cli/cmd_info.go (the crash site) - filter the slice
  via IsEmpty before the print loop, and switch the still-printed
  fields to the existing nil-safe Get* helpers.

  pkg/service/setup/setup.go - upgrade syncPresets's "ContentItem ==
  nil" continue-guard to IsEmpty so Shape B placeholders don't get
  persisted in the AfterTouch datastore and then surface as junk
  rows in the admin web UI.

  cmd/soundtouch-cli/cmd_events.go, cmd/websocket-demo/main.go - same
  nil-guard upgrade. These already nil-checked so were crash-safe;
  the change is for consistency and to stop printing
  "Preset 0:  (INVALID_SOURCE)" demo lines.

  examples/preset-management/main.go - had the same latent crash as
  cmd_info.go; same fix shape.

Regression tests in pkg/models/presets_test.go cover both shapes using
the exact XML observed in the wild: the reporter's three <preset/>
placeholders plus the three INVALID_SOURCE entries from a live device.
The reporter XML test walks every preset through the same accessor
path the CLI used and asserts no panic.

The soundtouch-web Go code does not deref preset.ContentItem.X
anywhere - presets flow through as JSON - so no separate crash trap
exists there. The web frontend will pick up the cleaner data once
syncPresets stops persisting placeholders.

Closes #308

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:36:55 +02:00
Tobias GesellchenandClaude Opus 4.7 e64481f008 docs(setup): record ST10 ≡ ST20 bundle equivalence + curl reproducer
Two doc-only additions to TestValidateRealSpeakerBundle's header
comment:

  - Cross-model note: ST10 and ST20 ship the byte-identical CA
    bundle on firmware 27.0.6.46330.5043500 (md5
    2d150987b312e4280fc576b508e62b43, 165 certs, ~251 KB).
    Verified against firmware/_backup_ST10/_/etc/pki/tls/certs/
    ca-bundle.crt 2026-05-16. The existing
    testdata/ca_bundle_st20_pristine.crt fixture therefore stands
    in for both models on that firmware build, so any expired-root
    hypothesis evaluated against it covers both.
  - Curl reproducer: three one-liners that point curl at the fixture
    and probe the actual TuneIn stream chain a SoundTouch speaker
    would walk (using K-LOVE / s33828 as the canonical example —
    matches the case from #292). Control with the system trust
    store shown alongside. Both bundles handle the chain (Amazon
    Root CA 1 + DigiCert Global Root, valid through 2026+) so the
    expired-root hypothesis is ruled out for firmware 27 — recorded
    in the comment so future-me / reviewers can replay the same
    probe without re-deriving it from chat context.

No code change; test still passes.

Related to https://github.com/gesellix/Bose-SoundTouch/issues/292.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:23:23 +02:00
Tobias GesellchenandClaude Opus 4.7 06916226df feat(setup): tag service-side IP resolve with a sentinel + observe SSH cost
The migration-summary preflight always emitted a "resolved from service,
not from device"  row whenever the target was a hostname — even when
SSH was available and could have answered authoritatively. Two
problems compounded: the summary builder passed `nil` for the SSH
client (skipping the device-side ping), and resolveIP's service-side
fallback returned a bare fmt.Errorf the caller couldn't distinguish
from a real failure.

Changes:

  - ErrResolvedFromServiceOnly sentinel; service-side fallback wraps
    it with fmt.Errorf("%w: ...") so callers can errors.Is()-check.
    Apply-path callers that pass a real SSH client keep getting the
    same error shape they always did.
  - populatePlannedNetworkConfig now takes an SSHClient. GetMigrationSummary
    opens one when probe.SSHOK is true and passes it through, so the
    summary's resolve call uses the same device-side authority the
    apply paths use. Skipping the dial when SSH is known dead keeps
    a stale handshake-timeout from burning the preflight budget.
  - MigrationSummary gains ResolveIPSource ("device" / "service") and
    ResolveIPDurationMS so we can observe the SSH-ping cost in the
    wild. The historical comment claimed 2-5 s on firmware-27 devices —
    we now have data instead of a guess.
  - CLI renderer prints the new source + timing line, and only renders
    the  ResolveIPError row for hard failures (both SSH ping AND
    service DNS failed).
  - Two regression tests cover the sentinel-tagging contract and the
    device-success-returns-nil-error path.

Related to https://github.com/gesellix/Bose-SoundTouch/issues/282.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:16:53 +02:00
Tobias GesellchenandClaude Opus 4.7 695dd954e7 test(setup): regression for telnet-only migration detection
Pins the ordering invariant fixed in the preceding commit. Builds a
fake-speaker scenario where:

  - SSH is unavailable (every SSH-driven axis stays false)
  - telnet getpdo reports the AfterTouch hostname

Pre-fix, checkIsMigratedFromProbe ran before the telnet channel was
drained, so summary.TelnetVerifiedConfig was empty when
isTelnetMigrated read it — the telnet axis came back false and
summary.IsMigrated followed. The CLI's `setup verify` exited
non-zero, the web UI rendered "Not Migrated". Reproduced by
foob61451 on #293.

The test asserts:

  - summary.TelnetVerifiedConfig is populated (sanity guard — the
    downstream assertions are meaningless if the probe didn't run)
  - summary.TelnetMigrated == true
  - summary.IsMigrated == true

Verified locally: the test PASSES with the ordering fix applied and
FAILS without it. Failure messages name PR #294 by number so a
future regression points at the same code path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:46:04 +02:00
Marcin MennemannandTobias Gesellchen 3bd82f3bf9 adj: comment numbers 2026-05-16 14:46:04 +02:00
Marcin MennemannandTobias Gesellchen 5d2f5d12ec fix: detect telnet-only migrations in summary by waiting for probe result 2026-05-16 14:46:04 +02:00
Tobias GesellchenandClaude Opus 4.7 355328da57 fix(cli): retry wifi-push once when the speaker's first ACK times out
The previous 10s→30s timeout bump didn't help — the first POST to
/addWirelessProfile on the speaker's AP-mode endpoint frequently
hangs until the deadline elapses, then a second POST a few seconds
later succeeds immediately. Empirically the workaround was "just
run wifi-push twice"; this commit folds that into the function.

PushWiFiCredentials now:
  - caps each attempt at 12 s (well above the sub-second healthy
    response time) so a stuck first attempt doesn't burn the whole
    budget
  - waits 2 s between attempts so the speaker's setup endpoint can
    finish whatever the first POST kicked off
  - falls through cleanly if the first attempt succeeds (the second
    never fires)
  - returns the second attempt's error if both fail, with context
    cancellation surfaced explicitly

Total budget is well under the CLI's 30 s --request-timeout, so
the flag still acts as a hard ceiling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:14:18 +02:00
Tobias GesellchenandClaude Opus 4.7 74007c7cb2 feat(setup): align <PairDeviceWithAccount> with the official Bose app shape
The Stockholm app (stockholm/setup/js/workflow_add_devices.js:23,77)
and Zimbo88's OpenCloudTouch USB-less script
(https://github.com/scheilch/opencloudtouch/discussions/201) both send
<boseServer>, <updateServer>, and <accountEmail> alongside the
<accountId>/<userAuthToken> pair. AfterTouch's setMargeAccount
historically sent only the latter two.

Adds:

  - MargePairingExtras struct on SessionConfig, opt-in via
    BoseServer (UpdateServer + AccountEmail default-derived when
    empty).
  - DefaultMargeAuthToken constant ("Bearer AfterTouch") and
    DefaultMargePairingEmail constant ("local@aftertouch.invalid",
    RFC 2606 reserved .invalid TLD).
  - buildPairDeviceWithAccountXML helper extracted so tests can
    pin both the minimal-payload and extended-payload shapes
    without driving a full WebSocket session.
  - --token flag on `soundtouch-cli setup pair` so we can override
    the placeholder for token-shape experiments.
  - runPairBare threads --service-url through to PairingExtras so
    `--mode=bare --service-url=...` ships the extended payload too;
    runPairFull already used it via applyInitPlanDefaults.

The speaker accepts any non-empty Bearer string (verified during
#195 investigation: "Bearer AfterTouch" passes and the speaker
re-derives its post-pair state from the marge endpoints regardless
of token content). The Stockholm-app payload shape is purely
documentation alignment; it did NOT fix the post-pair AUX/preset
breakage that turned out to be the cloud /full source list (see the
preceding marge commit). Keeping the wiring so the switches are
ready when we want to experiment further.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:14:18 +02:00
Tobias GesellchenandClaude Opus 4.7 824ed920ff fix(cli): give wifi-push the time the speaker needs to ACK
The speaker confirms AddWirelessProfile then tears down its AP within
~30 s. The default 10 s --request-timeout races that ACK whenever the
speaker is busy reconciling state — and a hard-coded 10 s on the
internal http.Client capped the user-passed timeout silently, so a
longer --request-timeout had no effect.

The CLI default is now 30 s and the inner http.Client lets the
context govern alone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:14:18 +02:00
Tobias GesellchenandClaude Opus 4.7 6e6e4838e6 fix(setup): fire <sourcesUpdated/> after data sync to recover post-factory-reset sources
Closes the AfterTouch-side half of issue #234. After a factory reset
the speaker's /sources only lists the always-on local entries (AUX,
BLUETOOTH, AIRPLAY, NOTIFICATION, QPLAY, plus a SpotifyConnectUserName
placeholder); TUNEIN, LOCAL_INTERNET_RADIO, DEEZER, and linked
Spotify accounts are absent until the device receives the
<sourcesUpdated/> notification the reporter ran by hand. SyncDeviceData
now POSTs that notification as the final step, so users get the
visible-source-list recovery for free when they click Data Sync.

The other half — re-creating Marge.xml so playback resumes — is
already handled by the wizard's pair-account flow: it detects an
empty <margeAccountUUID/> in /info and prompts the user to pick a
known account or generate a new one. The wizard's pairing UI is
deliberately user-driven (the user picks the ID); the notification
nudge is purely automatic because there's no choice to make.

Implementation routes through the existing client surface rather
than reinventing it. setup.notifySpeakerSourcesUpdated delegates to
pkg/client.Client.NotifySourcesUpdated — the same path
handlers_mgmt.go already uses after music-service account changes
(handlers_mgmt.go:304, :637). The wire shape lives in one place
(pkg/models.NewSourcesUpdatedNotification). Fire-and-forget: a
notification failure logs but doesn't fail the sync.

Adjacent UX changes:

  - docs/guides/TROUBLESHOOTING.md: new section "Presets flash then
    revert to 'Select a preset' after a factory reset". Names the
    symptom, the Marge.xml + reduced-/sources cause, and walks the
    user through re-opening the Migration tab + Data Sync.

  - pkg/service/handlers/web/js/script.js: devices list now renders
    a "⚠ Not paired — re-pair" badge in the account-ID column for
    speakers whose live /info reports an empty margeAccountUUID.
    Clicking it opens the Migration tab pre-filled with that device,
    surfacing the wizard's existing "Not paired (factory-reset or
    never paired)" flow without making users discover it cold.

  - pkg/service/testing/fakespeaker/testdata/info.xml: demo speaker
    now reports margeAccountUUID=1234567 instead of the misleading
    0000000 (which AfterTouch happens to accept as syntactically
    valid but is not a documented sentinel anywhere — the convention
    is empty for factory-reset, a real 7-digit number otherwise,
    matching pkg/client/testdata/info_response_st{10,20}.xml).
    Screenshots regenerated accordingly.

Test scaffolding:

  - fakespeaker grows a POST /notification recorder that captures
    body + Content-Type; tests assert on s.Notifications().
  - TestIssue234_FactoryResetSpeakerSyncsReducedSources now drives
    SyncDeviceData end-to-end (exercises the wiring) and asserts
    the notification fires with the right deviceID and shape.
  - TestFakeSpeakerNotificationRecorder pins the recorder contract
    and the POST-only method gate.

Refs #234.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:25:07 +02:00
Tobias GesellchenandClaude Opus 4.7 61c33d527c fix(setup): atomic CA-bundle install with PEM-frame verification
Hardens TrustCACertFromBytes against the failure mode behind issue
#262 (corrupted /etc/pki/tls/certs/ca-bundle.crt on a SoundTouch 20)
and against silent transport-time corruption of our own writes.
Three-part change.

1. Atomic write path. The previous flow piped bytes straight into the
   live bundle via `cat > <path>`; a dropped SSH session or partial
   write left the device with a half-written trust store and no way
   to roll back. The new path:

     - uploads to <bundlePath>.aftertouch.tmp (sibling on the same
       filesystem, same rw remount),
     - reads the tmp back over SSH,
     - validates the readback at the PEM-frame layer + the AfterTouch
       sentinel bracketing,
     - atomically `mv`s the tmp into place,
     - on any verification failure: `rm -f` the tmp; the live bundle
       is never touched, so there is no rollback semantics to reason
       about.

   The .original backup written on first install stays as
   defense-in-depth (manual recovery for corruption from outside this
   code path), but it is no longer the primary safety net.

2. New validators in pkg/service/setup/ca_validation.go.

     - validateCABundleBytes: BEGIN/END marker counts match, every
       decoded block is a CERTIFICATE with a non-empty body, decoded
       block count equals BEGIN-marker count (catches a block with
       unparseable base64 body), trailing non-PEM/non-comment content
       rejected.
     - validateAfterTouchLabelBracketing: CALabel appears exactly
       twice and brackets exactly one CERTIFICATE block.
     - stripAfterTouchEntries: collapses any number of stale
       AfterTouch entries from the existing bundle. Older releases
       reported to have appended without stripping, so long-lived
       devices can carry several copies; we strip them all and log
       the cleanup count rather than failing validation. Unpaired
       sentinels (truncated prior install) surface as a structured
       anomaly the caller logs and warns about.

   The validators stay at the PEM-frame layer on purpose — an
   earlier iteration called x509.ParseCertificate per block and
   rejected the real ST20 bundle on block 29 (Go 1.23+ disallows
   negative serial numbers, but Mozilla CCADB still ships ancient
   CA roots that have them). Shipping that version would have made
   every legitimate speaker install fail. The corruption mode #262
   surfaces at the PEM-framing layer; x509-level checks aren't what
   we needed.

3. testdata/ca_bundle_st20_pristine.crt is the pristine
   /etc/pki/tls/certs/ca-bundle.crt captured off a real SoundTouch 20
   (firmware 27.0.6.46330.5043500, snapshot 2022-08-04). Mozilla
   CCADB public dataset, 165 certs, ~251 KB. TestValidateRealSpeakerBundle
   locks in the cert count and asserts the strip pass is a no-op
   against a bundle that has never been touched by AfterTouch.

Test infrastructure. mockSSH (both the setup-package and the
handlers-package copies) now mirrors UploadContent into a private
map so a subsequent `cat <path>` on the same path returns what was
written there. Lets the tmp-readback step in TrustCACertFromBytes
work against tests that only scripted the live-bundle path, without
per-test wiring. Two new behavioural tests in setup_test.go:
TestTrustCACert_StripsMultipleStaleEntriesSilently (pins the
multi-entry cleanup contract) and
TestTrustCACert_PostUploadVerificationFailureCleansUpTmp (pins the
rollback-free recovery: live bundle untouched, tmp removed).

Refs #262.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:05:41 +02:00
Tobias GesellchenandClaude Opus 4.7 0e10bfcb14 test(setup): wire issue #235 — Spotify Connect /now_playing reports IsPresetable=false
Two-part iteration. First, the fakespeaker grows a `/now_playing`
route with a default STANDBY fixture — issue #235 is the first one in
this series that needs to override /now_playing, and adding the route
on its own would be infrastructure noise; bundled here it has an
immediate consumer.

The regression test then locks in the device-side signal at the heart
of #235: when a SoundTouch is targeted by Spotify Connect (Spotify
app sends audio to the speaker), the speaker's /now_playing reports

  - source = SPOTIFY
  - sourceAccount = SpotifyConnectUserName (the marker)
  - ContentItem.location = /playback/container/<base64 spotify:...>
    — a perfectly resolvable URI
  - **ContentItem.isPresetable = false**

The contradiction (resolvable location + isPresetable=false) is the
reason the CLI's storeCurrentPreset at
cmd/soundtouch-cli/cmd_preset.go:41 refuses to act and emits "current
content cannot be preset" — exactly the reporter's symptom.

The test base64-decodes the location to surface the contradiction
explicitly: it should yield a `spotify:` URI. When AfterTouch grows a
fallback path (CLI --force, or service-side resolution to the
device's own Spotify integration via the SoundTouch Spotify source
provider), the assertion here stays sound — it tests what the device
emits, not what the CLI decides — but a sibling test should assert
the new fallback path produces a successful preset.

Fixture pattern matches the rest of the issue series:
testdata/issue235/ next to the test, fakespeaker driven via
FixtureOverrides, doc-comment naming what would have to change for
the assertion to flip.

Refs #235.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:13:24 +02:00
Tobias GesellchenandClaude Opus 4.7 dd535cdb52 test(setup): pin factory-reset behaviour from issue #234
Wires the device-side state the reporter described in
https://github.com/gesellix/Bose-SoundTouch/issues/234 into the
fakespeaker via FixtureOverrides, and exercises GetLiveDeviceInfo +
syncSources against it.

The factory-reset state has two observable signals:

  - `/info` returns an empty `<margeAccountUUID/>` because Marge.xml
    is missing from the persistence partition. AfterTouch's
    "is the device paired?" check at setup.go:632 keys on AccountID,
    so this is the canonical "needs re-pairing" signal.
  - `/sources` lists only AUX, BLUETOOTH, AIRPLAY, the
    SpotifyConnectUserName placeholder, NOTIFICATION, and QPLAY —
    TUNEIN, LOCAL_INTERNET_RADIO, and any post-pairing Spotify
    accounts are gone until the speaker is nudged with a
    `<sourcesUpdated/>` notification or re-pairs.

Today AfterTouch has no auto-recovery for either signal — it just
passes the state through. The test locks in that contract by
asserting:

  - GetLiveDeviceInfo reports an empty MargeAccountUUID,
  - persisted Sources.xml contains AUX/BLUETOOTH/AIRPLAY sourceKeys,
  - persisted Sources.xml does NOT contain TUNEIN/LOCAL_INTERNET_RADIO.

When auto-recovery lands (e.g. an automatic POST of the
sourcesUpdated notification during sync, or marge-side source
replenishment), the absence assertions will flip — at which point
update them to assert the survivors are *present*, and adjust the
doc-comment so the contract stays in sync with the code.

Pattern mirrors pkg/service/setup/issue218_regression_test.go: a
testdata fixture next to the test, fakespeaker driven via
Config.FixtureOverrides, doc-comment naming what would have to
change for the assertion to flip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:13:24 +02:00
Tobias GesellchenandClaude Opus 4.7 2fabdece64 test(fakespeaker): wire issue-specific payloads via Config.FixtureOverrides
Introduces a per-route fixture-override hook on fakespeaker.Config so
open issues with concrete device-side payloads can become repeatable
regression tests, then demonstrates the pattern by wiring issue #218.

Foundation. Config grows a single optional field:

  FixtureOverrides map[string][]byte

Routes named in the map (e.g. "/presets", "/sources", "/info") return
the supplied bytes; routes not in the map fall through to the embedded
testdata defaults the screenshot pipeline relies on. Stateful handlers
(/getGroup, /addGroup, /updateGroup, /removeGroup) are unaffected
because they're code-driven, not fixture-driven. The override slice is
snapshotted at construction so later mutations of the caller's slice
don't change the served body. Zero-value Config keeps the existing
behaviour, so cmd/dummy-speaker + scripts/screenshots are untouched.

Iteration zero — issue #218.
pkg/service/setup/issue218_regression_test.go starts a fakespeaker
serving the reporter's LOCAL_INTERNET_RADIO preset XML verbatim (URL:
content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/station?…),
runs Manager.syncPresets against it, then asserts the persisted
Presets.xml retains the Bose cloud URL prefix. This locks in the
"location preserved through sync" contract; when AfterTouch starts
rewriting the URL to its own base (the eventual fix for #218), the
assertion flips and the fixture stays unchanged — the test is the
carrier for the decision.

Pattern reference for future issue regression tests: this exemplar
mirrors pkg/service/marge/recents_sourceproviderid_regression_test.go's
style (issue link, trigger chain in the doc-comment, locked-in
assertion) but is the first one to drive the device side via fakespeaker
rather than an inline httptest.NewServer. Subsequent issues with
device-side payloads (#234 factory-reset state, #235 Spotify-as-preset,
…) can reuse the FixtureOverrides hook without further infrastructure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:13:24 +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 e3450ffd00 refactor(setup): split high-complexity functions into per-axis helpers
Brings the five remaining gocyclo > 20 warnings to zero by extracting
cohesive sub-functions; same observable behaviour, smaller surface to
read at each call site. Bonus: the new helpers are individually testable.

- pkg/models/clockdisplay.go: split ClockDisplay.UnmarshalXML attr
  handling into applyClockDisplayOuterAttrs (legacy flat shape) and
  applyClockConfigAttrs (current nested shape).
- pkg/service/setup/ssh_probe_apply.go: split applyProbeToSummary into
  applyProbeCurrentConfig / applyProbeResolvConf /
  applyProbeRemoteServices / applyProbeCACert — one helper per
  MigrationSummary axis the probe populates.
- pkg/service/setup/init_plan.go: split ExecuteInitPlan into
  applyInitPlanDefaults, runURLRewrite, resolveAccountID, and
  verifyPairing. Cleans up several shadowed err variables in the
  process.
- cmd/soundtouch-cli/cmd_setup.go: split renderInspectReport into
  renderInspectIdentityAndPairing / renderInspectNetwork /
  renderInspectSources / renderInspectPresets / renderInspectRuntimeURLs,
  and buildPlanSteps into resetSteps + migrationSteps helpers.

golangci-lint run ./pkg/service/setup/... ./pkg/models/...
./cmd/soundtouch-cli/... now reports zero findings. Tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:43:36 +02:00
Tobias GesellchenandClaude Opus 4.7 9e384840ba style(setup): un-stutter exported type names and tighten range loops
- Rename SetupStateMachine → setup.StateMachine, SetupSessionConfig →
  setup.SessionConfig, SetupSession → setup.Session, and
  DialSetupSession → setup.DialSession. The Setup* prefix only stutters
  in package context (`setup.SetupSession`); the renamed forms read
  cleaner at every call site (revive: exported).
- Iterate r.Network.Interfaces.Interfaces by index in cmd_setup.go
  rather than by value — each NetworkInterface is 168 bytes and the
  per-iteration copy was unnecessary (gocritic: rangeValCopy).

Test fixtures (fakeSetupSession → fakeSession, TestSetupSession_* →
TestSession_*) renamed by the same substring replacement to keep
naming consistent inside the package.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:43:36 +02:00
Tobias GesellchenandClaude Opus 4.7 a1ae10650f style(setup): address actionable golangci-lint findings
Fixes the lint hits that pointed at real bugs or dead code; leaves the
remaining style-only suggestions (rangeValCopy micro-copies, gocyclo
informational, intentional name choices like SetupStateMachine) alone.

- pkg/models/clockdisplay.go: restore <clockDisplay> XMLName tag on both
  ClockDisplay and ClockDisplayRequest. The earlier `xml:"-"` clashed
  with ClockDisplayUpdatedEvent.ClockDisplay's `xml:"clockDisplay"` tag
  (SA5008). Custom MarshalXML/UnmarshalXML still own the wire format.
- pkg/service/setup/setup.go: drop the now-unused checkRemoteServices
  helper (replaced by applyProbeToSummary) and rename the unused
  deviceIP parameter of populatePlannedNetworkConfig to _.
- pkg/service/setup/setup_session.go: collapse sendStep's (string, error)
  return to plain error — every caller already discarded the string.
- pkg/service/setup/init_plan.go: rename shadowed err variables to
  rwErr / genErr / invalidErr / nilErr / stepErr.
- cmd/soundtouch-cli/cmd_setup.go: drop redundant int(syscall.Stdin)
  conversion (already int) and rename a shadowed err to pairErr.

go build ./..., go vet ./..., and tests for the touched packages all
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:43:36 +02:00
Tobias GesellchenandClaude Opus 4.7 29a462da2b feat(setup): add CLI setup command group for end-to-end speaker provisioning
Add `soundtouch-cli setup` subcommand group covering the full reset →
re-provision → pair lifecycle as a scriptable alternative to the web UI:

  inspect, verify, plan, factory-reset, wait-ap, wifi-push, wait-online,
  ssh-check, install-ca, migrate, reboot, pair (bare | full state machine)

Supporting library code lives in pkg/service/setup: factory_reset.go,
wifi_provision.go, inspect.go, init_plan.go, setup_session.go.

Confirmed against ST10 firmware 27.0.6 that bare setMargeAccount over
WebSocket — no SETUP_START/SETUP_ENTER/SETUP_LEAVE bracket — is
sufficient to pair a factory-reset speaker; the firmware materializes
SystemConfigurationDB.xml and Sources.xml itself and the pairing
survives reboot. Result and field-by-field SystemConfigurationDB
comparison documented in docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md.
Captures the device's pre-reset DELETE-to-marge plus its LAN peer
notification flow in docs/analysis/FACTORY-RESET-PROTOCOL.md.

Perf: batch GetMigrationSummary's SSH probes into one Run() call via
ssh_probe.go / ssh_probe_apply.go — was ~8 sequential dials at
500-1000 ms each on FW 27 crypto, now one round-trip. Same data shape,
same MigrationSummary fields populated.

Fixes /clockTime and /clockDisplay wire formats — firmware 27 rejects
the legacy flat XML ("Error parsing request"). ClockTimeRequest now
uses utcTime attribute; ClockDisplayRequest emits the nested
<clockConfig> envelope with timezoneInfo/timeFormat/brightnessLevel.

Removes cmd/example-init-speaker (superseded by setup pair).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:43:36 +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 d74bb9b5ca feat(service): add passive peer-reachability probe handler
RunPeerReachabilityProbe is the post-migration replacement for the
active swUpdateUrl round-trip: register the device IP with the
in-process observer, nudge :8090/swUpdateCheck, and wait for any
inbound from that IP. No device-state mutation. Any inbound counts
as proof — on a migrated speaker, DNS interception routes the
daemon's outbounds through this service regardless of which URL it
resolved internally, so reachability reduces to "did the device
dial us at all."

PeerHit and the abstract observer interface live in setup alongside
the probe logic; handlers.peerObserver implements the interface and
the existing observer files now import from setup.

Route: POST /setup/peer-probe/{deviceId}. Timeout: 30s, surfaced as
result.ElapsedMs so the budget can be tuned from real data. The
pre-flight orchestrator gains the branch in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:31:24 +02:00
Tobias GesellchenandClaude Opus 4.7 6617c22967 style(setup): satisfy govet shadow + thelper lints
Two lint findings flagged by golangci-lint:

  - telnet_probe.go:90 — t.Dial()'s local err shadowed the outer
    url.Parse error (govet shadow). Renamed the inner one to
    dialErr.
  - migration_summary_telnet_test.go:20 — telnetSummaryEnv didn't
    call t.Helper(), so test failures pointed at the helper rather
    than the calling test (thelper). Now mirrors the t.Helper() in
    telnetSummaryEnvWithInfo.

No behaviour change.

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 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 10954c6161 feat(setup): XML migration honors per-field URL overrides
Adds applyURLOverrides — a tiny helper that, given a PrivateCfg and the
migration options map, copies any non-empty marge_url / stats_url /
sw_update_url / bmx_url value into the matching PrivateCfg field. The
helper runs after applyProxyOptions in both the read path
(GetMigrationSummary's planned-config preview) and the write path
(migrateViaXML's actual XML upload), so the planned diff and the file
the migration writes both reflect what the user typed.

Precedence: a literal *_url override wins over the legacy
self/proxied/original mode set on the same field, because the user
picked a URL and the migration honors it verbatim. Empty/missing
overrides leave the field unchanged. The legacy mode handling stays
in place for API back-compat — only the UI is moving away from it.

Tests cover the helper directly, the override-vs-mode precedence rule,
and a full GetMigrationSummary round-trip that verifies the override
shows up in the rendered PlannedConfig XML.

This is the data-layer half of the upcoming unified per-field URL
editor in the Plan card; no UI changes here.

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 bcd0970e35 feat(setup): expose per-axis migration booleans on MigrationSummary
Adds XMLMigrated, HostsMigrated, ResolvMigrated, TelnetMigrated, and
IsPaired as explicit fields on the summary so the UI can render
partial-state cells (URLs flipped via telnet but the on-disk XML
hasn't caught up; DNS interception in place but no CA installed; etc.)
and surface pairing as its own precondition. IsMigrated remains
backward-compatible — it is now the OR of the four migration axes.

checkIsMigrated stops short-circuiting and writes each axis verdict
unconditionally so a "partial" state on any axis is always visible to
the UI even when another axis already reports the device migrated.
populateDeviceInfo now derives IsPaired from the live :8090/info
margeAccountUUID (clobbering any stale datastore copy), so a
factory-reset speaker is correctly flagged as unpaired.

Tests cover the per-axis verdicts independently and the IsPaired
derivation in both the populated and empty live-info cases.

This is the data layer for the upcoming three-axis "state view" panel
on the migration tab. No frontend or behavior changes here.

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 ae21552878 fix(setup,web): parse the protobuf-text getpdo reply real devices send
The live SoundTouch firmware (FW 27.0.6.46330.5043500, ST 20) replies
to `getpdo CurrentSystemConfiguration` with a Protobuf-text-like
nested-block format, not the key=value format my parser was written
against:

    margeServerUrl {
      text: "https://streaming.bose.com"
    }
    statsServerUrl {
      text: "https://events.api.bosecm.com"
    }
    ...
    ->OK
    ->

Effect of the bug: the four "Current on Device" cells in the telnet
URL Targets table stayed empty after a summary load, and the
crossCheckPreflights helper silently produced no warnings even when
SSH-XML and telnet-getpdo would have disagreed. Both behaviours were
reported from a real-device summary fetched against the running
service.

Both parsers (Go setup.parseGetpdoConfig and JS
parseTelnetVerifiedConfig) now accept the protobuf-text shape and keep
the legacy key=value path as a tolerance fallback. An isIdentifier
guard prevents protobuf "text: …" lines from being misread as flat
fields and keeps prompt characters (->, ->OK) out of the result map.

A new TestParseGetpdoConfig_ProtobufTextRealDevice test pins the
parser to the verbatim live response so this regression cannot recur
silently.

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 d5f9d16e42 feat(setup): per-field telnet URLs with envswitch derivation rule
Refactors telnetURLConfigCommands into a telnetURLs value type with
explicit per-field URLs (Marge, Stats, SwUpdate, BmxRegistry) and adds
telnetURLsFromOptions to resolve those four URLs from a base targetURL
plus optional per-field overrides via the migration options map
(marge_url, stats_url, sw_update_url, bmx_url).

Envswitch derivation rule: arg1 = u.Marge verbatim, arg2 = u.SwUpdate
verbatim. The soundcork case (Marge has /marge appended) is handled
without any branching — envswitch arg1 carries the same suffix and the
parallel persistence layer stays consistent with the runtime layer on
the next reboot.

The default path is unchanged for users who only enter a base URL: all
four fields share targetURL with the canonical /updates/soundtouch and
/bmx/registry/v1/services suffixes. MigrateSpeaker plumbs the options
map through so the existing handler's option dictionary works for telnet
without UI changes; the UI can layer per-field input on top later.

Existing telnet migration tests updated to call the new signature.
TestMigrateViaTelnet_SoundcorkMargeSuffixPropagatesToEnvswitch is the
load-bearing regression test for the derivation rule.

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 720f12d4d7 feat(setup): cross-check SSH-XML against telnet-getpdo URL fields
When both preflights succeed, GetMigrationSummary now compares the URL
fields in the parsed SoundTouchSdkPrivateCfg.xml (read via SSH) against
the matching keys in `getpdo CurrentSystemConfiguration` (read via
telnet) and appends a Warnings entry for any field whose values differ.

The two sources can briefly disagree because `sys configuration …`
writes the runtime layer while envswitch writes the parallel persistence
layer and the on-device XML file is only re-rendered after a reboot.
The warning text says exactly that, so the UI can surface a non-fatal
hint instead of treating a freshly-migrated-but-not-yet-rebooted device
as broken.

Adds Warnings []string on MigrationSummary, parseGetpdoConfig (a
key=value parser tolerant to banner/prompt noise), and
crossCheckPreflights wired in as step 9 of GetMigrationSummary.

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 cb7c3f319d feat(setup): detect telnet-only migrated devices via getpdo
Adds Manager.isTelnetMigrated, which substring-matches m.ServerURL's
hostname against TelnetVerifiedConfig — the response captured by the
preflight's `getpdo CurrentSystemConfiguration`. Mirrors the existing
isXMLMigrated semantics so users see consistent migration-state
detection regardless of which transport the device exposes.

checkIsMigrated no longer early-returns on !SSHSuccess. Telnet runs
first and unconditionally; the SSH-based hosts/resolv.conf checks still
run when SSH is reachable, since neither variant shows up in
`getpdo CurrentSystemConfiguration`. This closes the gap where a
USB-unlock-refusing speaker (SA-5, ST520, recent ST Portable) that had
already been migrated via telnet was silently reported as IsMigrated:
false in the UI.

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 91ba28c52e feat(setup): run telnet preflight in parallel with SSH probes
GetMigrationSummary now kicks off telnetPreflight in a goroutine at
entry and merges the four Telnet* fields into the main summary just
before returning. Wall time becomes max(ssh, telnet); the two transports
are queried independently and their results combined — SSH retains
visibility into /etc/hosts, /etc/resolv.conf and the on-device XML
config, while telnet contributes the live URL set readable via
`getpdo CurrentSystemConfiguration` without root.

Race-free by construction: the goroutine writes to its own
MigrationSummary instance and only the four telnet fields are copied
back. Verified with `go test -race`.

Tests cover telnet-only, ssh-only, and both-succeed paths.

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 c84cfeb757 feat(setup): read-only telnet preflight populating MigrationSummary
Adds Manager.telnetPreflight that dials port 17000, captures the banner,
and runs `getpdo CurrentSystemConfiguration` to read back the device's
live URL configuration. Errors are recorded on TelnetProbeError instead
of returned, so the probe is best-effort and never breaks summary
construction.

This is the data-gathering layer that the four already-declared
TelnetReachable / TelnetBanner / TelnetVerifiedConfig / TelnetProbeError
fields on MigrationSummary were waiting for. Subsequent iterations wire
the preflight into GetMigrationSummary (in parallel with SSH) and use
TelnetVerifiedConfig as a SSH-free signal for "already migrated".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:37:11 +02:00