Commit Graph
182 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Opus 4.7 e496974f0d feat(web): default --interface to --bind's interface name
When the user passes --bind <iface> and doesn't set --interface,
discovery now reuses the same interface name instead of auto-picking.
Common single-interface setups stop needing to repeat the flag, while
the two flags remain independent for the cases that legitimately want
HTTP and discovery on different interfaces.

Update the --interface help text to document the default. The --bind
text is unchanged: it still describes the HTTP listener address.

Refs #264

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:37:56 +02:00
Tobias GesellchenandClaude Opus 4.7 206ef1f665 fix(web): harden --bind interface resolution and add tests
The previous implementation silently returned the literal interface
name when the interface existed but had no IPv4 address (or when
listing addresses failed). That reproduces the exact error from #264
("listen tcp: lookup eth103 on ...: no such host") for users on
IPv6-only or admin-down interfaces, so the fix only worked for the
happy path.

Return an explicit error for those cases and fatal in main with a
message that identifies the offending --bind value. Add an IPv6
fallback (single non-link-local address, bracketed) and treat any
ambiguity -- multiple IPv4 or multiple IPv6 addresses on the same
interface -- as an error rather than picking one silently. Log when an
interface name was resolved to an IP so the indirection is visible.
Update the --bind flag help text to reflect the supported inputs.

Add a test covering the pass-through cases (host, IP, empty, unknown
name) and a portable loopback-interface test that skips cleanly when
the loopback isn't in a single-IPv4 configuration.

Refs #264

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:37:56 +02:00
mehmet turacandTobias Gesellchen 413ae74315 fix: resolve interface names for web bind address
Fixes #264

Signed-off-by: mehmet turac <mehmetturac@gmail.com>
2026-05-14 15:37:56 +02:00
Tobias GesellchenandClaude Opus 4.7 cb071c9b1b feat(discovery): allow pinning mDNS and UPnP to a specific interface
On a multi-homed host the discovery layer used to walk net.Interfaces()
and pick the first non-loopback IPv4 NIC, while UPnP/SSDP bound a
wildcard UDP socket and let the kernel route the multicast send. That
meant --bind on soundtouch-web only moved the HTTP listener; the
discovery still went out whatever interface the kernel preferred (often
the wrong one on hosts where the speakers sit behind a secondary NIC).

Introduce a separate DiscoveryInterface knob:

  * pkg/config: DiscoveryInterface field + DISCOVERY_INTERFACE env var.
  * pkg/discovery/mdns: NewMDNSDiscoveryServiceWithInterface; the
    interface resolver now honours an explicit name and validates it
    has a usable IPv4 address before handing it to hashicorp/mdns.
  * pkg/discovery/upnp: when an interface is configured, bind the UDP
    socket's source IP to the NIC's IPv4 and call
    ipv4.PacketConn.SetMulticastInterface so M-SEARCH leaves the right
    NIC. Without an interface, behaviour is unchanged.
  * cmd/soundtouch-web: new --interface flag (DISCOVERY_INTERFACE env)
    plumbed into the config before the discovery service is built.

go.mod/go.sum reflect promoting golang.org/x/net from indirect to a
direct dependency (now imported for ipv4.PacketConn).

Refs #264.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:36:06 +02:00
Tobias GesellchenandClaude Opus 4.7 67111b6f5e docs(web): clarify that --bind takes a host or IP, not an interface name
The flag's value is concatenated with ":PORT" and passed to
http.ListenAndServe, so it has always been a host/IP. The previous help
text invited users to pass an interface name like "eth0", which then
failed with a confusing DNS lookup error.

Refs #264.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:36:06 +02:00
Tobias GesellchenandClaude Opus 4.7 8b0a41744d fix(setup): cast syscall.Stdin to int for Windows cross-compile
term.ReadPassword takes an int, but syscall.Stdin is syscall.Handle
(uintptr) on Windows. The explicit cast keeps the call building on
Windows while a //nolint:unconvert silences the false positive on Unix
where syscall.Stdin is already int.

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 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 cbbbaa9707 feat(group): add ST-10 stereo-pair support end-to-end
Implements the speaker-side group API surface (path 1 of the two
approaches gmuth outlined in issue #252): clients form, rename, and
dissolve stereo pairs directly on the device, and the resulting
GroupService.xml persists on disk in the same shape the device emits
over /getGroup.

What landed:

- pkg/models/group.go: Status field + IsEmpty() helper, matching the
  GET /getGroup response shape (id-attr, masterDeviceId, roles,
  senderIPAddress).
- pkg/client/client.go: GetGroup, AddGroup, UpdateGroup, RemoveGroup.
  The endpoint name is /getGroup (not /group, despite some wiki docs)
  — confirmed against a real ST-10's /supportedURLs. RemoveGroup uses
  GET per the wire spec.
- cmd/soundtouch-cli/cmd_group.go + main.go: new `group` subcommand
  with status / create --left --right [--name] / rename / remove,
  mirroring gmuth's group.sh recipe.

WebSocket notifications:

- pkg/models/websocket.go: EventTypeGroupUpdated +
  GroupUpdatedEvent + dispatch helpers. The device fans this out to
  both LEFT and RIGHT speakers on every group mutation, including
  empty-group teardowns; the parse test covers both shapes.
- pkg/client/websocket.go: OnGroupUpdated registration and dispatch.
- cmd/soundtouch-cli/cmd_events.go: `group` filter +
  handleGroupEvent formatter.

WebSocket observability (came up while validating the above against
a real device):

- New RawMessageHandler type + OnRawMessage hook that fires for every
  incoming frame before parsing, with the parse error alongside.
- New --debug flag on `events subscribe` with modes all / unknown /
  errors. Raw output goes to stderr so it composes cleanly with
  shell redirects.

The pkg/client refactor in this commit also adopts speaker.HTTPPort
(introduced in the previous refactor) — the unexported
defaultSoundTouchPort and three hard-coded 8090 literals are gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:18:08 +02:00
Tobias GesellchenandClaude Opus 4.7 bb71253690 feat(screenshots): add headless-Chrome capture pipeline with fake speaker
Refreshes docs/images/ui-{settings,devices,sync,migration}.png by
driving the web UI in chromedp against a synthetic speaker, so
documentation can be regenerated without real hardware and without
leaking personal data from the local network.

Three independent pieces:

- pkg/service/testing/fakespeaker — embeddable library serving the
  HTTP and telnet surface the migration wizard probes (/info,
  /presets, /recents and a getpdo CurrentSystemConfiguration reply
  that places the device on the unmigrated happy path).
- cmd/dummy-speaker — thin CLI wrapping the library; self-registers
  with a running service via POST /setup/devices.
- scripts/screenshots — chromedp runner driven by a JSON manifest;
  decoupled from speaker/service setup so it can target any backend
  URL. run.sh orchestrates a one-shot end-to-end capture and seeds
  settings.json with a generic hostname plus discovery disabled to
  keep real-network state out of the captures.

Captures are at DPR=2 for retina-sharp text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:37:23 +02:00
Tobias GesellchenandClaude Opus 4.7 0e8ab1cd89 test(service): update routes snapshot after round-trip probe removal
TestPrintRoutes compares the live router against
testdata/router_routes.txt; the deletion commit (ba69fc0) changed the
route set but didn't regenerate the golden file. Drops
/probe/{token}[/*] and /setup/telnet-probe/{deviceId}; adds
/setup/peer-probe/{deviceId}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:31:24 +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 dc924e351c feat(service): add peer observer registry and middleware
Adds an in-process observer that records device->service requests by
source IP. PeerObserverMiddleware fires on every inbound after RealIP
trust and Recoverer; the registry exposes Register/Signal/Forget keyed
on the device IP with a buffered one-shot delivery.

No callers yet — this is the substrate for the passive reachability
probe that replaces the broken active swUpdateUrl round-trip on
migrated speakers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:31:24 +02:00
Tobias Gesellchen ae04ac3128 fix/update routes test 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 f951fc92df feat(handlers): proxy-aware RemoteAddr via opt-in TrustForwardedHeaders
Wire up X-Real-IP / X-Forwarded-For / True-Client-IP support for
deployments fronted by a reverse proxy, while staying safe on flat-LAN
deployments where a malicious speaker could spoof those headers
directly.

Two new fields on `datastore.Settings`:

* TrustForwardedHeaders (bool, default false) — opt-in switch.
* TrustedProxyCIDRs ([]string, default `["127.0.0.0/8", "::1/128"]`)
  — only requests whose immediate TCP peer falls in one of these
  blocks may have their source IP rewritten from forwarded headers.
  Loopback default matches the documented same-host nginx layout in
  docs/guides/HTTPS-SETUP.md.

New middleware in `pkg/service/handlers/middleware_realip.go`:

* TrustedRealIP wraps `chi/middleware.RealIP` with a trusted-peer
  gate. When the immediate TCP peer is in the allowlist, chi's
  parsing handles the actual header → IP rewrite. When it isn't
  (e.g. a speaker sending forwarded headers itself), we ignore the
  headers and r.RemoteAddr stays as-is.
* ParseTrustedProxyCIDRs converts string CIDRs into *net.IPNet,
  applying the loopback default on empty input and erroring loudly
  on invalid entries.

Server.TrustedRealIPMiddleware() returns the middleware (or nil) by
reading the live settings; the router setup in
cmd/soundtouch-service/main.go installs it as the very first
middleware so SnapshotMiddleware and downstream handlers see the
correct r.RemoteAddr.

HandleMargePowerOn now prefers r.RemoteAddr over the body's
self-reported `<IPAddress>` for outbound credential push:

* The body field is treated as a hint only — a malicious LAN speaker
  could set it to any value; using it for outbound HTTP requests is
  the SSRF surface the previous zeroconf hardening was guarding
  against from the sink side. Fixing it at the source as well closes
  the gap entirely.
* When body IP and TCP source disagree, a log line names both and
  the device ID so the discrepancy is investigable.
* RemoteAddr is unparseable → fall back to the body so we don't
  silently drop the priming.

docs/guides/HTTPS-SETUP.md gains a follow-up note next to the existing
nginx snippet explaining the new flag, the loopback-only default, and
the explicit warning against enabling the flag on a flat-LAN
deployment without a real proxy.

Eleven test cases in middleware_realip_test.go lock in the gate
behaviour: trusted peers honoured for X-Real-IP / X-Forwarded-For /
no-headers / IPv6, untrusted peers' headers ignored, garbage values
rejected, ParseTrustedProxyCIDRs covers default / override / invalid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:40:15 +02:00
Tobias GesellchenandClaude Opus 4.7 fb47807f70 feat(telnet): add port-17000 migration method and account pairing
Adds an SSH-free third migration path that drives the SoundTouch device's
diagnostic shell on TCP port 17000, plus a hardened replacement for the
fragile /setMargeAccount HTTP pairing call.

* `pkg/telnet` — new reusable, dependency-free client (sibling of `pkg/ssh`)
  with deadline-driven Dial / Probe / SendCommand / Close. Mock-server tests
  cover happy path, command-not-found, mid-stream close, and the wedged-device
  read-timeout scenario.

* `setup.MigrationMethodTelnet` — runs `sys configuration` for all four URLs
  plus the parallel `envswitch boseurls set` persistence layer that otherwise
  wins on reboot, then verifies with `getpdo CurrentSystemConfiguration`.
  Aborts on the first non-OK response so configuration is never half-written.
  No SSH backup or rw pre-flight (the path is SSH-free by design).

* `setup.PairAccount` — probes :8090/supportedURLs first, time-bounds
  POST /setMargeAccount aggressively (5s connect / 12s total) to avoid the
  hangs reported in #236, and falls back to `envswitch accountid set <id>`
  over telnet when the HTTP endpoint is missing or wedged. Returns a
  PairAccountResult breadcrumb so the UI can show which path actually
  succeeded.

* `setup.Reboot(deviceIP, method)` — gains a RebootMethod selector;
  RebootMethodSSH stays the default (preserving prior behavior),
  RebootMethodTelnet sends `sys reboot` over a fresh telnet session and
  treats the inevitable socket-close as success.

* New endpoints on `/setup`:
  - GET  /account-id-suggestions/{deviceId} — returns the device's current
    margeAccountUUID (from :8090/info) plus known account IDs from the
    datastore, so the UI can offer reuse.
  - POST /pair-account/{deviceId}?account_id=NNNNNNN — invokes PairAccount;
    the existing reboot endpoint reads ?method=ssh|telnet from the query
    string.

* Helpers `IsValidAccountID` (exactly 7 digits) and `GenerateAccountID`
  (crypto/rand, retries on collision against a known-IDs list).

Documentation in docs/analysis/TELNET-MIGRATION-METHOD.md is updated to match
the implementation: bare-URL convention for `soundtouch-service`, no automatic
`sys reboot` (user-initiated via the existing button with a method selector),
and the realised package layout. The /etc/hosts method is intentionally not
exposed in the new flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:43:36 +02:00
969bdf8704 feat(service): add --discovery-enabled CLI flag and treat 0 interval as disabled (#229)
Why: Operators need to control device discovery from the command line
without touching the persisted settings file, and a zero discovery
interval should be unambiguously off rather than running an
immediate-fire scan loop.

- Add --discovery-enabled BoolFlag (default true, env DISCOVERY_ENABLED)
and thread it through serviceConfig, applyPersistedSettings, and
createDefaultSettings so CLI/env can seed initial state and persisted
settings still take precedence on subsequent runs.
- HandleUpdateSettings now forces discoveryEnabled=false whenever the
resulting discoveryInterval is zero.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:18:15 +02:00
Tobias GesellchenandClaude Sonnet 4.6 f6e733de6b fix(certmanager): use hostname as server cert CN instead of a random Bose domain
domains[0] was non-deterministic (Go map iteration) and could resolve to
any domain in the list including Bose-owned domains. Adds CommonName field
to CertificateManager, defaulting to "localhost", set to the device hostname
at startup. All Bose domains remain in the SAN where clients actually look.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:15:48 +02:00
Tobias GesellchenandClaude Sonnet 4.6 84585b8034 perf(service): move TLS cert generation off the startup path
On constrained hardware (e.g. ARMv7), RSA key generation can block
startup for minutes. HTTP now starts immediately; HTTPS is brought up
in a background goroutine once cert generation completes. A log message
informs the user that HTTPS will be available shortly after startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:11:41 +02:00
16ab9dbba1 feat(alexa): stub POST /alexa/certificate with 501 and add voice.api.bose.io to DNS (#200)
Registers HandleAlexaCertificate on POST /alexa/certificate. The handler
logs the device MAC from the request body and returns 501 Not
Implemented with a JSON error explaining that AWS IoT integration is
required to provision Alexa device certificates.

Adds voice.api.bose.io to both /etc/hosts domain lists in setup.go (DNS
intercept was already covered by the bose.io wildcard entry in dns.go).

Relates to https://github.com/gesellix/Bose-SoundTouch/discussions/84

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 21:37:30 +02:00
e99c04888c feat: implement missing endpoints and serve static resources from downloads/media hosts (#199)
Endpoints:

- POST /streaming/music/musicprovider/{id}/trial/is_eligible (reuses
is_eligible handler)
- POST /bmx/tunein/v1/favorite/{stationID} with datastore persistence
(SaveTuneInFavorite)
- DELETE /bmx/tunein/v1/favorite/{stationID} (DeleteTuneInFavorite)
- POST /bmx/core02/svc-bmx-adapter-orion/prod/orion/token (anonymous
Orion token)
- GET /bmx-icons/* serving embedded static/media assets (media.bose.io)
- GET /ced/* serving embedded firmware index, release notes, and 10
app-help XMLs (downloads.bose.com)

Add media.bose.io and downloads.bose.com to DNS redirect lists (setup.go
both domain slices, dns.go shouldIntercept list, main.go getDomains
map). Document implemented endpoints in
tests/interactions_20260502_missing_external.md; mark rows 0246–0247 as
self/☑ in the interactions table.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 21:22:36 +02:00
86825c44af feat(backup): add soundtouch-backup tool for cloud and local speaker backup (#197)
Introduces a standalone `soundtouch-backup` CLI with three subcommands:
- `all`: authenticates with the Bose cloud, backs up account data, then
reads device IPs from devices.xml and backs up each reachable speaker
- `cloud`: fetches account profile, devices, sources, presets, and full
endpoint from streaming.bose.com
- `local`: backs up each speaker via HTTP API (12 endpoints) and
optionally via SSH (individual files + /opt/Bose/etc/ and
/mnt/nv/BoseApp-Persistence/1/ directories)

Also centralises pkg/service/ssh → pkg/ssh so both the service and the
backup tool share the same SSH client; adds ReadFile and ReadDir
methods, and handles the firmware quirk where cat exits 1 on empty
files.

Output is a single dated .tar.gz or .zip archive.

Example flow:

```shell
gesellix@Mac Bose-SoundTouch % go run ./cmd/soundtouch-backup all --output _/cloud-backup --email user@example.com
Password: 
Authenticating as user@example.com...
  ✓ Authenticated (account ID: 1234567)
  ✓ email address (107 bytes)
  ✓ devices (1492 bytes)
  ✓ sources (1111 bytes)
  ✓ presets (2585 bytes)
  ✓ full account (55037 bytes)
Found 2 device(s) in cloud account, attempting local backup...
  ✓ ST20: 12 files via HTTP
  ⚠ ST20: SSH skipped /etc/remote_services (Process exited with status 1)
  ⚠ ST20: SSH empty file /mnt/nv/remote_services
  ✓ ST20: 64 files via SSH
  ✓ ST10: 12 files via HTTP
  ⚠ ST10: SSH empty file /etc/remote_services
  ⚠ ST10: SSH skipped /mnt/nv/remote_services (Process exited with status 1)
  ✓ ST10: 48 files via SSH
Archive written: _/cloud-backup/soundtouch-backup-2026-05-02.tar.gz (141 files)
```

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 14:00:23 +02:00
Tobias GesellchenandClaude Sonnet 4.6 147a1a8490 feat: add Spotify and Amazon credential fields to Settings UI
- Add SpotifyClientID/Secret/RedirectURI and AmazonClientID/Secret/RedirectURI
  fields to datastore.Settings for persistent storage
- Server: add amazonClientID/Secret/RedirectURI fields, SetAmazonConfig,
  GetSpotifyConfig/GetAmazonConfig, ReinitSpotifyService/ReinitAmazonService,
  and applyMusicServiceCredentials (called under lock from HandleUpdateSettings)
- GET /setup/settings: expose credential fields; mask secrets as "***" when set
- POST /setup/settings: apply credential updates and reinitialize services live
- applyPersistedSettings: fill in music credentials from settings.json when not
  set via CLI/env (CLI takes precedence)
- Settings tab: replace read-only Spotify status with editable Client ID / Secret /
  Redirect URI inputs for both Spotify and Amazon; save via existing Save button
- script.js: populate and collect the six new fields in fetchSettings/updateSettings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:30:06 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ca7f8d5453 feat: wire Amazon mock into http-client integration tests
- Add cmd/mock-amazon/main.go (mirrors mock-spotify, uses testutils/amazon)
- Add amazon-mock service to docker-compose.yml (port 8082)
- Add AMAZON_CLIENT_ID/SECRET/TOKEN_URL/PROFILE_URL to docker-compose.ci.yml
- Add amazon_registration.http: registers account via /mgmt/amazon/callback
  before the token-refresh test runs (mirrors spotify_registration.http)
- Update {{amazonRefreshToken}} in env to match mock response (Atzr|amazon-refresh-token)
- Log amazon-mock output on test failure in Makefile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:30:06 +02:00
Tobias GesellchenandClaude Sonnet 4.6 cb037df831 feat: add Amazon Music management handlers and CLI wiring
- Add HandleMgmtAmazonInit/Callback/Confirm/Accounts/Token/PrimeDeviceAmazon
- Add bridgeAmazonToMarge (AmazonSecret JSON envelope, Marge registration, speaker notification with OAuth/sync/legacy fallbacks)
- Add PrimeDeviceWithAmazon and pushAmazonTokenToDevice to Server
- Wire --amazon-client-id/secret/redirect-uri/token-url/profile-url CLI flags
- Initialize Amazon service on startup alongside Spotify
- Register /mgmt/amazon/* routes (callback unauthenticated, rest Basic Auth)
- Update router_routes.txt snapshot with 6 new Amazon routes
- Fix errchkjson lint: use typed amazon.Account in test fixtures
- Fix gocyclo lint: extract initMusicServices helper from main action

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:30:06 +02:00
Tobias Gesellchen 1bf083ae0d Add endpoint for handling Amazon token exchange 2026-04-29 20:30:06 +02:00
9412b5ffa0 feat: add group CRUD endpoints (POST add, POST modify, DELETE delete) (#191)
Groups (stereo pairs of ST10 speakers) were read-only — the GET endpoint
always returned an empty <group/>. Add POST /account/{account}/group,
POST /account/{account}/group/{groupId}, and DELETE
/account/{account}/group/{groupId} with datastore persistence, matching
the API shape observed in soundcork. The GET endpoint now reads live
group state from the datastore.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:28:38 +02:00
Tobias GesellchenandGitHub 4a46df1167 Make the soundtouch-web port configurable via env (#186)
See
https://github.com/gesellix/Bose-SoundTouch/issues/181#issuecomment-4313151490
2026-04-25 21:29:13 +02:00
Tobias GesellchenandGitHub cdaf9f0c0a Build and publish a soundtouch-web Docker image (#184)
Relates to https://github.com/gesellix/Bose-SoundTouch/issues/181
2026-04-23 22:23:00 +02:00
Tobias GesellchenandGitHub 522492177d Embed web resources in soundtouch-web (#182)
Relates to https://github.com/gesellix/Bose-SoundTouch/issues/181
2026-04-23 20:51:00 +02:00
Tobias Gesellchen 7a3eef110b Allow multiple sources for the same source type and different provider 2026-04-20 19:18:39 +02:00
Tobias Gesellchen 88c83b6131 Fix security issues 2026-04-19 21:59:55 +02:00
Tobias Gesellchen 56e82d5a01 Add TuneIn search/browse/playback
We might peek into https://github.com/core-hacked/tunein-api for more advanced use cases
2026-04-19 21:59:55 +02:00
Tobias Gesellchen 56256de47b lint 2026-04-19 21:59:55 +02:00
Tobias Gesellchen 5b99d7f46b Add a web-based app 2026-04-19 21:59:55 +02:00
Tobias GesellchenandGitHub aa5a25b382 Enhance version-info (#170) 2026-04-17 22:16:47 +02:00
Tobias GesellchenandGitHub 0e2f05e6e5 Improve source sync by adding deduction of known source IDs (#167) 2026-04-17 18:50:51 +02:00
Tobias Gesellchen c4cf078d2a Add Spotify mock server and integration tests 2026-04-06 15:05:44 +02:00
Tobias GesellchenandGitHub 382567d67d Add .../api_versions.xml and .../musicprovider/{providerID}/is_eligible (#150) 2026-04-05 23:25:30 +02:00
Tobias GesellchenandGitHub de96b1f119 Add /streaming/account/{account}/presets/all (#149) 2026-04-05 23:10:53 +02:00
Tobias GesellchenandGitHub d22dc99c9e Add /streaming/account/{account}/devices (#148) 2026-04-05 10:16:22 +02:00
Tobias GesellchenandGitHub bd0e3d64a3 Add /streaming/account/{account}/sources (#147) 2026-04-05 01:09:02 +02:00
Tobias Gesellchen 379ac758f6 Add /bmx/tunein/v1/navigate and /bmx/tunein/v1/search (dummy) 2026-04-05 00:55:40 +02:00
Tobias Gesellchen 6d0b5f2c78 Add /bmx/registry/v1/servicesAvailability 2026-04-05 00:55:40 +02:00
Tobias GesellchenandGitHub f354c63bac Add /v1/report (#145)
https://github.com/gesellix/Bose-SoundTouch/issues/135
2026-04-04 23:23:04 +02:00