Compare commits

...
104 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Opus 4.7 0556492fe4 ci: pin GitHub Actions to commit SHAs
Replace floating major-tag references (uses: foo/bar@vN) with the
specific commit SHAs they currently resolve to, annotated with the
fully-versioned tag (# vX.Y.Z) for human readability. Pinning to a SHA
makes the action behaviour reproducible across runs and removes the
supply-chain risk of a maintainer (or attacker) moving a tag to a new
commit.

One documented exception: semgrep/semgrep-action does not publish
v1.x.y semver tags — v1 is their only canonical release name on that
line — so it keeps a "# v1" annotation with an inline explanation.

actions/dependency-review-action's previous "@v5" reference would have
failed at run time: that repo only ships fully-versioned tags
(v5.0.0), no moving v5 alias. Pinned to v5.0.0 explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:47:50 +02:00
Tobias GesellchenandClaude Opus 4.7 6078309724 test(datastore): compare MAC lookup to update by ratio, not wall clock
The lookup branch of AccountDeviceDir does up to two Stat() syscalls,
so its wall-clock cost is dominated by filesystem latency. On shared
CI runners that latency varies enough that the existing 70 ms absolute
threshold has been tripped repeatedly -- the previous bump from 50 ms
to 70 ms in d97cd45 was the same story. Incrementally relaxing an
absolute bound to track CI noise is a treadmill.

Replace the lookup-time wall-clock check with a ratio against the
in-memory update cost (currently ~8x on dev machines, ~12x on CI).
The 30x threshold leaves comfortable headroom for noise while still
catching an algorithmic regression in the lookup path, where the ratio
would explode well past 30 (an O(n^2) walk over 1000 entries would
push it into the hundreds).

The update path's absolute cap stays in place as a backstop against
catastrophic regressions in that hot in-memory path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:30:56 +02:00
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 turac 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 8ee15bb034 test(handlers): use deterministic IP in BMX registry test
soundtouch.local relied on mDNS resolution, which works on developer
macOS but not in CI/Linux. With the new server_url validation, an
unresolvable hostname now correctly causes DNS to refuse to start --
which flips dnsEnabled to false and made the test fail honestly instead
of passing while DNS was silently broken. Switch the fixture to
127.0.0.1 so the test exercises the DNS-enabled path everywhere.

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 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 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
dependabot[bot] 1ab4295653 ci(deps): Bump actions/dependency-review-action
Bumps the actions-core group with 1 update: [actions/dependency-review-action](https://github.com/actions/dependency-review-action).


Updates `actions/dependency-review-action` from 4 to 5
- [Release notes](https://github.com/actions/dependency-review-action/releases)
- [Commits](https://github.com/actions/dependency-review-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/dependency-review-action
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-core
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-12 20:22:01 +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 c8c38b78e6 refactor(speaker): introduce pkg/speaker leaf for shared protocol constants
The HTTP port and on-device paths for the SoundTouch speaker were
duplicated across pkg/client (unexported) and pkg/service/constants
(under a service-layer prefix). Both spots needed the same values, and
the next round of work (group/persistence handling in the CLI) would
have created a third — or worse, dragged pkg/service into the CLI's
dependency graph just for a port number.

pkg/speaker is a no-deps leaf that holds the speaker-protocol
constants: HTTPPort, the request paths, and the on-device persistence
file locations (now including GroupServiceFileLocation, for the
upcoming stereo-pair sync work). The client library, the service, the
CLI, and tests can all import it without introducing a layering edge.

This commit moves nothing into pkg/speaker that doesn't belong there —
the service-specific constants (provider IDs, file names, date stub,
etc.) stay in pkg/service/constants. Only the genuinely
protocol-level values move.

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 952200ee26 docs: align migration guide and analysis with simplified pre-flight
MIGRATION-GUIDE.md step 5 — replaces the "Telnet round-trip probe"
bullet with two honest variants: the new passive observer for
already-migrated speakers, and a skip-row explainer for not-yet-
migrated speakers pointing at the Apply + reboot cycle. The rollback
section drops the obsolete tangent about the probe step leaving
persisted URLs untouched (the probe no longer exists, and the wizard
already writes both layers).

TELNET-MIGRATION-METHOD.md — §9.4's pre-flight table swaps the
deprecated `POST /setup/telnet-probe` row for the new
`POST /setup/peer-probe` row plus a skip-explainer row for the
not-yet-migrated case. §9.5 gains a "REMOVED — see §9.8" header
pointer (the section is kept as historical record of what was
tried). §9.6's backend-additions table replaces the deleted
`probeRegistry` + `RunTelnetRoundTripProbe` + `/setup/telnet-probe`
row with the `peerObserver` + `RunPeerReachabilityProbe` +
`/setup/peer-probe` row that supersedes it.

NEXT.md is local-working-tree only (deliberately untracked) and
gains a  Resolved header pointing at §9.8; not part of this
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 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 9a7646bf58 feat(web): branch pre-flight on migration state
The pre-flight panel's reachability check now picks one of two paths
based on summary.is_migrated:

  - Migrated → run the new passive peer-reachability probe
    (POST /setup/peer-probe/{deviceId}) and label the row
    "Reachability check (passive observer)".
  - Not migrated (incl. partial) → render a skip row
    "Round-trip validation runs after Apply + reboot" with the
    rationale "daemon caches swUpdateUrl at boot". Per-axis state
    remains visible in the State card so the user sees which parts
    are already in place.

Adds checkPeerReachability() alongside checkTelnetRoundTrip(). The
latter is marked DEPRECATED inline — no longer called by the
orchestrator, scheduled for removal in a follow-up 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 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
dependabot[bot] fa2883f66b deps(deps): Bump the golang group with 2 updates
Bumps the golang group with 2 updates: [golang.org/x/net](https://github.com/golang/net) and [golang.org/x/tools](https://github.com/golang/tools).


Updates `golang.org/x/net` from 0.53.0 to 0.54.0
- [Commits](https://github.com/golang/net/compare/v0.53.0...v0.54.0)

Updates `golang.org/x/tools` from 0.44.0 to 0.45.0
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.44.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.54.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/tools
  dependency-version: 0.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-11 15:52:45 +02:00
Tobias GesellchenandClaude Opus 4.7 10c9edbb25 fix(marge): keep <sourceproviderid> in recents to satisfy speaker's protobuf
The speaker decodes /streaming/account/.../full into a protobuf message where
recents>recent>source>sourceproviderid is a required field. A laut.fm recent
(location "/custom/v1/playback/...") POSTed against an account with no
Sources.xml fell into classifyLearnedSource's default branch, which wrote
sourceKey type="INVALID" with no providerid. That entry then re-appeared
in /full with an empty <sourceproviderid> element, which the post-marshal
strip-empty step deleted entirely — aborting the speaker's account sync
with "MargePB.account.devices.device[N].recents.recent[K].source.sourceproviderid"
missing and forcing a 60-second retry loop.

Three changes, each defended by the new regression test:

* classifyLearnedSource recognises LocalInternetRadio via sourceProviderID
  == 11 and via the /custom/v1/playback/ URL pattern, and stops writing the
  "INVALID" sentinel that locked sources out of every read-side repair path.

* mapToFullResponseSource falls back to the canonical SourceProviderID
  keyed by source ID (10002/10003/10004/10005) so already-poisoned data
  on disk still renders a non-empty providerid at /full time, with no
  manual data scrub required.

* AccountFullToXML no longer strips empty <sourceproviderid> elements.
  The strip-empty was added for parity with upstream's standalone <sources>
  block, but it's wrong inside recents/preset source blocks where the field
  is protobuf-required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:14:54 +02:00
Tobias Gesellchen 93c3f68443 Bump the default version in install scripts to v0.74.0 2026-05-11 00:49:16 +02:00
Tobias Gesellchen 41a0f32296 chore 2026-05-11 00:39:28 +02:00
Tobias Gesellchen ae04ac3128 fix/update routes test 2026-05-11 00:37:11 +02:00
Tobias GesellchenandClaude Opus 4.7 a08c2c3072 feat(web): standalone Pre-flight button beside each Apply
"Test first, decide later" affordance: the same check sequence Apply
runs is now reachable without committing to the migration. Useful
for spot-checking a speaker after editing URLs, or for verifying a
fresh device is reachable before the user commits to writing
anything.

Two buttons, one per Apply path:

  - #plan-preflight-btn  (Suggested Plan side) — reads the chosen
    method from plan-apply-btn.dataset.method, same source the
    real Apply uses, so what's tested matches what would be
    applied.
  - #customize-preflight-btn (Custom Plan side) — walks the same
    radio choices applyCustomPlan reads and builds the same
    methods array, then runs the checks against it.

Both share the existing pre-flight panel and runApplyPreflight
orchestrator. New renderPreflightPreviewSummary terminates the
panel with a single Close button instead of Proceed Anyway /
Cancel — there's nothing to proceed to in preview mode.

Both Pre-flight buttons share the disabled-state gate of their
Apply counterparts (no plan / invalid URLs disables both) so users
can't accidentally pre-flight a plan that wouldn't apply.

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 9ad159d41d feat(web): run telnet round-trip probe on SSH-capable speakers too
Previously the SSH-capable branch and the telnet-only branch were
mutually exclusive — speakers with both transports reachable only
got the curl-from-device HTTPS check, never the round-trip probe.
That left a class of bugs invisible to pre-flight: an asymmetric
network path where the speaker's userspace can reach our service
(curl works) but the swUpdateUrl fan-out can't (or vice versa).

Each transport now gets its own check; both run when both are
reachable. The two exercise meaningfully different code paths in
the speaker:

  - SSH curl-from-device: speaker's normal userspace HTTP stack
    over an arbitrary inbound TCP to our HTTP/HTTPS port.
  - Telnet round-trip: speaker's firmware-internal swUpdateCheck
    fan-out, which writes to its own DNS resolver and outbound
    HTTP code path that the curl test doesn't go near.

A speaker that passes one and fails the other reveals a real
connectivity asymmetry worth surfacing before the migration
writes its target URLs.

Cost: ~1s extra on the success path (probe is fast on healthy FW
27.0.6), up to ~6s extra on the timeout path. The probe restores
the runtime swUpdateUrl unconditionally so there's no lingering
state regardless of outcome.

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 ae9b02a42b docs(service): API reference for the *_url option family + telnet-probe
The /setup/migrate/{deviceIP} reference table covered only the legacy
self/proxied/original mode selectors, with a one-line "Custom service
URL" mention of target_url. The wizard has been writing literal
per-field URLs via marge_url / stats_url / sw_update_url / bmx_url
for weeks; external API callers had nothing to read.

Expanded the table into three blocks with precedence rules:

  1. Top-level params — method, target_url, proxy_url with the
     four migration mechanisms (xml / telnet / resolv, hosts marked
     deprecated).
  2. Per-field implementation mode — the legacy self/proxied/original
     family, kept for API back-compat with a note that the UI no
     longer sets them.
  3. Per-field literal URL overrides — marge_url / stats_url /
     sw_update_url / bmx_url with a "literal wins over mode" rule
     and the soundcork-suffix-propagates-to-envswitch note.

Three example curl invocations (canonical XML, soundcork telnet,
resolv with HTTPS) replace the old proxy=original-only snippet up
top.

Also added stub reference entries for POST /setup/telnet-probe and
the internal GET /probe/{token}[/*] catch-all — the SSH-less
reachability check the wizard runs automatically in its pre-flight
panel.

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 2b8e652b7e docs(web): "Migration Process at a Glance" no longer SSH-only
The landing-tab overview still framed SSH as a hard prerequisite —
"Migration requires SSH access." That was true under the original
design, but the wizard now probes both SSH and Telnet:17000
automatically and uses whichever the device exposes. SSH-less
speakers (USB-unlock-refusing firmware like SA-5, ST520, recent ST
Portables) can migrate over telnet without ever opening a shell.

Updates:

  - Prerequisite box retitled "Speaker shell access" with two
    sub-bullets that match the state card's Transports row:
      * SSH — richest option, required for XML / DNS / CA install,
        same USB-stick procedure as before
      * Telnet:17000 — SSH-less fallback, no setup, HTTP-only
  - Step 1 (Settings) now mentions that Target URL can be edited
    inline on the Migration tab with Save as default, since the
    Settings tab is no longer the only place to set it.
  - Step 4 (Migration) replaces "we recommend the XML Configuration
    method" with a description of the actual wizard: Apply
    Suggested Plan, Customize three-axis form, and the visible
    pre-flight check.

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 fe58b61c11 refactor(web): pre-flight HTTPS check uses the actual migration target
The pre-flight connection check always hit summary.server_https_url
(the HTTPS health endpoint), regardless of what URL the migration
would actually write to the speaker. That gave a useful baseline
("can the device reach our service over HTTPS at all?") but didn't
test the right thing for HTTP-target migrations — the dominant
configuration when SSH is available and the user goes with the
Suggested Plan's XML+HTTP default.

preflightConnectionTestURL now picks the test URL by intent:

  - methods.includes("resolv") → server_https_url. DNS interception
    leaves the device hitting https://*.bose.com (firmware-hardcoded
    scheme) which DNS redirects to our HTTPS endpoint; testing the
    health URL is the right shape.
  - URL-flip methods (xml / telnet) → derived from the user's
    targetUrl: scheme + host + "/health". HTTP-target migrations get
    an HTTP test, HTTPS-target migrations get an HTTPS test (still
    with use_explicit_ca=true so the trust path is forward-looking
    when CA install is part of the plan).
  - Fallback to server_https_url when targetUrl can't be parsed, so
    older call shapes keep working.

The row label is now dynamic: "HTTPS connection from device" or
"HTTP connection from device" depending on the actual test scheme,
so the panel tells the user which path is being exercised.

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 56c3e4f641 docs(guide): user-facing migration guide reflects the wizard
The guide still described the pre-wizard UI: "SSH status, CA trust
status, and connection test results before letting you apply the
redirect" and two methods (XML / DNS). The migration tab now opens
with the state card + Plan card + Customize three-axis form + visible
pre-flight panel, and a third transport (Telnet:17000) lets users
without SSH access migrate too.

Updates:

  - Step 3 retitled "Enable shell access on each speaker" with two
    sub-sections: SSH (the richest option, required for XML / DNS /
    CA install) and Telnet:17000 (the SSH-less fallback, no setup
    required, HTTP-only).
  - Step 5 rewritten to walk through the actual UI:
      * the state card's three rows (Transports, Migration State,
        Preconditions) with the action affordances inline
      * the Plan card — target URL with Save as default, per-field
        Service URLs editor with validation and soundcork-mode,
        account pairing, and Apply Suggested Plan
      * the visible pre-flight checks panel with its three or four
        checks per method and the Proceed Anyway / Cancel branch
      * Customize this migration with three independent axes
  - Step 6 mentions the auto-expand of Customize on Apply success
    and the per-transport reboot picking.
  - Rollback section adds the telnet-only "reboot reverts the
    runtime layer if envswitch isn't written" property, plus the
    rename to "Revert to Defaults" matching the button label.

The image reference (ui-migration.png) stays pointing at the
existing screenshot; a fresh capture is needed once the wizard is
final but the surrounding prose is now accurate either way.

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 8a61c43cfa refactor(web): prune deprecated hosts-redirection-test markup and JS
The /etc/hosts migration method has been hidden from the UI since
before the wizard refactor — the Customize three-axis form doesn't
expose it, the suggested-plan engine never picks it, and
onCustomizeChange explicitly force-hides the legacy
#hosts-redirection-test pane. The pane was sitting in the DOM doing
nothing.

Removed:

  - The hosts-redirection-test <div> (button, result pane, header)
  - test-hosts-btn.onclick wiring in showSummary
  - The testHostsRedirection() function (orphaned once the button is
    gone)
  - The show("hosts-redirection-test", false) toggle in
    onCustomizeChange (orphaned once the pane is gone)

Backend untouched:

  - /setup/test-hosts/{deviceId} and HandleTestHostsRedirection still
    exist for API back-compat. Same pattern we used when retiring the
    XML method's self/proxied/original dropdowns — only the UI
    surface moves; the manager-level entry points stay.

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 441632b642 docs(analysis): post-implementation addendum (§9) for the telnet method
The feasibility analysis (§§1–8) was written before any of the wizard
shipped, and §7 forecast the surface area roughly. The migration tab
grew considerably during implementation — three-axis state model,
Plan card with per-field URL editor and validation, Customize
three-axis form, visible pre-flight panel, account pairing folded
into the wizard, and the SSH-less round-trip probe — none of which
the original §7 captures faithfully.

Added §9 "What actually shipped (post-implementation addendum)" with:

  §9.1 Three-axis state model (per-axis migration booleans, IsPaired,
        the state-card layout)
  §9.2 Plan card per-field URL editor (single source of URL overrides
        for both XML and Telnet, live optimistic preview)
  §9.3 Customize three-axis form (URL flip / DNS / CA radios driving
        applyCustomPlan)
  §9.4 Pre-flight panel (visible check list, decision tree, override
        affordances)
  §9.5 Telnet round-trip probe (the SSH-less reachability check via
        swUpdateUrl flip + :8090/swUpdateCheck trigger + probe-token
        registry)
  §9.6 Backend additions worth knowing (applyURLOverrides, parser,
        option allow-list, telnet timeout bumps)
  §9.7 Future probe candidates (pushCustomerSupportInfoToMarge;
        running the round-trip probe on SSH-capable speakers too)

§§1–8 stay verbatim as the historical feasibility record, with a
forward-pointer at the head of §7 so readers know the as-shipped
state is documented further down.

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 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 95e76b52ad docs(web): drop stale pair-account-panel note from Telnet pane
The Telnet method pane still said "After a successful migration a
Pair Account panel will appear below this one" — but pair-account-pane
was removed three commits ago when pairing was folded into the Plan
card as a configured-up-front step that runs as part of Apply. The
note pointed users at a panel that no longer exists.

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 23b2cd49ed feat(web): wire telnet round-trip probe into pre-flight panel
Replaces the placeholder "skip — telnet round-trip probe not yet
implemented" branch with an actual call to POST /setup/telnet-probe
when SSH is unreachable but Telnet:17000 is. SSH-less speakers now
get real reachability verification before any migration step runs,
instead of being silently ignored by the pre-flight pipeline.

Decision tree for the reachability check:

  - SSH reachable      → HTTPS connection test from device (existing)
  - Telnet:17000 only  → Telnet round-trip probe (new)
  - neither            → skip with "no transport reachable" message

The probe row reports its result inline with the existing pre-flight
panel idiom (🕐 / ⟳ /  / ), surfacing elapsed_ms on success so
users see how long the round-trip took. Failure messages from the
backend (timeout, sys configuration rejected, dial refused) propagate
verbatim so the user knows which step of the orchestration tripped.

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 102770e301 feat(web): account pairing folded into Plan card and Apply orchestrator
Pairing was previously its own post-telnet pop-up pane —
loadAccountIDSuggestions(deviceId) was called only after a successful
telnet migration, leaving the user to interact with a separate panel
and click a separate "Pair Account" button. XML migrations didn't
surface pairing at all.

The Plan card now has its own Account pairing section between Service
URLs and Suggested plan, with the same affordances (current state,
7-digit input, Generate button, datastore picker) but always
visible. The implicit intent — read by readPlanPairTarget — is:

  - empty input + currently paired      → no pairing step (current ID kept)
  - empty input + currently unpaired    → no pairing step (warning hint visible)
  - input matches summary.account_id    → no pairing step
  - input is exactly 7 digits, differs  → pair step queued at Apply
  - input is non-empty but malformed    → blocks Apply with a clear error

Both Apply orchestrators (applySuggestedPlan, applyCustomPlan) now
queue a `pairAccount(deviceId, accountId)` call when the intent says
to. It runs *after* the URL flip / DNS / CA steps so the user sees
the migration succeed before pairing — pairing is independent of
the migration target so order is purely UX. First-failure-aborts is
preserved: a pair-account error stops the rest of the sequence.

Removed:
  - #pair-account-pane HTML and all its descendants
  - loadAccountIDSuggestions / generateAccountID / pairAccount(deviceId)
    (the old pane-bound functions)
  - the "if method === telnet → loadAccountIDSuggestions" trigger in migrate()

Added:
  - renderPlanPairing(summary, deviceId) — populates the section on
    every showSummary
  - loadPlanAccountSuggestions(deviceId) — fetches /setup/account-id-
    suggestions; gracefully degrades on failure
  - onPlanPairIDChange / onPlanPairPick / generatePlanAccountID — UI
    handlers with implicit-intent status hints
  - readPlanPairTarget — orchestrator-facing intent extractor
  - pairAccount(deviceId, accountId) — POSTs and throws on failure
    (replaces the old pane-bound function with a step-friendly shape)
  - resetPlanCardForDeviceSwitch clears the pairing input on speaker
    change so the previous device's ID can't leak

Backend untouched — all the pairing endpoints (/setup/account-id-
suggestions, /setup/pair-account) and the setup.PairAccount + telnet-
fallback logic stay exactly as-is.

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 a7c9bb1eae feat(web): visible pre-flight panel runs the same checks the Test buttons run
Replaces the silent confirm()-dialog pre-flight with an inline panel
that pops up the moment Apply is clicked, walks through each
applicable check live, and surfaces the result before any backend
operation touches the speaker.

Three checks run in order:

  1. Backend summary re-check (always) — the existing
     runPreflightCheck logic, repackaged as the first row in the
     panel. Catches transport/resolve_ip drift since the cached
     summary loaded.
  2. HTTPS connection from the device (when SSH is reachable) —
     reuses /setup/test-connection with use_explicit_ca=true so the
     test exercises the trust path even when CA install is part of
     the plan. Identical to the manual "Test with Explicit CA.crt"
     button under HTTPS Connection Test, but runs without requiring
     the user to click it. SSH-less devices show a "skip" row with
     a note pointing at the future telnet round-trip probe.
  3. DNS redirection from the device (only when resolv is in the
     plan and SSH is reachable) — reuses /setup/test-dns. Same
     parity as #2 with the manual "Test DNS Redirection" button.

UX:

  - Each check renders with 🕐 pending → ⟳ running →  ok / 
    fail / — skipped, so the user sees feedback while the backend
    works.
  - On all green: a 700ms hold lets the success state register, then
    Apply auto-proceeds.
  - On any red: a "Proceed Anyway" / "Cancel" pair appears; default
    is to abort, but the user can override on a known false-positive.

Both Apply paths (applySuggestedPlan and applyCustomPlan) now share
runApplyPreflight and awaitPreflightDecision; the unused
confirmPreflightIssues helper is removed.

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 1d7f8e621e feat(web): authoritative pre-flight check before Apply
The Plan-card preview is now optimistic and renders client-side on
every keystroke (previous commit), so the view can drift from what
the backend would actually do — at least until the next summary
fetch. Runtime state can also drift between the cached summary the
user is looking at and the moment they click Apply (a transport
goes down, DNS hostname stops resolving, etc).

Adds runPreflightCheck which both Apply paths call once before
kicking off any backend operation:

  - applySuggestedPlan calls it with the single chosen method.
  - applyCustomPlan calls it with the full list of operations the
    sequence will run (flip method, optional resolv, optional
    trust-ca) so the SSH/Telnet reachability requirement is checked
    against the actual fresh summary, not the stale cached one.

The check covers four classes of inconsistency:

  - resolve_ip_error from the device's perspective
  - SSH reachable when xml / resolv / trust-ca is queued
  - Telnet:17000 reachable when telnet is queued
  - The backend's planned_config XML contains every per-field URL
    override we're about to send (sanity check that the client's
    optimistic preview agrees with the server's render before we
    write to the speaker)

On any issue, confirmPreflightIssues shows them in a confirm()
dialog so the user can override on a known-false-positive (slow
DNS, etc.) but the default is to abort.

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 af6fe78f3f feat(web): live planned-XML preview + reset stale form state on device switch
Two related fixes for the Plan-card → Customize-pane preview flow:

1. Live planned-XML preview. The Customize panel's "Planned Config
   (AfterTouch)" pane previously showed summary.planned_config —
   server-rendered, only updated on the next showSummary fetch. So
   editing a URL field in the Plan card had no visible effect on the
   preview until the user manually refreshed. The new
   renderPlannedXMLPreview composes the same XML client-side from
   plan-target-url + the four override inputs, mirroring exactly what
   migrateViaXML writes (target-derived defaults + applyURLOverrides),
   and is called from validatePlanURLs which already runs on every
   keystroke.

2. Per-device form-state isolation on speaker switch. The Plan card
   inputs preserve manual edits across summary refreshes (force=false)
   so a user's typed URL doesn't get clobbered by a re-fetch. That
   semantic is right within one device but wrong across devices: if
   the user edited a URL on speaker A and then picked speaker B in
   the dropdown, A's value silently appeared in B's preview.

   showSummary now compares the previous summary-device-id to the new
   one and, on change, calls resetPlanCardForDeviceSwitch to clear
   the four URL inputs, the Soundcork checkbox, the "saved" hint
   dataset, the URL-validation banner, and both apply-status lines.
   The downstream fillPlanURLInputs(defaults, force=false) then fills
   the now-empty inputs with the new device's canonical defaults.

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 967d516d4d fix(web): pair Current/Planned diffs per axis instead of mixing them
With XML+resolv selected together, the bottom panes rendered as
"Current XML | Planned XML | Planned resolv hook" plus a separate
full-width "Current /etc/resolv.conf" block above — three panes plus
a hanger above, each pair scattered.

Restructured into two side-by-side .diff-container rows that each
pair their own Current/Planned columns:

  - #xml-diff-row    — Current Config (on Speaker)   | Planned Config (AfterTouch)
  - #resolv-diff-row — Current /etc/resolv.conf      | Planned /etc/resolv.conf Hook

current-resolv-pane moved out of its standalone wrapper into the
resolv row. The deprecated #planned-hosts-pane is removed entirely
(hosts is no longer offered as a method, per the earlier UI cleanup).

onCustomizeChange now toggles the row IDs instead of per-pane IDs,
and uses display:"" rather than display:"block" so the .diff-container
flex layout isn't accidentally overridden.

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 12165ba58a feat(web): Customize panel — three-axis form with Apply Custom Plan
Replaces the migration-method dropdown and its toggleMigrationMethod
visibility logic with a unified three-axis form inside the Customize
details:

  - URL flip transport: XML over SSH / Telnet (Port 17000) / Skip
  - DNS interception:   None / /etc/resolv.conf hook
  - Local CA install:   checkbox (SSH-only)

Each radio/checkbox has a transport-availability hint next to it
(e.g. "(SSH unreachable)" or "(already trusted)") so users see *why*
an option is disabled before they pick. renderCustomizeForm runs on
every summary load to recompute these hints and pick a valid initial
selection when the previous default isn't reachable.

applyCustomPlan orchestrates the chosen combination as a sequence of
existing backend calls:

  - URL flip != none → POST /setup/migrate?method={xml,telnet}
  - DNS = resolv     → POST /setup/migrate?method=resolv
                       (already includes the CA install, so an explicit
                       CA step is skipped in that case)
  - CA install only  → POST /setup/trust-ca

Steps run in order; the first failure aborts the rest. After the
sequence completes, refreshSummary repopulates the state card.

migrate() now takes the method as an explicit parameter instead of
reading it from the dropdown; applySuggestedPlan and applyCustomPlan
both pass it directly. The legacy "Confirm Migration" button is
removed (Apply Custom Plan supersedes it). The reboot-method picker
now reads the URL flip radio rather than the dropdown.

The legacy per-method preview/test panes (xml-diff, planned-xml,
planned-resolv, current-resolv, dns-redirection-test) become
visibility-driven by the radio choices via onCustomizeChange instead
of the dropdown's toggleMigrationMethod (now removed). The hosts-
related panes are forced hidden — hosts is the deprecated method.

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 670252b230 refactor(web): remove legacy service-options table and Telnet URL Targets
The Plan card's per-field URL editor now drives both XML and Telnet
migrations via the same marge_url / stats_url / sw_update_url / bmx_url
options, so the two duplicate places that used to set those values are
gone:

  - The XML method's "Service Implementations" table (#service-options)
    with its self/proxied/original dropdowns. The legacy options keys
    (marge / stats / sw_update / bmx) stay accepted by the backend's
    applyProxyOptions for any direct API user, but the UI no longer
    sets them.
  - The "URL Targets" sub-pane inside #telnet-method-pane with its
    parallel set of telnet-marge-url / etc. inputs and its own
    Reset-to-defaults button. The Telnet pane retains its
    explanatory header and limitations note (no CA install, pairing
    panel below) — only the duplicate URL editor is gone.

Stripped the now-dead JS:

  - showSummary's #service-options visibility toggle and
    parsed_current_config-driven population of orig-marge etc.
  - showSummary's reads of opt-marge / opt-stats / opt-sw_update /
    opt-bmx in the summary query string.
  - migrate's reads of those same fields in the migrate query string.
  - fillTelnetURLInputs / readTelnetURLOptions /
    resetTelnetURLsToDefaults / defaultTelnetURLs entirely.
  - renderTelnetPreflight entirely (its writes were all into the
    removed elements; the state card and Plan card now own all the
    surfaces it used to populate).
  - toggleMigrationMethod's serviceOptions branches.

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 3dd3e3eaef feat(web): per-field URL editor with validation in the Plan card
Adds a Service URLs section to the Plan card with four free-form URL
inputs (margeServerUrl, statsServerUrl, swUpdateUrl, bmxRegistryUrl), a
"Current on Device" column populated from telnet getpdo (falling back
to the SSH-read XML config), a Soundcork-mode checkbox that flips the
/marge suffix on margeServerUrl, and a Reset-to-defaults button.

Validation runs on every keystroke (oninput) and on each summary
render: each URL must parse via the URL constructor, the scheme must
be http or https, the hostname must be non-empty, and "localhost" or
"127.0.0.1" are explicitly rejected (the speaker can't reach this
machine via that name). Invalid inputs get a red border, an inline
error list surfaces under the table, and the Apply Suggested Plan
button is disabled until everything is valid. migrate() also gates on
validatePlanURLs() and surfaces a clear status message rather than
sending typoed URLs that would silently brick the speaker.

The Plan card's per-field URLs feed both XML and Telnet migrations
via the marge_url / stats_url / sw_update_url / bmx_url options the
backend's applyURLOverrides honors. The legacy XML dropdowns
(self/proxied/original) and the duplicate URL Targets table inside
the Telnet pane stay in the markup for now — the next iteration
removes them once we're confident the Plan card flow covers
everything.

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 d48aa63b9a feat(telnet,web): relax timeouts and hint at transient probe failures
Two halves of the same flakiness fix:

  - pkg/telnet defaults: dial 2s→4s, read 5s→7s, write 2s→3s,
    idleWindow 400ms→600ms. The diagnostic shell on FW 27.0.6
    occasionally takes >2s to accept a fresh TCP connection (likely
    while servicing other work), and the previous tight budget
    produced flaky preflight results on healthy speakers that
    consistently recovered on a second attempt.

  - state card: when the probe error wraps an i/o timeout / "timed out"
    / "connection reset", the panel now appends a hint pointing the
    user at the ↻ refresh button next to the device dropdown — instead
    of leaving the user to assume telnet is permanently unreachable.
    looksTransient() keeps the substring match conservative so genuine
    "connection refused" / "host unreachable" errors keep the original
    framing.

The 4s dial budget adds at most ~2s to summary loads on devices
where telnet is genuinely down; that's an acceptable trade-off for
removing the false-negative reports.

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 c5362be11b refactor(web): drop obsolete overview lines, fold actions into state card
The state card now duplicates everything the legacy overview
paragraphs reported, so the redundant block between the card and the
Customize details was visible-but-stale: SSH/Telnet status, the two
Backup status paragraphs, Remote Services line, and the AfterTouch
Local Root CA Trusted line.

Removed wholesale, plus the original-config-pane and toggleOriginalConfig
that the Show Original Config button drove. Kept "Trust CA Now" and
"Download CA cert" (per user request), relocating both into the state
card's CA / TLS cell as inline actions next to the verdict — the
verdict text now writes to a #state-ca-line sub-span so re-renders
don't clobber the buttons.

Also gated the HTTPS Connection Test pane on summary.ssh_success: the
backend's TestConnection uploads a temp CA file and runs curl on the
device via SSH, so the panel makes no sense when SSH isn't reachable.
A telnet-poke + service-side observation alternative is on the roadmap
but not implemented yet.

Stripped the dead JS branches that wrote to ssh-status, ca-trust-status,
remote-services-status/found, original-config-status, no-original-config-status,
original-config-content, original-config-pane, and backup-config-btn.

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 b7175289c2 fix(web): clear DNS port warning when leaving the resolv method
toggleMigrationMethod()'s XML branch never reset
#dns-port-warning, so switching from resolv back to xml left the
"DNS Discovery is DISABLED" warning visible while the XML method was
selected — where the warning is irrelevant.

Reset the display to "none" in the default (XML) branch alongside
the existing telnet/hosts branches that already do this. The next
iteration's redesign of the Customize panel folds this state into
per-method preconditions and removes the global warning entirely;
this fix keeps the current UI honest until then.

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 b035dd3bf9 feat(web): Plan card with capabilities header, suggestion, save-as-default
Step-2 wizard, foundation iteration. Adds a new Plan card below the
state card on the Migration tab with three sections:

  - Target service URL: editable input mirrored bidirectionally with
    the canonical #target-domain field on Settings, plus a "Save as
    default" button that POSTs to /setup/settings (preserving other
    fields and the "***" secret-unchanged convention).
  - Capabilities: which transports the speaker exposes (SSH and
    Telnet:17000), and which migration recipes AfterTouch can offer
    given those transports — the "possible vs supported" surface that
    teaches the user *why* options are available before they pick.
  - Suggested plan: a one-click "Apply Suggested Plan" button driven
    by computeSuggestedPlan. The conservative default picks XML over
    SSH with HTTP (no DNS, no CA install) when SSH works; falls back
    to Telnet:17000 + HTTP when only telnet is reachable; and
    explains the absence of a path otherwise. Already-migrated
    devices show an info message instead of a button.

The legacy Migration Method dropdown, per-method panes, and action
buttons (Confirm/Revert/Reboot/Cancel) are preserved verbatim but
wrapped in a <details>"Customize this migration"</details> that opens
on demand. After a successful migrate(), the customize section is
auto-expanded so the prominent Reboot affordance is reachable from
the suggested-plan flow too.

The Apply button currently delegates to the existing migrate() entry
point by setting the dropdown value programmatically, which keeps the
options-plumbing path identical until the next iteration moves the
per-field URL editor and validation into the Plan card.

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 d6e9639d89 fix(web): URL Configuration verdict respects DNS interception
The URL Configuration cell flagged "Original (Bose cloud)" with a red
 even when the DNS hook (or /etc/hosts redirects, deprecated though
it is) was actively intercepting those hostnames and routing them at
AfterTouch — i.e. the expected migrated state for the DNS method.

urlConfigVerdict now factors in resolv_migrated/hosts_migrated:

  - URL flip (xml or telnet) active            →  "AfterTouch URLs"
  - URL flip not active, DNS interception on   →  "Original (Bose
    cloud) — intercepted via DNS, device reaches AfterTouch"
  - URL flip not active, no DNS interception   →  "Original (Bose
    cloud) — not intercepted, device will reach the real Bose cloud"

The third case is the only one that's actually broken; the first two
are valid migrated states for different methods.

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 9160d803da feat(web): three-axis state card at top of migration summary
The migration summary now opens with a dedicated state panel that
surfaces, in three tight blocks:

  - Transports — SSH and Telnet:17000 reachability, telnet banner if
    any, and a probe-error sub-line when a TCP dial succeeded but the
    shell rejected getpdo.
  - Migration State — three rows for the orthogonal axes: URL
    Configuration (verdict from xml_migrated/telnet_migrated, with the
    four URL fields shown as on-disk vs live pairs underneath), DNS
    Interception (resolv hook / hosts redirects / none), and CA / TLS
    (local root CA installed yes/no).
  - Preconditions — remote_services persistence, account-pairing
    state (from is_paired / live margeAccountUUID), and the XML
    .original backup presence.

Pure UI restructuring of data the backend already exposes. The
existing dropdown, method-specific panes, diff view, and per-field
service-options table are untouched so step 2 (the wizard refactor)
can replace them in a focused diff. The legacy SSH/Telnet status
paragraphs and the cross-check warnings banner stay below the card
during the transition; the next iteration removes them once the card
is the canonical surface.

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 ebbc1209e5 feat(web): refresh button next to migration device dropdown
Adds a circled-arrow (↻) button beside the migration tab's device
dropdown that re-runs the summary fetch for the selected speaker.
Reuses the existing refreshSummary() entry point, which now also
falls back to the dropdown value when no summary has been loaded yet
so the button works on a freshly-selected device too.

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 27dccc779f feat(web): per-field telnet URL inputs, preflight status, warnings
The migration tab gains:

  - Telnet (Port 17000) status line in the summary box, mirroring the
    SSH connection line. Shows /, the device's diagnostic shell
    banner if any, and a probe-error block when a TCP dial succeeded
    but the shell rejected getpdo.
  - Cross-check warnings banner that surfaces summary.warnings (the
    SSH-XML vs telnet-getpdo URL diffs from the parallel preflight) as
    informational notices above the migration controls.
  - URL Targets table inside the telnet method pane with four editable
    inputs (Marge, Stats, Software Update, BMX Registry) pre-filled
    from the canonical defaultTelnetURLs(target_url) derivation. Each
    row shows the device's current value alongside, parsed from
    summary.telnet_verified_config. A "Reset to defaults" button wipes
    user edits in the table.
  - Migrate / Reboot buttons now enable when *either* SSH or telnet is
    reachable, so the SSH-less telnet path can actually be triggered
    from the UI.

The four URL inputs are folded into the migrate query string as the
marge_url / stats_url / sw_update_url / bmx_url options the handler now
recognises. Empty fields are omitted so the service's
telnetURLsFromOptions canonical fallback runs.

JS helpers parseTelnetVerifiedConfig and defaultTelnetURLs mirror the
Go-side parseGetpdoConfig and defaultTelnetURLs — keep them in sync.

I cannot run a browser test from this environment, so this change is
verified only by go build, the Go test suite (setup + handlers, race),
and node --check on the modified script.js. Worth a manual smoke test
of: switching to telnet, observing the inputs pre-fill, editing one
field, kicking off a migration, and reading back the warnings banner
on a freshly-migrated speaker.

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 909a85883a feat(handlers): allow per-field telnet URL keys in migration options
Extracts the migration-options query-string parsing into a single
parseMigrationOptions helper used by both HandleGetMigrationSummary and
HandleMigrateDevice. The allow-list now covers two families:

  - marge / stats / sw_update / bmx (XML method's per-field
    self|proxied|original implementation selectors, unchanged)
  - marge_url / stats_url / sw_update_url / bmx_url (telnet method's
    per-field URL overrides; empty values fall back to the canonical
    derivation in setup.telnetURLsFromOptions)

Unknown keys are still dropped, so the manager only sees parameters the
handler explicitly opted into. Tests cover the allow-list, the noise
filter, and the empty-query case.

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
Tobias GesellchenandClaude Opus 4.7 eab1b7a15a fix(security): close go/path-injection alerts via os.Root containment
The previous filepath.IsLocal-up-front pattern in safeJoin/safeJoin-equivalents
turned out not to satisfy CodeQL's go/path-injection rule — the post-validation
filepath.Join still constructs the joined string from tainted input, so the
analyser conservatively assumes the os.* sink that consumes it is tainted
too. Only one of 34 alerts closed on the previous attempt.

Switch to *os.Root (Go 1.24+, available on the project's 1.26.3 toolchain).
The Go runtime guarantees that operations on a Root cannot escape the
anchored directory regardless of what's in the relative path, and CodeQL has
a built-in model that recognises *os.Root.* methods as path-traversal
sanitisers. Result: every os.* sink in the datastore, marge, recorder,
mirror parity-mismatch writer, and docs handler is now reached only via a
*os.Root, which closes the rule-level alerts cleanly.

Changes per file:

* pkg/service/datastore/datastore.go — Adds a `root *os.Root` to DataStore,
  lazily opened at first use (after MkdirAll-ing baseDir) and closed by a
  new `(*DataStore).Close()`. Adds package-private helpers
  (rootStat / rootReadFile / rootWriteFile / rootMkdirAll / rootRemove /
  rootRemoveAll / rootRename / rootReadDir / rootOpen / rootExists) plus
  three exported wrappers (ReadDirUnderBase, MkdirAllUnderBase,
  WriteFileUnderBase) for the cross-package marge / handlers callers.
  Every os.* call that previously consumed safeJoin output now goes through
  these helpers. The post-join belt-and-suspenders prefix check inside
  safeJoin is preserved as a defence-in-depth fallback.

* pkg/service/marge/marge.go — Replaces the five `os.ReadDir(devicesDir)`
  call sites with `ds.ReadDirUnderBase(...)` so the datastore's root
  enforces containment.

* pkg/service/proxy/recorder.go — Mirrors the datastore pattern with its
  own `root *os.Root` anchored at Recorder.BaseDir, lazily opened. New
  helpers convert the eight existing `os.*` sites that consume sessionID
  / relPath / sanitizedSegments inputs. The earlier safeJoin (filepath.IsLocal
  pre-check) stays in place as the same belt-and-suspenders guard.

* pkg/service/handlers/handlers_docs.go — Opens a *os.Root at "docs" via
  sync.Once and reads file content (and SUMMARY.md sidebar) through it.
  Removes the prior filepath.IsLocal pre-check; the runtime now guarantees
  containment.

* pkg/service/handlers/mirror_middleware.go — Routes the parity-mismatch
  JSON write through `s.ds.WriteFileUnderBase` so the datastore's root
  performs the path-traversal sanitiser.

Behavioural fix: *os.File.ReadDir(-1) returns directory entries in
filesystem order, but os.ReadDir is documented to sort by name and at least
one regression test
(handlers.TestMargeAccountFullExcludesEmptyAmazonSource) depends on the
sorted contract. Both rootReadDir helpers explicitly sort by name to match.

All test suites pass for the touched packages; the unrelated
TestDocsConsistency failure about untracked working-tree docs is
pre-existing. golangci-lint reports 0 issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:18:24 +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 dc1f811a81 docs(zeroconf): clearer literal-IP error and a Security Considerations note
Building on the strict literal-IP validator from the previous commit,
make the runtime error self-explanatory so anyone tripping on a
hostname URL can fix it in one shot:

* Errors now lead with the offending zeroconf URL and the rejected
  host, so wrapping by GetInfo / PushCredentials / pushSimplifiedToken
  doesn't bury the actual bad value.
* The "host must be a literal IP" error suggests two concrete one-liner
  resolutions (`getent hosts <name>` and `dig +short <name>`) so the
  user has a copy-paste fix.
* The "host is not on a local network" error names the accepted ranges
  (loopback / RFC1918 private / link-local v4+v6) so the user knows
  what they're allowed to pass.

docs/guides/SOUNDTOUCH-SERVICE.md gains a bullet under Security
Considerations explaining the constraint and the rationale (LAN-resident
SSRF surface), so the strict behaviour is documented rather than a
surprise.

The 17 TestValidateZcBaseURL cases still pass — only the message bodies
changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:30:55 +02:00
Tobias GesellchenandClaude Opus 4.7 fbde4e136f fix(security): tighten zeroconf URL validation to literal local IPs
CodeQL re-fired three new go/request-forgery alerts (#134/135/136) on
the lines my previous validateZcBaseURL refactor introduced. The
previous validator accepted hostname-style hosts unchanged, so even
though the IP-class check ran when applicable, u.String() at the call
sites still emitted the original tainted host into the request URL —
which is exactly what CodeQL traces.

Tighten validateZcBaseURL to:

* require the host to parse as a literal IP — DNS / mDNS hostnames
  are rejected (with a clear error explaining the caller should
  resolve to a private IP first); doing the lookup inside the
  validator would re-introduce the SSRF surface CodeQL is flagging,
  because malicious DNS could point a *.local name at a public host
  between the lookup and the request.
* require that IP to be loopback / RFC1918 private / IPv4-or-IPv6
  link-local. Anything else (global IPs in either family) is refused.
* rebuild the returned *url.URL from validated components — scheme
  (already checked), the validated IP literal joined with the
  original port, and the original path. Pre-existing query/fragment
  are stripped so callers attach their own ?action= cleanly. CodeQL
  recognises this fresh-construction pattern as taint sanitisation.

In practice this matches what SoundTouch speakers actually announce:
IP-based zeroconf URLs at port 8200 against an LAN address. The
existing PushCredentials_FullRoundTrip and FallbackOnGetInfoFailure
tests already exercise the loopback path through httptest.NewServer
and pass unchanged.

Adds TestValidateZcBaseURL covering 17 inputs — 9 accept (loopback,
private 10/172/192, link-local v4, IPv6 loopback, IPv6 link-local,
strips query) and 8 reject (public IPv4, public IPv6, hostname,
plain hostname, ftp/file schemes, empty host, unparseable) — to lock
the new contract in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:30:55 +02:00
Tobias GesellchenandClaude Opus 4.7 339dc80bf1 feat(proxy): add UnsafeLogCredentialHeaders escape hatch for debugging
The previous commit made credential-header redaction unconditional in
proxy log output, which is the right safety floor for production but
inconvenient for local debugging when a developer wants to inspect
Authorization / Cookie / X-Bose-Token values flowing through the
service.

Add an explicit "I-know-what-I-am-doing" toggle:

* New LoggingProxy.UnsafeLogCredentialHeaders bool field.
* Default off — the redaction floor stays in place.
* Reads the LOG_PROXY_CREDENTIALS env var so a developer can flip it
  on without recompiling, mirroring the existing LOG_PROXY_BODY
  pattern.
* When true, formatHeaders skips both the always-sensitive floor and
  the broader Redact policy, so log lines contain raw header values.

CodeQL's go/clear-text-logging rule continues to be satisfied because
the default code path still redacts; only an explicit opt-in via
configuration produces unredacted output, mirroring how
AllowInsecureUpstreamTLS works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:30:55 +02:00
Tobias GesellchenandClaude Opus 4.7 fb75c8b1f3 fix(security): validate zeroconf URLs against local-network allowlist
CodeQL alerts #121, #122, #123 (go/request-forgery) flagged the three
client.Get / client.PostForm sites in pkg/service/zeroconf/zeroconf.go
that build their request URL by string-concatenating the caller-supplied
zcBaseURL with "?action=…". The base URL ultimately originates from a
device-pairing payload that the speaker pushes to us, so unvalidated
input could redirect outbound HTTP requests to arbitrary hosts (server-
side request forgery).

Add validateZcBaseURL which:

* parses zcBaseURL via net/url so the scheme and host are first-class
  values rather than substrings,
* requires the scheme to be http or https,
* rejects literal IP hosts that aren't loopback / RFC1918 private /
  link-local — those are the only places a real SoundTouch speaker
  can live on a local network, and a global IP would be an obvious
  exfiltration target,
* leaves hostname-style hosts (e.g. mDNS *.local) accepted: name
  resolution itself is a separate trust boundary on the local segment.

A small withAction helper builds the per-call URL from the validated
base URL via url.Values rather than string concatenation, which CodeQL
recognises as a non-tainted construction.

GetInfo, PushCredentials and pushSimplifiedToken each call
validateZcBaseURL up-front so all three CodeQL alerts close in a
single pass. PushCredentials also re-validates even though it then
calls GetInfo (which validates again) so the fallback to
pushSimplifiedToken on getInfo failure is also gated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:30:55 +02:00
Tobias GesellchenandClaude Opus 4.7 fbeae8bb11 fix(security): make upstream TLS verification opt-in via settings flag
CodeQL alerts #70 and #71 (go/disabled-certificate-check) flagged the
hard-coded `InsecureSkipVerify: true` in handlers_proxy.go (the
/proxy/{url} reverse proxy) and mirror_middleware.go (the parity-check
mirror). Both target *.bose.com whose certificate chain is becoming
unreliable post end-of-service, but unconditionally disabling
verification is still wrong: a deployment that doesn't actually need
the bypass loses TLS hygiene for free.

Add an `AllowInsecureUpstreamTLS bool` field to datastore.Settings,
default false. Read it in both call sites — they aren't on a hot path
— and pass the value as InsecureSkipVerify. CodeQL accepts the
configurable boolean as a non-flag (vs. the previously hard-coded
`true`), and the runtime behaviour now defaults to verifying
certificates with an explicit opt-in for the broken-chain scenario.

Behaviour change: TLS upstream traffic is verified by default. Anyone
relying on the previous always-skip behaviour can re-enable it by
setting `"allow_insecure_upstream_tls": true` in settings.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:30:55 +02:00
Tobias GesellchenandClaude Opus 4.7 be45b3485d fix(security): always redact credential headers in proxy logs
CodeQL alert #43 (go/clear-text-logging) flagged that headers flow to
log.Printf in pkg/service/proxy/proxy.go. The existing implementation
only redacted when LoggingProxy.Redact was true — an opt-in. CodeQL is
right to flag this: the safety floor for credential-bearing headers
should not depend on caller configuration.

Split the sensitive-header list into two:

* alwaysSensitiveHeaders — Authorization, Proxy-Authorization, Cookie,
  Set-Cookie, X-Api-Key, X-Bose-Token. Redacted unconditionally,
  regardless of LoggingProxy.Redact.
* sensitiveHeaders — kept as a compatibility alias pointing at the same
  list, and still gated on Redact for any future use cases that want
  *additional* opt-in redaction beyond the floor.

Behaviour change is strict tightening: nothing that was previously
hidden becomes visible, and credentials that would have been logged
when Redact was false are now hidden by default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:30:55 +02:00
Tobias GesellchenandClaude Opus 4.7 426232e699 fix(security): close go/reflected-xss alerts via html.EscapeString
CodeQL flagged five reflected-XSS sites where caller-supplied query
parameters or path segments were concatenated into HTML responses
without escaping:

* handlers_mgmt.go:147 — Spotify oauth error landing page
* handlers_mgmt.go:490 — Amazon oauth error landing page
* handlers_docs.go:65 — <title> built from r.URL.Path
* recorder_middleware.go:83, mirror_middleware.go:205 — passthrough
  Write()s carrying tainted bytes from the three sources above

Wrap each user-controlled value in html.EscapeString before it lands
in the HTML body. The escaped output covers the upstream sources so
the middleware passthrough alerts close as well.

For handlers_docs the rendered markdown (`output`) and sidebar are
server-controlled (loaded from on-disk doc files) and intentionally
contain HTML, so only the URL path is escaped — the documentation
content itself still renders normally.

Handler test suite passes; pre-existing TestDocsConsistency failure
about untracked working-tree docs is unrelated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:30:55 +02:00
Tobias GesellchenandClaude Opus 4.7 648eedefde fix(security): close go/path-injection alerts via filepath.IsLocal sanitiser
CodeQL flagged 34 go/path-injection alerts across datastore.go, marge.go,
recorder.go, handlers_docs.go and mirror_middleware.go. The existing
defences (DataStore.safeJoin's post-join prefix check, handlers_docs's
HasPrefix(filepath.Clean(...))) are functionally correct but sit
downstream of the join, so CodeQL's interprocedural taint tracking
treats every os.* sink that consumes them as still tainted.

Move the validation up-front using filepath.IsLocal, which CodeQL
recognises as a path-traversal sanitiser. IsLocal rejects absolute
paths, ".." segments, and (on Windows) reserved device names — the
same set the existing checks intended to block, just expressed in the
shape the analyser understands.

Changes:

* DataStore.safeJoin (datastore.go) — pre-validates each non-empty
  element with filepath.IsLocal before joining. Existing post-join
  prefix check stays as belt-and-suspenders. ~30 of the 34 alerts
  flow through this helper.

* Recorder (recorder.go) — adds a new (*Recorder).safeJoin method
  with the same sanitiser. getRecordingDir, DeleteSession,
  GetInteractionContent and ArchiveSession route through it; their
  signatures already returned error so plumbing it through is local.

* HandleDocs (handlers_docs.go) — replaces the post-join HasPrefix
  check with an up-front filepath.IsLocal gate.

* Mirror parity recorder (mirror_middleware.go) — also strips
  backslash separators (Windows) and gates the resulting filename
  component on filepath.IsLocal, falling back to "invalid" rather
  than letting malformed paths reach os.WriteFile.

No behaviour change for legitimate inputs (account IDs, device IDs,
session IDs, doc paths all satisfy IsLocal). Datastore and proxy
test suites pass; handler suite's pre-existing TestDocsConsistency
failure is unrelated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:30:55 +02:00
Tobias GesellchenandClaude Opus 4.7 9ce42f3965 fix(ui): switch display-into-innerHTML status writes to textContent
Sweeps the remaining instances of the same pattern that triggered CodeQL
alert 132 in PR #240's review: status messages built by string-concatenating
the user-controlled `display` (device name) into `.innerHTML`. None of
these had ever needed HTML formatting; they're all plain status text.

Converts 26 sites across reboot(), revert(), migrate(), showSummary(),
trustCA(), ensureRemoteServices(), removeRemoteServices(), backup(),
plus fetchDevices' error fallback and the loadAccount sync log line.

The one site that genuinely needs intentional <strong> formatting — the
migrate() success message ("Please reboot the device to activate the
changes.") — is rebuilt with replaceChildren + createElement so the
device name still flows through createTextNode rather than HTML parsing.

Out of scope (intentionally left for a separate pass): the dashboard
table rows, account-metadata templates, and the error.message-into-
colored-span / redirectUrl-into-href patterns. Those are different
classes and benefit from a focused refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:14:49 +02:00
Tim Vahlbrock bc8213f0a1 change default discovery interval 2026-05-10 12:58:00 +02:00
Tim Vahlbrock 9ca5b88025 notes on storage limits 2026-05-10 12:58:00 +02:00
Tim Vahlbrock 8c01edaae4 allow usage of custom tmp directory for updates 2026-05-10 12:58:00 +02:00
Tim Vahlbrock 759c6da52a create tmp/aftertouch directory 2026-05-10 12:58:00 +02:00
Tim Vahlbrock 3da023aa78 make curl less verbose 2026-05-10 12:58:00 +02:00
Tim Vahlbrock 7d410eef24 store updates on tmp 2026-05-10 12:58:00 +02:00
Tim Vahlbrock b2dc2cb802 make curl verbose 2026-05-10 12:58:00 +02:00
Tim Vahlbrock bd2e594ba8 download updates to /media to not require additional storage space 2026-05-10 12:58:00 +02:00
Tim Vahlbrock 4257c100ac note on reverting the migration in uninstallation guide 2026-05-10 12:58:00 +02:00
Tim VahlbrockandTobias Gesellchen e008bb6a2b Apply suggestions from code review
Co-authored-by: Tobias Gesellchen <tobias@gesellix.de>
2026-05-10 12:58:00 +02:00
Tim Vahlbrock 1d9264437d add reference to on-device installer to README.md 2026-05-10 12:58:00 +02:00
Tim Vahlbrock ff8bf75982 make default version number the next minor release 2026-05-10 12:58:00 +02:00
Tim Vahlbrock dbe5b90d8d fix typo 2026-05-10 12:58:00 +02:00
Tim Vahlbrock 981ecf6d89 fix typo 2026-05-10 12:58:00 +02:00
Tim Vahlbrock 37d758f7f3 minor fixes 2026-05-10 12:58:00 +02:00
Tim Vahlbrock 370b587fcf feat: Provide scripts and documentation for on-device install 2026-05-10 12:58:00 +02:00
Tobias GesellchenandClaude Opus 4.7 e3dac8b5a6 fix(ui): close CodeQL js/xss-through-dom finding (PR #240 review)
CodeQL alert 132 flagged the reboot status line as a sink that received
user-controlled DOM text (device names from the migration/sync select
options and table rows) without escaping. Six data-flow paths converged
on script.js:1950.

Switch the sink at line 1950 from .innerHTML to .textContent — the
status message has never needed HTML formatting. The pre-existing
display-into-innerHTML pattern still exists elsewhere in this file but
those lines aren't in this PR's scope and are tracked by their own
historical alerts.

Also harden the (newer) `currentP.innerHTML = ... <strong> + data.current
+ </strong> ...` line in loadAccountIDSuggestions: rebuild the paragraph
with replaceChildren + createElement so the account ID never becomes
HTML, even though it's expected to be a 7-digit string.

Coerce known account IDs to String() when populating the existing-account
dropdown so the IDE's type inference stops complaining about
opt.value = id; / opt.textContent = id; on data of unknown[] type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:43:36 +02:00
Tobias GesellchenandClaude Opus 4.7 0b579a7e59 fix(ui): clarify telnet pane wording about deferred Pair Account panel
The previous copy said "see the panel below" while the Pair Account panel
is intentionally hidden until migration succeeds (loadAccountIDSuggestions
makes it visible). Reword so users know the panel will appear after they
click Migrate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:43:36 +02:00
Tobias GesellchenandClaude Opus 4.7 d3b1593953 docs(analysis): add device compatibility matrix for telnet migration
New section §8 records what is currently known about which devices and
firmware our migrateViaTelnet flow handles end-to-end, derived from the
six community sources catalogued in TELNET-COMMAND-REFERENCE.md plus our
issue threads.

* §8.1 — proven to work end-to-end (ST 10, 20, 300, Wave III, Wave IV on
  FW 27.0.6 with multi-reporter agreement).
* §8.2 — proven to need the PairAccount telnet fallback (ST Portable,
  BST20 Portable: /setMargeAccount missing or wedged on those firmware
  builds).
* §8.3 — likely to fail (SA-5 on FW 9.x with the older shell generation;
  newer ST Portable builds with shrunk command set). The preflight +
  abort-on-first-rejection design ensures these fail cleanly, leaving no
  half-configured state.
* §8.4 — unverified targets that are expected to work but lack concrete
  captures (ST 30, ST 520, Wave Music System I/II).
* §8.5 — flags the apparent contradiction between S5's enumerated
  "valid roots" on ST 10 / FW 27.0.6 (which omits envswitch) and #221's
  successful envswitch use on the same firmware. Most plausible reading:
  S5 is a non-exhaustive probe, not a negative claim; preflight catches
  any real absence.
* §8.6 — maps every failure mode to its observable outcome and the unit
  test that exercises it.
* §8.7 — TL;DR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:43:36 +02:00
Tobias GesellchenandClaude Opus 4.7 889470716b docs(analysis): add consolidated Telnet command reference
Synthesises every Bose SoundTouch port-17000 telnet command we have evidence
for, across six community sources: flarn2006's 2014 root-shell post,
Sam Hobbs's 2016 ST 10 setup-mode walkthrough, izndgroup's 2021 reissue,
sijeffrey's 2017 `bose` remote-control script, the 2026 r/bose telnet
probing thread (FW 27.0.6 ST 10), and our own #221 / #236 / soundcork#141
findings.

Groups the commands by family — `key` (front-panel button emulation, the
addition the Reddit thread brought in), `network` (WiFi profile management),
`sys` (verbs + the XML-tag-keyed `sys configuration` setter our migration
uses), `envswitch` (parallel persistence layer), `getpdo` (PDO read), `scm`,
`ws`, `swupdate`, and the historic shell-unlock commands. Each entry notes
firmware-era availability so implementations know whether to expect
"Command not found" on newer builds.

Records the four top-level command roots that S5 confirmed reachable on a
vanilla FW 27.x ST 10 (`key`, `net`, `sys`, `getpdo`), and flags that
`envswitch` works on other ST 20 / Wave models running the same firmware
family — a per-model variation the migration's preflight already handles.

Cross-linked from TELNET-MIGRATION-METHOD.md §2 and indexed in SUMMARY.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:43:36 +02:00
Tobias GesellchenandClaude Opus 4.7 12ca412ed2 feat(ui): wire up telnet migration method and account-id picker
* Migration dropdown gains a "Telnet (Port 17000) — no SSH required" option
  and drops the deprecated /etc/hosts entry from the visible choices. The
  hosts code path still exists in the backend for now; it is just no longer
  reachable through the UI.

* New `telnet-method-pane` shows a brief explanation, the HTTP-only
  limitation, and a hint that pairing may be required after migration.

* New `pair-account-pane` (initially hidden) renders three controls:
  - dropdown of accounts already in the local datastore (so a fresh device
    can be re-attached to an existing account),
  - 7-digit input field with HTML pattern validation,
  - a Generate button that picks a random non-colliding 7-digit ID.
  When :8090/info already exposes a margeAccountUUID the panel pre-fills
  it and offers to keep it; otherwise the device is treated as fresh.

* `pairAccount(deviceId)` POSTs to /setup/pair-account/{deviceId} with the
  selected ID and surfaces the breadcrumb (HTTP vs telnet fallback) in the
  status line.

* `reboot()` now passes ?method=telnet|ssh, derived from the migration
  method dropdown (telnet for telnet, ssh otherwise) so a device that was
  migrated without SSH access can also be rebooted without SSH access.

* After a successful telnet migration, `loadAccountIDSuggestions` runs
  automatically so the user is led straight into the pairing step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:43:36 +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
Tobias GesellchenandClaude Opus 4.7 d9894be7db docs(analysis): add Telnet (port 17000) migration method analysis
Documents the SSH-free third migration path on top of the device's diagnostic
shell, synthesised from #221, #236, scheilch/opencloudtouch#167,
deborahgu/soundcork#228, and deborahgu/soundcork#141.

Captures the URL configuration command sequence, the dual persistence layers
(`sys configuration` + `envswitch boseurls set`), the `/setMargeAccount`
failure modes (404, hang, post-migration 502 on power_on) with their bounded
fallbacks, port-17000 preflight requirements, and account-ID sourcing rules
(reuse from `:8090/info`, pick from `DataStore.ListAccounts`, or 7-digit
manual/randomized entry). Cross-links the new doc from
DEVICE-REDIRECT-METHODS.md, marks the `/etc/hosts` method as deprecated, and
fixes the existing margeServerUrl example to use our service's bare-URL
convention with an explicit note for soundcork's `/marge` sub-path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:43:36 +02:00
Tobias GesellchenandClaude Opus 4.7 255dd9612a fix(datastore): normalize AUX source to canonical id/type after sync (#233)
The on-device Sources.xml carries only displayName + sourceKey for AUX,
no id and no type. The previous read path synthesized id="2000001+i" and
type="AUX" (echoed from SourceKey.Type), which the speaker rejects as
INVALID_SOURCE once it pulls config from soundtouch-service after
migration. Look up known providers in getDefaultSources and fill
canonical id/type/sourceproviderid; also drop the AUX carve-out in
marge's ensureSourceType so existing poisoned type="AUX" entries are
normalized to type="Audio" at the served-XML layer.

Relates to https://github.com/gesellix/Bose-SoundTouch/issues/195

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:16:19 +02:00
Tobias GesellchenandClaude Opus 4.7 cf81fc033f ci: build all binaries on 7 platforms and publish PR preview Docker images (#237)
- Match release matrix: linux/amd64, linux/arm64, linux/armv7,
darwin/amd64, darwin/arm64, windows/amd64, freebsd/amd64; build cli,
service, web, backup
- Push Docker images on same-repo PRs with preview-pr-N /
preview-sha-<sha> tags so previews are unambiguous and tied to the PR
(forks build but skip push)
- Add a step summary listing each published image as docker pull
commands

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:12:27 +02:00
dependabot[bot]andlnx01 653652b57d deps(deps): bump the golang group with 6 updates (#231)
Bumps the golang group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [golang.org/x/crypto](https://github.com/golang/crypto) | `0.50.0` |
`0.51.0` |
| [golang.org/x/term](https://github.com/golang/term) | `0.42.0` |
`0.43.0` |
| [golang.org/x/image](https://github.com/golang/image) | `0.39.0` |
`0.40.0` |
| [golang.org/x/mod](https://github.com/golang/mod) | `0.35.0` |
`0.36.0` |
| [golang.org/x/sys](https://github.com/golang/sys) | `0.43.0` |
`0.44.0` |
| [golang.org/x/text](https://github.com/golang/text) | `0.36.0` |
`0.37.0` |

Updates `golang.org/x/crypto` from 0.50.0 to 0.51.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/crypto/commit/b8a14a8d65f88c0c79c139171f1354c69a6cdb8a"><code>b8a14a8</code></a>
go.mod: update golang.org/x dependencies</li>
<li><a
href="https://github.com/golang/crypto/commit/9d9d5078968ddb8a279092c665a24e7de4178778"><code>9d9d507</code></a>
x509roots/fallback/bundle: fix bundle test with Go 1.27+</li>
<li><a
href="https://github.com/golang/crypto/commit/fd0b90d21f9ab4b5dd398e9526b570bfea86e370"><code>fd0b90d</code></a>
acme: include Problem in OrderError.Error</li>
<li><a
href="https://github.com/golang/crypto/commit/b9e53593a6073e6a786c49e9ad27956a9b77e54e"><code>b9e5359</code></a>
pbkdf2: turn into a wrapper for crypto/pbkdf2</li>
<li><a
href="https://github.com/golang/crypto/commit/cc0e4fc1d49127130b0d00612a2eeed2ab745d40"><code>cc0e4fc</code></a>
hkdf: forward Extract to the standard library</li>
<li><a
href="https://github.com/golang/crypto/commit/a8e9237a216b050e1b11e041863825104a6811db"><code>a8e9237</code></a>
x509roots/fallback: update bundle</li>
<li>See full diff in <a
href="https://github.com/golang/crypto/compare/v0.50.0...v0.51.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `golang.org/x/term` from 0.42.0 to 0.43.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/term/commit/3c3e4855f7d2eb06c3e48933554add9ec6b599b5"><code>3c3e485</code></a>
go.mod: update golang.org/x dependencies</li>
<li>See full diff in <a
href="https://github.com/golang/term/compare/v0.42.0...v0.43.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `golang.org/x/image` from 0.39.0 to 0.40.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/image/commit/542a3d9571611fd83b47afa41e76e7c6c7b3f991"><code>542a3d9</code></a>
go.mod: update golang.org/x dependencies</li>
<li><a
href="https://github.com/golang/image/commit/5cbe89a0e573c3c4e2cc193c1e24d8401bdf3e60"><code>5cbe89a</code></a>
tiff: reject 0-size images</li>
<li>See full diff in <a
href="https://github.com/golang/image/compare/v0.39.0...v0.40.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `golang.org/x/mod` from 0.35.0 to 0.36.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/mod/commit/643da9ba74f1165d8cae1505d453b3de3cf21b7b"><code>643da9b</code></a>
go.mod: update golang.org/x dependencies</li>
<li><a
href="https://github.com/golang/mod/commit/ccc3cdf529d1eee2a832437eb1b85240044d21cb"><code>ccc3cdf</code></a>
zip: include 'but content has correct sum' note in TestVCS</li>
<li><a
href="https://github.com/golang/mod/commit/ab3031803214705d2c9f1102318b083e7086a155"><code>ab30318</code></a>
zip: update zip hashes for new flate compression</li>
<li>See full diff in <a
href="https://github.com/golang/mod/compare/v0.35.0...v0.36.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `golang.org/x/sys` from 0.43.0 to 0.44.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/sys/commit/fb1facd76f95fa87c151018200ea5e4892ff115d"><code>fb1facd</code></a>
windows: avoid uint16 overflow in NewNTUnicodeString</li>
<li><a
href="https://github.com/golang/sys/commit/94ad893e1e59c1d079221324d38945d2aad8703f"><code>94ad893</code></a>
windows: add GetIfTable2Ex, GetIpInterface{Entry,Table},
GetUnicastIpAddressT...</li>
<li><a
href="https://github.com/golang/sys/commit/54fe89f8411576c06b345b341ca79a77d878a4ad"><code>54fe89f</code></a>
cpu: use IsProcessorFeaturePresent to calculate ARM64 on windows</li>
<li><a
href="https://github.com/golang/sys/commit/df7d5d7b60641d17d87e2b50911124cb65f954fd"><code>df7d5d7</code></a>
unix: automatically remove container created by mkall.sh</li>
<li><a
href="https://github.com/golang/sys/commit/68a4a8e945b22751c1a619261b1d755372a1d5f7"><code>68a4a8e</code></a>
unix: avoid nil pointer dereference in Utime</li>
<li><a
href="https://github.com/golang/sys/commit/690c91f6ecf3b3ef141ad2aedb1306a868b3a176"><code>690c91f</code></a>
unix: add CPUSetDynamic for systems with more than 1024 CPUs</li>
<li>See full diff in <a
href="https://github.com/golang/sys/compare/v0.43.0...v0.44.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `golang.org/x/text` from 0.36.0 to 0.37.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/text/commit/3ef517e623a4bfc08d6457f87d73afda7af7d8e1"><code>3ef517e</code></a>
go.mod: update golang.org/x dependencies</li>
<li>See full diff in <a
href="https://github.com/golang/text/compare/v0.36.0...v0.37.0">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 21:42:39 +02:00
134 changed files with 17019 additions and 1082 deletions
+132 -40
View File
@@ -17,15 +17,15 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
- name: Cache Go modules
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.cache/go-build
@@ -53,7 +53,7 @@ jobs:
run: make test-http-client
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
file: ./coverage.out
flags: unittests
@@ -66,10 +66,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
@@ -77,7 +77,7 @@ jobs:
run: sudo apt-get install -y libpcap-dev
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
with:
version: latest
args: --timeout=5m
@@ -86,39 +86,76 @@ jobs:
name: Build
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
exclude:
# Windows ARM64 builds are experimental
- goos: windows
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: linux
goarch: arm
goarm: 7
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
- goos: windows
goarch: amd64
- goos: freebsd
goarch: amd64
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
- name: Build CLI
- name: Cache Go modules
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.mod') }}-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Build binaries
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOARM: ${{ matrix.goarm }}
CGO_ENABLED: 0
run: |
output_name="soundtouch-cli-${{ matrix.goos }}-${{ matrix.goarch }}"
if [ "${{ matrix.goos }}" = "windows" ]; then
output_name="${output_name}.exe"
ARCH_SUFFIX="${{ matrix.goos }}-${{ matrix.goarch }}"
if [[ -n "${{ matrix.goarm }}" ]]; then
ARCH_SUFFIX="${ARCH_SUFFIX}v${{ matrix.goarm }}"
fi
go build -trimpath -ldflags="-s -w" -o "$output_name" ./cmd/soundtouch-cli
EXT=""
if [[ "${{ matrix.goos }}" == "windows" ]]; then
EXT=".exe"
fi
mkdir -p build
for binary in soundtouch-cli soundtouch-service soundtouch-web soundtouch-backup; do
OUTPUT="build/${binary}-${ARCH_SUFFIX}${EXT}"
echo "Building $OUTPUT"
go build -trimpath -ldflags="-s -w" -o "$OUTPUT" "./cmd/$binary"
done
ls -la build/
- name: Upload build artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: soundtouch-cli-${{ matrix.goos }}-${{ matrix.goarch }}
path: soundtouch-cli-*
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
path: build/
security:
name: Basic Security Check
@@ -126,10 +163,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
@@ -153,7 +190,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check documentation links
run: |
@@ -211,10 +248,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
@@ -266,14 +303,28 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Determine push eligibility
id: push-check
run: |
# Push on main, and on same-repo PRs (forks can't push to GHCR via GITHUB_TOKEN).
SHOULD_PUSH="false"
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
SHOULD_PUSH="true"
elif [[ "${{ github.event_name }}" == "pull_request" && \
"${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then
SHOULD_PUSH="true"
fi
echo "should-push=$SHOULD_PUSH" >> "$GITHUB_OUTPUT"
echo "Will push: $SHOULD_PUSH"
- name: Log in to GitHub Container Registry
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v4
if: steps.push-check.outputs.should-push == 'true'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -281,20 +332,22 @@ jobs:
- name: Extract metadata (tags, labels) for soundtouch-service
id: meta-service
uses: docker/metadata-action@v6
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
type=ref,event=pr
type=ref,event=pr,prefix=preview-pr-
type=sha,prefix=preview-sha-,format=short,enable=${{ github.event_name == 'pull_request' }}
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
- name: Build and push soundtouch-service Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
target: soundtouch-service
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
push: ${{ steps.push-check.outputs.should-push == 'true' }}
tags: ${{ steps.meta-service.outputs.tags }}
labels: ${{ steps.meta-service.outputs.labels }}
cache-from: type=gha
@@ -302,25 +355,64 @@ jobs:
- name: Extract metadata (tags, labels) for soundtouch-web
id: meta-web
uses: docker/metadata-action@v6
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ghcr.io/${{ github.repository }}-web
tags: |
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
type=ref,event=pr
type=ref,event=pr,prefix=preview-pr-
type=sha,prefix=preview-sha-,format=short,enable=${{ github.event_name == 'pull_request' }}
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
- name: Build and push soundtouch-web Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
target: soundtouch-web
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
push: ${{ steps.push-check.outputs.should-push == 'true' }}
tags: ${{ steps.meta-web.outputs.tags }}
labels: ${{ steps.meta-web.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Summarize published images
if: steps.push-check.outputs.should-push == 'true'
env:
SERVICE_TAGS: ${{ steps.meta-service.outputs.tags }}
WEB_TAGS: ${{ steps.meta-web.outputs.tags }}
EVENT_NAME: ${{ github.event_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REF_NAME: ${{ github.ref_name }}
run: |
{
echo "## 🐳 Published Docker Images"
echo ""
if [[ "$EVENT_NAME" == "pull_request" ]]; then
echo "**Preview** images for PR #${PR_NUMBER}. These are not release builds."
elif [[ "$REF_NAME" == "main" ]]; then
echo "**Edge** images from \`main\`."
else
echo "**Preview** images from branch \`${REF_NAME}\`. These are not release builds."
fi
echo ""
echo "### soundtouch-service"
echo ""
echo '```bash'
while IFS= read -r tag; do
[[ -n "$tag" ]] && echo "docker pull $tag"
done <<< "$SERVICE_TAGS"
echo '```'
echo ""
echo "### soundtouch-web"
echo ""
echo '```bash'
while IFS= read -r tag; do
[[ -n "$tag" ]] && echo "docker pull $tag"
done <<< "$WEB_TAGS"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
notify:
name: Notify Status
runs-on: ubuntu-latest
@@ -355,7 +447,7 @@ jobs:
- name: Update commit status
if: always()
uses: actions/github-script@v9
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
try {
+5 -5
View File
@@ -20,18 +20,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Pages
uses: actions/configure-pages@v6
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Build with Jekyll
uses: actions/jekyll-build-pages@v1
uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 # v1.0.13
with:
source: 'docs/'
destination: '_site'
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: '_site'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
+21 -21
View File
@@ -28,7 +28,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@@ -64,7 +64,7 @@ jobs:
fi
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: ${{ env.GO_VERSION_FILE }}
@@ -102,15 +102,15 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: ${{ env.GO_VERSION_FILE }}
- name: Cache Go modules
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.cache/go-build
@@ -206,7 +206,7 @@ jobs:
echo "✅ Checksums generated successfully"
- name: Upload build artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
path: |
@@ -223,7 +223,7 @@ jobs:
steps:
- name: Download binary artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: binaries-*
path: ./binaries
@@ -280,7 +280,7 @@ jobs:
fi
- name: Upload checksums
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: checksums
path: |
@@ -291,7 +291,7 @@ jobs:
retention-days: 1
- name: Upload all release assets
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-assets
path: binaries/release-files/
@@ -305,12 +305,12 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Download release assets
uses: actions/download-artifact@v8
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-assets
path: ./release-assets
@@ -468,7 +468,7 @@ jobs:
echo "release_notes_file=release_notes.md" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ github.event.inputs.tag }}
name: "Bose SoundTouch Go Library ${{ github.event.inputs.tag }}"
@@ -494,13 +494,13 @@ jobs:
steps:
- name: Download release assets
uses: actions/download-artifact@v8
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-assets
path: ./release-assets
- name: Upload additional assets to existing release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ github.event.release.tag_name }}
files: |
@@ -521,13 +521,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -535,7 +535,7 @@ jobs:
- name: Extract metadata (tags, labels) for soundtouch-service
id: meta-service
uses: docker/metadata-action@v6
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
@@ -544,7 +544,7 @@ jobs:
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
- name: Build and push soundtouch-service Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
target: soundtouch-service
@@ -557,7 +557,7 @@ jobs:
- name: Extract metadata (tags, labels) for soundtouch-web
id: meta-web
uses: docker/metadata-action@v6
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ghcr.io/${{ github.repository }}-web
tags: |
@@ -566,7 +566,7 @@ jobs:
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
- name: Build and push soundtouch-web Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
target: soundtouch-web
+13 -13
View File
@@ -19,10 +19,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
@@ -48,7 +48,7 @@ jobs:
- name: Upload vulnerability scan results
if: failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vulnerability-scan-results
path: |
@@ -63,10 +63,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
@@ -84,7 +84,7 @@ jobs:
echo "::endgroup::"
- name: Run Semgrep security analysis
uses: semgrep/semgrep-action@v1
uses: semgrep/semgrep-action@713efdd345f3035192eaa63f56867b88e63e4e5d # v1 (no v1.x.y semver tag exists)
with:
config: >-
p/security-audit
@@ -95,7 +95,7 @@ jobs:
- name: Upload Semgrep SARIF results
if: always()
uses: github/codeql-action/upload-sarif@v4
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
sarif_file: semgrep.sarif
continue-on-error: true
@@ -110,22 +110,22 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install libpcap
run: sudo apt-get install -y libpcap-dev
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
languages: go
config-file: ./.github/codeql-config.yml
- name: Autobuild
uses: github/codeql-action/autobuild@v4
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
category: "/language:go"
@@ -138,10 +138,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Dependency Review
uses: actions/dependency-review-action@v4
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
fail-on-severity: moderate
allow-ghsas: GHSA-xxxx-xxxx-xxxx # Add specific allowlisted advisories if needed
+2
View File
@@ -16,12 +16,14 @@ dist/
/soundtouch-cli
/soundtouch-service
/soundtouch-web
/dummy-speaker
/example-mdns
/example-upnp
/example-unified
/mdns-scanner
/websocket-demo
/main
/screenshots
# Environment configuration
.env
+6 -1
View File
@@ -1,4 +1,4 @@
.PHONY: all build build-cli test test-coverage check fmt vet lint clean dev help
.PHONY: all build build-cli test test-coverage check fmt vet lint clean dev help screenshots
# Go parameters
GOCMD=go
@@ -336,6 +336,10 @@ docker-run-ports:
@echo "Running Docker container with port mapping (discovery will be manual)..."
docker run --rm -it -p 8000:8000 -v $$(pwd)/data:/app/data soundtouch-service
screenshots:
@echo "Capturing documentation screenshots..."
@bash scripts/screenshots/run.sh
help:
@echo "Available targets:"
@echo " build - Build the CLI tool, service, and examples"
@@ -356,6 +360,7 @@ help:
@echo " dev - Build and show CLI help"
@echo " dev-service - Build and run service locally"
@echo " dev-service-proxy - Build and run service with proxy (PROXY_URL=url required)"
@echo " screenshots - Capture documentation screenshots (headless Chrome via chromedp)"
@echo " dev-discover - Build and run device discovery"
@echo " dev-info - Build and get device info (HOST=ip required)"
@echo " dev-mdns - Build and run mDNS discovery example"
+2
View File
@@ -20,6 +20,8 @@ See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVI
A local server that replaces the Bose cloud ("AfterTouch"). Once your speaker is redirected to it, you have full control without any Bose cloud dependency. The built-in web UI at `http://localhost:8000` handles all setup — no config files needed to get started.
If you want to run a server for this - no problem. The service is small enough to run on the SoundTouch itself. See the [On-Device Installer](./scripts/on-device-install/README.md) for instructions.
**Two scenarios:**
**Before shutdown — migrate your existing setup**
+107
View File
@@ -0,0 +1,107 @@
// Command dummy-speaker runs an HTTP-only fake SoundTouch speaker and
// optionally registers it with a running soundtouch-service so the web UI
// has a device to display.
//
// Intended for documentation screenshots and local UI smoke checks. Do not
// use against a real network — the fixture payload is synthetic and would
// confuse other tooling that expects live device data.
//
// Example:
//
// dummy-speaker --port 8090 --register http://localhost:8000
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/testing/fakespeaker"
)
func main() {
listen := flag.String("listen", "127.0.0.1:8090", "bind address for the fake speaker's HTTP API")
telnetListen := flag.String("telnet-listen", "127.0.0.1:17000", "bind address for the fake speaker's telnet diagnostic shell (empty to disable)")
register := flag.String("register", "", "service base URL (e.g. http://localhost:8000) to self-register with via POST /setup/devices")
registerAs := flag.String("register-as", "", "address to send to /setup/devices (defaults to --listen)")
flag.Parse()
s, err := fakespeaker.Start(fakespeaker.Config{
HTTPListen: *listen,
TelnetListen: *telnetListen,
})
if err != nil {
log.Fatalf("start fake speaker: %v", err)
}
log.Printf("fake speaker HTTP listening on http://%s", s.HTTPAddr())
if addr := s.TelnetAddr(); addr != "" {
log.Printf("fake speaker telnet listening on tcp://%s", addr)
}
if *register != "" {
target := *registerAs
if target == "" {
target = s.HTTPAddr()
}
if err := registerWithService(*register, target); err != nil {
log.Printf("self-register failed: %v (continuing anyway)", err)
} else {
log.Printf("registered %s with service at %s", target, *register)
}
}
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
log.Printf("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := s.Stop(ctx); err != nil {
log.Printf("stop: %v", err)
}
}
func registerWithService(serviceURL, deviceAddr string) error {
body, err := json.Marshal(map[string]string{"ip": deviceAddr})
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, serviceURL+"/setup/devices", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
return fmt.Errorf("service responded %s", resp.Status)
}
return nil
}
+27
View File
@@ -157,6 +157,33 @@ func setClockTimeNow(c *cli.Context) error {
return nil
}
// setClockDisplayTimezone POSTs only the timezoneInfo attribute,
// leaving format/brightness untouched. Useful after a clock now to
// make the speaker's logs and front-panel display tick in local time
// instead of UTC.
func setClockDisplayTimezone(c *cli.Context) error {
clientConfig := GetClientConfig(c)
tz := c.String("tz")
PrintDeviceHeader(fmt.Sprintf("Setting clock timezone to %s", tz), clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
request := models.NewClockDisplayRequest().SetTimeZone(tz)
if err := client.SetClockDisplay(request); err != nil {
PrintError(fmt.Sprintf("Failed to set timezone: %v", err))
return err
}
PrintSuccess(fmt.Sprintf("Timezone set to %s", tz))
return nil
}
// getClockDisplay retrieves the current clock display settings
func getClockDisplay(c *cli.Context) error {
clientConfig := GetClientConfig(c)
+113 -1
View File
@@ -23,6 +23,12 @@ func eventSubscribe(c *cli.Context) error {
filterStr := c.String("filter")
filters := parseEventFilters(filterStr)
debugMode, err := parseDebugMode(c.String("debug"))
if err != nil {
PrintError(err.Error())
return err
}
// Parse duration
duration := c.Duration("duration")
verbose := c.Bool("verbose")
@@ -60,6 +66,10 @@ func eventSubscribe(c *cli.Context) error {
// Set up event handlers
setupEventHandlers(wsClient, filters, verbose)
if debugMode != debugOff {
installDebugHook(wsClient, debugMode)
}
// Connect to WebSocket
fmt.Println("🔌 Connecting to WebSocket...")
@@ -127,11 +137,78 @@ func eventSubscribe(c *cli.Context) error {
return nil
}
// debugMode controls when the WebSocket subscribe loop prints raw frames
// to stderr. "off" disables debug output entirely (the production default
// when --debug is unset).
type debugMode int
const (
debugOff debugMode = iota
debugAll
debugUnknown
debugErrors
)
func parseDebugMode(s string) (debugMode, error) {
switch strings.TrimSpace(s) {
case "":
return debugOff, nil
case "all":
return debugAll, nil
case "unknown":
return debugUnknown, nil
case "errors":
return debugErrors, nil
default:
return debugOff, fmt.Errorf("invalid --debug value %q (want one of: all, unknown, errors)", s)
}
}
// installDebugHook wires an OnRawMessage handler that prints the raw
// frame to stderr based on the chosen mode. Stays out of stdout so
// debug output can be filtered/grep'd independently of normal events.
func installDebugHook(ws *client.WebSocketClient, mode debugMode) {
ws.OnRawMessage(func(data []byte, parseErr error) {
switch mode {
case debugAll:
printRawFrame(data, parseErr, "all")
case debugErrors:
if parseErr != nil {
printRawFrame(data, parseErr, "errors")
}
case debugUnknown:
// "Unknown" = parsed successfully but no known event types
// matched. Parse errors also qualify, since they're frames
// the client couldn't interpret either.
if parseErr != nil {
printRawFrame(data, parseErr, "unknown:parse-error")
return
}
ev, err := models.ParseWebSocketEvent(data)
if err != nil || len(ev.GetEventTypes()) == 0 {
printRawFrame(data, err, "unknown")
}
case debugOff:
// nothing
}
})
}
func printRawFrame(data []byte, parseErr error, tag string) {
prefix := "[ws-debug:" + tag + "]"
if parseErr != nil {
fmt.Fprintf(os.Stderr, "%s parse-error: %v\n", prefix, parseErr)
}
fmt.Fprintf(os.Stderr, "%s %s\n", prefix, string(data))
}
// parseEventFilters validates and parses the filter string
func parseEventFilters(eventFilter string) map[string]bool {
validFilters := map[string]bool{
"nowPlaying": true, "volume": true, "connection": true,
"preset": true, "zone": true, "bass": true,
"preset": true, "zone": true, "group": true, "bass": true,
"sdkInfo": true, "userActivity": true,
}
@@ -217,6 +294,13 @@ func setupEventHandlers(wsClient *client.WebSocketClient, filters map[string]boo
})
}
// Stereo-pair (group) events — ST-10 only
if filters == nil || filters["group"] {
wsClient.OnGroupUpdated(func(event *models.GroupUpdatedEvent) {
handleGroupEvent(event)
})
}
// Bass events
if filters == nil || filters["bass"] {
wsClient.OnBassUpdated(func(event *models.BassUpdatedEvent) {
@@ -358,6 +442,34 @@ func handleZoneEvent(event *models.ZoneUpdatedEvent) {
}
}
func handleGroupEvent(event *models.GroupUpdatedEvent) {
group := &event.Group
fmt.Printf("\n🎧 Stereo-Pair Update [%s]:\n", event.DeviceID)
if group.IsEmpty() {
fmt.Println(" ⛓️‍💥 Pair dissolved (no group configured)")
return
}
fmt.Printf(" 🆔 ID: %s\n", group.ID)
fmt.Printf(" 📛 Name: %s\n", group.Name)
fmt.Printf(" 👑 Master: %s\n", group.MasterDeviceID)
if group.Status != "" {
fmt.Printf(" ✅ Status: %s\n", group.Status)
}
for _, r := range group.Roles.Roles {
fmt.Printf(" %-5s %s", r.Role, r.DeviceID)
if r.IPAddress != "" {
fmt.Printf(" (IP: %s)", r.IPAddress)
}
fmt.Println()
}
}
func handleBassEvent(event *models.BassUpdatedEvent) {
bass := &event.Bass
fmt.Printf("\n🎵 Bass Update [%s]:\n", event.DeviceID)
+218
View File
@@ -0,0 +1,218 @@
package main
import (
"fmt"
"net"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/speaker"
"github.com/urfave/cli/v2"
)
// getGroupStatus retrieves and prints the device's current stereo-pair state.
func getGroupStatus(c *cli.Context) error {
clientConfig := GetClientConfig(c)
PrintDeviceHeader("Getting group information", clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
group, err := client.GetGroup()
if err != nil {
PrintError(fmt.Sprintf("Failed to get group: %v", err))
return err
}
if group.IsEmpty() {
fmt.Println("Device is not in a stereo pair")
return nil
}
printGroup(group)
return nil
}
// createGroup forms a stereo pair on the LEFT speaker, which becomes the master.
func createGroup(c *cli.Context) error {
leftIP := c.String("left")
rightIP := c.String("right")
name := c.String("name")
if net.ParseIP(leftIP) == nil {
PrintError(fmt.Sprintf("Invalid left IP address: %s", leftIP))
return fmt.Errorf("invalid left IP: %s", leftIP)
}
if net.ParseIP(rightIP) == nil {
PrintError(fmt.Sprintf("Invalid right IP address: %s", rightIP))
return fmt.Errorf("invalid right IP: %s", rightIP)
}
PrintDeviceHeader(fmt.Sprintf("Creating stereo pair: LEFT=%s RIGHT=%s", leftIP, rightIP), leftIP, speaker.HTTPPort)
leftInfo, err := fetchDeviceInfo(c, leftIP)
if err != nil {
PrintError(fmt.Sprintf("Failed to read LEFT device info: %v", err))
return err
}
rightInfo, err := fetchDeviceInfo(c, rightIP)
if err != nil {
PrintError(fmt.Sprintf("Failed to read RIGHT device info: %v", err))
return err
}
if name == "" {
name = fmt.Sprintf("%s + %s", leftInfo.Name, rightInfo.Name)
}
req := &models.Group{
Name: name,
MasterDeviceID: leftInfo.DeviceID,
Roles: models.GroupRoles{
Roles: []models.GroupRole{
{DeviceID: leftInfo.DeviceID, Role: "LEFT", IPAddress: leftIP},
{DeviceID: rightInfo.DeviceID, Role: "RIGHT", IPAddress: rightIP},
},
},
}
leftClient, err := clientForHost(c, leftIP)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client for LEFT: %v", err))
return err
}
result, err := leftClient.AddGroup(req)
if err != nil {
PrintError(fmt.Sprintf("Failed to create group: %v", err))
return err
}
PrintSuccess(fmt.Sprintf("Stereo pair created (id=%s)", result.ID))
printGroup(result)
return nil
}
// renameGroup updates the name of the existing stereo pair. The device
// requires the full structure on every update, so we fetch the current
// state first.
func renameGroup(c *cli.Context) error {
clientConfig := GetClientConfig(c)
newName := c.String("name")
if newName == "" {
PrintError("--name is required")
return fmt.Errorf("name is required")
}
PrintDeviceHeader(fmt.Sprintf("Renaming stereo pair to %q", newName), clientConfig.Host, clientConfig.Port)
stClient, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
current, err := stClient.GetGroup()
if err != nil {
PrintError(fmt.Sprintf("Failed to read current group: %v", err))
return err
}
if current.IsEmpty() {
PrintError("Device is not in a stereo pair — nothing to rename")
return fmt.Errorf("no group configured")
}
// Status is read-only on the device side; don't echo it back.
current.Status = ""
current.Name = newName
result, err := stClient.UpdateGroup(current)
if err != nil {
PrintError(fmt.Sprintf("Failed to rename group: %v", err))
return err
}
PrintSuccess(fmt.Sprintf("Stereo pair renamed to %q", result.Name))
printGroup(result)
return nil
}
// removeGroup tears down the device's stereo pair.
func removeGroup(c *cli.Context) error {
clientConfig := GetClientConfig(c)
PrintDeviceHeader("Removing stereo pair", clientConfig.Host, clientConfig.Port)
stClient, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
if err := stClient.RemoveGroup(); err != nil {
PrintError(fmt.Sprintf("Failed to remove group: %v", err))
return err
}
PrintSuccess("Stereo pair removed")
return nil
}
// fetchDeviceInfo builds a one-off client for the given IP and reads /info.
// Reused for both halves of a `create` invocation so the caller doesn't have
// to babysit two host/port pairs.
func fetchDeviceInfo(c *cli.Context, host string) (*models.DeviceInfo, error) {
stClient, err := clientForHost(c, host)
if err != nil {
return nil, err
}
return stClient.GetDeviceInfo()
}
// clientForHost mirrors CreateSoundTouchClient but overrides the host so we
// can talk to a speaker other than the one named in --host.
func clientForHost(c *cli.Context, host string) (*client.Client, error) {
cfg, err := loadConfig(c.Duration("timeout"))
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
return client.NewClient(&client.Config{
Host: host,
Port: speaker.HTTPPort,
Timeout: cfg.HTTPTimeout,
UserAgent: cfg.UserAgent,
}), nil
}
func printGroup(g *models.Group) {
fmt.Println("Stereo Pair Configuration:")
fmt.Printf(" ID: %s\n", g.ID)
fmt.Printf(" Name: %s\n", g.Name)
fmt.Printf(" Master: %s\n", g.MasterDeviceID)
if g.Status != "" {
fmt.Printf(" Status: %s\n", g.Status)
}
for _, r := range g.Roles.Roles {
fmt.Printf(" %-5s %s", r.Role, r.DeviceID)
if r.IPAddress != "" {
fmt.Printf(" (IP: %s)", r.IPAddress)
}
fmt.Println()
}
}
File diff suppressed because it is too large Load Diff
+310
View File
@@ -0,0 +1,310 @@
package main
import (
"bytes"
"io"
"os"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
)
// captureStdout runs fn and returns whatever it wrote to os.Stdout.
// renderSourceTable prints directly via fmt.Print* — this lets us assert
// on its output without restructuring the renderer to take an io.Writer.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
orig := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
os.Stdout = w
done := make(chan struct{})
buf := &bytes.Buffer{}
go func() {
_, _ = io.Copy(buf, r)
close(done)
}()
fn()
_ = w.Close()
os.Stdout = orig
<-done
return buf.String()
}
func TestRenderSourceTable_AlignsColumnsAndDedupsDisplayName(t *testing.T) {
items := []models.SourceItem{
// displayName != account → kept as "AUX (AUX IN)"
{Source: "AUX", SourceAccount: "AUX", DisplayName: "AUX IN", Status: "READY", IsLocal: true, MultiroomAllowed: true},
// displayName == account → dropped (would otherwise duplicate the next column)
{Source: "AMAZON", SourceAccount: "amzn1.account.AFKTQOUNVZL7ODQCF4STPAAMVMPA", DisplayName: "amzn1.account.AFKTQOUNVZL7ODQCF4STPAAMVMPA", Status: "READY", MultiroomAllowed: true},
// No displayName at all, no account
{Source: "BLUETOOTH", Status: "UNAVAILABLE", IsLocal: true, MultiroomAllowed: true},
// Long source name, no catalog entry → provider#?
{Source: "STORED_MUSIC_MEDIA_RENDERER", SourceAccount: "StoredMusicUserName", DisplayName: "StoredMusicUserName", Status: "UNAVAILABLE", MultiroomAllowed: true},
}
out := captureStdout(t, func() { renderSourceTable(items) })
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
if len(lines) != 4 {
t.Fatalf("got %d output lines, want 4:\n%s", len(lines), out)
}
// (1) AUX keeps "(AUX IN)" because it differs from both source and account.
if !strings.Contains(lines[0], "AUX (AUX IN)") {
t.Errorf("AUX line should keep displayName parenthesis: %q", lines[0])
}
// (2) AMAZON drops "(amzn1…)" because displayName equals sourceAccount.
if strings.Contains(lines[1], "(amzn1.account") {
t.Errorf("AMAZON line should drop displayName when it duplicates account: %q", lines[1])
}
// (3) provider#? for the uncatalogued source.
if !strings.Contains(lines[3], "provider#?") {
t.Errorf("uncatalogued source should be tagged provider#?: %q", lines[3])
}
// (4) Column starts must align across all rows — find the column index
// where "status=" appears in each line; they should all match.
statusCols := make([]int, len(lines))
for i, l := range lines {
statusCols[i] = strings.Index(l, "status=")
if statusCols[i] < 0 {
t.Fatalf("line %d missing status= column: %q", i, l)
}
}
for i := 1; i < len(statusCols); i++ {
if statusCols[i] != statusCols[0] {
t.Errorf("status= column misaligned: line 0 at col %d, line %d at col %d\n%s",
statusCols[0], i, statusCols[i], out)
}
}
// (5) account= column should likewise align across all rows.
accountCols := make([]int, len(lines))
for i, l := range lines {
accountCols[i] = strings.Index(l, "account=")
if accountCols[i] < 0 {
t.Fatalf("line %d missing account= column: %q", i, l)
}
}
for i := 1; i < len(accountCols); i++ {
if accountCols[i] != accountCols[0] {
t.Errorf("account= column misaligned: line 0 at col %d, line %d at col %d\n%s",
accountCols[0], i, accountCols[i], out)
}
}
}
func TestRenderSourceTable_EmptyShowsNonePlaceholder(t *testing.T) {
out := captureStdout(t, func() { renderSourceTable(nil) })
if !strings.Contains(out, "(none)") {
t.Errorf("expected (none) placeholder for empty list, got: %q", out)
}
}
func TestRecommendMigrationMethod_PrefersTelnet(t *testing.T) {
method, reason := recommendMigrationMethod("http://aftertouch.local:8000", &setup.MigrationSummary{
TelnetReachable: true,
SSHSuccess: true,
})
if method != setup.MigrationMethodTelnet {
t.Errorf("method = %q, want telnet (simplest path when telnet works)", method)
}
if !strings.Contains(reason, "Telnet") {
t.Errorf("reason should mention Telnet: %q", reason)
}
}
func TestRecommendMigrationMethod_HTTPSAddsCaveatToTelnet(t *testing.T) {
_, reason := recommendMigrationMethod("https://aftertouch.local:8443", &setup.MigrationSummary{
TelnetReachable: true,
})
if !strings.Contains(reason, "install-ca") {
t.Errorf("HTTPS service URL should flag the CA-install caveat in the reason: %q", reason)
}
}
func TestRecommendMigrationMethod_FallsBackToResolvWhenTelnetDown(t *testing.T) {
method, _ := recommendMigrationMethod("http://aftertouch.local:8000", &setup.MigrationSummary{
TelnetReachable: false,
SSHSuccess: true,
})
if method != setup.MigrationMethodResolvConf {
t.Errorf("method = %q, want resolv (DNS redirect via SSH)", method)
}
}
func TestRecommendMigrationMethod_EmptyWhenNoTransport(t *testing.T) {
method, _ := recommendMigrationMethod("http://aftertouch.local:8000", &setup.MigrationSummary{
TelnetReachable: false,
SSHSuccess: false,
})
if method != "" {
t.Errorf("method = %q, want empty when no transport works", method)
}
}
func TestBuildPlanSteps_NoOpWhenAlreadyMigratedAndPaired(t *testing.T) {
summary := &setup.MigrationSummary{IsMigrated: true, IsPaired: true, TelnetMigrated: true}
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "AABBCCDDEEFF"}}
steps := buildPlanSteps("192.168.1.42", "http://aftertouch.local:8000", "", true, false, inspect, summary)
if len(steps) != 0 {
t.Errorf("expected no steps for fully-set-up device, got %d:\n%v", len(steps), steps)
}
}
func TestBuildPlanSteps_RecommendsPairWhenMigratedButUnpaired(t *testing.T) {
summary := &setup.MigrationSummary{IsMigrated: true, IsPaired: false, TelnetMigrated: true, TelnetReachable: true}
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "AABBCCDDEEFF"}}
steps := buildPlanSteps("192.168.1.42", "http://aftertouch.local:8000", "", true, false, inspect, summary)
if len(steps) != 1 {
t.Fatalf("expected exactly the pair step, got %d:\n%v", len(steps), steps)
}
if !strings.Contains(steps[0].cmd, "setup pair") {
t.Errorf("expected pair command, got %q", steps[0].cmd)
}
}
func TestBuildPlanSteps_MigrateRebootThenPairWhenFresh(t *testing.T) {
summary := &setup.MigrationSummary{TelnetReachable: true, SSHSuccess: false, IsPaired: false}
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "AABBCCDDEEFF"}}
steps := buildPlanSteps("192.168.1.42", "http://aftertouch.local:8000", "", true, false, inspect, summary)
// migrate → reboot → pair. The reboot step exists because envswitch's
// parallel-persistence layer only fully wins on the next boot, and we
// want the new URLs locked in before pairing posts to the speaker.
if len(steps) != 3 {
t.Fatalf("expected migrate+reboot+pair, got %d steps:\n%v", len(steps), steps)
}
if !strings.Contains(steps[0].cmd, "setup migrate") || !strings.Contains(steps[0].cmd, "method=telnet") {
t.Errorf("step 1 should be telnet migrate, got %q", steps[0].cmd)
}
if !strings.Contains(steps[1].cmd, "setup reboot") {
t.Errorf("step 2 should be reboot, got %q", steps[1].cmd)
}
if !strings.Contains(steps[2].cmd, "setup pair") {
t.Errorf("step 3 should be pair, got %q", steps[2].cmd)
}
}
func TestBuildPlanSteps_DNSMethodPrependsCAInstall(t *testing.T) {
// Telnet down, SSH up, CA not yet trusted → plan must install-ca
// before applying the resolv migration.
summary := &setup.MigrationSummary{
TelnetReachable: false,
SSHSuccess: true,
CACertTrusted: false,
IsPaired: false,
}
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "X"}}
steps := buildPlanSteps("192.168.1.42", "http://aftertouch.local:8000", "", false, false, inspect, summary)
if len(steps) < 2 {
t.Fatalf("expected at least install-ca + migrate, got %d steps:\n%v", len(steps), steps)
}
if !strings.Contains(steps[0].cmd, "install-ca") {
t.Errorf("install-ca should come first when DNS method is chosen and CA is not trusted, got %q", steps[0].cmd)
}
if !strings.Contains(steps[1].cmd, "method=resolv") {
t.Errorf("step 2 should be resolv migrate, got %q", steps[1].cmd)
}
}
func TestBuildPlanSteps_ResetModeIncludesManualNetworkSwitches(t *testing.T) {
inspect := &setup.InspectReport{
Info: &setup.DeviceInfoXML{DeviceID: "506583DE4803"},
Network: &models.NetworkInformation{
Interfaces: models.NetworkInterfaces{
Interfaces: []models.NetworkInterface{
{Type: "WIFI_INTERFACE", SSID: "MyHomeNetwork"},
},
},
},
}
summary := &setup.MigrationSummary{IsMigrated: true, IsPaired: true} // doesn't matter in reset mode
steps := buildPlanSteps("192.168.1.42", "http://aftertouch.local:8000", "", true, true, inspect, summary)
// Expected sequence in --reset mode:
// factory-reset, manual AP switch, wait-ap, wifi-push, manual home switch,
// wait-online, migrate, pair (8 steps).
if len(steps) < 7 {
t.Fatalf("expected at least 7 steps in --reset mode, got %d:\n%v", len(steps), steps)
}
manualCount := 0
for _, s := range steps {
if s.manual {
manualCount++
}
}
if manualCount < 2 {
t.Errorf("expected at least 2 manual steps for the Wi-Fi switches, got %d", manualCount)
}
if !strings.Contains(steps[0].cmd, "factory-reset") {
t.Errorf("step 1 must be factory-reset, got %q", steps[0].cmd)
}
// wifi-push step should default to the inspected SSID
foundWiFi := false
for _, s := range steps {
if strings.Contains(s.cmd, "wifi-push") && strings.Contains(s.cmd, "MyHomeNetwork") {
foundWiFi = true
break
}
}
if !foundWiFi {
t.Errorf("expected wifi-push step to default to inspected SSID 'MyHomeNetwork'")
}
// wait-online --match should use the deviceID suffix
foundMatch := false
for _, s := range steps {
if strings.Contains(s.cmd, "wait-online") && strings.Contains(s.cmd, "--match=DE4803") {
foundMatch = true
break
}
}
if !foundMatch {
t.Errorf("expected wait-online step to use --match=DE4803 from deviceID suffix")
}
}
+80 -1
View File
@@ -1312,6 +1312,19 @@ func main() {
},
Before: RequireHost,
},
{
Name: "timezone",
Usage: "Set display timezone (IANA zone, e.g. Europe/Berlin)",
Action: setClockDisplayTimezone,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "tz",
Usage: "IANA timezone identifier (e.g. Europe/Berlin, America/New_York)",
Required: true,
},
},
Before: RequireHost,
},
},
},
},
@@ -1478,6 +1491,64 @@ func main() {
},
},
},
// Stereo-pair (group) commands — ST-10 only
{
Name: "group",
Aliases: []string{"g"},
Usage: "ST-10 stereo-pair management (left/right channel pairing)",
Subcommands: []*cli.Command{
{
Name: "status",
Usage: "Show the device's current stereo-pair configuration",
Action: getGroupStatus,
Before: RequireHost,
},
{
Name: "create",
Usage: "Form a stereo pair (LEFT speaker becomes master)",
Action: createGroup,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "left",
Aliases: []string{"l"},
Usage: "IP address of the LEFT speaker (will be master)",
Required: true,
},
&cli.StringFlag{
Name: "right",
Aliases: []string{"r"},
Usage: "IP address of the RIGHT speaker",
Required: true,
},
&cli.StringFlag{
Name: "name",
Aliases: []string{"n"},
Usage: "Pair name (defaults to \"<left> + <right>\")",
},
},
},
{
Name: "rename",
Usage: "Rename the existing stereo pair on the device",
Action: renameGroup,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "name",
Aliases: []string{"n"},
Usage: "New pair name",
Required: true,
},
},
Before: RequireHost,
},
{
Name: "remove",
Usage: "Dissolve the device's stereo pair",
Action: removeGroup,
Before: RequireHost,
},
},
},
// Advanced Audio commands
{
Name: "audio",
@@ -2093,7 +2164,7 @@ func main() {
&cli.StringFlag{
Name: "filter",
Aliases: []string{"f"},
Usage: "Filter events by type (comma-separated): nowPlaying,volume,connection,preset,zone,bass,sdkInfo,userActivity",
Usage: "Filter events by type (comma-separated): nowPlaying,volume,connection,preset,zone,group,bass,sdkInfo,userActivity",
},
&cli.DurationFlag{
Name: "duration",
@@ -2105,6 +2176,10 @@ func main() {
Name: "no-reconnect",
Usage: "Disable automatic reconnection on connection loss",
},
&cli.StringFlag{
Name: "debug",
Usage: "Print raw WebSocket frames to stderr — one of: all, unknown, errors",
},
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
@@ -2117,6 +2192,10 @@ func main() {
},
}
// Speaker provisioning (factory-reset, Wi-Fi, URL rewrite, pairing).
// Defined in cmd_setup.go to keep the top-level command list readable.
app.Commands = append(app.Commands, setupCommand())
// Sort commands alphabetically (including subcommands and flags recursively)
sortCommands(app.Commands)
+19
View File
@@ -852,15 +852,32 @@ func startDeviceDiscovery(server *handlers.Server) {
func setupRouter(server *handlers.Server) *chi.Mux {
r := chi.NewRouter()
// TrustedRealIP must run before any handler that reads r.RemoteAddr —
// SnapshotMiddleware captures the request, and several handlers
// (HandleMargePowerOn, etc.) inspect the source IP. The middleware is
// gated on Settings.TrustForwardedHeaders; when off (the safe default),
// it returns nil and we skip Use'ing it entirely.
if mw := server.TrustedRealIPMiddleware(); mw != nil {
r.Use(mw)
}
r.Use(server.SnapshotMiddleware)
r.Use(server.OriginMiddleware)
r.Use(middleware.Recoverer)
r.Use(server.PeerObserverMiddleware)
r.Use(server.ShortcutMiddleware)
r.Use(server.MirrorMiddleware)
r.Use(server.RecordMiddleware)
r.Get("/", server.HandleRoot)
r.Get("/health", server.HandleHealth)
// Passive peer-reachability probe. Registers a device IP with the
// in-process observer, nudges :8090/swUpdateCheck, and waits for
// any inbound from that IP. Used post-migration where the daemon
// caches its swUpdateUrl at boot and the active round-trip can't
// reach it without a reboot.
r.Post("/setup/peer-probe/{deviceId}", server.HandlePeerProbe)
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = "/media/favicon-braille.svg"
server.HandleMedia()(w, r)
@@ -1070,6 +1087,8 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
r.Post("/revert/{deviceId}", server.HandleRevertMigration)
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
r.Get("/account-id-suggestions/{deviceId}", server.HandleAccountIDSuggestions)
r.Post("/pair-account/{deviceId}", server.HandlePairAccount)
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services/{deviceId}", server.HandleRemoveRemoteServices)
+3
View File
@@ -48,6 +48,7 @@ GET /mgmt/spotify/callback handlers.(
GET /mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
GET /oauth/* handlers.(*Server).HandleBoseProxy-fm
GET /proxy/* handlers.(*Server).HandleProxyRequest-fm
GET /setup/account-id-suggestions/{deviceId} handlers.(*Server).HandleAccountIDSuggestions-fm
GET /setup/ca.crt handlers.(*Server).HandleGetCACert-fm
GET /setup/devices handlers.(*Server).HandleListDiscoveredDevices-fm
GET /setup/devices/{deviceId}/events handlers.(*Server).HandleGetDeviceEvents-fm
@@ -121,6 +122,8 @@ POST /setup/devices handlers.(
POST /setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
POST /setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
POST /setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
POST /setup/pair-account/{deviceId} handlers.(*Server).HandlePairAccount-fm
POST /setup/peer-probe/{deviceId} handlers.(*Server).HandlePeerProbe-fm
POST /setup/proxy-settings handlers.(*Server).HandleUpdateProxySettings-fm
POST /setup/reboot/{deviceId} handlers.(*Server).HandleRebootDevice-fm
POST /setup/remove-remote-services/{deviceId} handlers.(*Server).HandleRemoveRemoteServices-fm
+111 -2
View File
@@ -4,8 +4,10 @@ package main
import (
"context"
"embed"
"fmt"
"io/fs"
"log"
"net"
"net/http"
"os"
"time"
@@ -36,13 +38,34 @@ func main() {
},
&cli.StringFlag{
Name: "bind",
Usage: "Network interface to bind to",
Usage: "Address for the HTTP listener: host, IP, or local interface name (e.g. eth0). Leave empty to listen on all interfaces",
EnvVars: []string{"BIND_ADDR"},
},
&cli.StringFlag{
Name: "interface",
Usage: "Network interface name (e.g. eth0) for mDNS and UPnP device discovery. Defaults to the --bind interface name when one was given; leave empty otherwise to auto-pick",
EnvVars: []string{"DISCOVERY_INTERFACE"},
},
},
Action: func(c *cli.Context) error {
port := c.String("port")
bindAddr := c.String("bind")
rawBind := c.String("bind")
bindAddr, err := resolveBindAddr(rawBind)
if err != nil {
log.Fatal(err)
}
if rawBind != "" && bindAddr != rawBind {
log.Printf("Resolved --bind %q to %s", rawBind, bindAddr)
}
rawIface := c.String("interface")
ifaceName := defaultDiscoveryInterface(rawIface, rawBind, bindAddr)
if rawIface == "" && ifaceName != "" {
log.Printf("Defaulting --interface to %q from --bind", ifaceName)
}
addr := ":" + port
if bindAddr != "" {
@@ -63,6 +86,10 @@ func main() {
cfg.DiscoveryTimeout = 10 * time.Second
cfg.CacheEnabled = true
if ifaceName != "" {
cfg.DiscoveryInterface = ifaceName
}
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
// Discover devices on startup
@@ -91,6 +118,88 @@ func main() {
}
}
// defaultDiscoveryInterface picks the interface name to use for mDNS/UPnP
// discovery. An explicit --interface always wins; otherwise, when --bind was
// given an interface name (i.e. resolveBindAddr substituted an IP for it),
// that name is reused so the common single-interface case "just works".
// Returns the empty string when there is nothing to propagate, leaving the
// discovery service to auto-pick.
func defaultDiscoveryInterface(rawInterface, rawBind, resolvedBind string) string {
if rawInterface != "" {
return rawInterface
}
if rawBind != "" && rawBind != resolvedBind {
return rawBind
}
return ""
}
// resolveBindAddr returns the address to bind the HTTP listener to.
//
// If bindAddr names a local network interface, the interface's single IPv4
// address is returned. When no IPv4 is present, the function falls back to the
// interface's single non-link-local IPv6 address (wrapped in brackets so it
// composes correctly with ":port"). Ambiguous interfaces (multiple addresses
// in the chosen family) or interfaces with no usable address produce an error,
// so misconfiguration surfaces immediately instead of becoming an obscure DNS
// lookup failure at listen time.
//
// If bindAddr is not an interface name — including the empty string, a host
// name, or a literal IP — it is returned unchanged.
func resolveBindAddr(bindAddr string) (string, error) {
// A lookup failure here just means bindAddr isn't an interface name
// (it's a host, IP, or empty); fall through to pass-through.
iface, _ := net.InterfaceByName(bindAddr)
if iface == nil {
return bindAddr, nil
}
addrs, err := iface.Addrs()
if err != nil {
return "", fmt.Errorf("--bind %q: failed to list addresses for interface: %w", bindAddr, err)
}
var ipv4, ipv6 []net.IP
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil {
continue
}
if v4 := ip.To4(); v4 != nil {
ipv4 = append(ipv4, v4)
} else if !ip.IsLinkLocalUnicast() {
// Skip IPv6 link-local (fe80::); it requires a zone ID and
// can't be used as a plain "[ip]:port" listen address.
ipv6 = append(ipv6, ip)
}
}
switch {
case len(ipv4) == 1:
return ipv4[0].String(), nil
case len(ipv4) > 1:
return "", fmt.Errorf("--bind %q: interface has multiple IPv4 addresses (%v); specify one directly", bindAddr, ipv4)
case len(ipv6) == 1:
return "[" + ipv6[0].String() + "]", nil
case len(ipv6) > 1:
return "", fmt.Errorf("--bind %q: interface has multiple IPv6 addresses (%v); specify one directly", bindAddr, ipv6)
default:
return "", fmt.Errorf("--bind %q: interface has no usable IPv4 or IPv6 address", bindAddr)
}
}
func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) *chi.Mux {
r := chi.NewRouter()
@@ -0,0 +1,162 @@
package main
import (
"net"
"strings"
"testing"
)
func TestResolveBindAddr_PassThrough(t *testing.T) {
// Inputs that don't match any local interface name must be returned
// unchanged: empty string, hostnames, IPv4/IPv6 literals, and bogus
// strings the user might have typed.
tests := []string{
"",
"localhost",
"127.0.0.1",
"192.168.1.5",
"::1",
"definitely-not-an-iface-xyz",
}
for _, input := range tests {
t.Run(quoted(input), func(t *testing.T) {
got, err := resolveBindAddr(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != input {
t.Errorf("got %q, want %q (input should pass through unchanged)", got, input)
}
})
}
}
func TestResolveBindAddr_LoopbackInterface(t *testing.T) {
loopback, expected, ok := findLoopbackWithSingleIPv4(t)
if !ok {
t.Skipf("no loopback interface with exactly one IPv4 address found")
}
got, err := resolveBindAddr(loopback)
if err != nil {
t.Fatalf("unexpected error resolving %q: %v", loopback, err)
}
if got != expected {
t.Errorf("got %q, want %q for loopback interface %q", got, expected, loopback)
}
}
// findLoopbackWithSingleIPv4 returns the name of a loopback interface and the
// single IPv4 address attached to it. If the host has multiple loopback
// interfaces or the loopback has zero or several IPv4 addresses, it returns
// ok=false so the caller can skip the test rather than fail on an environment
// quirk.
func findLoopbackWithSingleIPv4(t *testing.T) (name, addr string, ok bool) {
t.Helper()
ifaces, err := net.Interfaces()
if err != nil {
t.Fatalf("net.Interfaces: %v", err)
}
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback == 0 {
continue
}
addrs, addrErr := iface.Addrs()
if addrErr != nil {
continue
}
var ipv4s []string
for _, a := range addrs {
if ipnet, isIPNet := a.(*net.IPNet); isIPNet {
if v4 := ipnet.IP.To4(); v4 != nil {
ipv4s = append(ipv4s, v4.String())
}
}
}
if len(ipv4s) == 1 {
return iface.Name, ipv4s[0], true
}
}
return "", "", false
}
func TestDefaultDiscoveryInterface(t *testing.T) {
tests := []struct {
name string
rawInterface string
rawBind string
resolvedBind string
want string
}{
{
name: "explicit interface wins over bind-derived default",
rawInterface: "eth1",
rawBind: "eth0",
resolvedBind: "192.168.1.5",
want: "eth1",
},
{
name: "derive from --bind when --bind was an interface name",
rawInterface: "",
rawBind: "eth0",
resolvedBind: "192.168.1.5",
want: "eth0",
},
{
name: "no derivation when --bind was an IP literal",
rawInterface: "",
rawBind: "192.168.1.5",
resolvedBind: "192.168.1.5",
want: "",
},
{
name: "no derivation when --bind was a hostname (pass-through)",
rawInterface: "",
rawBind: "localhost",
resolvedBind: "localhost",
want: "",
},
{
name: "both empty stays empty (auto-pick)",
rawInterface: "",
rawBind: "",
resolvedBind: "",
want: "",
},
{
name: "explicit interface alone, --bind empty",
rawInterface: "eth1",
rawBind: "",
resolvedBind: "",
want: "eth1",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := defaultDiscoveryInterface(tc.rawInterface, tc.rawBind, tc.resolvedBind)
if got != tc.want {
t.Errorf("got %q, want %q (rawInterface=%q rawBind=%q resolvedBind=%q)",
got, tc.want, tc.rawInterface, tc.rawBind, tc.resolvedBind)
}
})
}
}
func quoted(s string) string {
if s == "" {
return "(empty)"
}
return strings.ReplaceAll(s, "/", "_")
}
+4
View File
@@ -61,6 +61,10 @@
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
* [Telnet (Port 17000) Migration Method](analysis/TELNET-MIGRATION-METHOD.md)
* [Telnet Command Reference](analysis/TELNET-COMMAND-REFERENCE.md)
* [Setup WebSocket Experiment](analysis/SETUP-WEBSOCKET-EXPERIMENT.md)
* [Factory Reset Protocol](analysis/FACTORY-RESET-PROTOCOL.md)
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
+32 -23
View File
@@ -2,6 +2,8 @@
To enable offline operation or use custom services like **SoundCork** or **ÜberBöse API**, SoundTouch devices must be redirected from Bose's official cloud endpoints to a local or custom server. This document outlines the three known methods to achieve this, gathered from community reverse-engineering efforts in the **SoundCork** and **ÜberBöse API** projects.
> A fourth, **SSH-free** path — driving the device's diagnostic shell on TCP port 17000 — is being added as a peer to the XML and DNS methods. See **[TELNET-MIGRATION-METHOD.md](TELNET-MIGRATION-METHOD.md)** for the use cases, community findings, and feasibility analysis. The `/etc/hosts` method documented below is now deprecated and will not be exposed in the web UI.
## Overview of Redirection Targets
SoundTouch devices primarily communicate with the following domains:
@@ -32,19 +34,25 @@ The most robust and granular method involves modifying the device's private conf
Requires SSH access to the device.
```xml
<SoundTouchSdkPrivateCfg>
<margeServerUrl>http://192.168.1.10:8000/marge</margeServerUrl>
<margeServerUrl>http://192.168.1.10:8000</margeServerUrl>
<statsServerUrl>http://192.168.1.10:8000</statsServerUrl>
<swUpdateUrl>http://192.168.1.10:8000/updates/soundtouch</swUpdateUrl>
<bmxRegistryUrl>http://192.168.1.10:8000/bmx/registry/v1/services</bmxRegistryUrl>
</SoundTouchSdkPrivateCfg>
```
> **Note on `margeServerUrl`**`soundtouch-service` mounts the marge endpoints
> at the **root** of port 8000, so the URL has no `/marge` suffix.
> [`deborahgu/soundcork`](https://github.com/deborahgu/soundcork) routes marge
> under a `/marge` sub-path, so users redirecting to soundcork must append it
> (`http://192.168.1.10:8000/marge`).
### Pros & Cons
| Pros | Cons |
| :--- | :--- |
| **Granular Control**: Redirect specific services while leaving others (e.g., updates) intact. | **Requires SSH**: Must have root/SSH access to the device. |
| **Persistent**: Survives software updates (usually). | **Syntax Sensitive**: Errors in XML can cause boot issues or service failures. |
| **Native**: Uses the device's built-in configuration mechanism. | |
| Pros | Cons |
|:----------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------|
| **Granular Control**: Redirect specific services while leaving others (e.g., updates) intact. | **Requires SSH**: Must have root/SSH access to the device. |
| **Persistent**: Survives software updates (usually). | **Syntax Sensitive**: Errors in XML can cause boot issues or service failures. |
| **Native**: Uses the device's built-in configuration mechanism. | |
---
@@ -66,11 +74,11 @@ Requires SSH access. Add entries for the target domains:
```
### Pros & Cons
| Pros | Cons |
| :--- | :--- |
| **Simple**: Easy to understand and implement. | **Requires SSH**: Must have root access. |
| Pros | Cons |
|:--------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Simple**: Easy to understand and implement. | **Requires SSH**: Must have root access. |
| **Universal**: Affects all processes on the device attempting to reach those domains. | **HTTPS Issues**: Redirecting HTTPS domains to a local IP will cause SSL certificate errors unless the device is patched to skip verification or trust a custom CA. |
| | **Brittle**: Some firmware versions may overwrite `/etc/hosts` on reboot. |
| | **Brittle**: Some firmware versions may overwrite `/etc/hosts` on reboot. |
---
@@ -104,12 +112,12 @@ sed "s#\^https:....bose.\+apigee..net..#http[aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
4. Restore execution permissions and reboot.
### Pros & Cons
| Pros | Cons |
| :--- | :--- |
| **Bypass Config**: Works even if the firmware ignores XML settings. | **High Risk**: Modifying binaries can lead to permanent bricks or boot loops. |
| Pros | Cons |
|:------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------|
| **Bypass Config**: Works even if the firmware ignores XML settings. | **High Risk**: Modifying binaries can lead to permanent bricks or boot loops. |
| **Hardcoded Redirects**: Can catch URLs that aren't exposed in configuration files. | **Length Constraint**: Custom URLs must fit within the space of the original strings. |
| | **Firmware Specific**: Patches must be reapplied after every software update. |
| | **Complexity**: Requires understanding of binary structures and potential checksums. |
| | **Firmware Specific**: Patches must be reapplied after every software update. |
| | **Complexity**: Requires understanding of binary structures and potential checksums. |
---
@@ -117,11 +125,11 @@ sed "s#\^https:....bose.\+apigee..net..#http[aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
### Summary Table
| Method | Primary Use Case | Ease | Safety | Persistence | Granularity |
| :--- | :--- | :---: | :---: | :---: | :---: |
| **XML Config** | Logical service redirection | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| **`/etc/hosts`** | Quick global DNS override | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
| **Binary Patch** | Bypassing hardcoded checks | ⭐ | ⭐ | ⭐ | ⭐⭐⭐ |
| Method | Primary Use Case | Ease | Safety | Persistence | Granularity |
|:-----------------|:----------------------------|:-----:|:------:|:-----------:|:-----------:|
| **XML Config** | Logical service redirection | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| **`/etc/hosts`** | Quick global DNS override | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
| **Binary Patch** | Bypassing hardcoded checks | ⭐ | ⭐ | ⭐ | ⭐⭐⭐ |
---
@@ -176,9 +184,10 @@ As suggested by community members, you can configure the device to trust your ow
- **Method B (Symlinks)**: Add the certificate to `/etc/ssl/certs/` and create a hash symlink using `c_rehash` (if available) or manual mapping.
**Pros & Cons**:
| Pros | Cons |
| :--- | :--- |
| **Secure**: Maintains end-to-end encryption. | **Requires SSH**: Must have root access to modify the trust store. |
| Pros | Cons |
|:-------------------------------------------------------|:-----------------------------------------------------------------------|
| **Secure**: Maintains end-to-end encryption. | **Requires SSH**: Must have root access to modify the trust store. |
| **Clean**: No binary patching required for SSL bypass. | **Update Risk**: Firmware updates might overwrite the `ca-bundle.crt`. |
### Option 2: SSL Verification Bypass
+136
View File
@@ -0,0 +1,136 @@
# What a SoundTouch speaker does during factory reset
Observed live on ST10 firmware `27.0.6.46330.5043500` (build `epdbuild.trunk.hepdswbld04.2022-08-04`) on 2026-05-12, by running `soundtouch-cli setup factory-reset` and tailing the speaker's `logread` over SSH. The trace is preserved at `_/logs/factory-reset.txt` for reference.
## Sequence
1. **Telnet receives `sys factorydefault`.** The diagnostic shell on port 17000 accepts the command and acknowledges. Some firmwares close the socket as part of the reboot — our CLI's `setup factory-reset` tolerates that as success.
2. **Speaker DELETEs itself from its marge account.** Before wiping anything, the firmware does:
```
[MargeStateAssociated] HandleRemoveDeviceRequest - Removing this device from the user's Marge account
[MargeClient] RemoveDevice calling Marge Server with https://streaming.bose.com/streaming/account/{accountId}/device/{deviceId}
[MargeClient] RemoveDeviceCB - Device removed from the user's Marge account
[MargeStateAssociated] HandleRemoveDeviceRequestSuccessCB, Marge returned: {"ok": true}
```
AfterTouch already handles this — `HandleMargeRemoveDevice` (`pkg/service/handlers/handlers_marge.go:633`) routed via `r.Delete("/device/{device}", …)` in `cmd/soundtouch-service/main.go:955`. The handler calls `marge.RemoveDeviceFromAccount(s.ds, account, device)` and prunes the device from the datastore.
3. **Speaker notifies its LAN peers.** Two HTTP POSTs to each known peer at `:8090/notification`:
```
[NotificationSender] SendNotifyLisas_: URL: >>http://192.168.123.122:8090/notification<<, m_msgdata.size(58)
[SimpleURLFetcher] multipart/form-data text/xml
```
~58 bytes of `multipart/form-data` carrying `text/xml`. "Lisas" is the firmware's internal term for LAN peers (devices on the same account on the same network segment). AfterTouch is **not** on this path — it's pure peer-to-peer over the LAN. Peers presumably refresh their account info as a result.
4. **Local state teardown.** Bluetooth pairings cleared (`BTRemoteDeviceAccess::ClearPrevPairedList`), zone/group state torn down, all source proxies disconnected (`STSAccountProxy::Disconnect Requested` × many).
5. **Persistence cleanup.** Logs, core dumps wiped (`FactoryDefault: Clearing the CoreDump and BoseLogs … rm -rf /mnt/nv/BoseLog/*`). Notably **NOT wiped**: `/mnt/nv/aftertouch.resolv.conf`, `/mnt/nv/rc.local`'s Aftertouch hook, and `/mnt/nv/BoseApp-Persistence/1/SystemConfigurationDB.xml`. The reset only touches log directories and account-specific persistence under the same `/mnt/nv/BoseApp-Persistence/1/` tree.
6. **Reboot into setup mode.** Speaker drops Wi-Fi, comes back as its own AP `Bose SoundTouch XXXX` on 192.0.2.1.
## Implications for migration ordering
The DELETE in step 2 only reaches AfterTouch if the speaker's `margeURL` already points at AfterTouch *at the moment of reset*. A speaker still pointing at `streaming.bose.com` sends it into the void → AfterTouch keeps a stale `account/{id}/device/{id}` entry until someone manually prunes it.
Therefore for a clean datastore lifecycle on an already-Bose-paired speaker:
1. Migrate URLs first (`setup migrate --method=resolv` or `--method=telnet`).
2. Reboot to apply.
3. Factory reset.
4. Re-provision.
`soundtouch-cli setup plan --reset` currently runs factory-reset first (optimal for already-on-AfterTouch speakers); both `setup plan --reset` and `setup factory-reset` print a one-line note explaining the ordering tradeoff so users can pick the right sequence for their starting state.
## Implications for AfterTouch behaviour
- The DELETE handler is already correct; no changes needed.
- AfterTouch is invisible to the LAN-peer notification step — that's just LAN HTTP between speakers.
- If you build a "consolidate account" / "migrate fleet" feature later, the peer-notification channel is the propagation path the firmware uses internally; AfterTouch doesn't need to do anything analogous.
- The persistence layer at `/mnt/nv/` is **factory-reset-resistant**. Our DNS-redirect migration (`setup migrate --method=resolv`) writes there specifically so AfterTouch routing survives a reset. This is intentional — the user can factory-reset a speaker freely without re-running migration.
## Open questions
- Are there other peer endpoints the firmware POSTs to besides `:8090/notification`? Worth checking on a 3-speaker LAN.
- Does the `:8090/notification` payload format match the format used for play-as-notification audio pushes, or is it a distinct message shape? The "size(58)" byte count is too small for an audio URL but big enough for an XML envelope with an event type.
If you want either of these answered, capture two synchronised `logread -f` streams from two LAN speakers while one is being reset.
## Runbook — reset & re-provision an ST10 on AfterTouch
End-to-end command sequence used during the 2026-05-12 bare-pairing experiment, recorded verbatim from the test session. Replace IPs, SSID, password, service URL, and account ID with your own. Two manual Wi-Fi switches happen between `factory-reset` and `wifi-push` (host joins the speaker's AP) and again between `wifi-push` and `wait-online` (host re-joins home Wi-Fi).
```bash
# === 1. Reconnaissance — confirm what state the speaker is in before touching it. ===
# Identity, network, sources, presets.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup inspect
# Green/red status across every migration axis (SSH, telnet, CA, pairing, …).
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup verify \
--service-url=https://soundtouch.fritz.box
# What `setup plan --reset` would recommend, so you can preview the sequence.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup plan \
--service-url=https://soundtouch.fritz.box --reset
# === 2. Reset and Wi-Fi re-provisioning. ===
# Tell the speaker to wipe itself. Speaker drops Wi-Fi and reboots into AP mode.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup factory-reset
# Manual: switch this host to the speaker's setup AP.
# macOS: networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"
# Poll 192.0.2.1:8090/info until the speaker answers (interval=2s, timeout=5m).
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup wait-ap
# Push home Wi-Fi credentials. NOTE the single-quoted password: zsh expands `!`
# inside double quotes as history-expansion and will refuse the command.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup wifi-push \
--ssid="wifi-name" --pass='a.secure!password'
# Manual: switch host back to home Wi-Fi.
# macOS: networksetup -setairportnetwork en0 "wifi-name" 'a.secure!password'
# mDNS-poll for the speaker on the home network, matched by deviceID suffix
# (which survives the reset since it's the MAC). Returns the new IP.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup wait-online --match=536A98
# === 3. Clock, migrate, pair. From here on use the new IP wait-online reported. ===
# Set the speaker's wall-clock. `clock set --time=now` fails on FW 27;
# `clock now` is the working subcommand.
go run ./cmd/soundtouch-cli --host 192.168.123.123 clock now
# Reboot to clear any half-initialized resolver / NTP state from the wifi-push flap.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup reboot
# Apply DNS-redirect migration: routes *.bose.com to AfterTouch and installs its CA.
# Idempotent; safe to re-run.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup migrate \
--service-url=https://soundtouch.fritz.box --method=resolv
# Reboot again so the envswitch parallel-persistence layer and the resolv hook
# both take effect on the next boot.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup reboot
# Pair the device with an AfterTouch account — bare experiment variant.
# Drop --mode=bare and add --name=… / --language=… for the full state-machine variant.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup pair \
--mode=bare --account=1111111 --service-url='https://soundtouch.fritz.box'
# === 4. Verify. ===
# Reboot to verify persistence survives.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup reboot
# Snapshot the result. margeAccountUUID should still equal --account, and Sources
# should list ~14 entries (TUNEIN, RADIO_BROWSER, LOCAL_INTERNET_RADIO,
# SPOTIFY slots, AIRPLAY, etc.) materialized by the firmware.
go run ./cmd/soundtouch-cli --host 192.168.123.123 setup inspect
```
Total wall-clock for the above on this hardware: roughly 5 minutes including the two manual Wi-Fi switches and three reboots.
+215
View File
@@ -0,0 +1,215 @@
# Experiment: Does bare `setMargeAccount` work outside the SETUP bracket?
## Why we are doing this
Our captured pairing flow (`docs/reference/DEVICE-PAIRING-FLOW.md`) shows the official Bose app always sends `setMargeAccount` *inside* a `SETUP_START``SETUP_ENTER``SETUP_LEAVE` state-machine bracket over WebSocket. The question this experiment answers:
> If we open a WebSocket to a factory-reset speaker and send **only** `setMargeAccount` — no surrounding setupState messages — does the device honor it and write its persistence files (`SystemConfigurationDB.xml`, `Sources.xml`) cleanly?
The answer determines the shape of `PairAccount`:
- **If YES:** `PairAccount` becomes uniform: WebSocket-first, HTTP `/setMargeAccount` second, telnet `envswitch accountid set` third. One function, one ordering, all callers.
- **If NO:** WebSocket pairing is only meaningful inside the full state machine. Factory-reset path uses the state machine; re-pair path keeps today's HTTP→telnet ordering.
## Preconditions
- A SoundTouch speaker that has been **factory-reset** and joined to the test Wi-Fi.
- Speaker reachable on `:8090` (HTTP API) and `:8080` (WebSocket).
- Speaker's runtime marge URL already points at AfterTouch (run the existing telnet URL rewrite first — otherwise the device's downstream POST will land on the dead Bose cloud and we will not be able to distinguish "WS message refused" from "downstream cloud failed").
- A free 7-digit account ID — for example, generated via `setup.GenerateAccountID(nil)`.
## Step 0 — Baseline
```bash
DEVICE=192.168.x.x
curl -s http://$DEVICE:8090/info | xmllint --format -
curl -s http://$DEVICE:8090/sources | xmllint --format -
curl -s http://$DEVICE:8090/presets | xmllint --format -
```
Record:
- `<margeAccountUUID>` — expect empty on a factory-reset device.
- `<margeURL>` — expect the AfterTouch URL (preflight already applied).
- `<sources>` — expect a minimal list.
- `<presets>` — expect `<presets/>`.
## Step 1 — Send bare `setMargeAccount` over WebSocket
Build the CLI once:
```bash
make build
```
Then run the bare path against the speaker:
```bash
DEVICE=192.168.x.x
./build/soundtouch-cli setup pair --host=$DEVICE --account=1234567 --mode=bare
```
What it does:
1. Reads `/info` to discover `deviceID`, logs the pre-state.
2. Opens a WebSocket to `$DEVICE:8080` with the `gabbo` subprotocol.
3. Sends exactly one frame — the `setMargeAccount` envelope — **without** any preceding `SETUP_START`/`SETUP_ENTER`.
4. Reads frames for up to `--step-timeout=8s` (configurable), looking for an ack referencing our `requestID`.
5. Closes the WebSocket, waits 2 s, re-reads `/info`, prints whether `margeAccountUUID` now equals our supplied ID.
The exact frame sent (built by `setup.SetupSession.SetMargeAccount`):
```xml
<msg><header deviceID="DEVICE_ID" url="setMargeAccount" method="POST"><request requestID="1"/></header><body>
<PairDeviceWithAccount>
<accountId>1234567</accountId>
<userAuthToken>Bearer aftertouch</userAuthToken>
</PairDeviceWithAccount>
</body></msg>
```
Outcomes the CLI will surface:
- `Device accepted bare pairing.` (post-`/info` shows our ID) → **bare path works**.
- `setMargeAccount: device rejected setMargeAccount: …` → device returned an `<error>` body → **bare path refused explicitly**.
- `setMargeAccount: await ack for setMargeAccount: …` (timeout or EOF) → **bare path refused silently**.
- `Device did NOT persist the pairing — bare path likely refused silently.` → ack received but persistence didn't follow.
## Step 2 — Record outcome
After step 1 (regardless of which branch happened):
```bash
sleep 2
curl -s http://$DEVICE:8090/info | grep margeAccountUUID
```
| Observed result | Verdict |
|------------------------------------------------------------------------------|-----------------------------|
| `<margeAccountUUID>1234567</margeAccountUUID>` appears | **YES** — Option 1 wins |
| `<margeAccountUUID></margeAccountUUID>` still empty, no error frame received | Refused silently → **NO** |
| Error frame returned (e.g. `<error name="UNSUPPORTED_STATE"/>`) | Refused explicitly → **NO** |
| Device drops the WebSocket connection without replying | Refused → **NO** |
If verdict is YES, also verify the device wrote persistence cleanly. Reboot the device, then:
```bash
ssh root@$DEVICE 'cat /mnt/nv/BoseApp-Persistence/1/SystemConfigurationDB.xml'
ssh root@$DEVICE 'cat /mnt/nv/BoseApp-Persistence/1/Sources.xml'
curl -s http://$DEVICE:8090/info | grep margeAccountUUID
```
The UUID must still be present after reboot, and `SystemConfigurationDB.xml` must contain `<AccountUUID>1234567</AccountUUID>`. If it survives reboot, **YES** is confirmed.
## Step 3 — Control: full state machine
Factory-reset the same speaker again and run the full state machine — the same CLI, `--mode=full`:
```bash
./build/soundtouch-cli setup pair --host=$DEVICE --account=1234567 --mode=full
```
This drives `setup.Manager.ExecuteInitPlan` with `SkipURLRewrite=true`, which runs:
```
SETUP_START
SETUP_IDENTIFY_DEVICE_ENTER
language sysLanguage=2
SETUP_ENTER
SETUP_IDENTIFY_DEVICE_LEAVE
setMargeAccount …
SETUP_LEAVE
pushCustomerSupportInfoToMarge
```
The CLI logs every step with status. Confirm `/info`, persistence, and reboot-survival checks pass. If the bare path failed but the full path succeeds, the SETUP bracket is load-bearing — a follow-up bisect (e.g. `SETUP_START + setMargeAccount + SETUP_LEAVE` only) tells us *which* surrounding messages the firmware actually requires.
## Full reset-and-rebuild loop
Once the bare/full question is decided, the loop for repeated experiments is:
```bash
# 0. Speaker is currently on home Wi-Fi at $DEVICE.
# Capture deviceID-suffix + current SSID first so wait-online and
# wifi-push have the right inputs.
./build/soundtouch-cli setup inspect --host=$DEVICE
./build/soundtouch-cli setup factory-reset --host=$DEVICE
# 1. Manually switch this host to the speaker's AP (Bose SoundTouch XXXX).
# macOS: networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"
./build/soundtouch-cli setup wait-ap
./build/soundtouch-cli setup wifi-push --ssid="$HOME_SSID" --pass="$HOME_PASS"
# 2. Manually switch this host back to home Wi-Fi.
./build/soundtouch-cli setup wait-online --match=DE4803 # deviceID suffix from /info before reset
# (note the new IP from the "Speaker discovered" line)
NEW_IP=192.168.x.y
./build/soundtouch-cli setup migrate --host=$NEW_IP --service-url=http://aftertouch.local:8000 # default --method=telnet
# Optional, if you want the DNS-redirect path instead of (or alongside) telnet envswitch:
# 1. ./build/soundtouch-cli setup ssh-check --host=$NEW_IP # USB-stick procedure if 22 is closed
# 2. ./build/soundtouch-cli setup install-ca --host=$NEW_IP --service-url=http://aftertouch.local:8000
# 3. ./build/soundtouch-cli setup migrate --host=$NEW_IP --service-url=http://aftertouch.local:8000 --method=resolv
./build/soundtouch-cli setup pair --host=$NEW_IP --mode=bare # or --mode=full
```
The two manual lines are user-side Wi-Fi switches that can't be automated portably. The `wait-ap` and `wait-online` subcommands poll for the corresponding network state, so timing them is hands-off.
## Recording the result
Append to this file under `## Results`:
```
- Date: YYYY-MM-DD
- Firmware: 27.x.x
- Model: ST10 / ST20 / ST30 / ST300
- Bare setMargeAccount accepted: yes/no
- Persistence written: yes/no
- Survives reboot: yes/no
- Notes: ...
```
One row per device tested. Once two devices on different firmware confirm the same verdict, we treat it as decided.
## Results
- Date: 2026-05-13
- Firmware: 27.0.6.46330.5043500 (build epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29)
- Model: SoundTouch 10 (deviceID A81B6A536A98)
- Bare setMargeAccount accepted: **yes** — pre-/info margeAccountUUID="" → post-/info margeAccountUUID="1111111"
- Persistence written: **yes** — device materialized 14-entry Sources.xml on its own
- Survives reboot: **yes**`setup inspect` after `setup reboot` shows margeAccountUUID still 1111111
- Notes: After bare pairing, the speaker did the full post-pairing handshake against AfterTouch (POST /streaming/support/power_on, GET /streaming/sourceproviders, GET /streaming/account/{id}/full, group/, provider_settings). No SETUP_START/SETUP_ENTER/SETUP_LEAVE was ever sent. Verdict: bare path is functionally equivalent to the full state machine on this firmware.
### Implication for the codebase
- `pkg/service/setup/setup_session.go` keeps the full state machine for completeness, but
- `pkg/service/setup/init_plan.go`'s default could be simplified to "send setMargeAccount only" once we have one more confirming run on a different model.
- The OCT issue-167 SSH-XML seeding workaround is **not required**.
### Appendix — SystemConfigurationDB.xml comparison
Post-experiment we compared the device-written `/mnt/nv/BoseApp-Persistence/1/SystemConfigurationDB.xml` from the bare-paired speaker against two SSH backups taken from speakers originally paired by the official Bose app (account 3230304, devices `A_Sound_Machine` and `Sound_Machinechen`). The diff is much smaller than expected — only two fields differ, and neither is set by the pairing protocol itself:
| Field | Bare-paired (1111111) | Real-Bose-paired (3230304) | Set by |
|--------------------------|--------------------------------------------|----------------------------|-----------------------------------------------------------------------------------------------------------|
| `DeviceName` | `Bose SoundTouch 536A98` (factory default) | `Sound Machinechen` | `name` WS message — only sent in `--mode=full` |
| `AccountAssociatedEMail` | empty | **empty** | Never populated, even by real Bose |
| `AccountUUID` | `1111111` | `3230304` | `setMargeAccount` — both paths set it |
| `Locale` | empty | **empty** | Never populated, even by real Bose |
| `acctMode` | `global` | `global` | Firmware-default; no protocol path observed to change it |
| `isMultiDeviceAccount` | `false` | `true` | Derived from the cloud's `/streaming/account/{id}/full` response — count of `<devices>` > 1 flips it true |
| `margeAuthServerToken` | empty | **empty** | Never populated, even by real Bose |
| `Password` | (encrypted blob) | (encrypted blob) | Device-local key; expected to differ |
Three of the seven informational fields are empty even after a real-Bose pairing — the firmware simply doesn't populate `AccountAssociatedEMail`, `Locale`, or `margeAuthServerToken` from the pairing flow. So bare pairing isn't missing any field that real pairing fills.
The two genuinely different fields:
- **`DeviceName`** — pure UX. Settable any time post-pair via `name` POST (`soundtouch-cli name set --value=…`) or by sending the `name` WS message during `--mode=full` pairing.
- **`isMultiDeviceAccount`** — not a pairing concern. It's derived from the account's device count on AfterTouch's side; flips to `true` automatically the next time the speaker refreshes account state if a second speaker has been paired to the same account.
So the experiment's YES verdict stands unqualified: bare `setMargeAccount` produces a `SystemConfigurationDB.xml` functionally equivalent to one written by the official pairing flow.
+265
View File
@@ -0,0 +1,265 @@
# Bose SoundTouch Telnet (Port 17000) Command Reference
A consolidated reference for the diagnostic shell that listens on TCP port
17000 across the SoundTouch line. Compiled from multiple community sources
to give a single map of what's been observed in the wild — useful both for
implementing automation against it (see
[TELNET-MIGRATION-METHOD.md](TELNET-MIGRATION-METHOD.md)) and for manual
recovery / WiFi setup.
> **Important caveat.** The command set is firmware-dependent. Anything that
> existed in firmware 1.x7.x (`flarn2006`'s era) was progressively trimmed;
> some commands listed here have been removed on firmware 27.x. Where a
> command's availability is known to vary, the **Availability** column says so.
## Sources
| # | Source | Era / focus |
|----|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| S1 | [flarn2006: "Hacking the Bose SoundTouch and its Linux insides"](https://flarn2006.blogspot.com/2014/09/hacking-bose-soundtouch-and-its-linux.html) (2014) | Firmware 1.x7.x; root shell discovery, codenames |
| S2 | [Sam Hobbs: "Connect Bose SoundTouch 10 to WiFi using Linux Telnet"](https://samhobbs.co.uk/2016/01/connect-bose-soundtouch-10-wifi-using-linux-telnet) (2016) | ST 10 setup mode; `network`/`sys` families |
| S3 | [izndgroup: "Connect Bose SoundTouch 10 to WiFi"](https://technical.izndgroup.com/2021/02/connect-bose-soundtouch-10-to-wifi.html) (2021) | Reissue of S2 with later-firmware notes |
| S4 | [sijeffrey/SoundTouch — `bose` script](https://github.com/sijeffrey/SoundTouch/blob/master/bose) (2017) | `nc`-based remote-control script using `sys`/`ws` |
| S5 | [r/bose "SoundTouch telnet probing"](https://www.reddit.com/r/bose/comments/1o5zkym/soundtouch_telnet_probing/) | Recent (post-EOS) probing on ST 10 firmware `27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29`; comments mirrored in [#221](https://github.com/gesellix/Bose-SoundTouch/issues/221) |
| S6 | Issue [#221](https://github.com/gesellix/Bose-SoundTouch/issues/221), [#236](https://github.com/gesellix/Bose-SoundTouch/issues/236), [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141) | The migration commands we already implement |
---
## Connecting to the shell
### From an already-on-network device
The shell binds to TCP port 17000 on every device family observed (ST 10/20/300, Wave III/IV, ST 520, SA-5 — see §"Firmware era notes" for caveats). No authentication.
```bash
# A no-op probe just to verify reach.
echo '' | nc -w 2 <device-ip> 17000
# Or interactively — works the same.
telnet <device-ip> 17000
```
The `bose` script (S4) goes one level lower and writes commands directly to a `/dev/tcp/<ip>/17000` redirection target instead of using `nc`. That's the same wire protocol with no library between.
### From a factory-fresh / WiFi-less device
Per S2/S3 — newer firmware may have closed this on some models:
1. **Enter setup mode.** Press and hold key **2** + **volume down** for 5 seconds until the WiFi LED turns amber.
2. **Connect your laptop to the speaker's open access point.** The speaker becomes its own AP.
3. **Telnet to `192.0.2.1` on port 17000.**
Once you've added a WiFi profile (see `network wifi profiles add` below) the speaker reboots into station mode and the AP goes away.
### Hardware key combinations on the device itself
| Combo | Effect | Source |
|-------------------|------------------------------------------|--------|
| `1` + volume-down | Factory reset | S2, S3 |
| `2` + volume-down | Setup mode (open WiFi AP at `192.0.2.1`) | S2, S3 |
| `3` + volume-down | Toggle WiFi / Bluetooth | S2, S3 |
| `4` + volume-down | Check for software updates | S2, S3 |
---
## The `network` family — WiFi & interfaces
| Command | Purpose | Availability | Source |
|------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|----------------------------|--------|
| `network wifi status` | Current SSID, state (e.g. `WIFI_STATION_CONNECTED`), signal strength. Returns XML-like `<WiFiStatus SSID="…" state="…">`. | Wide | S2, S3 |
| `network wifi scan [<maxresults>]` | Site survey. | Wide | S2 |
| `network wifi profiles info` | Lists stored WiFi profiles (passphrases shown encrypted). | Wide | S2, S3 |
| `network wifi profiles add <ssid> <security> [<password>]` | Adds a WiFi network. `<security>``none` \| `wep` \| `wpa_or_wpa2`. | Wide; setup-mode workhorse | S2, S3 |
| `network wifi profiles clear` | Wipes all stored profiles. | Wide | S2 |
| `network status` | All interfaces and IP addresses. | Wide | S2, S3 |
| `network dhcp` | Current DHCP interface info. | Wide | S2 |
| `network mode auto\|wifioff\|wifisetup` | Switch radio / setup-AP state. | Wide | S2 |
**Example session — adding a network from setup mode (S3):**
```
network wifi profiles add foobarHub wpa_or_wpa2 topsecret
```
The speaker stores the profile, drops the setup AP, and reboots into station mode.
---
## The `key` family — front-panel button emulation
Each `key …` command emulates a press of a physical button on the speaker
or remote. Confirmed working on ST 10 / FW `27.0.6.46330.5043500` (S5);
also visible on the ST 20/300/Wave captures in #221. Different from the
`sys presetkey N p` form (S4) — the `key prefix_N` shape on FW 27 is what
the device's own remote sends.
| Command | Effect | Source |
|---------------------------------|---------------------------------------------------------------------------------------|--------|
| `key prefix_1``key prefix_6` | Triggers preset 16 (same as a remote preset press). | S5 |
| `key play` | Begin / resume playback. | S5 |
| `key pause` | Pause playback. | S5 |
| `key stop` | Stop playback (does **not** terminate the underlying stream). | S5 |
| `key prev` | Restart current song / previous track. | S5 |
| `key next` | Next track. | S5 |
| `key aux` | Toggle Bluetooth / AUX input. | S5 |
| `key power` | Echoes "OK" but no observable effect on FW 27.x — possibly handled at a higher layer. | S5 |
The S4 `bose` script's `sys presetkey N p` form still works, but `key prefix_N` is shorter and matches what the remote already does on FW 27.x.
---
## The `sys` family — system control & service URLs
The `sys` family is the one our migration uses (see §"What we use during migration"). Two distinct sub-syntaxes coexist:
- **Single-token verbs:** `sys reboot`, `sys volume`, `sys power`, etc.
- **`sys configuration <key> <value>` setters** that modify persisted runtime configuration. Used for the four service URLs (margeServerUrl, statsServerUrl, swUpdateUrl, bmxRegistryUrl).
| Command | Purpose | Availability | Source |
|---------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------|------------|
| `sys reboot` | Restart the device. | Wide | S2, S6 |
| `sys factorydefault` | Reset to factory defaults. | Wide | S1, S2 |
| `sys ver` | Firmware version string, e.g. `BoseApp version: 27.0.6.46330.5043500 …`. | Wide; confirmed on FW 27.x | S1, S5 |
| `sys power` | Toggle power. Confirmed working on older firmware via S2/S4; on FW 27.x ST 10 the response is `OK` but with **no observable effect** — power state may be controlled elsewhere on that build. | Varies | S2, S4, S5 |
| `sys playpause` | Toggle playback. | Wide | S2 |
| `sys stop`, `sys pause` | Accepted (return `OK`) but **no observable effect** on FW 27.x ST 10 — the working stop/pause path on that firmware is `key stop` / `key pause`. | Wide / no-op | S5 |
| `sys volume` | Print current volume. The S4 script parses the 5th token of the first line. | Wide | S2, S4, S5 |
| `sys volume <int>` | Set absolute volume to `<int>`. | Wide | S5 |
| `sys volume up <n>` / `sys volume down <n>` | Adjust volume by `<n>` (steps, not dB). | Wide | S4 |
| `sys volume <value> updateDisplay` | Set absolute volume and update the front-panel display. | Wide | S2 |
| `sys presetkey <1-6> p` | Trigger a preset (`p` = press). Older shape of `key prefix_<N>`. | Wide | S4 |
| `sys timeout inactivity disable` (or `off`) | Stop the auto-shutoff timer. May need to be sent twice. | Wide | S1, S2 |
| `sys configuration` (no args) | Returns the usage hint `sys configuration <XMLTag> <XMLValue>` — confirms the underlying setter is XML-tag-keyed. | FW 27.x | S5 |
| `sys configuration bmxRegistryUrl <url>` | Set the Bose Media eXchange registry URL. | Wide; **migration** | S6 |
| `sys configuration statsServerUrl <url>` | Set the telemetry/stats endpoint. | Wide; **migration** | S6 |
| `sys configuration margeServerUrl <url>` | Set the marge / streaming endpoint. | Wide; **migration** | S6 |
| `sys configuration swUpdateUrl <url>` | Set the software-update endpoint. | Wide; **migration** | S6 |
Each `sys configuration` setter is reported by users to return `OK` on success. Wait for that token between commands (S6, `foob61451`).
---
## The `envswitch` family — parallel persistence layer
`envswitch` writes to a separate, lower-level persistence store that **wins on next reboot** if the corresponding `sys configuration` value differs. So our migration writes both — see TELNET-MIGRATION-METHOD.md §2.1.
| Command | Purpose | Source |
|---------------------------------------------------|-----------------------------------------------------------------------------------------------|---------|
| `envswitch boseurls set <margeUrl> <swUpdateUrl>` | Persist the marge and update URLs. **Two arguments**, in that order. | S6 |
| `envswitch accountid set <numeric-id>` | Equivalent to the HTTP `/setMargeAccount` POST. Used as fallback in our `PairAccount` helper. | S6 |
| `envswitch accountid get` | Plausible by symmetry but **not yet confirmed** across firmwares; we probe it best-effort. | (probe) |
---
## The `getpdo` family — read persisted configuration
`getpdo <selector>` prints the contents of a persisted-data-object. We use it as the verification step after writing URLs.
| Selector | Purpose | Source |
|-------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|
| `getpdo CurrentSystemConfiguration` | Echoes the resolved URL set, including margeServerUrl/bmxRegistryUrl/statsServerUrl/swUpdateUrl. We grep our targetURL out of this to confirm a successful migration. | S6 |
---
## The `scm` family — service control
`scm` (System Control / Module manager) lets you inspect and restart internal services.
| Command | Purpose | Availability | Source |
|-------------------------|------------------------------------------------------------------------------------------|----------------|------------------------------------------------------------------------------|
| `scm list` | List running services. | Older firmware | S1 |
| `scm restart <service>` | Restart a service by name. | Older firmware | S1 |
| `scm uboot_ver` | Print bootloader version (`U-Boot 2013.01.01-…`). Confirmed working on SA-5 with FW 9.x. | Older firmware | [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141) |
---
## Shell-unlock commands
These are the commands that gated SSH access on older firmware. Both have been progressively removed; on FW 27.x they generally do nothing useful.
| Command | Purpose | Availability | Source |
|-----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------|----------------------------------------------------------------------------------|
| `remote_services on` | Enable SSH on port 22. Volatile (re-enter after reboot). Response: `remote services on`. **Removed in FW 7.x+**. | Old | S1 |
| `local_services on` | Alternative enablement; works on some firmware where `remote_services` was removed. SA-5 FW 9.x reports `local services on`, but this alone does not appear to grant SSH on most models. | Old, hit-or-miss | S1, [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141) |
| `demo enter` / `mode enter` | Unlocks demo / button-test mode (used historically to recover bricked units). | Old | S1 |
---
## The `ws` and `swupdate` families
| Command | Purpose | Availability | Source |
|------------------|---------------------------------------------------------------------------------------------------------|--------------|--------|
| `ws getpresets` | Returns an XML list of presets — the S4 script parses the `<itemName>…<text>…` blocks to extract names. | Wide | S4 |
| `swupdate abort` | Cancel a software update in progress. | Wide | S1 |
---
## `help`
Lists the commands available on the running firmware. **Frequently removed** on later firmware — returns `Command not found` on FW 27.x in many of the captures we have. Still worth probing once during preflight: a successful response is a quick way to enumerate what this specific build supports without trial-and-error.
---
## Device codenames (S1)
These show up in `getpdo`, `network status`, and SSH-side hostnames. Useful for matching captures to hardware.
| Codename | Hardware |
|----------|------------------------------------------------|
| `lisa` | Adapter (older speakers running Bose firmware) |
| `spotty` | SoundTouch 20 |
| `rhino` | SoundTouch 10 |
| `mojo` | SoundTouch 30 |
| `taigan` | SoundTouch Portable |
---
## Firmware era notes
- **Firmware 1.x7.x** (S1 era): everything — `help`, `remote_services on`, full `scm`, and an in-shell login prompt. `flarn2006` documents the original Linux insides.
- **Firmware 8.x14.x** (S2 era): `remote_services on` removed; `network`, `sys`, `envswitch`, `getpdo` still present. `local_services on` works on some Wave/SA-5 models.
- **Firmware 27.x** (S5/S6 era — the long-lived "frozen" build that survived through EOS): `help`, `remote_services on`, and `sys ver` removed in some builds; `sys configuration …` and `envswitch …` confirmed working on ST 10, ST 20, ST 300, Wave III, Wave IV. **This is the firmware our migration targets**. The Portable on more recent firmware drops further commands and is the hardest target.
S5 enumerated the **top-level command roots** that don't return "Command not found" on a vanilla ST 10 (`rhino`) running `27.0.6.46330.5043500`:
```
key
net
sys
getpdo
```
Notably absent from that probe: `network`, `envswitch`, `scm`, `ws`, `swupdate`, `remote_services`, `local_services`, `demo`, `mode`, `help`. **However**, other captures on the same firmware family (S6, ST 20 / Wave III / Wave IV) accept `envswitch …`, suggesting either per-model variation in the shipped command table or an SSH/role gate the S5 author didn't trip. Implementations that use `envswitch` should treat its absence as a recoverable preflight outcome (we already do).
`net` is observed as a valid root by S5 but its sub-commands aren't enumerated; it may be a shorthand alias for `network` on FW 27.x ST 10.
---
## What we use during migration
For quick reference, the exact sequence our `pkg/service/setup.migrateViaTelnet` issues, all on the same connection, in this order:
```
sys configuration bmxRegistryUrl <serverURL>/bmx/registry/v1/services
sys configuration statsServerUrl <serverURL>
sys configuration margeServerUrl <serverURL>
sys configuration swUpdateUrl <serverURL>/updates/soundtouch
envswitch boseurls set <serverURL> <serverURL>/updates/soundtouch
getpdo CurrentSystemConfiguration
```
Plus, when pairing a fresh device whose `:8090/setMargeAccount` is missing or wedged, the helper falls back to:
```
envswitch accountid set <7-digit-id>
```
Reboot is **not** part of these sequences — it stays a user-initiated action via the existing reboot button, which now accepts `?method=telnet|ssh` and sends `sys reboot` when telnet is picked.
---
## Out of scope here, but worth recording
- **Setup-mode WiFi onboarding via 192.0.2.1.** The community uses this to add a fresh device to a network without the Bose app. Our `soundtouch-service` does not currently automate this, but `network wifi profiles add` is the entry point if we ever do.
- **Direct preset / playback control via `sys`.** The S4 `bose` script demonstrates a viable headless remote-control path that does not need our marge emulation at all. Useful as a fallback for tooling on devices that refuse to talk to any cloud.
- **`scm restart <service>`.** Not used today, but a possible recovery primitive on older firmware where a stuck service blocks streaming.
+730
View File
@@ -0,0 +1,730 @@
# Telnet (Port 17000) Migration Method — Analysis
This document captures the use cases, community findings, and feasibility analysis
for adding a **Telnet/port 17000** migration path to `soundtouch-service` as a
peer of the existing XML and DNS-based methods. The `/etc/hosts` method stays
deprecated and is intentionally kept off the visible UI options.
> **Sources** — community discussion synthesised from
> [gesellix/Bose-SoundTouch#221](https://github.com/gesellix/Bose-SoundTouch/issues/221),
> [gesellix/Bose-SoundTouch#236](https://github.com/gesellix/Bose-SoundTouch/issues/236),
> [scheilch/opencloudtouch#167](https://github.com/scheilch/opencloudtouch/issues/167),
> [deborahgu/soundcork#228](https://github.com/deborahgu/soundcork/issues/228),
> [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141),
> the post-EOS walkthrough PDF in `docs/`,
> [Bose SoundTouch Telnet Probing thread](https://www.reddit.com/r/bose/comments/1o5zkym/soundtouch_telnet_probing/),
> and [flarn2006's blog post on hacking SoundTouch](https://flarn2006.blogspot.com/2014/09/hacking-bose-soundtouch-and-its-linux.html).
---
## 1. Why a third method is needed
The two currently shipped methods both have hard preconditions that block real
users:
| Method | Preconditions | Failure modes seen in the wild |
|-----------------------------------------|--------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **XML** (`SoundTouchSdkPrivateCfg.xml`) | SSH/root access — needs `remote_services` USB unlock first | Some firmware revisions (e.g. SA-5, ST520, latest ST Portable) refuse the USB unlock entirely; `remote_services on` was removed from the telnet command set in firmware 7.x and later. |
| **DNS** (`resolv.conf` priority hook) | SSH/root access; service must own port 53 on the LAN gateway | Won't fit users behind ISP routers they can't reconfigure; still requires the device to be SSH-reachable to write the hook. |
The community has demonstrated a **third path that needs no SSH at all**:
the device's built-in **diagnostic Telnet shell on TCP port 17000** accepts
configuration commands that change exactly the same fields the XML method would.
### 1.1 Confirmed user reports (firmware 27.0.6.46330.5043500 unless noted)
| Reporter | Hardware | Outcome |
|--------------------|---------------------------|-------------------------------------------------------------------------------------------------------------|
| `foob61451` (#221) | ST 10, ST 20 (non-rooted) | All four URLs persisted via `sys configuration …`; `envswitch boseurls set …` survived `sys reboot`. |
| `bveenker` (#221) | Wave III | URLs accepted; presets work after pairing via `/setMargeAccount` (see §3). |
| `stephan48` (#221) | Wave IV | Telnet:1700 + USB stick `remote_services` did **not** work; **port 17000 telnet** worked for all four URLs. |
| `mcdona1d` (#141) | ST 20, ST 300 | Confirmed working with `sys configuration …` + `envswitch …` + `sys reboot`. |
| `TJGigs` (#228) | ST 20 ×2, ST 10 | Wraps telnet:17000 into an admin "Smart Inject" tool; uses `sys reboot` over telnet to nudge devices. |
So the method is plausible across **at least ST 10/20/300 and Wave III/IV** on
the most common firmware that survived the EOS cut, **without the USB unlock
dance** that newer firmware refuses.
---
## 2. The Telnet:17000 command set we rely on
> For a broader catalogue of every telnet command the community has documented
> across firmware eras (the `key`, `network`, `sys`, `envswitch`, `getpdo`,
> `scm`, `ws`, `swupdate`, and shell-unlock families), see
> **[TELNET-COMMAND-REFERENCE.md](TELNET-COMMAND-REFERENCE.md)**. This
> section only lists the subset our migration actually drives.
### 2.1 URL configuration (the migration payload)
The sequence we send for `soundtouch-service` (community-validated in #221, #141):
```
sys configuration bmxRegistryUrl http://<service-host>:8000/bmx/registry/v1/services
sys configuration statsServerUrl http://<service-host>:8000
sys configuration margeServerUrl http://<service-host>:8000
sys configuration swUpdateUrl http://<service-host>:8000/updates/soundtouch
envswitch boseurls set http://<service-host>:8000 http://<service-host>:8000/updates/soundtouch
getpdo CurrentSystemConfiguration
```
`sys reboot` is **not** part of this sequence. The migration flow only writes
configuration — the reboot is user-initiated via the existing reboot button in
the web UI, mirroring what XML/DNS migration already does. See §6.2 for how
that button gains a `?method=ssh|telnet` selector.
Three important details from the discussion:
1. **`sys configuration` alone is not enough.** `stephan48` reported that
without the `envswitch boseurls set …` line his typo in `bmxRegistryUrl` was
silently restored on reboot — i.e. there is a parallel "envswitch" persistence
layer that wins on next boot if you don't also write to it. **We must always
issue both.**
2. **margeServerUrl path is bare for `soundtouch-service`.** We mount the marge
endpoints at the **root** of port 8000, matching what the existing XML
migration writes (`Manager.migrateViaXML` in `pkg/service/setup/setup.go`
sets `MargeServerUrl: targetURL` without any suffix). Some community
recipes appended `/marge` because they were targeting
[`deborahgu/soundcork`](https://github.com/deborahgu/soundcork), which
routes marge under that sub-path. **For our service: bare URL. For users
redirecting to soundcork: append `/marge`** to both `margeServerUrl` and
the first argument of `envswitch boseurls set`.
3. **Each command must be sent one at a time, waiting for the device's `OK`
response** before sending the next one (`foob61451`'s explicit warning).
### 2.2 Account pairing fallback
`envswitch accountid set <numeric-id>` was reported by `bveenker` (#221) as an
in-band equivalent to the HTTP `/setMargeAccount` call, useful when the
`/setMargeAccount` endpoint is missing on the firmware (see §3).
### 2.3 Probing / preflight
- A bare TCP connect to `<deviceIP>:17000` answers (no auth) on devices we care
about.
- Useful read-only verification command: `getpdo CurrentSystemConfiguration`
prints the URLs after the changes have been applied so we can verify before
rebooting.
- `sys reboot` is the trigger that re-reads both layers.
### 2.4 What Telnet:17000 cannot do
- It does **not** install a custom CA. So if a user wants HTTPS rather than HTTP
redirection to our service (the DNS-method scenario, where `resolv.conf`
redirection collides with the device's TLS validation unless our root CA is
trusted on the device), telnet alone won't cover it. This is fine for our
default flow, which uses plain `http://` URLs to the service's port 8000.
- It does not give us a way to read or write `Sources.xml` (third-party
account credentials) — that still requires SSH, but for a migration we don't
actually need it.
---
## 3. The `/setMargeAccount` problem (issue #236, #228)
### 3.1 What it is
A factory-reset speaker has an empty `<margeAccountUUID/>` in `:8090/info`. The
marge endpoints fail with 502 / unhandled until that field is populated, which
is why several users (#221, #236) saw **everything except AUX** broken after
migration:
```
POST http://<deviceIP>:8090/setMargeAccount
Content-Type: application/xml
<PairDeviceWithAccount>
<accountId>1234567</accountId>
<userAuthToken>soundcorkdoesntcare</userAuthToken>
</PairDeviceWithAccount>
```
The values are not validated by the local service, so any numeric `accountId`
will work — soundcork's runbook (#228) literally calls the token
`soundcorkdoesntcare` to make the point.
### 3.2 Why it's broken in practice
There are **three independent failure modes** observed:
| Symptom | Cause | Detection |
|-----------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------|
| Endpoint returns 404 / "not implemented" | Newer firmware (e.g. some BST20 Portable, latest ST Portable) drops the endpoint entirely. | `GET /supportedURLs` does **not** list `/setMargeAccount` in `<URL location="…"/>`. |
| Endpoint hangs (no response / socket stays open) | "Broken state" the user explicitly called out — endpoint advertised, but handler is wedged. | Caller has to time out; we currently have no timeout, so the request appears to hang the migration UI indefinitely. |
| `POST /marge/streaming/support/power_on` → 502 unhandled (#236) | Device keeps polling marge after migration but no `margeAccountUUID` was ever assigned, so all subsequent calls fail. | `:8090/info` shows `<margeAccountUUID/>` empty after reboot. |
### 3.3 Required handling
Per the user's brief, the migration logic must:
1. **Probe** `GET http://<deviceIP>:8090/supportedURLs` and check whether
`/setMargeAccount` is in the list **before** trying to POST it.
2. **Time-bound** the POST aggressively (e.g. ≤5s connect + ≤10s read) and treat
anything over the budget as a failure rather than waiting indefinitely.
3. On either failure mode, **fall back** to the telnet equivalent
`envswitch accountid set <id>` over the same `pkg/telnet` connection used
for the URL flip. Reboot stays a user-initiated action (§6.2).
4. If telnet:17000 is **also** unreachable, surface a clear "your firmware does
not support unattended pairing — please pair manually via the official Bose
app *before* it goes EOS, or open SSH and use the XML method" error rather
than leaving the device in a half-migrated state.
### 3.4 Where the `<id>` comes from
The device's current account ID is already discoverable through endpoints we
control:
- **`GET :8090/info`** returns `<margeAccountUUID>…</margeAccountUUID>`. If it
is non-empty the device is already paired — **reuse that ID**, do not
reassign. Our local marge accepts any ID, so the existing one is fine.
- If it is empty (factory reset), the user picks one in the UI:
1. **Pick from existing accounts.** The setup UI lists IDs returned by
`DataStore.ListAccounts()` so a user can re-attach a fresh device to an
account that already has presets/recents/sources.
2. **Enter manually.** Free-form text input, validated as **exactly 7
numeric digits** (the format every Bose-cloud-issued ID has had in the
captures we've seen, and the format the wider community uses in their
recipes).
3. **Randomize.** A "Generate" button that picks a 7-digit number and
re-rolls if it collides with an existing account in the local datastore.
- **Telnet read-back (best-effort).** `envswitch accountid get` is plausible by
symmetry with `envswitch accountid set` (#221) but is not yet confirmed
across firmwares. We will probe it during preflight; if it returns a value
we cross-check it against `:8090/info` and warn on mismatch.
This means the user is never *forced* to invent a number — the common path is
"the device already has an ID, reuse it" — and the manual/randomize controls
only show up when the device is genuinely fresh.
---
## 4. Port 17000 availability
The diagnostic shell is gated by firmware build and product family. Anecdotally:
- ST 10 / ST 20 / ST 300 / Wave III / Wave IV on FW 27.0.6 → **open**.
- SA-5 with FW 9.x → some commands present (`local_services on`) but
**no `remote_services on`** and no SSH on FW 9.0.43.23466 (#141).
- Modern firmware on some Portables → endpoint set has shrunk further.
Because of this, we cannot assume port 17000 is reachable. The migration flow
must:
1. **Probe** with a TCP connect to `<deviceIP>:17000`, with a tight timeout
(≤2s). A successful TCP handshake is necessary but not sufficient — some
hardened firmware closes the port immediately.
2. **Banner check.** After connecting, read whatever the device sends within
~1s. The diagnostic shell prints a small banner (firmware-dependent); a
blank read or an immediate close means we should treat it as "telnet not
usable" and disable the option.
3. **Capability check.** Issue a no-op like `getpdo CurrentSystemConfiguration`
and look for any non-empty response. If the device replies "Command not
found" we abort and suggest XML or DNS instead.
4. **Surface state to the UI.** The migration form should grey out the Telnet
option when the probe fails and show *why* (closed, banner missing,
command rejected) instead of letting the user click into a dead end.
---
## 5. Implementation feasibility — Telnet client in Go
This is a feasibility check only; no code is written yet.
### 5.1 Protocol
"Telnet" on port 17000 is effectively a line-oriented plain-TCP shell. The
device prints a small prompt (`->` in the SA-5 captures from #141) and reads
newline-terminated commands. There is **no** real Telnet option negotiation
(no `IAC`/`DO`/`WILL` exchanges visible in the wild captures), so we don't
need `golang.org/x/crypto/ssh`-class machinery.
### 5.2 Standard-library only
A minimal client is just `net.DialTimeout("tcp", host+":17000", 2*time.Second)` +
`bufio.Scanner` + `time.Time`-based deadlines on `Conn`. No third-party Telnet
library is needed; `github.com/reiver/go-telnet` would be overkill and adds
maintenance surface for no benefit. This matches the project's KISS principle
in `docs/CLAUDE.md` §3.
### 5.3 Cross-platform compatibility
`net.Dial` over TCP works identically on Windows, macOS, Linux and (with
limitations on listening) WASM. WASM-side: `soundtouch-service` runs server-side
anyway, so this only matters for `soundtouch-cli`, where TCP dial works in any
target other than browser-WASM — an acceptable carve-out documented separately.
### 5.4 Concurrency / safety
Each migration is a single goroutine driving one device. The client must:
- enforce per-command response deadlines so a wedged device cannot stall the
migration UI (mirrors the `/setMargeAccount` requirement);
- abort the rest of the sequence on the first non-`OK` response so we don't
half-write configuration;
- always close the socket on error.
### 5.5 Testing strategy
We can test without a real speaker by spinning up a `net.Listen("tcp", "127.0.0.1:0")`
in the test, scripting it to consume our commands and emit canned `OK`/error
responses. That gives us deterministic coverage for:
- happy path (all four URLs accepted),
- single-command failure → sequence aborts, no further commands sent,
- "command not found" on `envswitch …` → fallback path exercised,
- TCP closed mid-stream → migration aborts cleanly,
- read deadline triggers when the device hangs (the broken-state simulation).
The repo already follows the "real device responses preferred, mock servers
otherwise" rule (see `docs/CLAUDE.md` §1, §8). The tests above are the mock-server
half of that pattern.
### 5.6 Where it lives
The protocol client is **a standalone package**, not buried inside
`pkg/service/setup`, so it can be reused from CLI tools, future setup wizards,
and tests without dragging the migration manager in:
```
pkg/telnet/ # NEW reusable package
client.go # Dial / SendCommand / Probe / Close
client_test.go # mock-server tests against a net.Listen
pkg/service/setup/
telnet_migration.go # NEW thin wrapper that imports pkg/telnet
# and runs the URL config sequence
marge_pairing.go # NEW /setMargeAccount probe + post + telnet
# `envswitch accountid set` fallback
setup.go # add MigrationMethodTelnet const + case
```
UI plumbing is `pkg/service/handlers/web/index.html` (option list) and
`pkg/service/handlers/web/js/script.js` (`toggleMigrationMethod()`). The
deprecated `hosts` option is already hidden from the dropdown when we ship
this; we just add a `telnet` option next to `xml`/`resolv`.
### 5.7 Verdict
**Feasible and small.** Estimated scope: ~200 lines of client code in
`pkg/telnet`, ~300 lines of tests, plus a `MigrationMethodTelnet` branch in
`Manager.MigrateSpeaker`, plus the preflight probe described in §4 and the
`/setMargeAccount` guarding described in §3.
---
## 6. Decisions made (was: open questions)
1. **Account-ID generation.** Resolved — see §3.4. The migration form reads
`:8090/info` first; if `margeAccountUUID` is non-empty it is reused.
Otherwise the UI offers (a) pick from `DataStore.ListAccounts()`,
(b) manual entry validated as 7 numeric digits, (c) a "Generate" button
that randomizes a 7-digit number and re-rolls on collision.
2. **Reboot policy.** Migration writes configuration only — it does **not**
issue `sys reboot` itself. Reboot stays user-initiated via the existing
reboot button in the web UI, the same way XML/DNS migration already works.
That button's endpoint (`POST /setup/reboot/{deviceId}`,
`Manager.Reboot(deviceIP)`) gains an optional `?method=ssh|telnet` query
parameter; default stays `ssh` so existing behavior is preserved. The
button itself uses a plain `confirm()` dialog before firing.
3. **CA / HTTPS story.** Telnet has no way to install a custom CA. Documented
as an explicit limitation: telnet method = HTTP-only redirect to our
service. Users who need end-to-end TLS must use the XML or DNS method.
*Possible future enhancement* — a hybrid "install CA via SSH/XML, then drive
the URL flip via Telnet" path. Feasibility unknown; not in this iteration.
---
> **See §9 for the as-shipped state.** Section 7 below records the
> original forecast; the wizard grew larger during implementation and
> §9 documents what actually landed.
## 7. Summary of what changes when this lands
- **New reusable package `pkg/telnet`** — sibling of `pkg/ssh`, line-oriented
TCP client with `Dial`, `SendCommand`, `Probe`, `Close`, all deadline-driven.
No external dependencies, usable from CLI, service, and tests.
- **New `MigrationMethodTelnet = "telnet"`** constant in `pkg/service/setup/setup.go`
plus a `migrateViaTelnet` branch in `Manager.MigrateSpeaker`.
- **New `pkg/service/setup/telnet_migration.go`** orchestrating the URL
configuration sequence (§2.1) on top of `pkg/telnet`. Configuration only —
no `sys reboot` here.
- **New `pkg/service/setup/marge_pairing.go`** with `PairAccount(deviceIP, id)`:
probes `/supportedURLs`, time-bounded `POST /setMargeAccount`, falls back to
telnet `envswitch accountid set <id>` on missing/wedged endpoint.
- **`Manager.Reboot` and `HandleRebootDevice` gain a method selector** —
signature changes to `Reboot(deviceIP string, method RebootMethod) (string, error)`
with `RebootMethodSSH` (default, today's behavior) and `RebootMethodTelnet`
(sends `sys reboot` over a fresh `pkg/telnet` connection). Handler reads
`?method=ssh|telnet` from the query string.
- **`MigrationSummary` gains** `TelnetReachable`, `TelnetBanner`,
`TelnetCommandsAccepted`, `SetMargeAccountSupported`, `CurrentAccountID`,
`KnownAccountIDs` so the UI can show preflight outcomes and offer reuse.
- **UI**`web/index.html` dropdown gets a `telnet` option (greyed out when
preflight fails) and a new pane for picking/entering/randomizing a 7-digit
account ID when `:8090/info` reports an empty `margeAccountUUID`. The
existing reboot button gets a method selector (radio or dropdown) wired to
the new query param, with `confirm()` before firing. The legacy `hosts`
option stays out of the dropdown (deprecated).
---
## 8. Device compatibility today
What follows is the current best read on which devices our `migrateViaTelnet`
flow handles end-to-end, derived from the same six sources catalogued in
[TELNET-COMMAND-REFERENCE.md](TELNET-COMMAND-REFERENCE.md) plus the issue
threads cited above. This is migration-outcome perspective; for per-command
availability see the reference doc.
### 8.1 Proven to work end-to-end
All on the firmware-27.0.6 family, which is what survived through Bose's
end-of-service cut. Multi-reporter agreement on every row.
| Device | Reporter(s) | Source | Confirmed |
|----------|-----------------------------|------------------|----------------------------------------------------------------------------|
| ST 10 | foob61451, TJGigs | #221, #228 | All four URLs persist; `envswitch boseurls set` survives `sys reboot` |
| ST 20 | foob61451, mcdona1d, TJGigs | #221, #141, #228 | Same; multiple independent reports |
| ST 300 | mcdona1d | #141 | `sys configuration` + `envswitch` + `sys reboot` round-trip |
| Wave III | bveenker | #221 | URLs accepted; presets work after pairing fallback (§3) |
| Wave IV | stephan48 | #221 | Port-17000 path **was the only one that worked** — USB-stick unlock failed |
The exact sequence each reporter ran by hand is the sequence our migration
sends (§2.1). So the migration's happy path is exercised against five
hardware variants in independent captures.
### 8.2 Proven to need the pairing fallback
Migration of the URLs themselves works on these models, but
`POST /setMargeAccount` is missing or wedged on the firmware build, so
pairing has to go through the telnet `envswitch accountid set <id>` path
that `setup.PairAccount` already implements.
| Device | Reporter | Source | Why fallback is needed |
|--------------------------------|----------|-----------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|
| ST Portable / FW 27.0.6 | jmosen | #236 | After migration: `POST /marge/streaming/support/power_on` → 502; `<margeAccountUUID/>` empty. Time-bounded HTTP path fails; envswitch fallback succeeds. |
| BST20 Portable (factory reset) | ubittner | scheilch/opencloudtouch#167 | `<margeAccountUUID/>` empty; `/setMargeAccount` not in `/supportedURLs`. HTTP path skipped entirely; only the telnet fallback works. |
### 8.3 Likely to fail (but the failure is clean)
Our preflight + abort-on-first-rejection design (`TestMigrateViaTelnet_CommandNotFoundAborts`)
means none of these scenarios leave a device half-configured. The user is
told what failed and pointed to the XML or DNS method.
| Device | Source | Likely cause |
|--------------------------------------------|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **SA-5** (sound amplifier) on FW 9.0.43.x | soundcork#141 | FW 9.x has a different shell generation: `->` prompt, `local_services on`, `scm uboot_ver`. **`sys configuration` and `envswitch` are not documented as working there.** Migration fails on command #1. |
| **Recent ST Portable** (post-27.0.6.46330) | #236 (indirect) | `/setMargeAccount` removal points to broader command-set shrinkage. If `envswitch accountid set` is also gone, both migration and pairing fallback fail; user is told to pair via the official Bose app before EOS, or use XML over SSH. |
### 8.4 Unknown — would benefit from real-device verification
| Device | Why unknown | What we'd want to confirm |
|----------------------------|---------------------------------------------------------------------|--------------------------------------------------------------------------|
| **ST 30** (`mojo`) | No concrete capture in any of the six sources | Almost certainly works — same FW family as ST 10/20/300 — but unverified |
| **ST 520 / Home Cinema** | USB-unlock reports failing (#141), no port-17000 capture either way | Whether `sys configuration` and `envswitch` are exposed at all |
| **Wave Music System I/II** | `flarn2006`-era hardware, not seen in 27.x reports | Whether port 17000 is even open on those models |
### 8.5 The S5 "valid roots" tension
S5 (the r/bose telnet-probing thread) lists only `key`, `net`, `sys`,
`getpdo` as command roots that don't return "Command not found" on its
ST 10 / FW 27.0.6 — which would seem to rule out `envswitch`. But foob61451
on the same hardware/firmware ran `envswitch boseurls set` successfully
(#221).
The most plausible reading is that **S5 is a non-exhaustive probe**, not a
negative claim: the author writes "I've made some educated guesses and come
up with the following valid commands" and never says they tested
`envswitch`. We do not down-weight `envswitch` availability on the strength
of S5 alone — but if a real-device run ever shows `envswitch` rejected on
an ST 10, our preflight catches it, the migration aborts on the first
non-OK response, and the user gets a clear error rather than partial state.
### 8.6 Failure-mode matrix
What `migrateViaTelnet` does in each failure mode (verified by
`pkg/telnet` and `pkg/service/setup` unit tests):
| Failure | Outcome | Test |
|------------------------------------------------|---------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------|
| Port 17000 closed / TCP unreachable | `Dial` errors before any command is sent; UI shows the error; nothing persisted | `TestMigrateViaTelnet_DialFailureReturnsError` |
| `sys configuration` rejected (cmd #1) | Sequence aborts; verification not sent; rest of commands not attempted | `TestMigrateViaTelnet_CommandNotFoundAborts` (envswitch variant — generalises) |
| `envswitch boseurls set` rejected | Sequence aborts; runtime-only `sys configuration` state reverts on reboot — no permanent damage | `TestMigrateViaTelnet_CommandNotFoundAborts` |
| Verification mismatch (URLs not echoed back) | Loud "verification failed" error; live state may persist until reboot but UI never claims success | `TestMigrateViaTelnet_VerifyMismatchFails` |
| `/setMargeAccount` 502 / hang | 5s connect + 12s total budget enforced; falls through to telnet `envswitch accountid set` | `TestPairAccount_FallsBackWhenHTTPReturnsServerError` |
| `/setMargeAccount` missing in `/supportedURLs` | HTTP path skipped; goes straight to telnet `envswitch accountid set` | `TestPairAccount_FallsBackWhenSetMargeAccountMissing` |
| Both pairing paths unavailable | Structured error: "use the official Bose app before EOS, or open SSH and use the XML method" | `TestPairAccount_NoTelnetAndHTTPMissingReturnsClearError`, `TestPairAccount_TelnetCommandNotFoundReportsBothPaths` |
### 8.7 TL;DR
- **Green light** — ST 10, ST 20, ST 300, Wave III, Wave IV on FW 27.0.6 (multi-reporter agreement).
- **Yellow** — ST Portable and BST20 Portable: migration works, pairing needs our fallback (already implemented).
- **Red, but fails cleanly** — SA-5 on FW 9.x, possibly newer ST Portable builds.
- **Unverified but expected to work** — ST 30, ST 520, Wave Music System I/II.
The most useful next verification step is touching a real ST 30 and ST 520
— those are the two "expected to work" models with zero concrete captures.
Beyond that, every behaviour the doc predicts is exercised by the unit
tests in `pkg/telnet` and `pkg/service/setup`.
---
## 9. What actually shipped (post-implementation addendum)
§7 forecast the surface area roughly; the wizard ended up larger. This
section is the present-day map of the migration tab and the supporting
backend pieces — kept appended rather than rewritten in place so the
feasibility analysis above stays a faithful design record.
### 9.1 Three-axis state model
`MigrationSummary` now exposes the four mechanism-specific booleans
that `checkIsMigrated` writes individually:
- `XMLMigrated` — parsed SoundTouchSdkPrivateCfg.xml's URLs point at us.
- `HostsMigrated``/etc/hosts` carries Bose-domain redirects (the
deprecated method, kept detectable for legacy speakers).
- `ResolvMigrated` — the `/etc/resolv.conf` priority-nameserver hook
is in place (with CA trusted).
- `TelnetMigrated``getpdo CurrentSystemConfiguration` reports the
service hostname.
`IsMigrated` is the OR. Plus `IsPaired` from the live
`:8090/info.margeAccountUUID` value.
The frontend opens with a state card that surfaces three orthogonal
axes derived from these flags:
| Axis | Verdict semantics |
|-------------------|-----------------------------------------------------------------------------------------------------------------------|
| URL Configuration | URL flip active → ✅; original Bose URLs + DNS hook active → ✅ (intercepted); original + no DNS → ❌ (not intercepted). |
| DNS Interception | None / resolv.conf hook / /etc/hosts (with deprecated badge). |
| CA / TLS | Local root CA installed yes/no. |
Plus a Preconditions row: `remote_services` persistence, account
pairing state, XML config backup presence. Action affordances
(`Trust CA Now`, `Download CA cert`) live inline next to their verdicts.
### 9.2 Plan card with per-field URL editor
Replaces the XML method's `self/proxied/original` dropdowns and the
duplicate URL inputs that used to live inside the telnet method pane:
- Target service URL input with `Save as default` (POSTs to
`/setup/settings`, preserving the `***` secret-unchanged convention).
- Capabilities header: detected transports (SSH / Telnet:17000) and
the recipes AfterTouch can offer given those transports.
- Service URLs table: four free-form URL inputs (Marge / Stats /
SwUpdate / BmxRegistry) with on-keystroke validation
(`validatePlanURLs`), a Soundcork-mode checkbox that flips `/marge`
on `margeServerUrl`, and a `Reset to defaults` button.
- Account pairing section: ID input + Generate + datastore picker;
the implicit intent (`readPlanPairTarget`) queues a pair step at
Apply when the input differs from the current `account_id`.
- Suggested plan box: one-click conservative default — XML + HTTP
when SSH works, Telnet + HTTP otherwise; "Already migrated" info
state when `IsMigrated` is already true.
The per-field URLs feed both XML and Telnet migrations via the
`marge_url` / `stats_url` / `sw_update_url` / `bmx_url` option family
(see §9.6). Live preview rewrites `#planned-config` purely client-side
on every keystroke — optimistic; the backend's perspective gates the
write via §9.4's pre-flight.
### 9.3 Customize three-axis form
The `<details>` "Customize this migration" section replaces the old
migration-method dropdown with three independent radio groups:
1. **URL flip transport**: XML / Telnet:17000 / Skip.
2. **DNS interception**: None / `/etc/resolv.conf` hook.
3. **Local CA install**: checkbox.
Each option carries a per-axis availability hint
(`(SSH unreachable)`, `(already trusted)`, etc.) so users see *why*
an option is disabled. `applyCustomPlan` orchestrates the chosen
combination as a sequence of existing backend calls
(`/setup/migrate?method=…` for each flip/resolv step plus
`/setup/trust-ca` for standalone CA install, and the queued pair
step from §9.2). Resolv already bundles a CA install, so a redundant
standalone CA step is skipped. First failure aborts the rest.
### 9.4 Pre-flight panel
Both Apply paths run a visible pre-flight panel before any backend
operation touches the speaker. Each check renders inline with the
🕐 / ⟳ / ✅ / ❌ / — idiom. On all-green the panel holds for ~700ms so
the success state registers, then auto-proceeds. On any failure the
panel surfaces `Proceed Anyway` / `Cancel` buttons; default is to
abort.
Checks:
| Check | When | Backend route |
|---------------------------------------|------------------------------------------------------------|--------------------------------|
| Backend summary re-check | always | `GET /setup/summary` |
| HTTPS connection from device | `ssh_success && server_https_url` | `POST /setup/test-connection` |
| Reachability check (passive observer) | `telnet_reachable && is_migrated` (see §9.8) | `POST /setup/peer-probe` |
| Round-trip skip explainer | `telnet_reachable && !is_migrated` — runs after reboot | _none_ (UI-side skip row) |
| DNS redirection from device | `methods.includes("resolv") && ssh_success` | `POST /setup/test-dns` |
The HTTPS check uses `use_explicit_ca=true` so it exercises the trust
path even when CA install is part of the plan (i.e. forward-looking).
The reachability skip row is explicit ("neither SSH nor Telnet:17000
is reachable") rather than silently dropped, per the user's
"feedback always visible" requirement.
### 9.5 Telnet round-trip probe — the SSH-less reachability check
> **REMOVED — see §9.8.** Empirical testing showed the swUpdate
> daemon caches its target URL at boot and ignores live config
> writes, so the active flip described below could never reach the
> running daemon. The section is retained as a historical record of
> what was tried; the running code uses the passive observer in §9.8.
The reachability gap §7 left open for USB-unlock-refusing speakers is
closed by `Manager.RunTelnetRoundTripProbe`
(`pkg/service/setup/telnet_probe.go`). Sequence:
1. Telnet `getpdo CurrentSystemConfiguration` to capture the
speaker's current `swUpdateUrl`.
2. Generate a random 24-hex-char token; register a one-shot signal
channel under it on the new `probeRegistry` (sibling field on
`handlers.Server`).
3. Telnet `sys configuration swUpdateUrl <targetURL>/probe/<token>`
— **runtime layer only, deliberately not `envswitch boseurls set
…`**. The persistence layer keeps the original URL, so a reboot
heals the device naturally if our restore step fails.
4. `HTTP GET <deviceIP>:8090/swUpdateCheck` — the cleanest
`:8090` endpoint that triggers exactly one outbound to the
configured `swUpdateUrl`. Read-only on the cloud side
(doesn't initiate an update); independent of `margeAccountUUID`
so it works on factory-reset speakers.
5. Wait on the registered channel up to `telnetProbeTimeout` (6s).
6. Telnet `sys configuration swUpdateUrl <originalURL>` — deferred
restore so it runs even on the failure path.
The 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 speaker's `swUpdateCheck`
doesn't choke on a missing structure. The `/*` sub-path is
registered because some firmware appends a path component to the
configured `swUpdateUrl`.
### 9.6 Backend additions worth knowing
| Addition | Where | Why |
|------------------------------------------------------------------------|----------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `applyURLOverrides(cfg, options)` | `pkg/service/setup/setup.go` | Per-field literal `marge_url` / `stats_url` / `sw_update_url` / `bmx_url` overrides win over `applyProxyOptions`. Honored by both `GetMigrationSummary` and `migrateViaXML`. |
| `telnetURLsFromOptions(targetURL, options)` | `pkg/service/setup/telnet_migration.go` | Same option family as above, plus envswitch arg derivation rule (arg1 = final Marge verbatim; the soundcork-suffix case drops out). |
| Per-axis booleans + `IsPaired` + `Warnings` | `MigrationSummary` | Surfaces partial-state cells and SSH-XML ⇄ telnet-getpdo cross-check disagreements. |
| `parseGetpdoConfig` | `pkg/service/setup/preflight_crosscheck.go` | Parses the Protobuf-text-like nested-block reply (`key { text: "..." }`) FW 27.0.6 actually sends, plus the legacy `key=value` shape as a tolerance path. |
| `peerObserver` + `RunPeerReachabilityProbe` + `/setup/peer-probe` | `pkg/service/handlers` / `pkg/service/setup` | §9.8. Replaces the removed `probeRegistry` + `RunTelnetRoundTripProbe` + `/setup/telnet-probe` from §9.5. |
| `migrationOptionKeys` allow-list | `pkg/service/handlers/migration_options.go` | Unknown query keys never reach the manager. Both XML mode keys and `*_url` keys are recognised. |
| Telnet client default timeouts: dial 4s, read 7s, write 3s, idle 600ms | `pkg/telnet/telnet.go` | Bumped from the original 2s/5s/2s/400ms after observing transient i/o-timeout flakes on healthy speakers that recovered on retry. |
### 9.7 Future probe candidates
- `:8090/pushCustomerSupportInfoToMarge` — flagged as a potential
"ask the device about itself" probe that could feed a richer
device-info pane (firmware build dates, hardware revisions). Not
implemented.
- Running the round-trip probe on SSH-capable speakers too (as
additional validation alongside the curl-from-device HTTPS test),
not just as the SSH-less fallback it is today. **Subsumed by §9.8
— the round-trip probe is being removed; the passive observer is
transport-agnostic and replaces it for migrated speakers.**
### 9.8 The swUpdate daemon-cache finding and removal of §9.5
The §9.5 round-trip probe was retired after empirical testing on a
fully-migrated speaker (FW 27.0.6) revealed that the `swUpdate`
daemon **caches its target URL at boot and ignores live config
writes**. The diagnostic sequence:
1. Manual telnet flip of both layers — `sys configuration swUpdateUrl
<probe-url>` (runtime) **and** `envswitch boseurls set <marge>
<probe-url>` (persistence). `getpdo CurrentSystemConfiguration`
confirmed both writes stuck.
2. HTTP GET `:8090/swUpdateCheck` to trigger fan-out.
3. Service access log showed the device outbound landed on
`/updates/soundtouch` (the **previous** `swUpdateUrl` value, current
at the last daemon boot) and `/streaming/software/update/account/<id>`
(a separate Bose URL the daemon hits, routed to this service by DNS
interception). The probe URL was never dialed.
This falsifies the original NEXT.md hypothesis that the persistence
layer would override the runtime layer for the daemon's fan-out, and
points instead at daemon-level URL caching. Two consequences:
- **The §9.5 probe cannot work on migrated speakers without a
reboot.** The cached URL is set when the daemon starts; flipping
config after that point has no effect on what the daemon dials.
- **The §9.5 probe likely cannot work on unmigrated speakers
either**, for the same reason — the daemon caches whatever URL it
read at startup, which on an unmigrated speaker is the Bose cloud
URL. We have no service running with the probe URL registered on
unmigrated speakers, so the original "it worked in testing" claim
has no empirical basis; it likely failed silently because nothing
was watching.
The honest replacement is a **passive observer** (see
`pkg/service/setup/peer_probe.go`):
1. Register the device IP with an in-process observer
(`handlers.peerObserver`, wired via `PeerObserverMiddleware`).
2. Nudge `:8090/swUpdateCheck` to make the daemon fan out *something*
sooner than its ~5min timer.
3. Wait up to 30s for any inbound from that IP. On a migrated
speaker, DNS interception means the daemon's outbounds (update
fan-out, marge polls, BMX registry calls) all funnel through this
service regardless of which URL the daemon resolved internally —
so reachability reduces to *"did the device dial us at all."*
Endpoint: `POST /setup/peer-probe/{deviceId}`. No device-state
mutation; safe to re-run. Returns `{ok, result: {reached,
observed_path, elapsed_ms}, error}` with the same UI keying as the
old probe (`result.reached`).
#### 9.8.1 The pre-flight panel branch
The web UI's pre-flight orchestrator (`runApplyPreflight` in
`script.js`) branches on `summary.is_migrated`:
| Migration state | Reachability row |
|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------|
| Migrated (`is_migrated=true`) | "Reachability check (passive observer)" — calls `POST /setup/peer-probe/{deviceId}`. |
| Not migrated (incl. partial) | Skip row "Round-trip validation runs after Apply + reboot" with the rationale "daemon caches swUpdateUrl at boot". |
Per-axis booleans (`xml_migrated`, `hosts_migrated`, `resolv_migrated`,
`telnet_migrated`) remain visible in the State card, so the user can
see which parts of the migration are already in place even when the
overall flag is false. The skip row does not attempt the active probe
on unmigrated speakers — the canonical telnet flow is:
```
Apply telnet config → user-initiated reboot → re-run pre-flight on
the now-migrated speaker → passive observer confirms fan-out.
```
#### 9.8.2 Removal trail
Removed (or scheduled for removal in a follow-up commit) at the time
of §9.8 landing:
- `pkg/service/setup/telnet_probe.go``RunTelnetRoundTripProbe`,
`ProbeRegistrar`, `TelnetProbeResult`, `generateProbeToken`.
- `pkg/service/handlers/handlers_telnet_probe.go``HandleTelnetProbe`,
`HandleProbeInbound`, `telnetProbeTimeout`, `telnetProbeResponse`.
- `pkg/service/handlers/probe_registry.go``probeRegistry` + tests.
- `Server.probes` field.
- Routes `/probe/{token}`, `/probe/{token}/*`, `/setup/telnet-probe/{deviceId}`.
- The `target_url` query-param plumbing on the deprecated endpoint.
- `script.js``checkTelnetRoundTrip` (orchestrator call site removed
in the commit that added the branch; function itself removed later).
`isCommandNotFound` and `parseGetpdoConfig` stay — they are also used
by the migration writer (`telnet_migration.go`), preflight reader
(`telnet_preflight.go`), pairing path (`marge_pairing.go`), and
cross-check (`preflight_crosscheck.go`).
+15
View File
@@ -70,6 +70,21 @@ server {
}
```
> **Tell the service to honour `X-Real-IP`/`X-Forwarded-For`.** When deploying
> behind a reverse proxy on the same host as above, set
> `"trust_forwarded_headers": true` in `data/settings.json`. With that flag
> on, the service rewrites `r.RemoteAddr` from the proxy-supplied headers,
> so handlers that act on the source IP (e.g. the Spotify priming triggered
> by `/marge/streaming/support/power_on`) see the speaker's real address
> instead of the proxy's loopback peer.
>
> By default only `127.0.0.0/8` and `::1/128` are trusted to set those
> headers. If your reverse proxy lives on a different host, list its CIDR(s)
> in `"trusted_proxy_cidrs"` (e.g. `["10.0.0.0/8"]`). Do **not** enable
> `trust_forwarded_headers` on a flat LAN deployment without a proxy: a
> malicious speaker on the LAN can send the headers itself and spoof its
> source IP.
---
## Manual CA injection (advanced)
+56 -25
View File
@@ -90,9 +90,13 @@ If you plan to use DNS/DHCP redirect, enable the **DNS Discovery Server** and se
---
## Step 3: Enable SSH on each speaker
## Step 3: Enable shell access on each speaker
The migration writes updated configuration to the speaker's filesystem, which requires SSH access. Enable it once per device:
The wizard supports **two transports** for talking to the speaker. Pick whichever your device exposes:
### SSH (recommended — required for XML migration, DNS interception, and CA install)
The XML migration writes updated configuration to the speaker's filesystem, which requires SSH access. Enable it once per device:
1. Format a USB drive as FAT (FAT32). Some speakers require the **bootable flag** to be set on the partition — see [SoundCork issue #172](https://github.com/deborahgu/soundcork/issues/172) for details.
2. Create an empty file named **`remote_services`** (no extension) in the root of the drive.
@@ -102,6 +106,12 @@ The migration writes updated configuration to the speaker's filesystem, which re
You only need to do this once per speaker. SSH can remain enabled for future maintenance or be disabled after migration — your choice.
### Telnet:17000 (fallback when SSH isn't possible)
If the USB-stick unlock doesn't work on your speaker (some firmware revisions refuse it — notably SA-5, ST520, and recent ST Portables), the wizard falls back to the speaker's **built-in diagnostic shell on TCP port 17000**. No setup required — most SoundTouch firmware exposes it automatically. The wizard detects which transports are available and picks the right one; you don't have to choose manually.
Telnet-only migrations are limited to HTTP (no CA install possible without SSH). The wizard surfaces this clearly when it applies.
---
## Step 4: Add and sync your speaker
@@ -124,44 +134,64 @@ If the Bose cloud is still running, Sync also fetches your account data from Bos
## Step 5: Migrate
Click **Migrate** next to a device on the Devices tab to open the Migration tab. It shows SSH status, CA trust status, and connection test results before letting you apply the redirect.
Click **Migrate** next to a device on the Devices tab to open the Migration tab. The tab opens with a **Migration Summary** that shows where your speaker currently stands, then offers a one-click suggested plan and a fully customizable form underneath.
![Migration tab showing HTTPS and DNS connection tests](../images/ui-migration.png)
![Migration tab showing the state card and Plan card](../images/ui-migration.png)
Two redirect methods are available:
### What you see at the top — the state card
### XML redirect (recommended for first-time / testing)
Three rows tell you the speaker's current state at a glance:
Uploads a configuration file to the speaker via the SoundTouch Web API. This changes the application-level service URLs without touching the speaker's network configuration. It's the least invasive option.
- **Transports** — whether SSH and Telnet:17000 are reachable. The wizard's choices are driven by these.
- **Migration State** — three orthogonal axes:
- *URL Configuration* — original Bose URLs or AfterTouch URLs (with a special "intercepted via DNS" verdict when the resolv.conf hook is doing the redirect).
- *DNS Interception* — none, or `/etc/resolv.conf` hook active.
- *CA / TLS* — local root CA installed on the device, with `Trust CA Now` and `Download CA cert` actions inline.
- **Preconditions**`remote_services` persistence, account pairing state, and XML config backup presence.
The web UI guides you through:
1. Previewing the config change (current vs. planned XML)
2. Optionally installing the AfterTouch CA certificate on the speaker (requires SSH; needed for HTTPS)
3. Applying the XML redirect
4. Verifying the speaker can reach the local service
### The Plan card — the happy path
### DNS/DHCP redirect (recommended for permanent / all-device setup)
Below the state card is the **Plan** card. For most users this is the only thing you'll touch:
Configures the speaker to use a custom DNS server that resolves Bose cloud hostnames to the local service. This is the most robust method — it covers all Bose endpoints automatically and survives reboots.
1. **Target service URL** — pre-filled from your Settings. Edit inline and click *Save as default* to update Settings without bouncing tabs.
2. **Capabilities** — what transports the speaker exposes and what AfterTouch can offer given those.
3. **Service URLs** — four URL inputs (margeServerUrl, statsServerUrl, swUpdateUrl, bmxRegistryUrl) pre-filled with canonical defaults. Most users leave them as-is; soundcork users tick the *Soundcork mode* checkbox to append `/marge` to `margeServerUrl`. URL validation runs on every keystroke.
4. **Account pairing** — pre-filled with the speaker's current account ID. Leave it to keep the existing pairing, change it to re-pair, or click *Generate* to assign a new 7-digit ID on a factory-reset device.
5. **Suggested plan** — one big green button: *Apply Suggested Plan*. The wizard picks the most conservative recipe for your speaker (XML over SSH with HTTP when SSH works; telnet URL flip with HTTP when only telnet works) and runs it.
Requirements:
- The AfterTouch DNS server must be running and bound to **port 53** on your network. Enable it in the **Settings** tab (`DNS Discovery` → enabled).
- HTTPS is required. The web UI walks you through trusting the CA certificate on the speaker (via SSH).
### What happens when you click Apply
The web UI guides you through:
1. Verifying the DNS server is running and reachable
2. Installing the CA certificate on the speaker
3. Configuring the speaker to use the AfterTouch DNS server
4. Verifying DNS resolution and HTTPS connectivity
The wizard switches to a visible **Pre-flight checks** panel and runs every applicable verification before touching the speaker:
- **Backend summary re-check** — confirms transports, hostname resolution, and that the URLs you plan to write match what the backend would produce.
- **HTTPS connection from device** (SSH-capable speakers) — uploads a temporary CA and runs `curl` from the speaker to your service.
- **Reachability check (passive observer)** (already-migrated speakers) — nudges `:8090/swUpdateCheck` on the device and watches for *any* request from the speaker to land on the service. Used when the speaker is already migrated and the service is the natural target of its outbounds.
- **"Round-trip validation runs after Apply + reboot"** (not-yet-migrated speakers) — surfaced as a skip row with a rationale. The speaker's swUpdate daemon caches its URL at boot, so there is no useful no-reboot round-trip check pre-migration; the canonical telnet flow is Apply → reboot → re-run pre-flight on the migrated speaker.
- **DNS redirection from device** — when DNS interception is part of the plan.
On all-green, the wizard auto-proceeds. On any failure, it pauses with *Proceed Anyway* / *Cancel* buttons so you can override on a known-false-positive (slow DNS, etc.) or fix the underlying issue and retry.
### Customize this migration — for mix-and-match
Expand the `▸ Customize this migration` section to pick any combination of three independent axes:
- **URL flip transport** — XML over SSH / Telnet (Port 17000) / Skip
- **DNS interception** — None / `/etc/resolv.conf` hook
- **Local CA install** — checkbox (SSH-only)
Each option carries a per-axis availability hint (e.g. *(SSH unreachable)*, *(already trusted)*) so you see why an option is disabled before you pick. *Apply Custom Plan* runs the chosen combination as a sequence; the same pre-flight panel gates the execution.
> **Note**: DNS interception bundles the CA install on the backend, so a standalone CA-install step is skipped automatically when DNS is part of the plan. The wizard handles this for you.
---
## Step 6: Reboot and verify
After migration, **power-cycle the speaker** (unplug and replug). This applies all configuration changes.
After a successful Apply the wizard auto-expands the Customize section and highlights the **Reboot Speaker** button. Click it (or power-cycle the speaker manually) to apply all configuration changes. The reboot transport is picked automatically from your URL flip choice — telnet reboot for SSH-less speakers, SSH reboot otherwise.
After reboot:
- The speaker should appear as **migrated** in the Devices tab
- The state card on the Migration tab should now show ✅ for URL Configuration (or "intercepted via DNS" if you used the resolv.conf hook)
- Presets should load and play (served from the local service)
- TuneIn browsing should work
- Recently played items should appear
@@ -180,8 +210,9 @@ Each speaker is migrated independently. You can run multiple migrations in paral
If you need to undo a migration:
- **From the web UI**: Use the **Revert** action on the device — this restores the `.original` backup files created on the speaker during migration.
- **Via SSH**: The original config is backed up on the speaker with a `.original` suffix. Restore it manually if the UI is unreachable.
- **From the web UI**: Use the **Revert to Defaults** action on the device — this restores the `.original` backup files created on the speaker during the XML migration.
- **Telnet-only migrations**: the wizard writes both the runtime configuration layer (`sys configuration …`) and the persistent layer (`envswitch boseurls set …`) so the migration survives reboot. If you want to revert quickly, the cleanest path is to re-run the wizard with the original Bose URLs in the URL editor.
- **Via SSH**: The original XML config is backed up on the speaker with a `.original` suffix. Restore it manually if the UI is unreachable.
- **Factory reset**: As a last resort, perform a factory reset (see [Device Initial Setup](DEVICE-INITIAL-SETUP.md) for button sequences). This wipes all configuration and returns the speaker to out-of-box state.
---
+79 -9
View File
@@ -225,13 +225,21 @@ curl http://localhost:8000/setup/devices
#### Advanced Migration Options
```bash
# Migration with proxy fallback for original services
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?proxy_url=http://localhost:8000&marge=original&stats=original"
# Migration with custom target URL
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?target_url=https://my-server.com:8000"
# Per-field literal URL overrides (preferred — used by the web wizard)
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?method=xml&target_url=http://server:8000&marge_url=http://server:8000/marge"
# SSH-less migration over the device's port-17000 diagnostic shell
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?method=telnet&target_url=http://server:8000"
# Legacy proxy-fallback for selected fields (kept for API back-compat)
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?proxy_url=http://localhost:8000&marge=original&stats=original"
```
See the full parameter reference at `POST /setup/migrate/{deviceIP}` below for `method`, `target_url`, `*_url`, and the legacy mode selectors.
### Post-Migration Verification
After migration, verify the device is working correctly:
@@ -393,12 +401,73 @@ Analyzes device configuration and provides migration preview.
Migrates device to use local services.
**Query Parameters:**
- `target_url`: Custom service URL (optional)
- `proxy_url`: Proxy URL for fallback (optional)
- `marge`: Set to "original" to proxy Marge requests (optional)
- `stats`: Set to "original" to proxy stats requests (optional)
- `sw_update`: Set to "original" to proxy update requests (optional)
- `bmx`: Set to "original" to proxy BMX requests (optional)
| Parameter | Values | Notes |
|--------------|-----------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `method` | `xml` (default), `telnet`, `resolv`, `hosts` (deprecated) | Picks the redirect mechanism. `xml` writes `SoundTouchSdkPrivateCfg.xml` via SSH; `telnet` flips the four URLs via the device's port-17000 diagnostic shell; `resolv` installs the `/etc/resolv.conf` priority-nameserver hook and the local CA via SSH. |
| `target_url` | Any URL, e.g. `http://soundtouch.local:8000` | Service base URL the per-field defaults derive from. Falls back to the service's configured `ServerURL` when omitted. |
| `proxy_url` | Any URL | Proxy base used when the legacy `marge=proxied` / `stats=proxied` / `sw_update=proxied` / `bmx=proxied` modes are set. Defaults to `target_url`. |
**Per-field implementation mode** (XML method's legacy semantics — kept for API back-compat, UI no longer sets them):
| Parameter | Values | Effect on the matching `*ServerUrl` / `*RegistryUrl` field |
|-------------|-----------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------|
| `marge` | `self` (default), `proxied`, `original` | `self`: write `target_url` (canonical). `proxied`: write `<proxy_url>/proxy/<original-marge-url>`. `original`: keep the speaker's existing value. |
| `stats` | same | same |
| `sw_update` | same | same |
| `bmx` | same | same |
**Per-field literal URL overrides** (preferred — used by the wizard's Plan card; honored for both `xml` and `telnet` methods):
| Parameter | Effect |
|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `marge_url` | Writes the exact URL to `<margeServerUrl>` regardless of `target_url` derivation or `marge` mode. Empty / missing → fall back to canonical default from `target_url`. |
| `stats_url` | Same shape for `<statsServerUrl>`. |
| `sw_update_url` | Same for `<swUpdateUrl>`. |
| `bmx_url` | Same for `<bmxRegistryUrl>`. |
**Precedence**: `*_url` overrides win over the `marge / stats / sw_update / bmx` mode selectors. The setup package applies `applyProxyOptions` first, then `applyURLOverrides` clobbers any field where a literal `*_url` was supplied. So if you send both `marge=proxied&marge_url=http://x:8000/marge`, the literal `http://x:8000/marge` is written.
**Soundcork redirect**: append `/marge` to `marge_url`. The telnet method derives `envswitch boseurls set <margeServerUrl> <swUpdateUrl>` from the final URLs verbatim, so the suffix propagates to the parallel persistence layer automatically — no separate flag needed.
**Examples**:
```bash
# Canonical XML migration over SSH to the default service URL
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?method=xml"
# Telnet migration with the soundcork redirect (only marge gets the /marge suffix)
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?method=telnet&target_url=http://soundcork.local:8000&marge_url=http://soundcork.local:8000/marge"
# DNS interception (writes /etc/resolv.conf hook + installs CA) — *_url overrides are ignored
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?method=resolv&target_url=https://my-server.com:8443"
```
#### `POST /setup/telnet-probe/{deviceIP}`
SSH-less reachability check. Temporarily flips the speaker's `swUpdateUrl` via the port-17000 diagnostic shell, triggers `:8090/swUpdateCheck` on the device, and observes whether the resulting outbound lands on this service's `/probe/{token}` handler within 6 s. Always attempts to restore the original `swUpdateUrl` even on failure.
**Query Parameters:**
- `target_url` (optional): defaults to the service's configured `ServerURL`. The probe URL written to the device is `<target_url>/probe/<token>`.
**Response:**
```json
{
"ok": true,
"result": {
"reached": true,
"restored": true,
"original_url": "https://worldwide.bose.com/updates/soundtouch",
"probe_url": "http://soundtouch.local:8000/probe/abc123…",
"elapsed_ms": 412,
"logs": "…"
}
}
```
`reached=true` means the device's outbound landed on our `/probe/{token}` route within the timeout. `restored=true` means the runtime `swUpdateUrl` was reverted to its captured original (the envswitch persistence layer is left untouched throughout, so a reboot heals the device naturally if our restore step fails).
#### `GET /probe/{token}[/*]`
Catch-all endpoint that signals the matching pre-flight probe channel. Used internally by `/setup/telnet-probe/{deviceIP}`; not intended to be called directly by API consumers. Returns a minimal `<swUpdateIndex/>` XML so the device's `swUpdateCheck` doesn't choke on a missing structure.
### BMX Services (Bose Media eXchange)
@@ -836,6 +905,7 @@ fi
- **SSH Access**: Migration requires SSH access to devices. Ensure your network security policies allow this.
- **Proxy Logging**: Disable `REDACT_PROXY_LOGS` only in development environments.
- **Data Protection**: The data directory contains device configurations and usage patterns. Secure appropriately.
- **Spotify / Amazon Music credential push (zeroconf)**: outbound credential-push requests are restricted to literal IP hosts on local-network ranges (loopback, RFC1918 private, IPv4/IPv6 link-local). Hostname-style URLs (DNS, mDNS `*.local`) are rejected at runtime; if you have a hostname, resolve it first (`getent hosts <name>` or `dig +short <name>`) and pass the resolved IP. This guards against a malicious LAN-resident speaker pointing the credential push at a non-speaker host (server-side request forgery).
## Performance Tuning
Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 KiB

After

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 516 KiB

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 95 KiB

+15 -8
View File
@@ -3,6 +3,7 @@ module github.com/gesellix/bose-soundtouch
go 1.26.3
require (
github.com/chromedp/chromedp v0.15.1
github.com/go-chi/chi/v5 v5.2.5
github.com/google/gopacket v1.1.19
github.com/gorilla/websocket v1.5.3
@@ -13,18 +14,24 @@ require (
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.50.0
golang.org/x/term v0.42.0
golang.org/x/crypto v0.51.0
golang.org/x/net v0.54.0
golang.org/x/term v0.43.0
)
require (
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
golang.org/x/image v0.39.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/image v0.40.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.44.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.45.0 // indirect
)
+35 -16
View File
@@ -1,3 +1,9 @@
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b h1:fpvdcCAe2z3H8OvVY00iKOp3Wapbs/Gy375Fn6l/XM4=
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -5,6 +11,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836 h1:5KGUhXZFTN1PrCY4zUZLe1J8n7uBNmPDbCLCn78EbPQ=
github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
@@ -16,9 +30,13 @@ github.com/hashicorp/mdns v1.0.6/go.mod h1:X4+yWh+upFECLOki1doUPaKpgNQII9gy4bUdC
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
@@ -44,10 +62,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -56,8 +74,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -69,8 +87,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -88,13 +106,14 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -105,8 +124,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -117,8 +136,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
@@ -127,8 +146,8 @@ golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+4 -3
View File
@@ -6,6 +6,7 @@ import (
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/speaker"
)
// Integration tests for bass control functionality
@@ -426,7 +427,7 @@ func BenchmarkClient_Bass_Integration(b *testing.B) {
// This is a simple version for test use
func parseBassHostPort(hostPort string) (string, int) {
if !containsSubstring(hostPort, ":") {
return hostPort, defaultSoundTouchPort
return hostPort, speaker.HTTPPort
}
// Simple parsing - in real use, we'd use net.SplitHostPort
@@ -448,7 +449,7 @@ func parseBassHostPort(hostPort string) (string, int) {
if len(parts) == 2 {
// Try to parse port
port := defaultSoundTouchPort
port := speaker.HTTPPort
portStr := parts[1]
portInt := 0
@@ -468,5 +469,5 @@ func parseBassHostPort(hostPort string) (string, int) {
return parts[0], port
}
return hostPort, defaultSoundTouchPort
return hostPort, speaker.HTTPPort
}
+56 -6
View File
@@ -153,11 +153,9 @@ import (
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/speaker"
)
// defaultSoundTouchPort is the standard port for SoundTouch devices
const defaultSoundTouchPort = 8090
// Client represents a SoundTouch API client
type Client struct {
baseURL string
@@ -204,7 +202,7 @@ func NewClient(config *Config) *Client {
// Fallback for invalid URLs
port := config.Port
if port == 0 {
port = 8090
port = speaker.HTTPPort
}
return &Client{
@@ -223,7 +221,7 @@ func NewClient(config *Config) *Client {
// No port in the host string, use the one from config or default
port := config.Port
if port == 0 {
port = 8090
port = speaker.HTTPPort
}
u.Host = net.JoinHostPort(u.Host, fmt.Sprintf("%d", port))
@@ -231,7 +229,7 @@ func NewClient(config *Config) *Client {
// Empty port, use config or default
port := config.Port
if port == 0 {
port = 8090
port = speaker.HTTPPort
}
u.Host = net.JoinHostPort(u.Hostname(), fmt.Sprintf("%d", port))
@@ -1380,6 +1378,58 @@ func (c *Client) GetZoneMembers() ([]string, error) {
return zone.GetAllDeviceIDs(), nil
}
// GetGroup retrieves the current stereo-pair configuration from the device.
// An empty <group/> response is reported as a zero-value Group; callers can
// distinguish with (*Group).IsEmpty().
//
// ST-10 is the only product that supports stereo pairs; on other devices
// the call is harmless but will always return an empty group. The endpoint
// is named /getGroup on the device (mirroring /getZone), even though some
// third-party wikis document it as plain /group.
func (c *Client) GetGroup() (*models.Group, error) {
var g models.Group
err := c.get("/getGroup", &g)
return &g, err
}
// AddGroup creates a new stereo pair on the device addressed by this client,
// which becomes the master. The supplied group must contain both LEFT and
// RIGHT roles; the device assigns the group ID and echoes the full state
// in the response.
func (c *Client) AddGroup(group *models.Group) (*models.Group, error) {
var result models.Group
if err := c.postWithResponse("/addGroup", group, &result); err != nil {
return nil, err
}
return &result, nil
}
// UpdateGroup renames or otherwise updates an existing stereo pair. The
// device requires the full group structure on every update, not just the
// changed fields.
func (c *Client) UpdateGroup(group *models.Group) (*models.Group, error) {
var result models.Group
if err := c.postWithResponse("/updateGroup", group, &result); err != nil {
return nil, err
}
return &result, nil
}
// RemoveGroup tears down the device's stereo pair. The device returns an
// empty <group/> on success — surfaced here as a non-error nil.
//
// Note: the wiki specifies GET (not DELETE) for this endpoint, so we honour
// that despite the state-mutating semantics.
func (c *Client) RemoveGroup() error {
var g models.Group
return c.get("/removeGroup", &g)
}
// SetName sets the device name
func (c *Client) SetName(name string) error {
nameRequest := models.Name{
+234
View File
@@ -0,0 +1,234 @@
package client
import (
"encoding/xml"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestClient_GetGroup_Configured(t *testing.T) {
responseXML := `<?xml version="1.0" encoding="UTF-8" ?>
<group id="1234567">
<name>Living Room Pair</name>
<masterDeviceId>9070658C9D4A</masterDeviceId>
<roles>
<groupRole>
<deviceId>9070658C9D4A</deviceId>
<role>LEFT</role>
<ipAddress>192.168.1.131</ipAddress>
</groupRole>
<groupRole>
<deviceId>F45EAB3115DA</deviceId>
<role>RIGHT</role>
<ipAddress>192.168.1.134</ipAddress>
</groupRole>
</roles>
<senderIPAddress>192.168.1.131</senderIPAddress>
<status>GROUP_OK</status>
</group>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/getGroup" {
t.Errorf("path = %q, want /getGroup", r.URL.Path)
}
if r.Method != http.MethodGet {
t.Errorf("method = %s, want GET", r.Method)
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(responseXML))
}))
defer server.Close()
g, err := createTestClient(server.URL).GetGroup()
if err != nil {
t.Fatalf("GetGroup: %v", err)
}
if g.ID != "1234567" {
t.Errorf("ID = %q, want 1234567", g.ID)
}
if g.Name != "Living Room Pair" {
t.Errorf("Name = %q, want Living Room Pair", g.Name)
}
if g.MasterDeviceID != "9070658C9D4A" {
t.Errorf("MasterDeviceID = %q", g.MasterDeviceID)
}
if g.Status != "GROUP_OK" {
t.Errorf("Status = %q, want GROUP_OK", g.Status)
}
if len(g.Roles.Roles) != 2 {
t.Fatalf("roles = %d, want 2", len(g.Roles.Roles))
}
if g.Roles.Roles[0].Role != "LEFT" || g.Roles.Roles[1].Role != "RIGHT" {
t.Errorf("role order LEFT/RIGHT not preserved: %+v", g.Roles.Roles)
}
if g.IsEmpty() {
t.Errorf("IsEmpty = true for populated group")
}
}
func TestClient_GetGroup_Empty(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<group />`))
}))
defer server.Close()
g, err := createTestClient(server.URL).GetGroup()
if err != nil {
t.Fatalf("GetGroup: %v", err)
}
if !g.IsEmpty() {
t.Errorf("IsEmpty = false for <group/>, got %+v", g)
}
}
func TestClient_AddGroup(t *testing.T) {
var capturedBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/addGroup" {
t.Errorf("path = %q, want /addGroup", r.URL.Path)
}
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
body, _ := io.ReadAll(r.Body)
capturedBody = string(body)
// Echo the request back with an assigned ID and GROUP_OK status —
// matches real device behaviour.
var got models.Group
if err := xml.Unmarshal(body, &got); err != nil {
t.Fatalf("decode request body: %v", err)
}
got.ID = "9999999"
got.Status = "GROUP_OK"
got.SenderIPAddress = "192.168.1.131"
w.Header().Set("Content-Type", "application/xml")
enc, _ := xml.Marshal(&got)
_, _ = w.Write(enc)
}))
defer server.Close()
req := &models.Group{
Name: "Living Room",
MasterDeviceID: "9070658C9D4A",
Roles: models.GroupRoles{
Roles: []models.GroupRole{
{DeviceID: "9070658C9D4A", Role: "LEFT", IPAddress: "192.168.1.131"},
{DeviceID: "F45EAB3115DA", Role: "RIGHT", IPAddress: "192.168.1.134"},
},
},
}
resp, err := createTestClient(server.URL).AddGroup(req)
if err != nil {
t.Fatalf("AddGroup: %v", err)
}
if resp.ID != "9999999" {
t.Errorf("response ID = %q, want 9999999", resp.ID)
}
if resp.Status != "GROUP_OK" {
t.Errorf("response Status = %q, want GROUP_OK", resp.Status)
}
// Wire-shape sanity: the request body must carry both roles and the
// master ID (the device validates these on the wire).
for _, want := range []string{"<role>LEFT</role>", "<role>RIGHT</role>", "9070658C9D4A"} {
if !strings.Contains(capturedBody, want) {
t.Errorf("request body missing %q\nbody:\n%s", want, capturedBody)
}
}
}
func TestClient_UpdateGroup_RenameRoundtrip(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/updateGroup" {
t.Errorf("path = %q, want /updateGroup", r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
var got models.Group
if err := xml.Unmarshal(body, &got); err != nil {
t.Fatalf("decode: %v", err)
}
got.Status = "GROUP_OK"
w.Header().Set("Content-Type", "application/xml")
enc, _ := xml.Marshal(&got)
_, _ = w.Write(enc)
}))
defer server.Close()
req := &models.Group{
ID: "1234567",
Name: "Kitchen Pair",
MasterDeviceID: "AAAA",
Roles: models.GroupRoles{
Roles: []models.GroupRole{
{DeviceID: "AAAA", Role: "LEFT"},
{DeviceID: "BBBB", Role: "RIGHT"},
},
},
}
resp, err := createTestClient(server.URL).UpdateGroup(req)
if err != nil {
t.Fatalf("UpdateGroup: %v", err)
}
if resp.Name != "Kitchen Pair" {
t.Errorf("Name = %q, want Kitchen Pair", resp.Name)
}
if resp.ID != "1234567" {
t.Errorf("ID = %q, want 1234567", resp.ID)
}
}
func TestClient_RemoveGroup(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/removeGroup" {
t.Errorf("path = %q, want /removeGroup", r.URL.Path)
}
// The wiki specifies GET (not DELETE) for /removeGroup. We honour
// that, surprising as it is for a state-mutating endpoint.
if r.Method != http.MethodGet {
t.Errorf("method = %s, want GET", r.Method)
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<group />`))
}))
defer server.Close()
if err := createTestClient(server.URL).RemoveGroup(); err != nil {
t.Fatalf("RemoveGroup: %v", err)
}
}
@@ -4,6 +4,8 @@ import (
"os"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/speaker"
)
// Integration tests for source selection functionality
@@ -386,7 +388,7 @@ func BenchmarkClient_SelectSource_Integration(b *testing.B) {
// This is a simple version for test use
func parseHostPort(hostPort string) (string, int) {
if !containsSubstring(hostPort, ":") {
return hostPort, defaultSoundTouchPort
return hostPort, speaker.HTTPPort
}
// Simple parsing - in real use, we'd use net.SplitHostPort
@@ -408,7 +410,7 @@ func parseHostPort(hostPort string) (string, int) {
if len(parts) == 2 {
// Try to parse port
port := defaultSoundTouchPort
port := speaker.HTTPPort
portStr := parts[1]
portInt := 0
@@ -428,5 +430,5 @@ func parseHostPort(hostPort string) (string, int) {
return parts[0], port
}
return hostPort, defaultSoundTouchPort
return hostPort, speaker.HTTPPort
}
+2 -5
View File
@@ -95,9 +95,7 @@ func TestClient_SetClockTime(t *testing.T) {
{
name: "Successful clock time set",
request: &models.ClockTimeRequest{
UTC: 1609459200,
Value: "2021-01-01 00:00:00",
Zone: "UTC",
UTCTime: 1609459200,
},
statusCode: http.StatusOK,
expectError: false,
@@ -112,8 +110,7 @@ func TestClient_SetClockTime(t *testing.T) {
{
name: "Server error",
request: &models.ClockTimeRequest{
UTC: 1609459200,
Value: "2021-01-01 00:00:00",
UTCTime: 1609459200,
},
statusCode: http.StatusInternalServerError,
expectError: true,
+55 -6
View File
@@ -140,6 +140,17 @@ func (ws *WebSocketClient) OnZoneUpdated(handler models.TypedEventHandler[*model
ws.handlers.OnZoneUpdated = handler
}
// OnGroupUpdated sets a handler for ST-10 stereo-pair update events.
// The device fans these out to both LEFT and RIGHT speakers whenever the
// pair is created, renamed, or removed, so callers will see one event per
// affected device.
func (ws *WebSocketClient) OnGroupUpdated(handler models.TypedEventHandler[*models.GroupUpdatedEvent]) {
ws.mu.Lock()
defer ws.mu.Unlock()
ws.handlers.OnGroupUpdated = handler
}
// OnBassUpdated sets a handler for bass update events
func (ws *WebSocketClient) OnBassUpdated(handler models.TypedEventHandler[*models.BassUpdatedEvent]) {
ws.mu.Lock()
@@ -156,6 +167,18 @@ func (ws *WebSocketClient) OnUnknownEvent(handler models.EventHandler) {
ws.handlers.OnUnknownEvent = handler
}
// OnRawMessage sets a handler that fires for every incoming frame with
// the raw bytes and the result of attempting to XML-parse them. The
// typed handlers (OnNowPlaying, OnGroupUpdated, ...) still run
// afterwards on successful parses, so OnRawMessage is purely additive —
// intended for debug/observability tooling.
func (ws *WebSocketClient) OnRawMessage(handler models.RawMessageHandler) {
ws.mu.Lock()
defer ws.mu.Unlock()
ws.handlers.OnRawMessage = handler
}
// OnSpecialMessage sets a handler for special (non-updates) messages
func (ws *WebSocketClient) OnSpecialMessage(handler models.SpecialMessageHandler) {
ws.mu.Lock()
@@ -379,26 +402,45 @@ func (ws *WebSocketClient) attemptReconnect(config *WebSocketConfig) {
// handleMessage processes incoming WebSocket messages
func (ws *WebSocketClient) handleMessage(data []byte) {
// Check if this is a SoundTouchSdkInfo or other non-updates message
// Special (non-updates) messages take their own decode path and
// surface raw payloads to the OnRawMessage hook from there, so
// observers see exactly one notification per frame.
if !ws.isUpdatesMessage(data) {
ws.handleSpecialMessage(data)
return
}
// Parse the WebSocket event
event, err := models.ParseWebSocketEvent(data)
if err != nil {
ws.logger.Printf("Failed to parse WebSocket message: %v", err)
event, parseErr := models.ParseWebSocketEvent(data)
ws.fireRawMessage(data, parseErr)
if parseErr != nil {
ws.logger.Printf("Failed to parse WebSocket message: %v", parseErr)
return
}
// Process each event type in the message
ws.handleEvent(event)
}
// fireRawMessage invokes the OnRawMessage hook if one is registered.
// Kept separate so the read path doesn't have to repeat the locking
// dance for every frame.
func (ws *WebSocketClient) fireRawMessage(data []byte, parseErr error) {
ws.mu.RLock()
handler := ws.handlers.OnRawMessage
ws.mu.RUnlock()
if handler != nil {
handler(data, parseErr)
}
}
// handleSpecialMessage processes special (non-updates) WebSocket messages
func (ws *WebSocketClient) handleSpecialMessage(data []byte) {
specialMessage, err := models.ParseSpecialMessage(data)
ws.fireRawMessage(data, err)
if err != nil {
ws.logger.Printf("Unknown special message type: %v", err)
ws.logger.Printf("Raw message: %s", string(data))
@@ -468,6 +510,13 @@ func (ws *WebSocketClient) dispatchTypedEventContinued(handlers *models.WebSocke
return true
case models.EventTypeGroupUpdated:
if handlers.OnGroupUpdated != nil && event.GroupUpdated != nil {
handlers.OnGroupUpdated(event.GroupUpdated)
}
return true
case models.EventTypeBassUpdated:
if handlers.OnBassUpdated != nil && event.BassUpdated != nil {
handlers.OnBassUpdated(event.BassUpdated)
+8
View File
@@ -19,6 +19,10 @@ type Config struct {
DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"5s"`
UPnPEnabled bool `env:"UPNP_ENABLED" default:"true"`
MDNSEnabled bool `env:"MDNS_ENABLED" default:"true"`
// DiscoveryInterface restricts mDNS and UPnP/SSDP discovery to a single
// network interface (e.g. "eth0"). Empty means "auto-pick the first
// suitable interface", which is the historical behaviour.
DiscoveryInterface string `env:"DISCOVERY_INTERFACE" default:""`
// Preferred devices from .env file
PreferredDevices []DeviceConfig `env:"PREFERRED_DEVICES"`
@@ -81,6 +85,10 @@ func LoadFromEnv() (*Config, error) {
config.MDNSEnabled = mdns == "true" || mdns == "1"
}
if iface := os.Getenv("DISCOVERY_INTERFACE"); iface != "" {
config.DiscoveryInterface = iface
}
if timeout := os.Getenv("HTTP_TIMEOUT"); timeout != "" {
if d, err := time.ParseDuration(timeout); err == nil {
config.HTTPTimeout = d
+60 -20
View File
@@ -14,17 +14,26 @@ import (
// MDNSDiscoveryService handles mDNS/Bonjour discovery of SoundTouch devices
type MDNSDiscoveryService struct {
timeout time.Duration
timeout time.Duration
ifaceName string
}
// NewMDNSDiscoveryService creates a new mDNS discovery service
func NewMDNSDiscoveryService(timeout time.Duration) *MDNSDiscoveryService {
return NewMDNSDiscoveryServiceWithInterface(timeout, "")
}
// NewMDNSDiscoveryServiceWithInterface creates a new mDNS discovery service
// pinned to the given network interface (e.g. "eth0"). An empty ifaceName
// falls back to the historical auto-pick behaviour.
func NewMDNSDiscoveryServiceWithInterface(timeout time.Duration, ifaceName string) *MDNSDiscoveryService {
if timeout == 0 {
timeout = defaultTimeout
}
return &MDNSDiscoveryService{
timeout: timeout,
timeout: timeout,
ifaceName: ifaceName,
}
}
@@ -218,8 +227,27 @@ func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *m
return device
}
// getIPv4Interface returns the first suitable IPv4 network interface
// getIPv4Interface returns the network interface to use for mDNS queries.
// If an explicit name was configured, it is resolved and validated; otherwise
// the first suitable, up, non-loopback IPv4 interface is returned.
func (m *MDNSDiscoveryService) getIPv4Interface() *net.Interface {
if m.ifaceName != "" {
iface, err := net.InterfaceByName(m.ifaceName)
if err != nil {
log.Printf("mDNS: Configured interface %q not found: %v", m.ifaceName, err)
return nil
}
if !interfaceHasIPv4(iface) {
log.Printf("mDNS: Configured interface %q has no usable IPv4 address", m.ifaceName)
return nil
}
log.Printf("mDNS: Using configured IPv4 interface: %s", iface.Name)
return iface
}
interfaces, err := net.Interfaces()
if err != nil {
log.Printf("mDNS: Failed to get network interfaces: %v", err)
@@ -234,30 +262,42 @@ func (m *MDNSDiscoveryService) getIPv4Interface() *net.Interface {
continue
}
// Check if this interface has IPv4 addresses
addrs, err := iface.Addrs()
if err != nil {
if !interfaceHasIPv4(&iface) {
continue
}
hasIPv4 := false
log.Printf("mDNS: Using IPv4 interface: %s", iface.Name)
for _, addr := range addrs {
if ipNet, ok := addr.(*net.IPNet); ok {
if ipNet.IP.To4() != nil && !ipNet.IP.IsLoopback() {
hasIPv4 = true
break
}
}
}
if hasIPv4 {
log.Printf("mDNS: Using IPv4 interface: %s", iface.Name)
return &iface
}
return &iface
}
log.Printf("mDNS: No suitable IPv4 interface found")
return nil
}
// interfaceHasIPv4 reports whether iface has at least one non-loopback IPv4
// address assigned and is administratively up.
func interfaceHasIPv4(iface *net.Interface) bool {
if iface.Flags&net.FlagUp == 0 {
return false
}
addrs, err := iface.Addrs()
if err != nil {
return false
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
if ipNet.IP.To4() != nil && !ipNet.IP.IsLoopback() {
return true
}
}
return false
}
+28
View File
@@ -92,6 +92,34 @@ func TestMDNSDiscoveryTimeout(t *testing.T) {
_ = err
}
func TestMDNSGetIPv4InterfaceUnknownName(t *testing.T) {
service := NewMDNSDiscoveryServiceWithInterface(5*time.Second, "definitely-not-a-real-iface-xyz")
if iface := service.getIPv4Interface(); iface != nil {
t.Errorf("Expected nil for unknown interface name, got %q", iface.Name)
}
}
func TestMDNSGetIPv4InterfaceExplicitMatchesAutoPick(t *testing.T) {
auto := NewMDNSDiscoveryService(5 * time.Second).getIPv4Interface()
if auto == nil {
t.Skip("No suitable IPv4 interface available on this host")
}
explicit := NewMDNSDiscoveryServiceWithInterface(5*time.Second, auto.Name).getIPv4Interface()
if explicit == nil {
t.Fatalf("Expected explicit lookup of %q to succeed", auto.Name)
}
if explicit.Name != auto.Name {
t.Errorf("Expected explicit interface %q, got %q", auto.Name, explicit.Name)
}
// Sanity: the resolved interface really has an IPv4 we could bind to.
if !interfaceHasIPv4(explicit) {
t.Errorf("Resolved interface %q has no IPv4 address", explicit.Name)
}
}
func TestMDNSDiscoveryWithCancelledContext(t *testing.T) {
service := NewMDNSDiscoveryService(5 * time.Second)
+1 -1
View File
@@ -142,7 +142,7 @@ func NewUnifiedDiscoveryService(cfg *config.Config) *UnifiedDiscoveryService {
return &UnifiedDiscoveryService{
ssdpService: NewServiceWithConfig(cfg),
mdnsService: NewMDNSDiscoveryService(timeout),
mdnsService: NewMDNSDiscoveryServiceWithInterface(timeout, cfg.DiscoveryInterface),
config: cfg,
cache: make(map[string]*models.DiscoveredDevice),
cacheTTL: cacheTTL,
+59 -4
View File
@@ -16,6 +16,7 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/config"
"github.com/gesellix/bose-soundtouch/pkg/models"
"golang.org/x/net/ipv4"
)
// Service handles UPnP SSDP discovery of SoundTouch devices
@@ -26,6 +27,7 @@ type Service struct {
mutex sync.RWMutex
config *config.Config
httpClient *http.Client
ifaceName string
}
// NewService creates a new UPnP discovery service
@@ -63,6 +65,7 @@ func NewServiceWithConfig(cfg *config.Config) *Service {
mutex: sync.RWMutex{},
config: cfg,
httpClient: &http.Client{Timeout: 5 * time.Second},
ifaceName: cfg.DiscoveryInterface,
}
}
@@ -189,15 +192,16 @@ func (d *Service) PerformDiscovery(ctx context.Context) ([]*models.DiscoveredDev
}
func (d *Service) setupUDPListener() (*net.UDPConn, error) {
listenAddr, err := net.ResolveUDPAddr("udp4", ":0")
listenIP, iface, err := d.resolveListenInterface()
if err != nil {
log.Printf("UPnP: Failed to resolve listen address: %v", err)
return nil, fmt.Errorf("failed to resolve listen address: %w", err)
return nil, err
}
listenAddr := &net.UDPAddr{IP: listenIP, Port: 0}
listener, err := net.ListenUDP("udp4", listenAddr)
if err != nil {
log.Printf("UPnP: Failed to create UDP listener: %v", err)
log.Printf("UPnP: Failed to create UDP listener on %s: %v", listenAddr, err)
return nil, fmt.Errorf("failed to create UDP listener: %w", err)
}
@@ -212,11 +216,62 @@ func (d *Service) setupUDPListener() (*net.UDPConn, error) {
return nil, fmt.Errorf("failed to cast local address to UDPAddr: %v", addr)
}
// Pin the outgoing multicast packets to the configured interface so the
// M-SEARCH leaves through the right NIC on multi-homed hosts.
if iface != nil {
if err := ipv4.NewPacketConn(listener).SetMulticastInterface(iface); err != nil {
log.Printf("UPnP: Failed to set multicast interface to %q: %v", iface.Name, err)
// Continue regardless — the kernel will fall back to its own routing decision.
}
}
log.Printf("UPnP: Created UDP listener on %s", localAddr.String())
return listener, nil
}
// resolveListenInterface returns the source IP to bind the UDP listener to and
// the interface to use for outgoing multicast. When no interface is configured,
// the IP is nil (wildcard) and the iface is nil, preserving the historical
// behaviour where the kernel picks a route.
func (d *Service) resolveListenInterface() (net.IP, *net.Interface, error) {
if d.ifaceName == "" {
return nil, nil, nil
}
iface, err := net.InterfaceByName(d.ifaceName)
if err != nil {
log.Printf("UPnP: Configured interface %q not found: %v", d.ifaceName, err)
return nil, nil, fmt.Errorf("configured interface %q not found: %w", d.ifaceName, err)
}
addrs, err := iface.Addrs()
if err != nil {
log.Printf("UPnP: Failed to read addresses for interface %q: %v", d.ifaceName, err)
return nil, nil, fmt.Errorf("read addresses for interface %q: %w", d.ifaceName, err)
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
ipv4Addr := ipNet.IP.To4()
if ipv4Addr == nil || ipNet.IP.IsLoopback() {
continue
}
log.Printf("UPnP: Binding UDP listener to interface %q (%s)", iface.Name, ipv4Addr)
return ipv4Addr, iface, nil
}
log.Printf("UPnP: Configured interface %q has no usable IPv4 address", d.ifaceName)
return nil, nil, fmt.Errorf("interface %q has no usable IPv4 address", d.ifaceName)
}
func (d *Service) sendMSearch(listener *net.UDPConn, multicastAddr *net.UDPAddr) error {
msearchRequest := d.buildMSearchRequest()
log.Printf("UPnP: Sending M-SEARCH request to %s:\n%s", ssdpAddr, strings.TrimSpace(msearchRequest))
+210 -14
View File
@@ -2,20 +2,155 @@ package models
import (
"encoding/xml"
"errors"
"fmt"
"io"
"strconv"
"strings"
)
// ClockDisplay represents the device's clock display settings
// ClockDisplay represents the device's clock display settings.
//
// Wire format (confirmed against ST10/ST20 firmware 27.0.6 — flat
// attributes on the outer <clockDisplay> are rejected with
// "Error parsing request"):
//
// <clockDisplay deviceID="…">
// <clockConfig timezoneInfo="Europe/Berlin"
// userEnable="true"
// timeFormat="TIME_FORMAT_24HOUR_ID"
// userOffsetMinute="0"
// brightnessLevel="70"
// userUtcTime="0"/>
// </clockDisplay>
//
// The struct keeps its historical flat-field public API so the CLI and
// other callers don't have to be rewritten; custom MarshalXML /
// UnmarshalXML methods bridge to the nested format on the wire.
type ClockDisplay struct {
XMLName xml.Name `xml:"clockDisplay"`
DeviceID string `xml:"deviceID,attr,omitempty"`
Enabled bool `xml:"enabled,attr,omitempty"`
Format string `xml:"format,attr,omitempty"`
Brightness int `xml:"brightness,attr,omitempty"`
AutoDim bool `xml:"autoDim,attr,omitempty"`
TimeZone string `xml:"timeZone,attr,omitempty"`
Value string `xml:",chardata"`
DeviceID string
Enabled bool
Format string // public-facing values: "12", "24", "auto"
Brightness int
AutoDim bool // not on the device's wire format; preserved for API compat
TimeZone string
Value string // kept for API compat — older fixtures stored chardata here
}
// Wire constants for clockConfig/@timeFormat.
const (
wireTimeFormat12Hour = "TIME_FORMAT_12HOUR_ID"
wireTimeFormat24Hour = "TIME_FORMAT_24HOUR_ID"
wireTimeFormatAuto = "TIME_FORMAT_AUTO_ID"
)
func mapToWireFormat(f string) string {
switch strings.ToLower(f) {
case "12":
return wireTimeFormat12Hour
case "24":
return wireTimeFormat24Hour
case "auto":
return wireTimeFormatAuto
default:
return ""
}
}
func mapFromWireFormat(wire string) string {
switch wire {
case wireTimeFormat12Hour:
return "12"
case wireTimeFormat24Hour:
return "24"
case wireTimeFormatAuto:
return "auto"
default:
return ""
}
}
// UnmarshalXML decodes the nested <clockDisplay><clockConfig …/></clockDisplay>
// into ClockDisplay's flat fields. Tolerates the older flat shape too —
// either because it appears in legacy captures or for forward-compat with
// firmwares that may revert.
func (c *ClockDisplay) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
applyClockDisplayOuterAttrs(c, start.Attr)
for {
tok, err := d.Token()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return err
}
switch t := tok.(type) {
case xml.StartElement:
if t.Name.Local == "clockConfig" {
applyClockConfigAttrs(c, t.Attr)
}
if err := d.Skip(); err != nil {
return err
}
case xml.CharData:
if text := strings.TrimSpace(string(t)); text != "" {
c.Value = text
}
case xml.EndElement:
return nil
}
}
return nil
}
// applyClockDisplayOuterAttrs handles the legacy flat-attribute format
// (deviceID, enabled, format, brightness, autoDim, timeZone) that older
// fixtures used directly on the <clockDisplay> element.
func applyClockDisplayOuterAttrs(c *ClockDisplay, attrs []xml.Attr) {
for _, attr := range attrs {
switch attr.Name.Local {
case "deviceID":
c.DeviceID = attr.Value
case "enabled":
c.Enabled = attr.Value == "true"
case "format":
c.Format = attr.Value
case "brightness":
c.Brightness, _ = strconv.Atoi(attr.Value)
case "autoDim":
c.AutoDim = attr.Value == "true"
case "timeZone":
c.TimeZone = attr.Value
}
}
}
// applyClockConfigAttrs handles the nested <clockConfig> attributes
// (timezoneInfo, userEnable, timeFormat, brightnessLevel) — the shape
// FW 27 emits and accepts.
func applyClockConfigAttrs(c *ClockDisplay, attrs []xml.Attr) {
for _, attr := range attrs {
switch attr.Name.Local {
case "timezoneInfo":
c.TimeZone = attr.Value
case "userEnable":
c.Enabled = attr.Value == "true"
case "timeFormat":
if mapped := mapFromWireFormat(attr.Value); mapped != "" {
c.Format = mapped
}
case "brightnessLevel":
c.Brightness, _ = strconv.Atoi(attr.Value)
}
}
}
// ClockFormat represents supported clock display formats
@@ -108,14 +243,16 @@ func (c *ClockDisplay) IsEmpty() bool {
return !c.Enabled && c.Format == "" && c.Brightness == 0 && c.TimeZone == ""
}
// ClockDisplayRequest represents a request to configure clock display settings
// ClockDisplayRequest represents a request to configure clock display
// settings. Fields use the same public names as the response struct;
// MarshalXML produces the nested wire format the device requires.
type ClockDisplayRequest struct {
XMLName xml.Name `xml:"clockDisplay"`
Enabled *bool `xml:"enabled,attr,omitempty"`
Format string `xml:"format,attr,omitempty"`
Brightness *int `xml:"brightness,attr,omitempty"`
AutoDim *bool `xml:"autoDim,attr,omitempty"`
TimeZone string `xml:"timeZone,attr,omitempty"`
Enabled *bool
Format string
Brightness *int
AutoDim *bool
TimeZone string
}
// NewClockDisplayRequest creates a new clock display configuration request
@@ -184,3 +321,62 @@ func (r *ClockDisplayRequest) Validate() error {
func (r *ClockDisplayRequest) HasChanges() bool {
return r.Enabled != nil || r.Format != "" || r.Brightness != nil || r.AutoDim != nil || r.TimeZone != ""
}
// MarshalXML emits the nested <clockDisplay><clockConfig …/></clockDisplay>
// envelope the device accepts. Empty fields are omitted so partial updates
// (e.g. "set only the timezone") don't accidentally clear other settings.
//
// AutoDim has no counterpart in the captured wire format; we still accept
// it in the public API for backward-compat but it is not emitted.
func (r ClockDisplayRequest) MarshalXML(e *xml.Encoder, _ xml.StartElement) error {
display := xml.StartElement{Name: xml.Name{Local: "clockDisplay"}}
if err := e.EncodeToken(display); err != nil {
return err
}
cfg := xml.StartElement{Name: xml.Name{Local: "clockConfig"}}
if r.TimeZone != "" {
cfg.Attr = append(cfg.Attr, xml.Attr{
Name: xml.Name{Local: "timezoneInfo"},
Value: r.TimeZone,
})
}
if r.Enabled != nil {
cfg.Attr = append(cfg.Attr, xml.Attr{
Name: xml.Name{Local: "userEnable"},
Value: strconv.FormatBool(*r.Enabled),
})
}
if r.Format != "" {
if wire := mapToWireFormat(r.Format); wire != "" {
cfg.Attr = append(cfg.Attr, xml.Attr{
Name: xml.Name{Local: "timeFormat"},
Value: wire,
})
}
}
if r.Brightness != nil {
cfg.Attr = append(cfg.Attr, xml.Attr{
Name: xml.Name{Local: "brightnessLevel"},
Value: strconv.Itoa(*r.Brightness),
})
}
if err := e.EncodeToken(cfg); err != nil {
return err
}
if err := e.EncodeToken(xml.EndElement{Name: cfg.Name}); err != nil {
return err
}
if err := e.EncodeToken(xml.EndElement{Name: display.Name}); err != nil {
return err
}
return e.Flush()
}
+51 -2
View File
@@ -641,7 +641,7 @@ func TestClockDisplayRequest_MarshalXML(t *testing.T) {
Enabled: &[]bool{true}[0],
Format: "24",
Brightness: &[]int{75}[0],
AutoDim: &[]bool{false}[0],
AutoDim: &[]bool{false}[0], // not on the wire format — must be silently dropped
TimeZone: "America/New_York",
}
@@ -650,8 +650,57 @@ func TestClockDisplayRequest_MarshalXML(t *testing.T) {
t.Fatalf("Failed to marshal XML: %v", err)
}
expected := `<clockDisplay enabled="true" format="24" brightness="75" autoDim="false" timeZone="America/New_York"></clockDisplay>`
// Must match the device's captured POST shape — firmware 27 rejects
// the legacy flat <clockDisplay enabled="…" format="…" .../> with
// "Error parsing request".
expected := `<clockDisplay><clockConfig timezoneInfo="America/New_York" userEnable="true" timeFormat="TIME_FORMAT_24HOUR_ID" brightnessLevel="75"></clockConfig></clockDisplay>`
if string(data) != expected {
t.Errorf("Expected XML %q, got %q", expected, string(data))
}
}
func TestClockDisplayRequest_MarshalXML_TimezoneOnly(t *testing.T) {
// Partial update: only set the timezone. Unset fields must be
// omitted so we don't clobber the device's other settings.
request := ClockDisplayRequest{TimeZone: "Europe/Berlin"}
data, err := xml.Marshal(request)
if err != nil {
t.Fatalf("Failed to marshal XML: %v", err)
}
expected := `<clockDisplay><clockConfig timezoneInfo="Europe/Berlin"></clockConfig></clockDisplay>`
if string(data) != expected {
t.Errorf("Expected XML %q, got %q", expected, string(data))
}
}
func TestClockDisplay_UnmarshalXML_NestedClockConfig(t *testing.T) {
// The real wire format — what firmware-27 devices emit and accept.
xmlData := `<clockDisplay deviceID="A81B6A536A98"><clockConfig timezoneInfo="Europe/Berlin" userEnable="true" timeFormat="TIME_FORMAT_24HOUR_ID" userOffsetMinute="0" brightnessLevel="70" userUtcTime="0"/></clockDisplay>`
var got ClockDisplay
if err := xml.Unmarshal([]byte(xmlData), &got); err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if got.DeviceID != "A81B6A536A98" {
t.Errorf("DeviceID = %q, want A81B6A536A98", got.DeviceID)
}
if got.TimeZone != "Europe/Berlin" {
t.Errorf("TimeZone = %q, want Europe/Berlin", got.TimeZone)
}
if !got.Enabled {
t.Error("Enabled = false, want true (from userEnable=true)")
}
if got.Format != "24" {
t.Errorf("Format = %q, want 24 (from timeFormat=TIME_FORMAT_24HOUR_ID)", got.Format)
}
if got.Brightness != 70 {
t.Errorf("Brightness = %d, want 70 (from brightnessLevel)", got.Brightness)
}
}
+25 -25
View File
@@ -182,44 +182,44 @@ func (c *ClockTime) SetUTC(utc int64) {
}
}
// ClockTimeRequest represents a request to set the device time
// ClockTimeRequest represents a request to set the device time.
//
// The POST body mirrors the device's GET /clockTime response shape —
// firmware 27 expects `utcTime` as the attribute name, not `utc`, and
// rejects any chardata or zone attribute with "Error parsing request"
// (confirmed against ST10/ST20/ST30 in live testing 2026-05-12).
//
// We deliberately do NOT send TimeFormat / Brightness in the request:
// those belong to /clockDisplay and including them here either gets
// ignored or rejected depending on firmware revision.
type ClockTimeRequest struct {
XMLName xml.Name `xml:"clockTime"`
Zone string `xml:"zone,attr,omitempty"`
UTC int64 `xml:"utc,attr,omitempty"`
Value string `xml:",chardata"`
UTCTime int64 `xml:"utcTime,attr"`
}
// NewClockTimeRequest creates a new clock time request from a time.Time
// NewClockTimeRequest creates a new clock time request from a time.Time.
// The input may be in any zone — we always send Unix-seconds, which the
// device interprets as UTC and renders according to its own clockDisplay
// configuration.
func NewClockTimeRequest(t time.Time) *ClockTimeRequest {
return &ClockTimeRequest{
Zone: t.Location().String(),
UTC: t.Unix(),
Value: t.UTC().Format("2006-01-02 15:04:05"),
}
return &ClockTimeRequest{UTCTime: t.Unix()}
}
// NewClockTimeRequestUTC creates a new clock time request from UTC timestamp
// NewClockTimeRequestUTC creates a new clock time request from a Unix
// timestamp in seconds.
func NewClockTimeRequestUTC(utc int64) *ClockTimeRequest {
t := time.Unix(utc, 0).UTC()
return &ClockTimeRequest{
UTC: utc,
Value: t.Format("2006-01-02 15:04:05"),
}
return &ClockTimeRequest{UTCTime: utc}
}
// Validate checks if the clock time request is valid
// Validate checks if the clock time request is valid.
func (r *ClockTimeRequest) Validate() error {
if r.UTC <= 0 && r.Value == "" {
return fmt.Errorf("either UTC timestamp or time value must be provided")
if r.UTCTime <= 0 {
return fmt.Errorf("UTC timestamp must be provided")
}
if r.UTC > 0 {
// Validate UTC timestamp is reasonable (after year 2000, before year 2100)
if r.UTC < 946684800 || r.UTC > 4102444800 {
return fmt.Errorf("UTC timestamp %d is outside reasonable range", r.UTC)
}
// Plausibility window: after year 2000, before year 2100.
if r.UTCTime < 946684800 || r.UTCTime > 4102444800 {
return fmt.Errorf("UTC timestamp %d is outside reasonable range", r.UTCTime)
}
return nil
+14 -50
View File
@@ -284,16 +284,8 @@ func TestNewClockTimeRequest(t *testing.T) {
request := NewClockTimeRequest(testTime)
if request.UTC != testTime.Unix() {
t.Errorf("Expected UTC %d, got %d", testTime.Unix(), request.UTC)
}
if request.Value != "2021-01-01 12:00:00" {
t.Errorf("Expected Value %q, got %q", "2021-01-01 12:00:00", request.Value)
}
if request.Zone != "UTC" {
t.Errorf("Expected Zone %q, got %q", "UTC", request.Zone)
if request.UTCTime != testTime.Unix() {
t.Errorf("Expected UTCTime %d, got %d", testTime.Unix(), request.UTCTime)
}
}
@@ -302,13 +294,8 @@ func TestNewClockTimeRequestUTC(t *testing.T) {
request := NewClockTimeRequestUTC(utcTimestamp)
if request.UTC != utcTimestamp {
t.Errorf("Expected UTC %d, got %d", utcTimestamp, request.UTC)
}
expectedValue := time.Unix(utcTimestamp, 0).UTC().Format("2006-01-02 15:04:05")
if request.Value != expectedValue {
t.Errorf("Expected Value %q, got %q", expectedValue, request.Value)
if request.UTCTime != utcTimestamp {
t.Errorf("Expected UTCTime %d, got %d", utcTimestamp, request.UTCTime)
}
}
@@ -319,25 +306,8 @@ func TestClockTimeRequest_Validate(t *testing.T) {
wantErr bool
}{
{
name: "Valid UTC request",
request: ClockTimeRequest{
UTC: 1609459200,
},
wantErr: false,
},
{
name: "Valid value request",
request: ClockTimeRequest{
Value: "2021-01-01 12:00:00",
},
wantErr: false,
},
{
name: "Valid request with both",
request: ClockTimeRequest{
UTC: 1609459200,
Value: "2021-01-01 12:00:00",
},
name: "Valid UTC request",
request: ClockTimeRequest{UTCTime: 1609459200},
wantErr: false,
},
{
@@ -346,17 +316,13 @@ func TestClockTimeRequest_Validate(t *testing.T) {
wantErr: true,
},
{
name: "UTC too old",
request: ClockTimeRequest{
UTC: 946684799, // Before year 2000
},
name: "UTC too old",
request: ClockTimeRequest{UTCTime: 946684799}, // Before year 2000
wantErr: true,
},
{
name: "UTC too far in future",
request: ClockTimeRequest{
UTC: 4102444801, // After year 2100
},
name: "UTC too far in future",
request: ClockTimeRequest{UTCTime: 4102444801}, // After year 2100
wantErr: true,
},
}
@@ -377,18 +343,16 @@ func TestClockTimeRequest_Validate(t *testing.T) {
}
func TestClockTimeRequest_MarshalXML(t *testing.T) {
request := ClockTimeRequest{
Zone: "UTC",
UTC: 1609459200,
Value: "2021-01-01 00:00:00",
}
request := ClockTimeRequest{UTCTime: 1609459200}
data, err := xml.Marshal(request)
if err != nil {
t.Fatalf("Failed to marshal XML: %v", err)
}
expected := `<clockTime zone="UTC" utc="1609459200">2021-01-01 00:00:00</clockTime>`
// Must match the device's GET /clockTime response attribute name
// — firmware 27 rejects `utc=` (no Time suffix) with "Error parsing request".
expected := `<clockTime utcTime="1609459200"></clockTime>`
if string(data) != expected {
t.Errorf("Expected XML %q, got %q", expected, string(data))
}
+9
View File
@@ -10,6 +10,15 @@ type Group struct {
MasterDeviceID string `xml:"masterDeviceId"`
Roles GroupRoles `xml:"roles"`
SenderIPAddress string `xml:"senderIPAddress,omitempty"`
// Status is populated by the device on GET /group (e.g. "GROUP_OK")
// and omitted from requests we send back.
Status string `xml:"status,omitempty"`
}
// IsEmpty reports whether the device returned an empty <group/> element,
// which is the speaker's way of saying "no stereo pair configured".
func (g *Group) IsEmpty() bool {
return g.ID == "" && g.MasterDeviceID == "" && len(g.Roles.Roles) == 0
}
// GroupRoles contains the role assignments for devices in a group.
+43
View File
@@ -21,6 +21,10 @@ const (
EventTypePresetUpdated WebSocketEventType = "presetsUpdated"
// EventTypeZoneUpdated indicates a zone configuration change
EventTypeZoneUpdated WebSocketEventType = "zoneUpdated"
// EventTypeGroupUpdated is emitted to both ROLE devices when an ST-10
// stereo pair is created, renamed, or removed via /addGroup,
// /updateGroup, or /removeGroup.
EventTypeGroupUpdated WebSocketEventType = "groupUpdated"
// EventTypeBassUpdated indicates a bass level change
EventTypeBassUpdated WebSocketEventType = "bassUpdated"
// EventTypeClockTimeUpdated indicates a clock time change
@@ -56,6 +60,8 @@ func (e WebSocketEventType) String() string {
return "Preset Updated"
case EventTypeZoneUpdated:
return "Zone Updated"
case EventTypeGroupUpdated:
return "Stereo Pair Updated"
case EventTypeBassUpdated:
return "Bass Updated"
case EventTypeClockTimeUpdated:
@@ -88,6 +94,7 @@ type WebSocketEvent struct {
ConnectionStateUpdated *ConnectionStateUpdatedEvent `xml:"connectionStateUpdated,omitempty"`
PresetUpdated *PresetUpdatedEvent `xml:"presetsUpdated,omitempty"`
ZoneUpdated *ZoneUpdatedEvent `xml:"zoneUpdated,omitempty"`
GroupUpdated *GroupUpdatedEvent `xml:"groupUpdated,omitempty"`
BassUpdated *BassUpdatedEvent `xml:"bassUpdated,omitempty"`
ClockTimeUpdated *ClockTimeUpdatedEvent `xml:"clockTimeUpdated,omitempty"`
ClockDisplayUpdated *ClockDisplayUpdatedEvent `xml:"clockDisplayUpdated,omitempty"`
@@ -122,6 +129,10 @@ func (e *WebSocketEvent) GetEvents() []interface{} {
events = append(events, e.ZoneUpdated)
}
if e.GroupUpdated != nil {
events = append(events, e.GroupUpdated)
}
if e.BassUpdated != nil {
events = append(events, e.BassUpdated)
}
@@ -215,6 +226,16 @@ type ZoneUpdatedEvent struct {
Zone Zone `xml:"zone"`
}
// GroupUpdatedEvent represents an ST-10 stereo-pair update notification.
// The device fans this event out to both LEFT and RIGHT speakers whenever
// the pair is created, renamed, or removed. Group will be the zero value
// for a teardown notification — see (*Group).IsEmpty.
type GroupUpdatedEvent struct {
XMLName xml.Name `xml:"groupUpdated"`
DeviceID string `xml:"deviceID,attr"`
Group Group `xml:"group"`
}
// Zone represents multiroom zone information
type Zone struct {
XMLName xml.Name `xml:"zone"`
@@ -373,6 +394,7 @@ type WebSocketEventHandlers struct {
OnConnectionState TypedEventHandler[*ConnectionStateUpdatedEvent]
OnPresetUpdated TypedEventHandler[*PresetUpdatedEvent]
OnZoneUpdated TypedEventHandler[*ZoneUpdatedEvent]
OnGroupUpdated TypedEventHandler[*GroupUpdatedEvent]
OnBassUpdated TypedEventHandler[*BassUpdatedEvent]
OnClockTimeUpdated TypedEventHandler[*ClockTimeUpdatedEvent]
OnClockDisplayUpdated TypedEventHandler[*ClockDisplayUpdatedEvent]
@@ -382,8 +404,19 @@ type WebSocketEventHandlers struct {
OnLanguageUpdated TypedEventHandler[*LanguageUpdatedEvent]
OnUnknownEvent EventHandler
OnSpecialMessage SpecialMessageHandler
// OnRawMessage fires for every received frame before any parsing
// happens. Use it for debug/observability tooling that wants to see
// exactly what the device sent on the wire — the typed handlers
// above still run afterwards, independently. parseErr is the result
// of the XML parse: nil for messages that decoded cleanly, non-nil
// for malformed payloads. The slice is owned by the caller; copy
// before retaining.
OnRawMessage RawMessageHandler
}
// RawMessageHandler defines the signature for raw-frame handlers.
type RawMessageHandler func(data []byte, parseErr error)
// ParseWebSocketEvent attempts to parse a WebSocket message into a specific event type
func ParseWebSocketEvent(data []byte) (*WebSocketEvent, error) {
var event WebSocketEvent
@@ -411,6 +444,8 @@ func (e *WebSocketEvent) getFieldByEventType(eventType WebSocketEventType) inter
field = e.PresetUpdated
case EventTypeZoneUpdated:
field = e.ZoneUpdated
case EventTypeGroupUpdated:
field = e.GroupUpdated
case EventTypeBassUpdated:
field = e.BassUpdated
case EventTypeClockTimeUpdated:
@@ -462,6 +497,8 @@ func isNil(i interface{}) bool {
return v == nil
case *ZoneUpdatedEvent:
return v == nil
case *GroupUpdatedEvent:
return v == nil
case *BassUpdatedEvent:
return v == nil
case *ClockTimeUpdatedEvent:
@@ -508,6 +545,8 @@ func (e *WebSocketEvent) HasEventType(eventType WebSocketEventType) bool {
return e.PresetUpdated != nil
case EventTypeZoneUpdated:
return e.ZoneUpdated != nil
case EventTypeGroupUpdated:
return e.GroupUpdated != nil
case EventTypeBassUpdated:
return e.BassUpdated != nil
case EventTypeClockTimeUpdated:
@@ -551,6 +590,10 @@ func (e *WebSocketEvent) GetEventTypes() []WebSocketEventType {
types = append(types, EventTypeZoneUpdated)
}
if e.GroupUpdated != nil {
types = append(types, EventTypeGroupUpdated)
}
if e.BassUpdated != nil {
types = append(types, EventTypeBassUpdated)
}
+84
View File
@@ -16,6 +16,7 @@ func TestWebSocketEventType_String(t *testing.T) {
{"ConnectionState", EventTypeConnectionState, "Connection State Updated"},
{"PresetUpdated", EventTypePresetUpdated, "Preset Updated"},
{"ZoneUpdated", EventTypeZoneUpdated, "Zone Updated"},
{"GroupUpdated", EventTypeGroupUpdated, "Stereo Pair Updated"},
{"BassUpdated", EventTypeBassUpdated, "Bass Updated"},
{"ClockTimeUpdated", EventTypeClockTimeUpdated, "Clock Time Updated"},
{"ClockDisplayUpdated", EventTypeClockDisplayUpdated, "Clock Display Updated"},
@@ -187,6 +188,89 @@ func TestParseWebSocketEvent(t *testing.T) {
t.Error("Expected error for invalid XML, got nil")
}
})
t.Run("ValidGroupUpdatedEvent", func(t *testing.T) {
// The device fans this out to both ROLE devices when a stereo
// pair is created via POST /addGroup.
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
<updates deviceID="9070658C9D4A">
<groupUpdated deviceID="9070658C9D4A">
<group id="1234567">
<name>Living Room Pair</name>
<masterDeviceId>9070658C9D4A</masterDeviceId>
<roles>
<groupRole>
<deviceId>9070658C9D4A</deviceId>
<role>LEFT</role>
<ipAddress>192.168.1.131</ipAddress>
</groupRole>
<groupRole>
<deviceId>F45EAB3115DA</deviceId>
<role>RIGHT</role>
<ipAddress>192.168.1.134</ipAddress>
</groupRole>
</roles>
<status>GROUP_OK</status>
</group>
</groupUpdated>
</updates>`
event, err := ParseWebSocketEvent([]byte(xmlData))
if err != nil {
t.Fatalf("ParseWebSocketEvent: %v", err)
}
if !event.HasEventType(EventTypeGroupUpdated) {
t.Fatal("HasEventType(EventTypeGroupUpdated) = false, want true")
}
if event.GroupUpdated == nil {
t.Fatal("GroupUpdated is nil")
}
g := event.GroupUpdated.Group
if g.ID != "1234567" {
t.Errorf("group ID = %q, want 1234567", g.ID)
}
if g.MasterDeviceID != "9070658C9D4A" {
t.Errorf("MasterDeviceID = %q", g.MasterDeviceID)
}
if len(g.Roles.Roles) != 2 || g.Roles.Roles[0].Role != "LEFT" || g.Roles.Roles[1].Role != "RIGHT" {
t.Errorf("roles not parsed as LEFT/RIGHT: %+v", g.Roles.Roles)
}
if g.Status != "GROUP_OK" {
t.Errorf("status = %q, want GROUP_OK", g.Status)
}
})
t.Run("GroupUpdatedTeardown", func(t *testing.T) {
// On /removeGroup, the device emits a groupUpdated with an empty
// <group/> body. Parsing must surface that as IsEmpty=true so the
// UI can render "pair dissolved" cleanly.
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
<updates deviceID="9070658C9D4A">
<groupUpdated deviceID="9070658C9D4A">
<group/>
</groupUpdated>
</updates>`
event, err := ParseWebSocketEvent([]byte(xmlData))
if err != nil {
t.Fatalf("ParseWebSocketEvent: %v", err)
}
if event.GroupUpdated == nil {
t.Fatal("GroupUpdated is nil")
}
if !event.GroupUpdated.Group.IsEmpty() {
t.Errorf("Group.IsEmpty() = false on teardown; got %+v", event.GroupUpdated.Group)
}
})
}
func TestWebSocketEvent_HasEventType(t *testing.T) {
+4 -5
View File
@@ -299,11 +299,10 @@ const (
RecentsFile = "Recents.xml"
SourcesFile = "Sources.xml"
SpeakerHTTPPort = 8090
SpeakerDeviceInfoPath = "/info"
SpeakerRecentsPath = "/recents"
SpeakerPresetsPath = "/presets"
SpeakerSourcesFileLocation = "/mnt/nv/BoseApp-Persistence/1/Sources.xml"
// Speaker-protocol constants (HTTP port, paths, on-device file
// locations) moved to github.com/gesellix/bose-soundtouch/pkg/speaker
// so the client library and CLI can share them without depending on
// the service package.
// DateStr is the hardcoded date used in many Bose XML responses
DateStr = "2012-09-19T12:43:00.000+00:00"
-4
View File
@@ -9,10 +9,6 @@ func TestConstants(t *testing.T) {
t.Error("DateStr should not be empty")
}
if SpeakerHTTPPort != 8090 {
t.Errorf("Expected SpeakerHTTPPort 8090, got %d", SpeakerHTTPPort)
}
if len(GetProviders()) == 0 {
t.Error("Providers should not be empty")
}
+431 -53
View File
@@ -69,6 +69,15 @@ type DataStore struct {
// baseDir is the absolute, normalized base directory used for path safety checks.
baseDir string
// rootMu guards lazy initialisation of root.
rootMu sync.Mutex
// root is an os.Root anchored at baseDir. All filesystem operations within
// the datastore go through it, so ".." or absolute paths in
// caller-supplied components cannot escape the root — the Go runtime
// enforces containment regardless of what safeJoin's output looks like.
// Lazily opened so NewDataStore stays a pure constructor.
root *os.Root
eventMutex sync.RWMutex
deviceEvents map[string][]models.DeviceEvent
idMutex sync.RWMutex
@@ -113,10 +122,31 @@ func NewDataStore(dataDir string) *DataStore {
}
// safeJoin joins the given path elements to the datastore baseDir and ensures
// that the resulting absolute path stays within baseDir. If the check fails,
// baseDir is returned to prevent directory traversal.
// that the resulting absolute path stays within baseDir. If any element would
// escape baseDir (absolute path, "..", or — on Windows — a drive/colon), the
// function falls back to baseDir to prevent directory traversal.
//
// The validation up-front uses filepath.IsLocal, which CodeQL recognises as a
// path-traversal sanitiser, so taint analysis at call sites that subsequently
// hand the result to os.ReadFile / os.Open / os.Remove etc. propagates safely.
// The post-join prefix check below stays as belt-and-suspenders for any
// unusual platform behaviour IsLocal does not cover.
func (ds *DataStore) safeJoin(elem ...string) string {
// Join the base directory with the provided elements.
for _, e := range elem {
if e == "" {
// filepath.Join silently skips empty elements, but IsLocal
// returns false for "" — treat empties as a no-op.
continue
}
if !filepath.IsLocal(e) {
// Element is absolute, contains ".." or a reserved Windows
// component. Refuse to join.
return ds.baseDir
}
}
// Join the base directory with the (now sanitised) elements.
path := filepath.Join(append([]string{ds.baseDir}, elem...)...)
absPath, err := filepath.Abs(path)
@@ -131,7 +161,8 @@ func (ds *DataStore) safeJoin(elem ...string) string {
return absPath
}
// Ensure the resolved path is within the base directory.
// Belt-and-suspenders: ensure the resolved path is within the base
// directory even if filepath.IsLocal somehow misjudged a component.
baseWithSep := base
if !strings.HasSuffix(baseWithSep, string(os.PathSeparator)) {
baseWithSep += string(os.PathSeparator)
@@ -150,6 +181,277 @@ func (ds *DataStore) SafeJoin(elem ...string) string {
return ds.safeJoin(elem...)
}
// getRoot returns the lazily-opened *os.Root anchored at baseDir. The root is
// created on first call after MkdirAll-ing baseDir; subsequent calls return
// the cached handle. Filesystem operations performed via the returned root
// cannot escape baseDir even if the relative path passed to them is malicious.
func (ds *DataStore) getRoot() (*os.Root, error) {
ds.rootMu.Lock()
defer ds.rootMu.Unlock()
if ds.root != nil {
return ds.root, nil
}
if ds.baseDir == "" {
return nil, fmt.Errorf("datastore: baseDir not configured")
}
if err := os.MkdirAll(ds.baseDir, 0755); err != nil {
return nil, fmt.Errorf("datastore: ensure baseDir %s: %w", ds.baseDir, err)
}
r, err := os.OpenRoot(ds.baseDir)
if err != nil {
return nil, fmt.Errorf("datastore: open root at %s: %w", ds.baseDir, err)
}
ds.root = r
return r, nil
}
// Close releases any open filesystem handles held by the datastore. Safe to
// call on a never-used DataStore.
func (ds *DataStore) Close() error {
ds.rootMu.Lock()
defer ds.rootMu.Unlock()
if ds.root == nil {
return nil
}
err := ds.root.Close()
ds.root = nil
return err
}
// rootRel converts a path produced by safeJoin (or by filepath.Join over
// ds.DataDir) into the form expected by *os.Root methods — relative to
// baseDir, no leading separator. Tolerates both absolute paths and paths
// whose root is the relative ds.DataDir.
//
// Returns "." for baseDir itself.
func (ds *DataStore) rootRel(absPath string) (string, error) {
// If the input is relative, absolutise so the comparison with baseDir
// works regardless of how DataDir was originally configured.
if !filepath.IsAbs(absPath) {
a, err := filepath.Abs(absPath)
if err != nil {
return "", fmt.Errorf("datastore: absolutise %s: %w", absPath, err)
}
absPath = a
}
if absPath == ds.baseDir {
return ".", nil
}
rel, err := filepath.Rel(ds.baseDir, absPath)
if err != nil {
return "", fmt.Errorf("datastore: %s is outside baseDir: %w", absPath, err)
}
if rel == "." || rel == "" {
return ".", nil
}
if strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("datastore: %s is outside baseDir", absPath)
}
return rel, nil
}
// rootStat is the os.Stat equivalent for a path under baseDir.
func (ds *DataStore) rootStat(absPath string) (os.FileInfo, error) {
r, err := ds.getRoot()
if err != nil {
return nil, err
}
rel, err := ds.rootRel(absPath)
if err != nil {
return nil, err
}
return r.Stat(rel)
}
// rootReadFile is the os.ReadFile equivalent.
func (ds *DataStore) rootReadFile(absPath string) ([]byte, error) {
r, err := ds.getRoot()
if err != nil {
return nil, err
}
rel, err := ds.rootRel(absPath)
if err != nil {
return nil, err
}
return r.ReadFile(rel)
}
// rootWriteFile is the os.WriteFile equivalent.
func (ds *DataStore) rootWriteFile(absPath string, data []byte, perm os.FileMode) error {
r, err := ds.getRoot()
if err != nil {
return err
}
rel, err := ds.rootRel(absPath)
if err != nil {
return err
}
return r.WriteFile(rel, data, perm)
}
// rootMkdirAll is the os.MkdirAll equivalent.
func (ds *DataStore) rootMkdirAll(absPath string, perm os.FileMode) error {
r, err := ds.getRoot()
if err != nil {
return err
}
rel, err := ds.rootRel(absPath)
if err != nil {
return err
}
if rel == "." {
return nil
}
return r.MkdirAll(rel, perm)
}
// rootRemove is the os.Remove equivalent.
func (ds *DataStore) rootRemove(absPath string) error {
r, err := ds.getRoot()
if err != nil {
return err
}
rel, err := ds.rootRel(absPath)
if err != nil {
return err
}
return r.Remove(rel)
}
// rootRemoveAll is the os.RemoveAll equivalent.
func (ds *DataStore) rootRemoveAll(absPath string) error {
r, err := ds.getRoot()
if err != nil {
return err
}
rel, err := ds.rootRel(absPath)
if err != nil {
return err
}
return r.RemoveAll(rel)
}
// rootRename is the os.Rename equivalent. Both paths must be under baseDir.
func (ds *DataStore) rootRename(oldAbs, newAbs string) error {
r, err := ds.getRoot()
if err != nil {
return err
}
oldRel, err := ds.rootRel(oldAbs)
if err != nil {
return err
}
newRel, err := ds.rootRel(newAbs)
if err != nil {
return err
}
return r.Rename(oldRel, newRel)
}
// rootReadDir lists the entries in absPath. Equivalent to os.ReadDir,
// including the same alphabetical-by-name sort order — *os.File.ReadDir(-1)
// returns entries in directory order, but callers (and existing tests)
// depend on the sorted contract that os.ReadDir documents.
func (ds *DataStore) rootReadDir(absPath string) ([]os.DirEntry, error) {
r, err := ds.getRoot()
if err != nil {
return nil, err
}
rel, err := ds.rootRel(absPath)
if err != nil {
return nil, err
}
f, err := r.Open(rel)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
entries, err := f.ReadDir(-1)
if err != nil {
return entries, err
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
return entries, nil
}
// rootExists is true when absPath exists under baseDir.
func (ds *DataStore) rootExists(absPath string) bool {
_, err := ds.rootStat(absPath)
return err == nil
}
// ReadDirUnderBase lists the entries in absPath, which must resolve to a
// directory under the datastore baseDir. Cross-package callers (marge,
// handlers, …) use this instead of os.ReadDir so that the underlying
// *os.Root sanitises the path against traversal.
func (ds *DataStore) ReadDirUnderBase(absPath string) ([]os.DirEntry, error) {
return ds.rootReadDir(absPath)
}
// MkdirAllUnderBase creates a directory tree under baseDir.
func (ds *DataStore) MkdirAllUnderBase(absPath string, perm os.FileMode) error {
return ds.rootMkdirAll(absPath, perm)
}
// WriteFileUnderBase atomically writes data to absPath, which must be under
// baseDir.
func (ds *DataStore) WriteFileUnderBase(absPath string, data []byte, perm os.FileMode) error {
return ds.rootWriteFile(absPath, data, perm)
}
// rootOpen is the os.Open equivalent for a path under baseDir. The caller
// owns the returned *os.File and must Close it.
func (ds *DataStore) rootOpen(absPath string) (*os.File, error) {
r, err := ds.getRoot()
if err != nil {
return nil, err
}
rel, err := ds.rootRel(absPath)
if err != nil {
return nil, err
}
return r.Open(rel)
}
// ListAccounts returns a list of all account IDs (directories in the data root).
func (ds *DataStore) ListAccounts() ([]string, error) {
ds.fileMutex.RLock()
@@ -157,11 +459,11 @@ func (ds *DataStore) ListAccounts() ([]string, error) {
// Account data is stored in 'accounts' subdirectory within the data root.
accountsDir := filepath.Join(ds.baseDir, "accounts")
if !exists(accountsDir) {
if !ds.rootExists(accountsDir) {
return []string{"default"}, nil
}
entries, err := os.ReadDir(accountsDir)
entries, err := ds.rootReadDir(accountsDir)
if err != nil {
return nil, err
}
@@ -199,7 +501,7 @@ func (ds *DataStore) AccountDeviceDir(account, device string) string {
// First, check if the device directory exists directly with the given deviceID
// This prioritizes MAC-based deviceIDs over legacy mappings
directPath := ds.safeJoin("accounts", account, constants.DevicesDir, device)
if _, err := os.Stat(directPath); err == nil {
if _, err := ds.rootStat(directPath); err == nil {
// Directory exists, use the direct deviceID (preferred for MAC-based IDs)
return directPath
}
@@ -219,7 +521,7 @@ func (ds *DataStore) AccountDeviceDir(account, device string) string {
if ok {
// Use the mapped device only if it exists and the direct path doesn't
mappedPath := ds.safeJoin("accounts", account, constants.DevicesDir, mappedDevice)
if _, err := os.Stat(mappedPath); err == nil {
if _, err := ds.rootStat(mappedPath); err == nil {
return mappedPath
}
}
@@ -241,7 +543,7 @@ func (ds *DataStore) getDeviceInfoNoLock(account, device string) (*models.Servic
path := ds.AccountDeviceDir(account, device)
deviceInfoPath := filepath.Join(path, constants.DeviceInfoFile)
data, err := os.ReadFile(deviceInfoPath)
data, err := ds.rootReadFile(deviceInfoPath)
if err != nil {
return nil, err
}
@@ -533,7 +835,7 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
data, err := os.ReadFile(path)
data, err := ds.rootReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return []models.ServicePreset{}, nil
@@ -597,7 +899,7 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
defer ds.fileMutex.Unlock()
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
if err := ds.rootMkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
@@ -661,11 +963,11 @@ func (ds *DataStore) atomicWriteFile(filename string, data []byte) error {
perm := os.FileMode(0644)
tempFile := filename + ".tmp"
if err := os.WriteFile(tempFile, data, perm); err != nil {
if err := ds.rootWriteFile(tempFile, data, perm); err != nil {
return err
}
return os.Rename(tempFile, filename)
return ds.rootRename(tempFile, filename)
}
// GetRecents returns the list of recently played items for the specified account and device.
@@ -675,7 +977,7 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)
data, err := os.ReadFile(path)
data, err := ds.rootReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return []models.ServiceRecent{}, nil
@@ -770,7 +1072,7 @@ func (ds *DataStore) SaveRecents(account, device string, recents []models.Servic
defer ds.fileMutex.Unlock()
dir := ds.AccountDeviceDir(account, device)
if err := os.MkdirAll(dir, 0755); err != nil {
if err := ds.rootMkdirAll(dir, 0755); err != nil {
return err
}
@@ -869,7 +1171,7 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
ds.mergeWithExistingDeviceInfo(account, device, info)
dir := ds.AccountDeviceDir(account, device)
if err := os.MkdirAll(dir, 0755); err != nil {
if err := ds.rootMkdirAll(dir, 0755); err != nil {
return err
}
@@ -1031,7 +1333,7 @@ func (ds *DataStore) SaveAccountInfo(accountID string, info *models.ServiceAccou
}
dir := ds.AccountDir(accountID)
if err := os.MkdirAll(dir, 0755); err != nil {
if err := ds.rootMkdirAll(dir, 0755); err != nil {
return err
}
@@ -1053,11 +1355,11 @@ func (ds *DataStore) GetAccountInfo(accountID string) (*models.ServiceAccountInf
// Try account root (canonical location)
path := filepath.Join(ds.AccountDir(accountID), "account.json")
if !exists(path) {
if !ds.rootExists(path) {
return &models.ServiceAccountInfo{AccountID: accountID, IsPlaceholder: true}, nil
}
data, err := os.ReadFile(path)
data, err := ds.rootReadFile(path)
if err != nil {
return nil, err
}
@@ -1077,7 +1379,7 @@ func (ds *DataStore) RemoveDevice(account, device string) error {
dir := ds.AccountDeviceDir(account, device)
return os.RemoveAll(dir)
return ds.rootRemoveAll(dir)
}
// RemoveDeviceDir is an alias for RemoveDevice for backwards compatibility.
@@ -1108,7 +1410,7 @@ func (ds *DataStore) collectDeducedIDs(account, device string) map[string]string
// Check recents and presets to find source IDs for provider IDs 2, 9, 11, 25
for _, filename := range []string{constants.RecentsFile, constants.PresetsFile} {
fileContent, err := os.ReadFile(filepath.Join(ds.AccountDeviceDir(account, device), filename))
fileContent, err := ds.rootReadFile(filepath.Join(ds.AccountDeviceDir(account, device), filename))
if err != nil {
continue
}
@@ -1214,7 +1516,7 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
data, err := os.ReadFile(path)
data, err := ds.rootReadFile(path)
if err != nil {
if os.IsNotExist(err) {
sources := ds.getDefaultSources()
@@ -1254,6 +1556,17 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
}
sources := make([]models.ConfiguredSource, len(sourcesWrap.Sources))
defaults := ds.getDefaultSources()
// Pre-claim IDs already explicitly set in the file so the canonical fill
// below doesn't reuse them when multiple entries share a SourceKey.Type.
claimedIDs := make(map[string]bool, len(sourcesWrap.Sources))
for i := range sourcesWrap.Sources {
if id := sourcesWrap.Sources[i].ID; id != "" {
claimedIDs[id] = true
}
}
for i := range sourcesWrap.Sources {
ps := &sourcesWrap.Sources[i]
s := &sources[i]
@@ -1293,11 +1606,9 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
s.SourceKeyAccount = s.SourceKey.Account
}
// Ensure Type is populated from SourceKey if missing
if s.Type == "" && s.SourceKey.Type != "" {
s.Type = s.SourceKey.Type
}
applyCanonicalDefaults(s, defaults, claimedIDs)
// Last-resort ID for unknown providers.
if s.ID == "" {
s.ID = strconv.Itoa(2000001 + i)
}
@@ -1306,13 +1617,58 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
return sources, nil
}
// applyCanonicalDefaults fills missing canonical ID/Type/SourceProviderID for
// known providers and repairs Type that was previously synthesized from
// SourceKey.Type (e.g. "AUX") rather than the canonical value (e.g. "Audio").
// Without this, the on-device Sources.xml — which carries only displayName +
// sourceKey — would round-trip as id="2000001+i" type="<sourceKey.Type>" and
// be rejected by the speaker as INVALID_SOURCE after migration.
//
// claimedIDs tracks which canonical IDs are already in use so that multiple
// entries with the same SourceKey.Type don't collide on the same ID.
func applyCanonicalDefaults(s *models.ConfiguredSource, defaults []models.ConfiguredSource, claimedIDs map[string]bool) {
def := findCanonicalSource(defaults, s.SourceKey.Type)
if def == nil {
return
}
if s.ID == "" && !claimedIDs[def.ID] {
s.ID = def.ID
claimedIDs[def.ID] = true
}
if s.Type == "" || s.Type == s.SourceKey.Type {
s.Type = def.Type
}
if s.SourceProviderID == "" {
s.SourceProviderID = def.SourceProviderID
}
}
// findCanonicalSource returns the default source matching the given
// SourceKey.Type, or nil if it's not one of our known providers.
func findCanonicalSource(defaults []models.ConfiguredSource, sourceKeyType string) *models.ConfiguredSource {
if sourceKeyType == "" {
return nil
}
for i := range defaults {
if defaults[i].SourceKey.Type == sourceKeyType {
return &defaults[i]
}
}
return nil
}
// SaveConfiguredSources saves the configured sources list for the specified account and device.
func (ds *DataStore) SaveConfiguredSources(account, device string, sources []models.ConfiguredSource) error {
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
if err := ds.rootMkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
@@ -1607,7 +1963,7 @@ func (ds *DataStore) Initialize() error {
func (ds *DataStore) GetETagForPresets(account, device string) int64 {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
info, err := os.Stat(path)
info, err := ds.rootStat(path)
if err != nil {
return 0
}
@@ -1618,7 +1974,7 @@ func (ds *DataStore) GetETagForPresets(account, device string) int64 {
// HasConfiguredSources reports whether a Sources.xml file exists for the given account and device.
func (ds *DataStore) HasConfiguredSources(account, device string) bool {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
_, err := os.Stat(path)
_, err := ds.rootStat(path)
return err == nil
}
@@ -1627,7 +1983,7 @@ func (ds *DataStore) HasConfiguredSources(account, device string) bool {
func (ds *DataStore) GetETagForSources(account, device string) int64 {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
info, err := os.Stat(path)
info, err := ds.rootStat(path)
if err != nil {
return 0
}
@@ -1639,7 +1995,7 @@ func (ds *DataStore) GetETagForSources(account, device string) int64 {
func (ds *DataStore) GetETagForRecents(account, device string) int64 {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)
info, err := os.Stat(path)
info, err := ds.rootStat(path)
if err != nil {
return 0
}
@@ -1663,7 +2019,7 @@ func (ds *DataStore) GetETagForAccount(account, device string) string {
if device != "" {
deviceDir := ds.AccountDeviceDir(account, device)
for _, name := range []string{constants.PresetsFile, constants.SourcesFile, constants.RecentsFile} {
f, err := os.Open(filepath.Join(deviceDir, name))
f, err := ds.rootOpen(filepath.Join(deviceDir, name))
if err != nil {
continue
}
@@ -1680,13 +2036,13 @@ func (ds *DataStore) GetETagForAccount(account, device string) string {
// Ignore error: missing directory is treated as no devices, producing a
// stable non-empty hash rather than "" which would false-match an absent
// If-None-Match header and return 304 on the first request.
entries, _ := os.ReadDir(devicesDir)
entries, _ := ds.rootReadDir(devicesDir)
for _, entry := range entries {
if entry.IsDir() {
deviceDir := ds.AccountDeviceDir(account, entry.Name())
for _, name := range []string{constants.PresetsFile, constants.SourcesFile, constants.RecentsFile} {
f, err := os.Open(filepath.Join(deviceDir, name))
f, err := ds.rootOpen(filepath.Join(deviceDir, name))
if err != nil {
continue
}
@@ -1724,6 +2080,28 @@ type Settings struct {
AmazonClientID string `json:"amazon_client_id,omitempty"`
AmazonClientSecret string `json:"amazon_client_secret,omitempty"`
AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"`
// AllowInsecureUpstreamTLS, when true, disables TLS certificate verification
// for the upstream Bose-cloud proxy and mirror traffic. The default (false)
// keeps verification on; opt in only when the upstream certificate chain is
// broken (post end-of-service) and a temporary unblock is required.
AllowInsecureUpstreamTLS bool `json:"allow_insecure_upstream_tls,omitempty"`
// TrustForwardedHeaders enables proxy-aware client IP resolution: when the
// immediate TCP peer is one of the TrustedProxyCIDRs, the X-Real-IP /
// X-Forwarded-For / True-Client-IP headers are honoured and replace
// r.RemoteAddr. Required when the service is fronted by nginx, Caddy, or
// any other reverse proxy. Default false — direct LAN deployments must
// not enable this, otherwise a malicious LAN-resident client could spoof
// its source IP via these headers.
TrustForwardedHeaders bool `json:"trust_forwarded_headers,omitempty"`
// TrustedProxyCIDRs is the list of CIDR blocks whose immediate TCP peers
// are allowed to set X-Forwarded-* headers when TrustForwardedHeaders is
// true. Defaults to loopback (127.0.0.0/8 and ::1/128) — i.e. only a
// reverse proxy on the same host. Override only if the proxy lives on a
// different host within a known-good private subnet.
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
}
// GetSettings retrieves the global service settings.
@@ -1733,11 +2111,11 @@ func (ds *DataStore) GetSettings() (Settings, error) {
}
path := filepath.Join(ds.DataDir, "settings.json")
if !exists(path) {
if !ds.rootExists(path) {
return Settings{}, nil
}
data, err := os.ReadFile(path)
data, err := ds.rootReadFile(path)
if err != nil {
return Settings{}, err
}
@@ -1756,7 +2134,7 @@ func (ds *DataStore) SaveSettings(settings Settings) error {
return nil
}
if err := os.MkdirAll(ds.DataDir, 0755); err != nil {
if err := ds.rootMkdirAll(ds.DataDir, 0755); err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
@@ -1773,7 +2151,7 @@ func (ds *DataStore) SaveSettings(settings Settings) error {
// SaveUsageStats saves usage statistics to the datastore.
func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error {
dir := filepath.Join(ds.DataDir, "stats", "usage")
if err := os.MkdirAll(dir, 0755); err != nil {
if err := ds.rootMkdirAll(dir, 0755); err != nil {
return err
}
@@ -1791,7 +2169,7 @@ func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error {
// SaveErrorStats saves error statistics to the datastore.
func (ds *DataStore) SaveErrorStats(stats models.ErrorStats) error {
dir := filepath.Join(ds.DataDir, "stats", "error")
if err := os.MkdirAll(dir, 0755); err != nil {
if err := ds.rootMkdirAll(dir, 0755); err != nil {
return err
}
@@ -1857,7 +2235,7 @@ func (ds *DataStore) SaveDNSDiscoveries(discoveries []DNSDiscoveryEntry) error {
}
dir := filepath.Join(ds.DataDir, "dns")
if err := os.MkdirAll(dir, 0755); err != nil {
if err := ds.rootMkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create dns directory: %w", err)
}
@@ -1883,11 +2261,11 @@ func (ds *DataStore) LoadDNSDiscoveries() ([]DNSDiscoveryEntry, error) {
}
path := filepath.Join(ds.DataDir, "dns", "discoveries.json")
if !exists(path) {
if !ds.rootExists(path) {
return []DNSDiscoveryEntry{}, nil
}
data, err := os.ReadFile(path)
data, err := ds.rootReadFile(path)
if err != nil {
return nil, err
}
@@ -1907,11 +2285,11 @@ func (ds *DataStore) ClearDNSDiscoveries() error {
}
path := filepath.Join(ds.DataDir, "dns", "discoveries.json")
if !exists(path) {
if !ds.rootExists(path) {
return nil
}
return os.Remove(path)
return ds.rootRemove(path)
}
// groupFilePath returns the on-disk path for a group file.
@@ -1923,7 +2301,7 @@ func (ds *DataStore) groupFilePath(account, groupID string) string {
func (ds *DataStore) generateGroupID(account string) string {
for {
id := fmt.Sprintf("%07d", rand.Int63n(10_000_000)) //nolint:gosec
if !exists(ds.groupFilePath(account, id)) {
if !ds.rootExists(ds.groupFilePath(account, id)) {
return id
}
}
@@ -1936,7 +2314,7 @@ func (ds *DataStore) GetGroupForDevice(account, deviceID string) (*models.Group,
dir := ds.AccountDevicesDir(account)
entries, err := os.ReadDir(dir)
entries, err := ds.rootReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrGroupNotFound
@@ -1950,7 +2328,7 @@ func (ds *DataStore) GetGroupForDevice(account, deviceID string) (*models.Group,
continue
}
data, readErr := os.ReadFile(filepath.Join(dir, e.Name()))
data, readErr := ds.rootReadFile(filepath.Join(dir, e.Name()))
if readErr != nil {
continue
}
@@ -1976,7 +2354,7 @@ func (ds *DataStore) AddGroup(account string, group *models.Group) (string, erro
defer ds.fileMutex.Unlock()
dir := ds.AccountDevicesDir(account)
if err := os.MkdirAll(dir, 0755); err != nil {
if err := ds.rootMkdirAll(dir, 0755); err != nil {
return "", err
}
@@ -1998,7 +2376,7 @@ func (ds *DataStore) ModifyGroup(account, groupID, newName string) (*models.Grou
path := ds.groupFilePath(account, groupID)
data, err := os.ReadFile(path)
data, err := ds.rootReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("group %s not found", groupID)
@@ -2031,7 +2409,7 @@ func (ds *DataStore) DeleteGroup(account, groupID string) error {
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
err := os.Remove(ds.groupFilePath(account, groupID))
err := ds.rootRemove(ds.groupFilePath(account, groupID))
if os.IsNotExist(err) {
return fmt.Errorf("group %s not found", groupID)
}
@@ -2047,11 +2425,11 @@ func (ds *DataStore) SaveTuneInFavorite(stationID string) error {
}
dir := ds.safeJoin("tunein", "favorites")
if err := os.MkdirAll(dir, 0755); err != nil {
if err := ds.rootMkdirAll(dir, 0755); err != nil {
return err
}
return os.WriteFile(ds.safeJoin("tunein", "favorites", stationID), nil, 0644)
return ds.rootWriteFile(ds.safeJoin("tunein", "favorites", stationID), nil, 0644)
}
// DeleteTuneInFavorite removes a previously saved TuneIn favorite marker file.
@@ -2061,7 +2439,7 @@ func (ds *DataStore) DeleteTuneInFavorite(stationID string) error {
return nil
}
err := os.Remove(ds.safeJoin("tunein", "favorites", stationID))
err := ds.rootRemove(ds.safeJoin("tunein", "favorites", stationID))
if os.IsNotExist(err) {
return nil
}
@@ -98,3 +98,155 @@ func TestSaveSources_Format(t *testing.T) {
t.Errorf("Sources.xml should not contain <sourceSettings> tag")
}
}
// TestGetConfiguredSources_MinimalAuxEntryNormalized covers the migration case from
// issue #195: the device's on-disk Sources.xml carries only displayName + sourceKey
// for AUX (no id, no type). When read back, the AUX entry must surface as the
// canonical id="10001" type="Audio" sourceproviderid="9", not synthesized values.
func TestGetConfiguredSources_MinimalAuxEntryNormalized(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-sources-min-aux-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := NewDataStore(tempDir)
account := "1234567"
device := "001122334455"
deviceDir := ds.AccountDeviceDir(account, device)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatal(err)
}
minimalSourcesXML := `<sources>
<source displayName="AUX IN" secret="">
<sourceKey type="AUX" account="AUX" />
</source>
</sources>`
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(minimalSourcesXML), 0644); err != nil {
t.Fatal(err)
}
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
t.Fatalf("GetConfiguredSources failed: %v", err)
}
if len(sources) != 1 {
t.Fatalf("expected 1 source, got %d", len(sources))
}
s := sources[0]
if s.ID != "10001" {
t.Errorf("expected canonical AUX id 10001, got %q", s.ID)
}
if s.Type != "Audio" {
t.Errorf("expected canonical AUX type 'Audio', got %q", s.Type)
}
if s.SourceKey.Type != "AUX" || s.SourceKey.Account != "AUX" {
t.Errorf("expected sourceKey type/account AUX/AUX, got %q/%q", s.SourceKey.Type, s.SourceKey.Account)
}
}
// TestGetConfiguredSources_DuplicateProviderUniqueIDs ensures that when a file
// contains multiple entries for the same SourceKey.Type (e.g. two AUX entries),
// only one gets the canonical ID; the rest fall back to synthesized IDs so they
// don't collide.
func TestGetConfiguredSources_DuplicateProviderUniqueIDs(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-sources-dup-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := NewDataStore(tempDir)
account := "1234567"
device := "001122334455"
deviceDir := ds.AccountDeviceDir(account, device)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatal(err)
}
dupXML := `<sources>
<source displayName="AUX IN" secret="">
<sourceKey type="AUX" account="AUX" />
</source>
<source displayName="AUX 2" secret="">
<sourceKey type="AUX" account="AUX" />
</source>
</sources>`
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(dupXML), 0644); err != nil {
t.Fatal(err)
}
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
t.Fatalf("GetConfiguredSources failed: %v", err)
}
if len(sources) != 2 {
t.Fatalf("expected 2 sources, got %d", len(sources))
}
if sources[0].ID == sources[1].ID {
t.Errorf("duplicate AUX entries must not share an ID, got %q for both", sources[0].ID)
}
// Both should still have Type repaired to the canonical "Audio".
for i, s := range sources {
if s.Type != "Audio" {
t.Errorf("source %d: expected Type 'Audio', got %q", i, s.Type)
}
}
}
// TestGetConfiguredSources_PoisonedAuxEntryRepaired covers the case where a previous
// version of the datastore already persisted bad synthesized values (type="AUX",
// id="2000001"). On read, those values must be repaired to the canonical defaults.
func TestGetConfiguredSources_PoisonedAuxEntryRepaired(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-sources-poisoned-aux-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := NewDataStore(tempDir)
account := "1234567"
device := "001122334455"
deviceDir := ds.AccountDeviceDir(account, device)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatal(err)
}
poisonedXML := `<sources>
<source displayName="AUX IN" id="2000001" secret="" secretType="" type="AUX">
<credential type=""></credential>
<sourceKey type="AUX" account="AUX"></sourceKey>
</source>
</sources>`
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(poisonedXML), 0644); err != nil {
t.Fatal(err)
}
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
t.Fatalf("GetConfiguredSources failed: %v", err)
}
if len(sources) != 1 {
t.Fatalf("expected 1 source, got %d", len(sources))
}
s := sources[0]
if s.Type != "Audio" {
t.Errorf("expected Type to be repaired to 'Audio', got %q", s.Type)
}
// ID repair is intentionally not aggressive — only empty IDs are filled
// from canonical defaults to avoid breaking references in recents/presets.
if s.ID != "2000001" {
t.Errorf("expected ID preserved as 2000001, got %q", s.ID)
}
}
+20 -2
View File
@@ -381,10 +381,28 @@ func TestMACMappingPerformance(t *testing.T) {
t.Logf(" Total mappings stored: %d (includes normalized versions)", totalMappings)
// Update is a pure in-memory map write; keep an absolute cap as a backstop
// against catastrophic regressions in that hot path.
if updateDuration > time.Millisecond*100 {
t.Errorf("Update performance too slow: %v", updateDuration)
}
if lookupDuration > time.Millisecond*70 {
t.Errorf("Lookup performance too slow: %v", lookupDuration)
// Lookup does up to two Stat() syscalls and is dominated by filesystem
// latency, which varies wildly on shared CI runners. Compare it to the
// in-memory update cost instead of an absolute bound: the ratio captures
// "lookup got disproportionately slower" (an algorithmic regression in the
// lookup path) while staying stable under uniform host slowdown.
if updateDuration <= 0 {
t.Fatalf("Update duration is non-positive (%v); cannot compute lookup/update ratio", updateDuration)
}
const maxLookupUpdateRatio = 30.0
ratio := float64(lookupDuration) / float64(updateDuration)
t.Logf(" Lookup/Update ratio: %.2fx (threshold %.0fx)", ratio, maxLookupUpdateRatio)
if ratio > maxLookupUpdateRatio {
t.Errorf("Lookup is %.2fx slower than update (>%.0fx threshold) — possible regression in AccountDeviceDir lookup path. Update=%v Lookup=%v",
ratio, maxLookupUpdateRatio, updateDuration, lookupDuration)
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ func TestHandleBMXRegistry_DNSDependent(t *testing.T) {
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
localURL := "https://soundtouch.local"
localURL := "https://127.0.0.1"
server := NewServer(ds, nil, localURL, false, false, false)
t.Run("DNSEnabled_UsesBoseURL", func(t *testing.T) {
@@ -25,6 +25,7 @@ func TestDNSSettingsValidation(t *testing.T) {
// Test Case 1: Enable DNS with empty upstream (should fallback to system DNS)
update := map[string]interface{}{
"server_url": "http://localhost:8001",
"dns_enabled": true,
"dns_upstream": "",
"dns_bind_addr": ":5353",
@@ -55,6 +56,7 @@ func TestDNSSettingsValidation(t *testing.T) {
// Test Case 2: Enable DNS with valid upstream
// Using a random port to avoid conflicts and ensure it's fast
updateValid := map[string]interface{}{
"server_url": "http://localhost:8001",
"dns_enabled": true,
"dns_upstream": "8.8.8.8",
"dns_bind_addr": "127.0.0.1:0", // Random port
+40 -9
View File
@@ -2,14 +2,38 @@ package handlers
import (
"fmt"
"html"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"github.com/russross/blackfriday/v2"
)
var (
docsRootOnce sync.Once
docsRoot *os.Root
)
// docsRootHandle returns a *os.Root anchored at the on-disk "docs" directory.
// All file reads from HandleDocs go through it so the Go runtime guarantees
// containment regardless of what HTTP path the caller sends — CodeQL also
// recognises *os.Root.* as a path-traversal sanitiser.
func docsRootHandle() *os.Root {
docsRootOnce.Do(func() {
r, err := os.OpenRoot("docs")
if err != nil {
// Fall back to nil; HandleDocs degrades to 404 below.
return
}
docsRoot = r
})
return docsRoot
}
// HandleDocs returns a handler for serving documentation files as HTML.
func (s *Server) HandleDocs(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/docs")
@@ -19,21 +43,23 @@ func (s *Server) HandleDocs(w http.ResponseWriter, r *http.Request) {
path = "guides/SURVIVAL-GUIDE.md"
}
// Ensure we only serve files from the docs directory
filePath := filepath.Join("docs", path)
if !strings.HasPrefix(filepath.Clean(filePath), "docs") {
http.Error(w, "Forbidden", http.StatusForbidden)
root := docsRootHandle()
if root == nil {
http.Error(w, "Documentation not available", http.StatusServiceUnavailable)
return
}
content, err := os.ReadFile(filePath)
content, err := root.ReadFile(path)
if err != nil {
// *os.Root.ReadFile rejects absolute paths and ".." segments at the
// runtime level, so any failure here is either "not found" or
// "traversal attempt blocked" — both 404 from the user's view.
http.Error(w, "File not found", http.StatusNotFound)
return
}
// Load sidebar (SUMMARY.md)
summaryContent, _ := os.ReadFile(filepath.Join("docs", "SUMMARY.md"))
summaryContent, _ := root.ReadFile("SUMMARY.md")
sidebar := ""
if len(summaryContent) > 0 {
@@ -52,7 +78,12 @@ func (s *Server) HandleDocs(w http.ResponseWriter, r *http.Request) {
// Render markdown to HTML
output := blackfriday.Run(content)
// Wrap in a documentation template with sidebar
// Wrap in a documentation template with sidebar. The user-supplied path
// is escaped before interpolation; the sidebar and rendered markdown
// output are server-controlled (loaded from local files) and may
// legitimately contain HTML.
titleSafe := html.EscapeString(path)
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprintf(w, `<!DOCTYPE html>
<html>
@@ -91,7 +122,7 @@ func (s *Server) HandleDocs(w http.ResponseWriter, r *http.Request) {
</div>
</div>
</body>
</html>`, path, sidebar, output)
</html>`, titleSafe, sidebar, output)
}
// fixSidebarLinks ensures that relative links in the SUMMARY.md (sidebar)
+26 -7
View File
@@ -289,13 +289,32 @@ func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) {
}
}
if deviceIP != "" {
go s.PrimeDeviceWithSpotify(deviceIP)
} else {
// Fallback to remote address if IP is missing from XML
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
go s.PrimeDeviceWithSpotify(host)
}
// Prefer the TCP source address over the body's self-reported IP for
// any outbound credential push. The body field is attacker-controllable
// (a malicious LAN-resident speaker can set it to any value), while
// r.RemoteAddr is the actual peer — and if the service runs behind a
// trusted reverse proxy, the TrustedRealIP middleware has already
// rewritten it from X-Real-IP / X-Forwarded-For. We log when the two
// disagree so the discrepancy is investigable but never trust the body.
remoteHost := ""
if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
remoteHost = h
}
if deviceIP != "" && remoteHost != "" && deviceIP != remoteHost {
log.Printf("[Marge] power_on body IP %q differs from TCP source %q for device %s — using TCP source for credential push",
deviceIP, remoteHost, deviceID)
}
target := remoteHost
if target == "" {
// RemoteAddr was unparseable (shouldn't happen under net/http) —
// fall back to the body so we don't silently skip the push.
target = deviceIP
}
if target != "" {
go s.PrimeDeviceWithSpotify(target)
}
w.WriteHeader(http.StatusOK)
+7 -2
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"html"
"io"
"log"
"net/http"
@@ -144,7 +145,9 @@ func (s *Server) HandleMgmtSpotifyCallback(w http.ResponseWriter, r *http.Reques
if errMsg := r.URL.Query().Get("error"); errMsg != "" {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`<html><body><h1>Spotify Authorization Failed</h1><p>Error: ` + errMsg + `</p></body></html>`))
// html.EscapeString neutralises any HTML metacharacters in the
// caller-supplied error string before it lands in the response.
_, _ = w.Write([]byte(`<html><body><h1>Spotify Authorization Failed</h1><p>Error: ` + html.EscapeString(errMsg) + `</p></body></html>`))
return
}
@@ -487,7 +490,9 @@ func (s *Server) HandleMgmtAmazonCallback(w http.ResponseWriter, r *http.Request
if errMsg := r.URL.Query().Get("error"); errMsg != "" {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`<html><body><h1>Amazon Authorization Failed</h1><p>Error: ` + errMsg + `</p></body></html>`))
// html.EscapeString neutralises any HTML metacharacters in the
// caller-supplied error string before it lands in the response.
_, _ = w.Write([]byte(`<html><body><h1>Amazon Authorization Failed</h1><p>Error: ` + html.EscapeString(errMsg) + `</p></body></html>`))
return
}
+138
View File
@@ -0,0 +1,138 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/go-chi/chi/v5"
)
// accountIDSuggestionsResponse is the body of GET /setup/account-id-suggestions/{deviceId}.
// `current` is the device's existing margeAccountUUID (empty when the device is fresh / factory-reset).
// `known` is the list of accountIDs already present in the local datastore, so the UI can offer
// the user a way to re-attach a fresh device to an existing account.
type accountIDSuggestionsResponse struct {
Current string `json:"current"`
Known []string `json:"known"`
}
// HandleAccountIDSuggestions returns the device's current account ID (from
// :8090/info, empty if unset) plus the list of account IDs already present
// in the local datastore.
func (s *Server) HandleAccountIDSuggestions(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
writeJSONError(w, http.StatusBadRequest, "Device ID is required")
return
}
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
if err != nil {
writeJSONError(w, http.StatusNotFound, err.Error())
return
}
resp := accountIDSuggestionsResponse{}
if info, err := s.sm.GetLiveDeviceInfo(deviceIP); err == nil {
resp.Current = info.MargeAccountUUID
}
if known, err := s.ds.ListAccounts(); err == nil {
resp.Known = known
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// pairAccountResponse is the body of POST /setup/pair-account/{deviceId}.
type pairAccountResponse struct {
OK bool `json:"ok"`
Result setup.PairAccountResult `json:"result"`
Output string `json:"output"`
Error string `json:"error,omitempty"`
}
// HandlePairAccount associates the device with the supplied 7-digit account ID,
// trying HTTP /setMargeAccount first and falling back to telnet
// `envswitch accountid set`.
//
// Query params:
// - account_id (required) — must pass setup.IsValidAccountID
func (s *Server) HandlePairAccount(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
writeJSONError(w, http.StatusBadRequest, "Device ID is required")
return
}
accountID := r.URL.Query().Get("account_id")
if !setup.IsValidAccountID(accountID) {
writeJSONError(w, http.StatusBadRequest, "account_id must be exactly 7 digits")
return
}
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
if err != nil {
writeJSONError(w, http.StatusNotFound, err.Error())
return
}
var t setup.TelnetClient
if s.sm.NewTelnet != nil {
t = s.sm.NewTelnet(deviceIP)
if dialErr := t.Dial(); dialErr != nil {
// Telnet not reachable — fall through with t=nil so PairAccount
// can decide based on HTTP availability alone.
t = nil
} else {
defer func() { _ = t.Close() }()
}
}
result, output, err := s.sm.PairAccount(deviceIP, accountID, t)
w.Header().Set("Content-Type", "application/json")
body := pairAccountResponse{
OK: err == nil,
Result: result,
Output: output,
}
if err != nil {
body.Error = err.Error()
w.WriteHeader(http.StatusInternalServerError)
}
if encErr := json.NewEncoder(w).Encode(body); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// jsonErrorBody is the static shape of error responses from this file.
// Avoiding map[string]interface{} keeps errchkjson satisfied: the typed
// struct guarantees encoding can't fail with a runtime type error.
type jsonErrorBody struct {
OK bool `json:"ok"`
Message string `json:"message"`
}
// writeJSONError is a small helper for the handlers in this file to keep
// error wiring out of the happy path. It mirrors what the rest of the
// package does inline.
func writeJSONError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(jsonErrorBody{OK: false, Message: message}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
@@ -0,0 +1,66 @@
package handlers
import (
"encoding/json"
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
// peerProbeTimeout caps how long the passive observer waits for any
// inbound from the device IP after the :8090/swUpdateCheck nudge. 30s
// is comfortable for daemon wake-up latency on slow devices while still
// keeping the panel responsive; result.ElapsedMs surfaces the actual
// observed latency so the budget can be tuned from real data.
const peerProbeTimeout = 30 * time.Second
// peerProbeResponse is the body of POST /setup/peer-probe/{deviceId}.
type peerProbeResponse struct {
OK bool `json:"ok"`
Result any `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// HandlePeerProbe runs the post-migration passive reachability check.
// Registers interest in the device's IP, nudges :8090/swUpdateCheck,
// and reports whether any inbound from that IP landed within
// peerProbeTimeout. Any inbound counts — on a migrated speaker, DNS
// interception routes the daemon's outbounds (update fan-out, marge,
// BMX) through this service regardless of which URL the daemon
// resolved internally, so the question reduces to "did the device
// dial us at all."
//
// Unlike the deprecated round-trip probe, this handler does not mutate
// device state. It presupposes the speaker is already migrated; the
// pre-flight orchestrator is responsible for only calling it in that
// state.
func (s *Server) HandlePeerProbe(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
writeJSONError(w, http.StatusBadRequest, "Device ID is required")
return
}
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
if err != nil {
writeJSONError(w, http.StatusNotFound, err.Error())
return
}
result, err := s.sm.RunPeerReachabilityProbe(deviceIP, s.peerObserver, peerProbeTimeout)
w.Header().Set("Content-Type", "application/json")
body := peerProbeResponse{
OK: err == nil && result != nil && result.Reached,
Result: result,
}
if err != nil {
body.Error = err.Error()
}
if err := json.NewEncoder(w).Encode(body); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
+8 -1
View File
@@ -62,6 +62,13 @@ func (s *Server) ServeProxy(target *url.URL) http.HandlerFunc {
r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
}
// AllowInsecureUpstreamTLS is opt-in via settings.json — defaults to
// false so the upstream certificate chain is verified normally. The
// opt-in exists for deployments stuck behind a broken Bose-cloud
// chain post end-of-service.
settings, _ := s.ds.GetSettings()
insecure := settings.AllowInsecureUpstreamTLS
rp := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(target)
@@ -77,7 +84,7 @@ func (s *Server) ServeProxy(target *url.URL) http.HandlerFunc {
lp.LogRequest(pr.Out)
},
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
},
}
+50 -41
View File
@@ -172,6 +172,14 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
dnsRunning, actualBind := s.GetDNSRunning()
var serverURLResolvedIP, serverURLResolveError string
if ip, err := s.resolveServerURLIP(serverURL); err == nil {
serverURLResolvedIP = ip
} else {
serverURLResolveError = err.Error()
}
// Mask secrets: return "***" if set so the UI can show "configured" without exposing the value.
if spotifyClientSecret != "" {
spotifyClientSecret = "***"
@@ -182,32 +190,34 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
}
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"server_url": serverURL,
"https_server_url": httpsServerURL,
"discovery_interval": discoveryInterval,
"discovery_enabled": discoveryEnabled,
"dns_enabled": dnsEnabled,
"dns_running": dnsRunning,
"dns_actual_bind": actualBind,
"dns_upstream": strings.Join(dnsUpstream, ","),
"dns_bind_addr": dnsBindAddr,
"mirror_enabled": mirrorEnabled,
"mirror_endpoints": mirrorEndpoints,
"skip_mirror_endpoints": skipMirrorEndpoints,
"preferred_source": preferredSource,
"internal_paths": internalPaths,
"redact_logs": redact,
"log_bodies": logBody,
"record_interactions": record,
"shortcuts": shortcuts,
"spotify_configured": spotifyConfigured,
"spotify_client_id": spotifyClientID,
"spotify_client_secret": spotifyClientSecret,
"spotify_redirect_uri": spotifyRedirectURI,
"amazon_configured": amazonConfigured,
"amazon_client_id": amazonClientID,
"amazon_client_secret": amazonClientSecret,
"amazon_redirect_uri": amazonRedirectURI,
"server_url": serverURL,
"server_url_resolved_ip": serverURLResolvedIP,
"server_url_resolve_error": serverURLResolveError,
"https_server_url": httpsServerURL,
"discovery_interval": discoveryInterval,
"discovery_enabled": discoveryEnabled,
"dns_enabled": dnsEnabled,
"dns_running": dnsRunning,
"dns_actual_bind": actualBind,
"dns_upstream": strings.Join(dnsUpstream, ","),
"dns_bind_addr": dnsBindAddr,
"mirror_enabled": mirrorEnabled,
"mirror_endpoints": mirrorEndpoints,
"skip_mirror_endpoints": skipMirrorEndpoints,
"preferred_source": preferredSource,
"internal_paths": internalPaths,
"redact_logs": redact,
"log_bodies": logBody,
"record_interactions": record,
"shortcuts": shortcuts,
"spotify_configured": spotifyConfigured,
"spotify_client_id": spotifyClientID,
"spotify_client_secret": spotifyClientSecret,
"spotify_redirect_uri": spotifyRedirectURI,
"amazon_configured": amazonConfigured,
"amazon_client_id": amazonClientID,
"amazon_client_secret": amazonClientSecret,
"amazon_redirect_uri": amazonRedirectURI,
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
@@ -247,6 +257,15 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
log.Printf("[DNS] DNS Discovery enabled without explicit upstreams, will try system DNS.")
}
// Validate server_url: the same value the DNS server uses to derive its
// intercept IP. Reject anything that does not resolve to a routable IP so
// users see the error in the UI instead of getting a silently-broken setup
// where DNS replies with `CNAME .` for every Bose hostname.
if _, err := s.resolveServerURLIP(settings.ServerURL); err != nil {
http.Error(w, "Invalid server_url: "+err.Error(), http.StatusBadRequest)
return
}
interval, err := time.ParseDuration(settings.DiscoveryInterval)
if err != nil && settings.DiscoveryInterval != "" {
http.Error(w, "Invalid discovery interval: "+err.Error(), http.StatusBadRequest)
@@ -411,13 +430,7 @@ func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Reques
targetURL := r.URL.Query().Get("target_url")
proxyURL := r.URL.Query().Get("proxy_url")
options := make(map[string]string)
for k, v := range r.URL.Query() {
if len(v) > 0 && (k == "marge" || k == "stats" || k == "sw_update" || k == "bmx") {
options[k] = v[0]
}
}
options := parseMigrationOptions(r.URL.Query())
summary, err := s.sm.GetMigrationSummary(deviceIP, targetURL, proxyURL, options)
if err != nil {
@@ -465,13 +478,7 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
proxyURL := r.URL.Query().Get("proxy_url")
method := setup.MigrationMethod(r.URL.Query().Get("method"))
options := make(map[string]string)
for k, v := range r.URL.Query() {
if len(v) > 0 && (k == "marge" || k == "stats" || k == "sw_update" || k == "bmx") {
options[k] = v[0]
}
}
options := parseMigrationOptions(r.URL.Query())
output, err := s.sm.MigrateSpeaker(deviceIP, targetURL, proxyURL, options, method)
if err != nil {
@@ -1066,7 +1073,9 @@ func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) {
return
}
output, err := s.sm.Reboot(deviceIP)
method := setup.RebootMethod(r.URL.Query().Get("method"))
output, err := s.sm.Reboot(deviceIP, method)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
+3 -3
View File
@@ -101,7 +101,7 @@ func TestProxySettingsAPI(t *testing.T) {
// 3. Test System Settings POST
sysUpdate := map[string]string{
"server_url": "http://new-server:8000",
"server_url": "http://127.0.0.1:8000",
}
sysBody, err := json.Marshal(sysUpdate)
@@ -122,13 +122,13 @@ func TestProxySettingsAPI(t *testing.T) {
// Verify server state
sURL, _ := server.GetSettings()
if sURL != "http://new-server:8000" {
if sURL != "http://127.0.0.1:8000" {
t.Errorf("POST /setup/settings: Server state did not update: serverURL=%s", sURL)
}
// 4. Test Mirror Settings persistence
mirrorUpdate := map[string]interface{}{
"server_url": "http://mirror-test:8000",
"server_url": "http://127.0.0.1:8000",
"mirror_enabled": true,
"mirror_endpoints": []string{"/test/*"},
"internal_paths": []string{"/setup/*"},
+2
View File
@@ -127,6 +127,8 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
r.Post("/revert/{deviceId}", server.HandleRevertMigration)
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
r.Get("/account-id-suggestions/{deviceId}", server.HandleAccountIDSuggestions)
r.Post("/pair-account/{deviceId}", server.HandlePairAccount)
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
@@ -0,0 +1,31 @@
package handlers
import (
"net"
"net/http"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
)
// PeerObserverMiddleware records every incoming request's source IP and
// path in the peerObserver registry. It fires on every request before
// the handler runs, so passive reachability probes can register a device
// IP and learn whether any inbound landed in their wait window.
//
// Placement: after TrustedRealIPMiddleware (so r.RemoteAddr reflects the
// trusted client IP) and after Recoverer (so any panic inside this
// middleware is contained). Before any short-circuiting middleware
// would be unnecessary — Signal runs before next.ServeHTTP, so the
// observation lands regardless of how later middleware handles the
// request.
func (s *Server) PeerObserverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil && host != "" {
s.peerObserver.Signal(host, setup.PeerHit{Path: r.URL.Path, At: time.Now()})
}
next.ServeHTTP(w, r)
})
}
+95
View File
@@ -0,0 +1,95 @@
package handlers
import (
"fmt"
"net"
"net/http"
"github.com/go-chi/chi/v5/middleware"
)
// defaultTrustedProxyCIDRs is the safe-by-default list applied when
// Settings.TrustedProxyCIDRs is empty. Only loopback addresses are trusted —
// i.e. a reverse proxy on the same host. Anyone deploying behind a proxy on a
// different host must override this in settings.json.
var defaultTrustedProxyCIDRs = []string{
"127.0.0.0/8",
"::1/128",
}
// TrustedRealIP returns a middleware that delegates to chi's RealIP — which
// rewrites r.RemoteAddr from True-Client-IP / X-Real-IP / X-Forwarded-For
// headers — but only when the immediate TCP peer is in `trustedPeers`. For
// any request whose peer is *not* trusted (i.e. anything other than the
// configured reverse proxy), the headers are ignored and r.RemoteAddr stays
// as-is.
//
// This avoids the standard X-Forwarded-* spoofing pitfall: on a flat LAN
// where a malicious speaker could send the headers itself, we won't honour
// them; behind a reverse proxy we will.
//
// Returns nil if trustedPeers is empty — caller should not Use a nil mw.
func TrustedRealIP(trustedPeers []*net.IPNet) func(http.Handler) http.Handler {
if len(trustedPeers) == 0 {
return nil
}
delegate := middleware.RealIP
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isFromTrustedPeer(r.RemoteAddr, trustedPeers) {
delegate(next).ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
})
}
}
// isFromTrustedPeer reports whether remoteAddr (in the host:port shape that
// net/http populates) is contained in any of the supplied CIDR blocks.
func isFromTrustedPeer(remoteAddr string, trustedPeers []*net.IPNet) bool {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
host = remoteAddr
}
ip := net.ParseIP(host)
if ip == nil {
return false
}
for _, n := range trustedPeers {
if n.Contains(ip) {
return true
}
}
return false
}
// ParseTrustedProxyCIDRs converts string CIDRs into *net.IPNet values, falling
// back to defaultTrustedProxyCIDRs when the input is empty. An invalid CIDR
// in the input list is reported as an error and stops parsing — better to
// fail loud than silently fall back.
func ParseTrustedProxyCIDRs(cidrs []string) ([]*net.IPNet, error) {
if len(cidrs) == 0 {
cidrs = defaultTrustedProxyCIDRs
}
out := make([]*net.IPNet, 0, len(cidrs))
for _, c := range cidrs {
_, n, err := net.ParseCIDR(c)
if err != nil {
return nil, fmt.Errorf("invalid trusted proxy CIDR %q: %w", c, err)
}
out = append(out, n)
}
return out, nil
}
@@ -0,0 +1,155 @@
package handlers
import (
"net"
"net/http"
"net/http/httptest"
"testing"
)
func TestTrustedRealIP(t *testing.T) {
cidrs, err := ParseTrustedProxyCIDRs([]string{"127.0.0.0/8", "::1/128"})
if err != nil {
t.Fatalf("ParseTrustedProxyCIDRs: %v", err)
}
mw := TrustedRealIP(cidrs)
if mw == nil {
t.Fatal("TrustedRealIP returned nil for non-empty trustedPeers")
}
cases := []struct {
name string
remoteAddr string
xRealIP string
xForwardedFor string
wantRemoteAddr string
}{
{
name: "trusted peer with X-Real-IP is honoured",
remoteAddr: "127.0.0.1:54321",
xRealIP: "192.168.1.10",
wantRemoteAddr: "192.168.1.10",
},
{
name: "trusted peer with X-Forwarded-For is honoured",
remoteAddr: "127.0.0.1:54321",
xForwardedFor: "192.168.1.20, 10.0.0.1",
wantRemoteAddr: "192.168.1.20",
},
{
name: "trusted peer with no headers leaves RemoteAddr alone",
remoteAddr: "127.0.0.1:54321",
wantRemoteAddr: "127.0.0.1:54321",
},
{
name: "untrusted peer's X-Real-IP is ignored",
remoteAddr: "192.168.1.99:54321",
xRealIP: "1.2.3.4",
wantRemoteAddr: "192.168.1.99:54321",
},
{
name: "untrusted peer's X-Forwarded-For is ignored",
remoteAddr: "192.168.1.99:54321",
xForwardedFor: "1.2.3.4",
wantRemoteAddr: "192.168.1.99:54321",
},
{
name: "trusted peer with garbage X-Real-IP leaves RemoteAddr alone",
remoteAddr: "127.0.0.1:54321",
xRealIP: "not-an-ip",
wantRemoteAddr: "127.0.0.1:54321",
},
{
name: "trusted IPv6 loopback peer is honoured",
remoteAddr: "[::1]:54321",
xRealIP: "fe80::1",
wantRemoteAddr: "fe80::1",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var got string
h := mw(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
got = r.RemoteAddr
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = tc.remoteAddr
if tc.xRealIP != "" {
req.Header.Set("X-Real-IP", tc.xRealIP)
}
if tc.xForwardedFor != "" {
req.Header.Set("X-Forwarded-For", tc.xForwardedFor)
}
h.ServeHTTP(httptest.NewRecorder(), req)
if got != tc.wantRemoteAddr {
t.Errorf("RemoteAddr = %q, want %q", got, tc.wantRemoteAddr)
}
})
}
}
func TestTrustedRealIP_NilForEmptyPeers(t *testing.T) {
if mw := TrustedRealIP(nil); mw != nil {
t.Error("TrustedRealIP(nil) returned non-nil; expected nil so caller can skip Use()")
}
if mw := TrustedRealIP([]*net.IPNet{}); mw != nil {
t.Error("TrustedRealIP([]) returned non-nil; expected nil so caller can skip Use()")
}
}
func TestParseTrustedProxyCIDRs(t *testing.T) {
t.Run("empty input yields loopback default", func(t *testing.T) {
got, err := ParseTrustedProxyCIDRs(nil)
if err != nil {
t.Fatalf("ParseTrustedProxyCIDRs: %v", err)
}
if len(got) != 2 {
t.Fatalf("default CIDR count = %d, want 2 (127/8 + ::1/128)", len(got))
}
// Should contain 127.0.0.1 and ::1.
if !isFromTrustedPeer("127.0.0.1:1", got) {
t.Error("default CIDRs should include 127.0.0.1")
}
if !isFromTrustedPeer("[::1]:1", got) {
t.Error("default CIDRs should include ::1")
}
})
t.Run("custom CIDRs override defaults", func(t *testing.T) {
got, err := ParseTrustedProxyCIDRs([]string{"10.0.0.0/8"})
if err != nil {
t.Fatalf("ParseTrustedProxyCIDRs: %v", err)
}
if len(got) != 1 {
t.Errorf("custom CIDR count = %d, want 1", len(got))
}
if !isFromTrustedPeer("10.1.2.3:1", got) {
t.Error("10.1.2.3 should be in 10.0.0.0/8")
}
if isFromTrustedPeer("127.0.0.1:1", got) {
t.Error("127.0.0.1 should NOT match when default is overridden")
}
})
t.Run("invalid CIDR returns error", func(t *testing.T) {
_, err := ParseTrustedProxyCIDRs([]string{"not-a-cidr"})
if err == nil {
t.Fatal("expected error on invalid CIDR")
}
})
}
+45
View File
@@ -0,0 +1,45 @@
package handlers
import "net/url"
// migrationOptionKeys is the allow-list of query parameters carried into
// the migration manager's options map. Two families coexist:
//
// - marge / stats / sw_update / bmx — the XML method's per-field
// "self | proxied | original" implementation selectors.
// - marge_url / stats_url / sw_update_url / bmx_url — the telnet
// method's per-field URL overrides (default: derive from target_url).
//
// Unrecognised keys are dropped so the manager never sees query
// parameters it did not opt into.
var migrationOptionKeys = map[string]struct{}{
"marge": {},
"stats": {},
"sw_update": {},
"bmx": {},
"marge_url": {},
"stats_url": {},
"sw_update_url": {},
"bmx_url": {},
}
// parseMigrationOptions copies the recognised keys from query into a
// fresh map. Empty values are preserved as empty strings so the caller
// can distinguish "explicitly cleared" from "not set" if it ever needs
// to; the setup package's telnetURLsFromOptions treats empty as "use
// default", which is the desired UI behaviour today.
func parseMigrationOptions(query url.Values) map[string]string {
out := make(map[string]string, len(migrationOptionKeys))
for k, v := range query {
if _, ok := migrationOptionKeys[k]; !ok {
continue
}
if len(v) > 0 {
out[k] = v[0]
}
}
return out
}
@@ -0,0 +1,71 @@
package handlers
import (
"net/url"
"reflect"
"testing"
)
func TestParseMigrationOptions_AllowsXMLAndTelnetKeys(t *testing.T) {
q := url.Values{
"marge": []string{"self"},
"stats": []string{"proxied"},
"sw_update": []string{"original"},
"bmx": []string{"self"},
"marge_url": []string{"http://example:8000/marge"},
"stats_url": []string{"http://example:8000"},
"sw_update_url": []string{"http://example:8000/updates/soundtouch"},
"bmx_url": []string{"http://example:8000/bmx/registry/v1/services"},
}
got := parseMigrationOptions(q)
want := map[string]string{
"marge": "self",
"stats": "proxied",
"sw_update": "original",
"bmx": "self",
"marge_url": "http://example:8000/marge",
"stats_url": "http://example:8000",
"sw_update_url": "http://example:8000/updates/soundtouch",
"bmx_url": "http://example:8000/bmx/registry/v1/services",
}
if !reflect.DeepEqual(got, want) {
t.Errorf("parseMigrationOptions = %v\nwant %v", got, want)
}
}
func TestParseMigrationOptions_DropsUnknownKeys(t *testing.T) {
q := url.Values{
"marge": []string{"self"},
"target_url": []string{"http://example:8000"}, // not an option
"method": []string{"telnet"}, // not an option
"random": []string{"value"}, // attacker-controlled noise
}
got := parseMigrationOptions(q)
if _, ok := got["target_url"]; ok {
t.Errorf("target_url leaked into options map: %v", got)
}
if _, ok := got["method"]; ok {
t.Errorf("method leaked into options map: %v", got)
}
if _, ok := got["random"]; ok {
t.Errorf("random key leaked into options map: %v", got)
}
if got["marge"] != "self" {
t.Errorf("marge = %q, want self", got["marge"])
}
}
func TestParseMigrationOptions_EmptyQueryReturnsEmptyMap(t *testing.T) {
got := parseMigrationOptions(url.Values{})
if len(got) != 0 {
t.Errorf("got %v, want empty map", got)
}
}
+22 -4
View File
@@ -271,6 +271,12 @@ func (s *Server) performMirror(r *http.Request) *mirrorResponseRecorder {
return nil
}
// AllowInsecureUpstreamTLS is opt-in via settings.json — defaults to
// false so verification stays on. The opt-in exists for deployments
// stuck behind a broken Bose-cloud certificate chain post EOS.
settings, _ := s.ds.GetSettings()
insecure := settings.AllowInsecureUpstreamTLS
// Create a proxy that doesn't write to the original ResponseWriter
proxy := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
@@ -279,7 +285,7 @@ func (s *Server) performMirror(r *http.Request) *mirrorResponseRecorder {
pr.Out.Header.Set("X-Mirror-Request", "true")
},
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
},
}
@@ -450,10 +456,22 @@ func (s *Server) saveParityMismatch(req *http.Request, local, upstream *mirrorRe
}
dir := filepath.Join(s.ds.DataDir, "parity_mismatches")
_ = os.MkdirAll(dir, 0755)
_ = s.ds.MkdirAllUnderBase(dir, 0755)
filename := fmt.Sprintf("%d_%s.json", time.Now().Unix(), strings.ReplaceAll(req.URL.Path, "/", "_"))
_ = os.WriteFile(filepath.Join(dir, filename), data, 0644)
// Build a single filename component from req.URL.Path. After replacing
// the obvious separators, gate on filepath.IsLocal so a malicious path
// containing ".." or platform-specific separators we missed cannot
// escape `dir`. The write itself goes through DataStore's *os.Root so
// the runtime enforces containment regardless of what's in pathSegment.
pathSegment := strings.ReplaceAll(req.URL.Path, "/", "_")
pathSegment = strings.ReplaceAll(pathSegment, "\\", "_")
if !filepath.IsLocal(pathSegment) {
pathSegment = "invalid"
}
filename := fmt.Sprintf("%d_%s.json", time.Now().Unix(), pathSegment)
_ = s.ds.WriteFileUnderBase(filepath.Join(dir, filename), data, 0644)
}
type mirrorResponseRecorder struct {
@@ -134,6 +134,7 @@ func TestSettingsAPI_PreferredSource(t *testing.T) {
// Test UPDATE
update := map[string]interface{}{
"server_url": "http://localhost:8000",
"preferred_source": "upstream",
}
body, err := json.Marshal(update)
+72
View File
@@ -0,0 +1,72 @@
package handlers
import (
"sync"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
)
// peerObserver is the rendezvous between the passive reachability probe
// (which registers interest in a device IP and waits for any inbound)
// and the chi middleware (which signals on every request whose source
// IP matches a registration).
//
// Unlike probeRegistry, which keys on a unique per-probe token, this
// observer keys on the device's IP — the probe doesn't mutate device
// state, so there's no token to thread through the request path. Any
// inbound from the IP counts as proof of reachability.
//
// PeerHit and the abstract handle interface live in the setup package
// alongside the probe logic; this type implements that interface.
type peerObserver struct {
mu sync.Mutex
pending map[string]chan setup.PeerHit
}
func newPeerObserver() *peerObserver {
return &peerObserver{pending: make(map[string]chan setup.PeerHit)}
}
// Register creates a one-shot buffered channel keyed by IP. The buffer
// of 1 lets the middleware deliver the first hit and silently drop
// subsequent hits during the wait window without blocking. Caller is
// responsible for pairing every Register with Forget.
func (o *peerObserver) Register(ip string) <-chan setup.PeerHit {
o.mu.Lock()
defer o.mu.Unlock()
ch := make(chan setup.PeerHit, 1)
o.pending[ip] = ch
return ch
}
// Signal delivers a hit to the channel for ip, non-blocking. Returns
// true when a matching registration existed AND the hit was delivered
// (i.e. the channel had buffer space — first hit during the window).
// Subsequent hits during the same window return false without blocking.
func (o *peerObserver) Signal(ip string, hit setup.PeerHit) bool {
o.mu.Lock()
defer o.mu.Unlock()
ch, ok := o.pending[ip]
if !ok {
return false
}
select {
case ch <- hit:
return true
default:
return false
}
}
// Forget removes the entry. Safe to call regardless of whether a hit
// landed — does not affect already-returned channels.
func (o *peerObserver) Forget(ip string) {
o.mu.Lock()
defer o.mu.Unlock()
delete(o.pending, ip)
}
@@ -0,0 +1,71 @@
package handlers
import (
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
)
func TestPeerObserver_RegisterSignalForget(t *testing.T) {
o := newPeerObserver()
ch := o.Register("192.168.1.42")
if ch == nil {
t.Fatal("Register returned nil channel")
}
first := setup.PeerHit{Path: "/updates/soundtouch", At: time.Now()}
if !o.Signal("192.168.1.42", first) {
t.Error("Signal returned false for registered IP")
}
// Second signal while the buffer is still full (no reader yet) drops
// silently and returns false — only the first hit per window matters.
if o.Signal("192.168.1.42", setup.PeerHit{Path: "/streaming/x"}) {
t.Error("second Signal returned true; expected false (buffer full, undrained)")
}
select {
case got := <-ch:
if got.Path != first.Path {
t.Errorf("hit.Path = %q, want %q", got.Path, first.Path)
}
case <-time.After(100 * time.Millisecond):
t.Error("Signal did not deliver hit to channel")
}
o.Forget("192.168.1.42")
// After Forget, Signal returns false.
if o.Signal("192.168.1.42", first) {
t.Error("Signal returned true after Forget")
}
}
func TestPeerObserver_UnknownIP(t *testing.T) {
o := newPeerObserver()
if o.Signal("10.0.0.1", setup.PeerHit{Path: "/anything"}) {
t.Error("Signal returned true for unregistered IP")
}
}
func TestPeerObserver_SignalIsNonBlocking(t *testing.T) {
o := newPeerObserver()
o.Register("192.168.1.42") // never drain
done := make(chan struct{})
go func() {
for i := 0; i < 100; i++ {
o.Signal("192.168.1.42", setup.PeerHit{Path: "/x"})
}
close(done)
}()
select {
case <-done:
// Signal never blocked even with no reader and a full buffer.
case <-time.After(500 * time.Millisecond):
t.Fatal("Signal blocked when buffer was full — must drop silently")
}
}
+88 -7
View File
@@ -61,6 +61,7 @@ type Server struct {
amazonClientSecret string
amazonRedirectURI string
amazonService *amazon.Service
peerObserver *peerObserver
}
// RequestSnapshot represents an immutable snapshot of an HTTP request.
@@ -95,11 +96,41 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, pro
recordEnabled: recordEnabled,
discoveryInterval: 5 * time.Minute,
discoveryEnabled: true,
peerObserver: newPeerObserver(),
}
return s
}
// TrustedRealIPMiddleware returns a chi middleware that rewrites
// r.RemoteAddr from X-Real-IP / X-Forwarded-For / True-Client-IP, but only
// when the immediate TCP peer is in the configured trusted-proxy list.
// Returns nil when Settings.TrustForwardedHeaders is false (the safe
// default), so the caller can skip wiring the middleware entirely.
//
// The trusted-peer gate prevents the typical X-Forwarded-* spoofing surface:
// on a flat LAN where a malicious speaker could send the headers itself, we
// won't honour them; behind a documented reverse proxy on loopback we will.
func (s *Server) TrustedRealIPMiddleware() func(http.Handler) http.Handler {
settings, err := s.ds.GetSettings()
if err != nil {
log.Printf("[RealIP] failed to load settings: %v — skipping forwarded-header trust", err)
return nil
}
if !settings.TrustForwardedHeaders {
return nil
}
cidrs, err := ParseTrustedProxyCIDRs(settings.TrustedProxyCIDRs)
if err != nil {
log.Printf("[RealIP] invalid trusted_proxy_cidrs: %v — skipping forwarded-header trust", err)
return nil
}
return TrustedRealIP(cidrs)
}
// SetVersionInfo sets the version information for the server.
func (s *Server) SetVersionInfo(version, commit, date, repoURL string) {
s.mu.Lock()
@@ -207,18 +238,68 @@ func (s *Server) SetDNSSettings(enabled bool, upstream, bind string) {
}
}
// resolveServerURLIP returns the IP that the DNS server would hand out as the
// intercept answer for the given server URL. An empty URL, empty hostname, or a
// hostname that cannot be resolved to an IP is reported as an error so callers
// can refuse to start (or reject user input) instead of silently degrading.
// "localhost" is treated as 127.0.0.1.
func (s *Server) resolveServerURLIP(serverURL string) (string, error) {
if strings.TrimSpace(serverURL) == "" {
return "", fmt.Errorf("server URL is empty")
}
u, err := url.Parse(serverURL)
if err != nil {
return "", fmt.Errorf("invalid server URL %q: %w", serverURL, err)
}
hostname := u.Hostname()
if hostname == "" {
return "", fmt.Errorf("server URL %q has no hostname", serverURL)
}
if hostname == "localhost" {
return "127.0.0.1", nil
}
if ip := net.ParseIP(hostname); ip != nil {
return ip.String(), nil
}
// Prefer the setup manager's resolver (it cascades through device SSH ping
// then system DNS). Fall back to plain system DNS when no manager is wired,
// so this works in tests and lightweight server constructions.
if s.sm != nil {
if resolved := s.sm.GetResolvedIP(hostname); net.ParseIP(resolved) != nil {
return resolved, nil
}
} else if ips, lookupErr := net.LookupIP(hostname); lookupErr == nil {
for _, ip := range ips {
if v4 := ip.To4(); v4 != nil {
return v4.String(), nil
}
}
if len(ips) > 0 {
return ips[0].String(), nil
}
}
return "", fmt.Errorf("hostname %q did not resolve to an IP — "+
"set the server URL to an IP, or to a hostname this host can resolve",
hostname)
}
func (s *Server) startDNSDiscovery(bind string, upstreamList []string) {
log.Printf("[DNS] Starting DNS discovery server on %s", bind)
u, _ := url.Parse(s.serverURL)
serviceIP, err := s.resolveServerURLIP(s.serverURL)
if err != nil {
log.Printf("[DNS] Cannot start DNS discovery server: %v", err)
serviceIP := u.Hostname()
if serviceIP == "localhost" || serviceIP == "" {
serviceIP = "127.0.0.1"
}
s.dnsEnabled = false
if s.sm != nil {
serviceIP = s.sm.GetResolvedIP(serviceIP)
return
}
s.dnsDiscovery = discovery.NewDNSDiscovery(upstreamList, serviceIP)
+417 -302
View File
@@ -53,34 +53,36 @@
<h3>Migration Process at a Glance</h3>
<div class="info-box prerequisite-box">
<strong>🔌 Prerequisite: Enable SSH</strong><br/>
Migration requires SSH access. To enable it:
<ol style="margin-top: 5px; margin-bottom: 5px">
<li>
Create an empty file named
<code>remote_services</code> on a USB stick.
</li>
<li>
Insert it into the speaker's
<strong>SERVICE</strong> port and reboot the
speaker.
</li>
</ol>
<strong>Verify connection:</strong>
<strong>🔌 Speaker shell access</strong><br/>
The wizard talks to the speaker over one of two transports.
The <strong>Migration</strong> tab probes both automatically
and uses whichever your device exposes — you don't have to
choose manually.
<ul style="margin-top: 5px; margin-bottom: 0; padding-left: 20px;">
<li>
Use the <strong>Migration</strong> tab to select
your device and verify that
<em>SSH Connection</em> shows ✅ Success.
</li>
<li>
Or manually:
<strong>SSH</strong> (richest option — required for
the XML migration, the <code>/etc/resolv.conf</code>
DNS hook, and installing the local CA). Enable it by
creating an empty <code>remote_services</code> file
on a USB stick, inserting it into the speaker's
<strong>SERVICE</strong> port, and rebooting. Verify
on the Migration tab — <em>SSH</em> in the state
card's <em>Transports</em> row should show ✅
Reachable. Manual check:
<code
>ssh -oHostKeyAlgorithms=+ssh-rsa
root@&lt;SPEAKER-IP&gt;</code
>
(no password).
</li>
<li>
<strong>Telnet (Port 17000)</strong> — the SSH-less
fallback. Most SoundTouch firmware exposes a
diagnostic shell on TCP/17000 automatically, no
USB-stick setup required. Limited to HTTP migrations
(no CA install possible without SSH). The state card
surfaces this in the same <em>Transports</em> row.
</li>
</ul>
</div>
<ol class="guide-steps">
@@ -90,7 +92,9 @@
Domain" and "Proxy Domain" use an IP address or domain
name that is
<strong>accessible from your speakers</strong> (usually
the IP of this server on your local network).
the IP of this server on your local network). You can
also edit the Target URL directly from the Migration tab
with a <em>Save as default</em> button.
</li>
<li>
<strong>Discovery:</strong> Go to the
@@ -107,10 +111,15 @@
</li>
<li>
<strong>Migration:</strong> In the
<strong>Migration</strong> tab, redirect your speaker to
this local service. We recommend the
<strong>XML Configuration</strong> method as it is
surgical and easily reversible.
<strong>Migration</strong> tab the wizard offers a
one-click <strong>Apply Suggested Plan</strong> that
picks the right recipe for your speaker (XML over SSH
when SSH is available, telnet URL flip otherwise). For
mix-and-match across the three independent axes — URL
flip transport, DNS interception, CA install — expand
<em>Customize this migration</em>. A visible pre-flight
check runs before any backend operation touches the
speaker.
</li>
<li>
<strong>Verification:</strong> After migration and
@@ -150,6 +159,7 @@
<label for="target-domain">Target Domain:</label>
<input type="text" id="target-domain" placeholder="http://192.168.x.x:8000" style="width: 300px"/>
<span style="font-size: 0.8em; color: #666">(Standard services URL)</span>
<div id="target-domain-resolved" style="font-size: 0.85em; margin-top: 4px; min-height: 1.2em"></div>
</div>
<div style="margin-bottom: 20px">
<strong>Device Discovery:</strong>
@@ -468,6 +478,14 @@
>
<option value="">-- Select a device --</option>
</select>
<button
type="button"
id="migration-refresh-btn"
onclick="refreshSummary()"
title="Reload summary for the selected device"
aria-label="Reload summary"
style="margin-left: 6px; padding: 2px 8px; font-size: 1em; line-height: 1; cursor: pointer"
>&#x21bb;</button>
</div>
<div id="status" class="status"></div>
@@ -502,56 +520,109 @@
Migration Summary for
<span id="summary-device-display"></span>
</h3>
<p>Migration Status: <span id="migration-status"></span></p>
<input type="hidden" id="summary-device-id"/>
<p>SSH Connection: <span id="ssh-status"></span></p>
<p id="original-config-status" style="display: none">
Backup: ✅ Found .original config at
<code
>/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original</code
>
<button onclick="toggleOriginalConfig()">
Show Original Config
</button>
</p>
<p id="no-original-config-status" style="display: none">
Backup: ❌ Not found
<button id="backup-config-btn">
Backup Config Now
</button>
</p>
<p>
Remote Services Enabled:
<span id="remote-services-status"></span>
<span
id="remote-services-found"
style="font-size: 0.8em; color: #666"
></span>
</p>
<p>
AfterTouch Local Root CA Trusted:
<span id="ca-trust-status"></span>
<button
id="trust-ca-btn"
style="
display: none;
background-color: #607d8b;
color: white;
border: none;
padding: 2px 8px;
font-size: 0.8em;
margin-left: 10px;
"
>
Trust CA Now
</button>
<a
href="/setup/ca.crt"
download="soundtouch-ca.crt"
style="margin-left: 10px; font-size: 0.85em"
title="Download CA cert to import into other clients"
>Download CA cert</a>
</p>
<p>Migration Status: <span id="migration-status"></span></p>
<div
id="migration-state-card"
style="margin: 10px 0 16px 0; padding: 12px; border: 1px solid #ddd; background: #fafafa; border-radius: 4px"
>
<div style="margin-bottom: 12px">
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Transports</h4>
<div style="display: flex; gap: 24px; flex-wrap: wrap; padding-left: 4px">
<div>
<strong>SSH:</strong> <span id="state-ssh"></span>
</div>
<div>
<strong>Telnet (Port 17000):</strong> <span id="state-telnet"></span>
<span id="state-telnet-banner" style="font-size: 0.85em; color: #666; margin-left: 4px"></span>
</div>
</div>
<div
id="state-telnet-error"
style="display: none; margin-top: 6px; padding: 4px 8px; background: #fff3e0; border-left: 3px solid #ef6c00; font-size: 0.85em; color: #5d4037"
></div>
</div>
<div style="margin-bottom: 12px">
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Migration State</h4>
<table style="width: 100%; border-collapse: collapse">
<tbody>
<tr style="border-top: 1px solid #eee">
<td style="padding: 6px 8px; width: 170px; vertical-align: top; color: #555">URL Configuration</td>
<td id="state-url" style="padding: 6px 8px; vertical-align: top"></td>
</tr>
<tr style="border-top: 1px solid #eee">
<td style="padding: 6px 8px; width: 170px; vertical-align: top; color: #555">DNS Interception</td>
<td id="state-dns" style="padding: 6px 8px; vertical-align: top"></td>
</tr>
<tr style="border-top: 1px solid #eee">
<td style="padding: 6px 8px; width: 170px; vertical-align: top; color: #555">CA / TLS</td>
<td id="state-ca" style="padding: 6px 8px; vertical-align: top">
<span id="state-ca-line"></span>
<span style="margin-left: 12px; white-space: nowrap">
<button
id="trust-ca-btn"
type="button"
style="display: none; background-color: #607d8b; color: white; border: none; padding: 2px 8px; font-size: 0.85em"
>Trust CA Now</button>
<a
href="/setup/ca.crt"
download="soundtouch-ca.crt"
style="margin-left: 6px; font-size: 0.85em"
title="Download CA cert to import into other clients"
>Download CA cert</a>
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div>
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Preconditions</h4>
<table style="width: 100%; border-collapse: collapse">
<tbody>
<tr style="border-top: 1px solid #eee">
<td style="padding: 4px 8px; width: 170px; color: #555">remote_services</td>
<td id="state-remote-services-cell" style="padding: 4px 8px"></td>
</tr>
<tr style="border-top: 1px solid #eee">
<td style="padding: 4px 8px; color: #555">Account paired</td>
<td id="state-paired" style="padding: 4px 8px"></td>
</tr>
<tr style="border-top: 1px solid #eee">
<td style="padding: 4px 8px; color: #555">XML config backup</td>
<td id="state-backup" style="padding: 4px 8px"></td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Pre-flight panel: appears when the user clicks Apply,
runs the configured checks live, then auto-proceeds on
success or surfaces failures with override buttons. -->
<div
id="apply-preflight-panel"
style="display: none; margin: 12px 0; padding: 12px; border: 1px solid #2196f3; background: #e3f2fd; border-radius: 4px"
>
<h4 style="margin: 0 0 8px 0">Pre-flight checks</h4>
<ul
id="apply-preflight-list"
style="list-style: none; padding-left: 0; margin: 0; font-family: monospace; font-size: 0.9em"
></ul>
<div id="apply-preflight-summary" style="margin-top: 8px; font-weight: bold"></div>
<div id="apply-preflight-actions" style="margin-top: 10px"></div>
</div>
<div
id="preflight-warnings"
style="display: none; margin: 10px 0; padding: 8px 12px; background-color: #fff8e1; border-left: 4px solid #ffb300; font-size: 0.9em"
>
<strong>Cross-check warnings:</strong>
<ul id="preflight-warnings-list" style="margin: 4px 0 0 1em; padding: 0"></ul>
</div>
<div
id="connection-test"
@@ -612,54 +683,6 @@
></div>
</div>
<div
id="hosts-redirection-test"
style="
margin: 15px 0;
padding: 10px;
border: 1px solid #ddd;
background-color: #fff4e6;
display: none;
"
>
<strong>Preliminary /etc/hosts Test:</strong><br/>
<span style="font-size: 0.85em; color: #555"
>Verify the device's /etc/hosts mechanism before
full migration.</span
>
<div style="margin-top: 10px">
Domain: <code>custom-test-api.bose.fake</code>
</div>
<div style="margin-top: 10px">
<button
id="test-hosts-btn"
style="
background-color: #ff9800;
color: white;
border: none;
padding: 5px 10px;
font-size: 0.9em;
"
>
Test Hosts Redirection
</button>
</div>
<div
id="hosts-test-result"
style="
margin-top: 10px;
display: none;
padding: 10px;
border-radius: 4px;
font-family: monospace;
white-space: pre-wrap;
font-size: 0.85em;
max-height: 200px;
overflow-y: auto;
"
></div>
</div>
<div
id="dns-redirection-test"
style="
@@ -709,160 +732,277 @@
</div>
<div
style="
margin: 15px 0;
padding: 10px;
border: 1px solid #ddd;
background-color: #f9f9f9;
"
id="migration-plan-card"
style="margin: 16px 0; padding: 12px; border: 1px solid #ddd; background: #fafafa; border-radius: 4px"
>
<label for="migration-method"
><strong>Migration Method:</strong></label
>
<select
id="migration-method"
onchange="toggleMigrationMethod()"
>
<option value="xml">
XML Configuration (Recommended - redirects
specific services)
</option>
<option value="hosts">
/etc/hosts + Root CA (Advanced - global
redirection)
</option>
<option value="resolv">
/etc/resolv.conf (DHCP-Aware - Redirect via DNS
Hook)
</option>
</select>
<h3 style="margin-top: 0">Plan</h3>
<div style="margin-bottom: 14px">
<label for="plan-target-url" style="font-weight: bold">Target service URL:</label>
<div style="margin-top: 4px">
<input
type="text"
id="plan-target-url"
oninput="onPlanTargetURLChange()"
style="width: 320px; font-family: monospace"
placeholder="http://192.168.x.x:8000"
/>
<button
type="button"
id="plan-save-default-btn"
onclick="saveTargetURLAsDefault()"
style="margin-left: 6px"
>Save as default</button>
</div>
<div id="plan-target-saved" style="font-size: 0.85em; color: #666; margin-top: 4px"></div>
</div>
<div style="margin-bottom: 14px">
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Capabilities</h4>
<div style="font-size: 0.9em; line-height: 1.7">
<div>This speaker exposes: <span id="plan-detected"></span></div>
<div>AfterTouch can offer: <span id="plan-possible"></span></div>
</div>
</div>
<div style="margin-bottom: 14px">
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Service URLs</h4>
<p style="margin: 0 0 8px 0; font-size: 0.85em; color: #555">
Pre-filled from the target URL above. Edit any field for advanced setups (e.g. soundcork users
append <code>/marge</code> to <code>margeServerUrl</code>). These overrides apply to both XML
and Telnet migrations.
</p>
<table style="width: 100%; border-collapse: collapse">
<thead>
<tr>
<th style="text-align: left; padding: 4px 6px; font-size: 0.85em">Field</th>
<th style="text-align: left; padding: 4px 6px; font-size: 0.85em">Current on Device</th>
<th style="text-align: left; padding: 4px 6px; font-size: 0.85em">Target URL</th>
</tr>
</thead>
<tbody>
<tr>
<td style="padding: 4px 6px; font-family: monospace; font-size: 0.85em">margeServerUrl</td>
<td id="plan-current-marge" style="padding: 4px 6px; font-family: monospace; font-size: 0.8em; color: #555"></td>
<td style="padding: 4px 6px">
<input
type="text"
id="plan-marge-url"
oninput="validatePlanURLs()"
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
/>
</td>
</tr>
<tr>
<td style="padding: 4px 6px; font-family: monospace; font-size: 0.85em">statsServerUrl</td>
<td id="plan-current-stats" style="padding: 4px 6px; font-family: monospace; font-size: 0.8em; color: #555"></td>
<td style="padding: 4px 6px">
<input
type="text"
id="plan-stats-url"
oninput="validatePlanURLs()"
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
/>
</td>
</tr>
<tr>
<td style="padding: 4px 6px; font-family: monospace; font-size: 0.85em">swUpdateUrl</td>
<td id="plan-current-sw_update" style="padding: 4px 6px; font-family: monospace; font-size: 0.8em; color: #555"></td>
<td style="padding: 4px 6px">
<input
type="text"
id="plan-sw_update-url"
oninput="validatePlanURLs()"
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
/>
</td>
</tr>
<tr>
<td style="padding: 4px 6px; font-family: monospace; font-size: 0.85em">bmxRegistryUrl</td>
<td id="plan-current-bmx" style="padding: 4px 6px; font-family: monospace; font-size: 0.8em; color: #555"></td>
<td style="padding: 4px 6px">
<input
type="text"
id="plan-bmx-url"
oninput="validatePlanURLs()"
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
/>
</td>
</tr>
</tbody>
</table>
<div style="margin-top: 6px; font-size: 0.85em">
<label>
<input
type="checkbox"
id="plan-soundcork-mode"
onchange="toggleSoundcorkMode()"
/>
Soundcork mode (append <code>/marge</code> to <code>margeServerUrl</code>)
</label>
<button
type="button"
onclick="resetPlanURLsToDefaults()"
style="margin-left: 16px; font-size: 0.85em"
>Reset to defaults</button>
</div>
<div
id="plan-url-validation"
style="display: none; margin-top: 8px; padding: 6px 10px; background: #ffebee; border-left: 3px solid #c62828; font-size: 0.85em; color: #c62828"
></div>
</div>
<div style="margin-bottom: 14px">
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Account pairing</h4>
<p id="plan-pair-current" style="margin: 0 0 6px 0; font-size: 0.85em; color: #555"></p>
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap">
<label for="plan-pair-id">Account ID:</label>
<input
type="text"
id="plan-pair-id"
maxlength="7"
pattern="[0-9]{7}"
placeholder="1234567"
style="font-family: monospace; width: 8em"
oninput="onPlanPairIDChange()"
/>
<button type="button" onclick="generatePlanAccountID()" style="font-size: 0.85em">Generate</button>
<select id="plan-pair-existing" onchange="onPlanPairPick()" style="font-size: 0.85em">
<option value="">— pick from datastore —</option>
</select>
</div>
<div id="plan-pair-status" style="margin-top: 4px; font-size: 0.85em; color: #666"></div>
</div>
<div>
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Suggested plan</h4>
<div
id="plan-suggestion"
style="border: 1px solid #c8e6c9; background: #f1f8e9; padding: 10px 12px; border-radius: 3px"
>
<div id="plan-suggestion-summary" style="font-weight: bold; margin-bottom: 4px"></div>
<ul id="plan-suggestion-steps" style="margin: 4px 0 8px 1.2em; padding: 0; font-size: 0.9em"></ul>
<button
type="button"
id="plan-preflight-btn"
onclick="preflightSuggestedPlan()"
title="Run the same pre-flight checks Apply runs, without proceeding to migrate"
style="font-size: 0.95em; margin-right: 6px"
>Pre-flight</button>
<button
type="button"
id="plan-apply-btn"
onclick="applySuggestedPlan()"
style="font-size: 0.95em"
>Apply Suggested Plan</button>
<span id="plan-apply-status" style="margin-left: 10px; font-size: 0.85em"></span>
</div>
</div>
</div>
<details style="margin: 16px 0">
<summary style="cursor: pointer; font-weight: bold">Customize this migration</summary>
<div
id="customize-form"
style="margin: 15px 0; padding: 12px; border: 1px solid #ddd; background-color: #f9f9f9; border-radius: 4px"
>
<p style="margin: 0 0 12px 0; font-size: 0.9em; color: #555">
Pick any combination of the three axes — the wizard runs
the matching backend operations in order. Disabled options
require a transport this speaker doesn't expose.
</p>
<fieldset style="margin: 0 0 10px 0; padding: 8px 12px; border: 1px solid #ddd">
<legend style="padding: 0 6px; font-weight: bold">URL flip transport</legend>
<label style="display: block; margin: 2px 0">
<input type="radio" name="customize-url-flip" value="xml" checked
onchange="onCustomizeChange()"/>
XML over SSH
<span class="customize-hint" data-axis="xml" style="margin-left: 6px; font-size: 0.85em; color: #c62828"></span>
</label>
<label style="display: block; margin: 2px 0">
<input type="radio" name="customize-url-flip" value="telnet"
onchange="onCustomizeChange()"/>
Telnet (Port 17000)
<span class="customize-hint" data-axis="telnet" style="margin-left: 6px; font-size: 0.85em; color: #c62828"></span>
</label>
<label style="display: block; margin: 2px 0">
<input type="radio" name="customize-url-flip" value="none"
onchange="onCustomizeChange()"/>
Skip — leave URLs at the Bose cloud (DNS interception will redirect them instead)
</label>
</fieldset>
<fieldset style="margin: 0 0 10px 0; padding: 8px 12px; border: 1px solid #ddd">
<legend style="padding: 0 6px; font-weight: bold">DNS interception</legend>
<label style="display: block; margin: 2px 0">
<input type="radio" name="customize-dns" value="none" checked
onchange="onCustomizeChange()"/>
None
</label>
<label style="display: block; margin: 2px 0">
<input type="radio" name="customize-dns" value="resolv"
onchange="onCustomizeChange()"/>
<code>/etc/resolv.conf</code> hook (also installs the local CA — needed when URLs stay at <code>https://*.bose.com</code>)
<span class="customize-hint" data-axis="resolv" style="margin-left: 6px; font-size: 0.85em; color: #c62828"></span>
</label>
</fieldset>
<fieldset style="margin: 0 0 10px 0; padding: 8px 12px; border: 1px solid #ddd">
<legend style="padding: 0 6px; font-weight: bold">Local CA install</legend>
<label style="display: block; margin: 2px 0">
<input type="checkbox" id="customize-ca-install"
onchange="onCustomizeChange()"/>
Install local root CA on the device via SSH (only needed when targeting <code>https://</code>)
<span class="customize-hint" data-axis="ca" style="margin-left: 6px; font-size: 0.85em; color: #c62828"></span>
</label>
</fieldset>
<div
id="dns-port-warning"
style="
margin-top: 5px;
color: #d32f2f;
font-weight: bold;
font-size: 0.9em;
display: none;
"
id="customize-validation"
style="display: none; margin: 8px 0; padding: 6px 10px; background: #ffebee; border-left: 3px solid #c62828; font-size: 0.85em; color: #c62828"
></div>
<div style="margin-top: 12px">
<button
type="button"
id="customize-preflight-btn"
onclick="preflightCustomPlan()"
title="Run the same pre-flight checks Apply runs, without proceeding to migrate"
style="padding: 8px 14px; font-size: 0.95em; margin-right: 6px"
>Pre-flight</button>
<button
type="button"
id="customize-apply-btn"
onclick="applyCustomPlan()"
style="background-color: #4caf50; color: white; border: none; padding: 8px 14px; font-size: 0.95em"
>Apply Custom Plan</button>
<span id="customize-apply-status" style="margin-left: 10px; font-size: 0.9em"></span>
</div>
</div>
<div
id="current-resolv-pane"
style="display: none; margin-bottom: 20px"
id="telnet-method-pane"
style="display: none; margin-bottom: 20px; padding: 10px; border: 1px solid #ddd; background-color: #f0f7ff;"
>
<span class="config-header"
>Current /etc/resolv.conf</span
>
<pre id="current-resolv-content"></pre>
<h4 style="margin-top: 0">Telnet Migration (Port 17000)</h4>
<p style="margin: 5px 0">
Drives the speaker's diagnostic shell over TCP/17000.
Requires no SSH access. Works on most ST 10/20/300 and
Wave III/IV firmware (27.0.6.x).
</p>
<p style="margin: 5px 0; font-size: 0.9em; color: #555">
Limitation: HTTP-only redirection — telnet has no way to
install a custom CA. If you need end-to-end TLS, use the
XML or DNS method instead.
</p>
</div>
<div
id="original-config-pane"
style="display: none; margin-bottom: 20px"
>
<span class="config-header"
>Original Config (Backup)</span
>
<pre id="original-config-content"></pre>
</div>
<div
id="service-options"
style="margin-bottom: 20px; display: none"
>
<h4>Service Implementations</h4>
<table>
<tr>
<th>Service</th>
<th>Original URL</th>
<th>Implementation</th>
</tr>
<tr>
<td>Marge (Streaming)</td>
<td id="orig-marge">loading...</td>
<td>
<select
id="opt-marge"
onchange="refreshSummary()"
>
<option value="self">
AfterTouch (Local Service)
</option>
<option value="proxied">
Proxied (via local service)
</option>
<option value="original">
Original (keep Bose URL)
</option>
</select>
</td>
</tr>
<tr>
<td>Stats</td>
<td id="orig-stats">loading...</td>
<td>
<select
id="opt-stats"
onchange="refreshSummary()"
>
<option value="self">
AfterTouch (Local Service)
</option>
<option value="proxied">
Proxied (via local service)
</option>
<option value="original">
Original (keep Bose URL)
</option>
</select>
</td>
</tr>
<tr>
<td>Software Update</td>
<td id="orig-sw_update">loading...</td>
<td>
<select
id="opt-sw_update"
onchange="refreshSummary()"
>
<option value="self">
AfterTouch (Local Service)
</option>
<option value="proxied">
Proxied (via local service)
</option>
<option value="original">
Original (keep Bose URL)
</option>
</select>
</td>
</tr>
<tr>
<td>BMX (Registry)</td>
<td id="orig-bmx">loading...</td>
<td>
<select
id="opt-bmx"
onchange="refreshSummary()"
>
<option value="self">
AfterTouch (Local Service)
</option>
<option value="proxied">
Proxied (via local service)
</option>
<option value="original">
Original (keep Bose URL)
</option>
</select>
</td>
</tr>
</table>
</div>
<div class="diff-container">
<!-- XML diff pair: Current Config | Planned Config. Shown
when URL flip = xml in the Customize form. -->
<div class="diff-container" id="xml-diff-row" style="display: none">
<div id="xml-diff-pane" class="diff-pane">
<span class="config-header"
>Current Config (on Speaker)</span
@@ -888,33 +1028,18 @@
<a href="https://github.com/gesellix/bose-soundtouch/blob/main/docs/guides/TROUBLESHOOTING.md#hostname-resolution" target="_blank" style="color: #856404;">Learn more →</a>
</div>
</div>
<div
id="planned-hosts-pane"
class="diff-pane"
style="display: none"
>
</div>
<!-- Resolv diff pair: Current /etc/resolv.conf | Planned hook.
Shown when DNS = resolv in the Customize form. -->
<div class="diff-container" id="resolv-diff-row" style="display: none; margin-top: 12px">
<div id="current-resolv-pane" class="diff-pane">
<span class="config-header"
>Planned /etc/hosts Entries</span
>Current /etc/resolv.conf</span
>
<pre id="planned-hosts"></pre>
<div
style="
margin-top: 10px;
font-size: 0.9em;
color: #666;
"
>
<strong>Note:</strong> This method also injects
the AfterTouch Local Root CA into
<code>/etc/pki/tls/certs/ca-bundle.crt</code> to
enable secure HTTPS communication.
</div>
<pre id="current-resolv-content"></pre>
</div>
<div
id="planned-resolv-pane"
class="diff-pane"
style="display: none"
>
<div id="planned-resolv-pane" class="diff-pane">
<span class="config-header"
>Planned /etc/resolv.conf Hook</span
>
@@ -937,17 +1062,6 @@
</div>
</div>
<div style="margin-top: 15px">
<button
id="confirm-migrate-btn"
style="
background-color: #4caf50;
color: white;
border: none;
padding: 10px 20px;
"
>
Confirm Migration
</button>
<button
id="revert-migrate-btn"
style="
@@ -1004,6 +1118,7 @@
Cancel
</button>
</div>
</details>
</div>
</div>
File diff suppressed because it is too large Load Diff
+48 -12
View File
@@ -105,7 +105,10 @@ func ensureTimestamps(s *models.ConfiguredSource) {
}
func ensureSourceType(s *models.ConfiguredSource) {
if s.Type == "" || (s.SourceKey.Type != "" && s.SourceKey.Type != constants.ProviderAux && s.SourceKey.Type != constants.ProviderBluetooth) {
// AUX must be normalized to Type="Audio" — the speaker rejects type="AUX"
// (which the datastore previously synthesized from SourceKey.Type).
// Bluetooth is left alone since its canonical Type isn't "Audio".
if s.Type == "" || (s.SourceKey.Type != "" && s.SourceKey.Type != constants.ProviderBluetooth) {
if s.SourceKey.Type == constants.ProviderAmazon {
s.Type = constants.ProviderAmazon
} else {
@@ -294,7 +297,7 @@ func mapPresetToParityXML(p models.ServicePreset, sources []models.ConfiguredSou
func AccountPresetsToXML(ds *datastore.DataStore, account string) ([]byte, error) {
accountDir := ds.AccountDevicesDir(account)
entries, err := os.ReadDir(accountDir)
entries, err := ds.ReadDirUnderBase(accountDir)
if err != nil {
if os.IsNotExist(err) {
return []byte(constants.XMLHeader + "\n<presets/>"), nil
@@ -756,9 +759,37 @@ func mapToFullResponseSource(s models.ConfiguredSource) models.FullResponseSourc
fullSource.Username = s.SourceKeyAccount
}
// SourceProviderID is a required protobuf field inside recents/preset
// source blocks. A persisted source that lost its SourceKey.Type (e.g.
// poisoned by an older "INVALID" classification) lands here with an
// empty value, so fall back to the canonical default whose ID matches.
if fullSource.SourceProviderID == "" && s.ID != "" {
if def := canonicalProviderIDByID(s.ID); def != "" {
fullSource.SourceProviderID = def
}
}
return fullSource
}
// canonicalProviderIDByID returns the canonical SourceProviderID for one of
// the well-known built-in source IDs (10001..10005), or "" if the ID isn't
// recognised.
func canonicalProviderIDByID(id string) string {
switch id {
case "10002":
return strconv.Itoa(constants.InternetRadioProviderID)
case "10003":
return strconv.Itoa(constants.LocalInternetRadioProviderID)
case "10004":
return strconv.Itoa(constants.TuneinProviderID)
case "10005":
return strconv.Itoa(constants.RadioBrowserProviderID)
}
return ""
}
func mapPresetsToFullResponse(presets []models.ServicePreset, sources []models.ConfiguredSource) []models.FullResponsePreset {
var fullPresets []models.FullResponsePreset
@@ -1036,7 +1067,7 @@ func mergeDefaultSources(stored, defaults []models.ConfiguredSource) []models.Co
func AccountSourcesToXML(ds *datastore.DataStore, account string) ([]byte, error) {
devicesDir := ds.AccountDevicesDir(account)
entries, err := os.ReadDir(devicesDir)
entries, err := ds.ReadDirUnderBase(devicesDir)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
@@ -1062,7 +1093,7 @@ func AccountSourcesToXML(ds *datastore.DataStore, account string) ([]byte, error
func AccountDevicesToXML(ds *datastore.DataStore, account string) ([]byte, error) {
devicesDir := ds.AccountDevicesDir(account)
entries, err := os.ReadDir(devicesDir)
entries, err := ds.ReadDirUnderBase(devicesDir)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
@@ -1118,7 +1149,7 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
fillDefaultProviderSettings(account, &resp)
fillAccountInfo(ds, account, &resp)
entries, err := os.ReadDir(devicesDir)
entries, err := ds.ReadDirUnderBase(devicesDir)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
@@ -1132,10 +1163,13 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
return nil, err
}
// Parity: use self-closing tags for empty components and sourceSettings
// Parity: use self-closing tags for empty components and sourceSettings.
// NOTE: do NOT strip empty <sourceproviderid> elements here — the speaker
// decodes /full into a protobuf message where recents>recent>source>
// sourceproviderid is a *required* field, so removing even an empty element
// trips "missing required field" and aborts the whole account sync.
data = bytes.ReplaceAll(data, []byte("<components></components>"), []byte("<components/>"))
data = bytes.ReplaceAll(data, []byte("<sourceSettings></sourceSettings>"), []byte("<sourceSettings/>"))
data = bytes.ReplaceAll(data, []byte("<sourceproviderid></sourceproviderid>"), []byte(""))
return append([]byte(constants.XMLHeader), data...), nil
}
@@ -1479,16 +1513,18 @@ func classifyLearnedSource(src *models.ConfiguredSource, sourceID, location, sou
switch {
case sourceProviderID == strconv.Itoa(constants.TuneinProviderID) || sourceID == constants.ProviderTunein || strings.Contains(location, "/v1/playback/station/"):
classifyAsTuneIn(src)
case sourceID == constants.ProviderLocalInternetRadio:
case sourceProviderID == strconv.Itoa(constants.LocalInternetRadioProviderID) || sourceID == constants.ProviderLocalInternetRadio || strings.Contains(location, "/custom/v1/playback/"):
classifyAsLocalInternetRadio(src)
case strings.Contains(location, "spotify") || strings.Contains(location, "c3BvdGlme") || sourceID == constants.ProviderSpotify:
classifyAsSpotify(src)
case strings.Contains(location, "amazon") || sourceID == constants.ProviderAmazon || sourceProviderID == strconv.Itoa(constants.AmazonProviderID):
classifyAsAmazon(src)
default:
src.SourceKey.Type = "INVALID"
src.SourceKeyType = "INVALID"
}
// If we can't classify, leave SourceKey.Type empty so the canonical-by-ID
// fallback in mapToFullResponseSource and the read-side applyCanonicalDefaults
// still have a chance to repair it. Writing a literal "INVALID" used to lock
// the source out of every repair path, producing a <source> block with no
// <sourceproviderid> and breaking the speaker's protobuf required-field check.
}
func classifyAsTuneIn(src *models.ConfiguredSource) {
@@ -1864,7 +1900,7 @@ func AddSource(ds *datastore.DataStore, account, username, providerID, secret, s
// List accounts directly from the account directory to be sure we find them.
devicesDir := ds.AccountDevicesDir(account)
entries, _ := os.ReadDir(devicesDir)
entries, _ := ds.ReadDirUnderBase(devicesDir)
for _, entry := range entries {
if !entry.IsDir() {
@@ -0,0 +1,193 @@
package marge
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// TestAccountFullToXML_RecentWithPoisonedSourceProviderID is a regression test
// for the production failure where the speaker's BoseApp rejected the
// /streaming/account/.../full response with:
//
// protobuf::FatalException - CHECK failed: IsInitialized():
// Message of type "MargePB.account" is missing required fields:
// devices.device[1].recents.recent[0].source.sourceproviderid
//
// Trigger sequence reproduced here:
//
// 1. The device POSTs a "laut.fm" recent (location "/custom/v1/playback/...")
// against an account that has no Sources.xml yet.
// 2. classifyLearnedSource fails to recognise /custom/v1/playback/ and the
// numeric source id "10003", and historically wrote sourceKey type="INVALID"
// with an empty sourceproviderid.
// 3. The persisted Sources.xml then re-appears in /full with an empty
// <sourceproviderid> element inside recents>recent>source, which the
// post-marshal cleanup stripped entirely — making the speaker's protobuf
// decode fail on a required field.
//
// The fix combines three things, all exercised below:
//
// - classifyLearnedSource recognises LocalInternetRadio via the
// /custom/v1/playback/ URL pattern and via sourceProviderID == "11".
// - mapToFullResponseSource falls back to the canonical SourceProviderID
// keyed by source ID, so already-poisoned data on disk still renders a
// non-empty providerid.
// - AccountFullToXML no longer strips empty <sourceproviderid> elements
// inside recents/preset source blocks.
func TestAccountFullToXML_RecentWithPoisonedSourceProviderID(t *testing.T) {
tempDir, err := os.MkdirTemp("", "marge-recent-provid-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
account := "1234567"
device := "ABCDEF012345"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("Failed to create device dir: %v", err)
}
if err := os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(`<?xml version="1.0" encoding="UTF-8"?>
<info deviceID="ABCDEF012345">
<name>Kitchen</name>
<type>SoundTouch</type>
<moduleType>10 sm2</moduleType>
</info>`), 0644); err != nil {
t.Fatalf("Failed to write DeviceInfo.xml: %v", err)
}
// Sources.xml reproduces the poisoned entry observed in the user's
// backup (May 11): id="10003" with sourceKey type="INVALID" and no
// sourceproviderid attribute. Older repair paths (applyCanonicalDefaults,
// ensureSourceProviderID) all key off sourceKey.type, so the entry stays
// broken at load time.
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
<sources>
<source id="10003" secret="" secretType="">
<credential type=""></credential>
<sourceKey type="INVALID" account=""></sourceKey>
</source>
</sources>`
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644); err != nil {
t.Fatalf("Failed to write Sources.xml: %v", err)
}
// Recents.xml references the poisoned source via <sourceid>10003</sourceid>.
// The location is a laut.fm stream proxied through /custom/v1/playback/ —
// exactly the URL pattern the old classifier failed to recognise.
recentsXML := `<?xml version="1.0" encoding="UTF-8"?>
<recents>
<recent deviceID="ABCDEF012345" utcTime="1778014606" id="260505002">
<contentItem source="INVALID" type="stationurl" location="http://192.168.123.123/custom/v1/playback/aHR0cHM6Ly9zdHJlYW0ubGF1dC5mbS9zbW9vdGgtamF6eg==" sourceAccount="" isPresetable="true">
<itemName>Smooth Jazz Instrumental 24/7</itemName>
</contentItem>
<createdOn>2026-05-05T20:56:49.305+00:00</createdOn>
<updatedOn>2026-05-05T20:56:49.305+00:00</updatedOn>
<sourceid>10003</sourceid>
</recent>
</recents>`
if err := os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(recentsXML), 0644); err != nil {
t.Fatalf("Failed to write Recents.xml: %v", err)
}
ds := datastore.NewDataStore(tempDir)
fullXML, err := AccountFullToXML(ds, account)
if err != nil {
t.Fatalf("AccountFullToXML failed: %v", err)
}
body := string(fullXML)
// Locate the recents block and assert every <source> inside it carries a
// non-empty <sourceproviderid>. Without the fix, the post-marshal
// strip-empty step deletes the empty element and the speaker rejects
// the message with "missing required field".
recentsRE := regexp.MustCompile(`(?s)<recents>(.*?)</recents>`)
matches := recentsRE.FindAllStringSubmatch(body, -1)
if len(matches) == 0 {
t.Fatalf("Expected at least one <recents> block; body:\n%s", body)
}
sourceInRecentRE := regexp.MustCompile(`(?s)<source(?:\s[^>]*)?>(.*?)</source>`)
for _, recentsBlock := range matches {
for _, src := range sourceInRecentRE.FindAllStringSubmatch(recentsBlock[1], -1) {
inner := src[1]
if !strings.Contains(inner, "<sourceproviderid>") {
t.Errorf("<source> inside <recents> has no <sourceproviderid> element; block:\n%s", src[0])
continue
}
if strings.Contains(inner, "<sourceproviderid></sourceproviderid>") {
t.Errorf("<source> inside <recents> has empty <sourceproviderid>; block:\n%s", src[0])
}
}
}
// And spot-check the canonical fallback fired for the laut.fm recent.
if !strings.Contains(body, "<sourceproviderid>11</sourceproviderid>") {
t.Errorf("Expected <sourceproviderid>11</sourceproviderid> (LocalInternetRadio) in /full; body:\n%s", body)
}
}
// TestClassifyLearnedSource_LocalInternetRadioCustomPlayback locks in the
// classifier behaviour: a recent POSTed with a /custom/v1/playback/ URL must
// classify as LocalInternetRadio. Previously this fell into the "INVALID"
// default and poisoned Sources.xml — see the regression test above.
func TestClassifyLearnedSource_LocalInternetRadioCustomPlayback(t *testing.T) {
cases := []struct {
name string
sourceID string
location string
sourceProviderID string
}{
{
name: "laut.fm /custom/v1/playback URL",
sourceID: "10003",
location: "http://192.168.123.123/custom/v1/playback/aHR0cHM6Ly9zdHJlYW0ubGF1dC5mbS9zbW9vdGgtamF6eg==",
},
{
name: "sourceProviderID==11 alone",
sourceID: "999999",
location: "http://example.invalid/whatever",
sourceProviderID: "11",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
src := createLearnedSource(tc.sourceID, tc.location, "", "", tc.sourceProviderID, "", "")
if src.SourceKey.Type == "INVALID" || src.SourceKeyType == "INVALID" {
t.Errorf("classifier wrote INVALID for %s; src=%+v", tc.name, src)
}
if src.SourceKey.Type != "LOCAL_INTERNET_RADIO" {
t.Errorf("expected SourceKey.Type=LOCAL_INTERNET_RADIO, got %q", src.SourceKey.Type)
}
})
}
}
// TestClassifyLearnedSource_UnknownLeavesKeyEmpty verifies the new default
// branch leaves SourceKey.Type empty instead of writing the literal "INVALID"
// sentinel that locks the source out of every downstream repair path.
func TestClassifyLearnedSource_UnknownLeavesKeyEmpty(t *testing.T) {
src := createLearnedSource("SOMETHING_UNKNOWN", "http://example.invalid/nothing", "", "", "", "", "")
if src.SourceKey.Type == "INVALID" || src.SourceKeyType == "INVALID" {
t.Errorf("classifier still writes INVALID sentinel; src=%+v", src)
}
if src.SourceKey.Type != "" {
t.Errorf("expected SourceKey.Type empty for an unrecognised source, got %q", src.SourceKey.Type)
}
}
+49 -8
View File
@@ -12,12 +12,22 @@ import (
"strings"
)
var sensitiveHeaders = []string{
// alwaysSensitiveHeaders are stripped from log output unconditionally — they
// carry credentials whose plaintext value should never appear in a log line
// regardless of how the LoggingProxy was constructed.
var alwaysSensitiveHeaders = []string{
"Authorization",
"Proxy-Authorization",
"Cookie",
"Set-Cookie",
"X-Api-Key",
"X-Bose-Token",
}
// sensitiveHeaders is kept for backwards compatibility with callers that
// reference it by name; it now mirrors alwaysSensitiveHeaders.
var sensitiveHeaders = alwaysSensitiveHeaders
// LoggingProxy wraps a ReverseProxy to provide instrumentation.
type LoggingProxy struct {
Proxy *httputil.ReverseProxy
@@ -26,15 +36,25 @@ type LoggingProxy struct {
RecordEnabled bool
MaxBodySize int64
Recorder *Recorder
// UnsafeLogCredentialHeaders disables the otherwise-unconditional
// redaction of credential-bearing headers (Authorization, Cookie, …) in
// LogRequest / LogResponse output. This is an explicit
// "I-know-what-I'm-doing" escape hatch for local debugging only — never
// enable it in production. Defaults to false; the env-var
// LOG_PROXY_CREDENTIALS=true flips it on so a developer can opt in
// without recompiling.
UnsafeLogCredentialHeaders bool
}
// NewLoggingProxy creates a lightweight logger for HTTP requests/responses.
func NewLoggingProxy(_ string, redact bool) *LoggingProxy {
// targetURL logic should be handled by the caller or we can parse it here
return &LoggingProxy{
Redact: redact,
LogBody: os.Getenv("LOG_PROXY_BODY") == "true",
MaxBodySize: 1024 * 10, // 10KB default limit for logging
Redact: redact,
LogBody: os.Getenv("LOG_PROXY_BODY") == "true",
UnsafeLogCredentialHeaders: os.Getenv("LOG_PROXY_CREDENTIALS") == "true",
MaxBodySize: 1024 * 10, // 10KB default limit for logging
}
}
@@ -45,7 +65,7 @@ func (lp *LoggingProxy) SetRecorder(r *Recorder) {
// LogRequest prints an abbreviated request with optional header/body redaction.
func (lp *LoggingProxy) LogRequest(r *http.Request) {
headers := formatHeaders(r.Header, lp.Redact)
headers := formatHeaders(r.Header, lp.Redact, lp.UnsafeLogCredentialHeaders)
bodyStr := "[HIDDEN]"
@@ -69,7 +89,7 @@ func (lp *LoggingProxy) LogRequest(r *http.Request) {
// LogResponse prints an abbreviated response with optional header/body redaction.
func (lp *LoggingProxy) LogResponse(r *http.Response) {
headers := formatHeaders(r.Header, lp.Redact)
headers := formatHeaders(r.Header, lp.Redact, lp.UnsafeLogCredentialHeaders)
bodyStr := "[HIDDEN]"
@@ -95,14 +115,23 @@ func (lp *LoggingProxy) LogResponse(r *http.Response) {
}
}
func formatHeaders(h http.Header, redact bool) string {
func formatHeaders(h http.Header, redact, unsafeLogCredentials bool) string {
var sb strings.Builder
// In Go, http.Header is a map[string][]string.
// Iterating over the map directly allows us to see the actual keys
// stored in the map, which might not be canonical if set directly.
for k, vv := range h {
val := strings.Join(vv, ", ")
if redact && isSensitive(k) {
// Credentials (Authorization, Cookie, …) are redacted by default.
// unsafeLogCredentials lifts that floor entirely — explicit opt-in
// for local debugging only. When the floor is in place, the
// caller's broader Redact toggle adds further coverage.
switch {
case unsafeLogCredentials:
// No redaction.
case isAlwaysSensitive(k):
val = "[REDACTED]"
case redact && isSensitive(k):
val = "[REDACTED]"
}
@@ -112,6 +141,18 @@ func formatHeaders(h http.Header, redact bool) string {
return strings.TrimSuffix(sb.String(), "\n")
}
// isAlwaysSensitive returns true for credential-bearing headers that must
// never appear unredacted in logs regardless of caller configuration.
func isAlwaysSensitive(header string) bool {
for _, h := range alwaysSensitiveHeaders {
if strings.EqualFold(h, header) {
return true
}
}
return false
}
func isSensitive(header string) bool {
for _, h := range sensitiveHeaders {
if strings.EqualFold(h, header) {
+254 -20
View File
@@ -29,6 +29,13 @@ type Recorder struct {
variables map[string]string
mu sync.Mutex
queue chan recordingTask
// rootMu guards lazy initialisation of root.
rootMu sync.Mutex
// root is an os.Root anchored at BaseDir; all filesystem operations
// that take a caller-derivable path go through it so the Go runtime
// guarantees containment regardless of what the path string contains.
root *os.Root
}
type recordingTask struct {
@@ -90,6 +97,191 @@ func (r *Recorder) Close() {
close(r.queue)
// We might want to wait here, but for now just closing is a start
}
r.rootMu.Lock()
defer r.rootMu.Unlock()
if r.root != nil {
_ = r.root.Close()
r.root = nil
}
}
// getRoot lazily opens the *os.Root anchored at r.BaseDir. The directory is
// MkdirAll-created on first call.
func (r *Recorder) getRoot() (*os.Root, error) {
r.rootMu.Lock()
defer r.rootMu.Unlock()
if r.root != nil {
return r.root, nil
}
if r.BaseDir == "" {
return nil, fmt.Errorf("recorder: BaseDir not configured")
}
if err := os.MkdirAll(r.BaseDir, 0755); err != nil {
return nil, fmt.Errorf("recorder: ensure BaseDir %s: %w", r.BaseDir, err)
}
root, err := os.OpenRoot(r.BaseDir)
if err != nil {
return nil, fmt.Errorf("recorder: open root at %s: %w", r.BaseDir, err)
}
r.root = root
return root, nil
}
// rootRel converts an absolute path under r.BaseDir to its root-relative form.
func (r *Recorder) rootRel(absPath string) (string, error) {
if !filepath.IsAbs(absPath) {
a, err := filepath.Abs(absPath)
if err != nil {
return "", err
}
absPath = a
}
if absPath == r.BaseDir {
return ".", nil
}
rel, err := filepath.Rel(r.BaseDir, absPath)
if err != nil {
return "", fmt.Errorf("recorder: %s outside BaseDir: %w", absPath, err)
}
if rel == "." || rel == "" {
return ".", nil
}
if strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("recorder: %s outside BaseDir", absPath)
}
return rel, nil
}
func (r *Recorder) rootMkdirAll(absPath string, perm os.FileMode) error {
root, err := r.getRoot()
if err != nil {
return err
}
rel, err := r.rootRel(absPath)
if err != nil {
return err
}
if rel == "." {
return nil
}
return root.MkdirAll(rel, perm)
}
func (r *Recorder) rootWriteFile(absPath string, data []byte, perm os.FileMode) error {
root, err := r.getRoot()
if err != nil {
return err
}
rel, err := r.rootRel(absPath)
if err != nil {
return err
}
return root.WriteFile(rel, data, perm)
}
func (r *Recorder) rootReadFile(absPath string) ([]byte, error) {
root, err := r.getRoot()
if err != nil {
return nil, err
}
rel, err := r.rootRel(absPath)
if err != nil {
return nil, err
}
return root.ReadFile(rel)
}
func (r *Recorder) rootStat(absPath string) (os.FileInfo, error) {
root, err := r.getRoot()
if err != nil {
return nil, err
}
rel, err := r.rootRel(absPath)
if err != nil {
return nil, err
}
return root.Stat(rel)
}
func (r *Recorder) rootRemoveAll(absPath string) error {
root, err := r.getRoot()
if err != nil {
return err
}
rel, err := r.rootRel(absPath)
if err != nil {
return err
}
return root.RemoveAll(rel)
}
func (r *Recorder) rootReadDir(absPath string) ([]os.DirEntry, error) {
root, err := r.getRoot()
if err != nil {
return nil, err
}
rel, err := r.rootRel(absPath)
if err != nil {
return nil, err
}
d, err := root.Open(rel)
if err != nil {
return nil, err
}
defer func() { _ = d.Close() }()
// *os.File.ReadDir(-1) returns directory order; os.ReadDir sorts by
// name. Match the sorted contract so callers don't see a surprise.
entries, err := d.ReadDir(-1)
if err != nil {
return entries, err
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
return entries, nil
}
func (r *Recorder) rootOpen(absPath string) (*os.File, error) {
root, err := r.getRoot()
if err != nil {
return nil, err
}
rel, err := r.rootRel(absPath)
if err != nil {
return nil, err
}
return root.Open(rel)
}
// Record logs an interaction to the configured category.
@@ -99,9 +291,13 @@ func (r *Recorder) Record(category string, req *http.Request, res *http.Response
}
sanitizedSegments, replacements := r.getSanitizedSegments(req.URL.Path)
dir := r.getRecordingDir(category, sanitizedSegments)
if err := os.MkdirAll(dir, 0755); err != nil {
dir, err := r.getRecordingDir(category, sanitizedSegments)
if err != nil {
return err
}
if err := r.rootMkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
@@ -203,7 +399,7 @@ func (r *Recorder) save(task recordingTask) {
r.writeResponseWithEnrichment(&buf, task.res, enriched)
}
if err := os.WriteFile(task.path, buf.Bytes(), 0644); err != nil {
if err := r.rootWriteFile(task.path, buf.Bytes(), 0644); err != nil {
log.Printf("failed to write recording to %s: %v", task.path, err)
}
@@ -237,13 +433,40 @@ func (r *Recorder) getSanitizedSegments(path string) ([]string, map[string]strin
return sanitizedSegments, replacements
}
func (r *Recorder) getRecordingDir(category string, sanitizedSegments []string) string {
// safeJoin joins r.BaseDir with elem and refuses to construct paths that
// would escape BaseDir. Each element must satisfy filepath.IsLocal — i.e.
// it must not be absolute, must not contain ".." segments, and (on Windows)
// must not name a reserved device. CodeQL recognises filepath.IsLocal as
// a path-traversal sanitiser, so taint analysis at call sites that hand the
// result to os.* terminates here.
func (r *Recorder) safeJoin(elem ...string) (string, error) {
if r.BaseDir == "" {
return "", fmt.Errorf("recorder: BaseDir not configured")
}
for _, e := range elem {
if e == "" {
// filepath.Join silently skips empty components, but
// filepath.IsLocal returns false for "" — treat empties as
// no-ops to preserve the existing call shapes.
continue
}
if !filepath.IsLocal(e) {
return "", fmt.Errorf("recorder: path component %q escapes BaseDir", e)
}
}
return filepath.Join(append([]string{r.BaseDir}, elem...)...), nil
}
func (r *Recorder) getRecordingDir(category string, sanitizedSegments []string) (string, error) {
subDir := "root"
if len(sanitizedSegments) > 0 {
subDir = filepath.Join(sanitizedSegments...)
}
return filepath.Join(r.BaseDir, "interactions", r.SessionID, category, subDir)
return r.safeJoin("interactions", r.SessionID, category, subDir)
}
func (r *Recorder) getRecordingPath(dir, method string) string {
@@ -395,7 +618,7 @@ func (r *Recorder) updateEnvFile(newVars map[string]string) error {
return err
}
return os.WriteFile(envFile, data, 0644)
return r.rootWriteFile(envFile, data, 0644)
}
// GetInteractionStats returns statistics about recorded interactions.
@@ -406,7 +629,7 @@ func (r *Recorder) GetInteractionStats() (*InteractionStats, error) {
}
interactionsDir := filepath.Join(r.BaseDir, "interactions")
if _, err := os.Stat(interactionsDir); os.IsNotExist(err) {
if _, err := r.rootStat(interactionsDir); os.IsNotExist(err) {
return stats, nil
}
@@ -445,7 +668,7 @@ func (r *Recorder) ListInteractions(sessionFilter, categoryFilter, sinceFilter s
interactions := make([]Interaction, 0)
interactionsDir := filepath.Join(r.BaseDir, "interactions")
if _, err := os.Stat(interactionsDir); os.IsNotExist(err) {
if _, err := r.rootStat(interactionsDir); os.IsNotExist(err) {
return interactions, nil
}
@@ -567,7 +790,7 @@ func (r *Recorder) parseInteractionFile(rel, path string, parts []string) (Inter
// extractSCMUDCFromFile parses SCMUDC enrichment data from a .http file
func (r *Recorder) extractSCMUDCFromFile(path string) *EnrichedSCMUDCEvent {
content, err := os.ReadFile(path)
content, err := r.rootReadFile(path)
if err != nil {
return nil
}
@@ -703,7 +926,7 @@ func (r *Recorder) getFullTimestamp(sessionID, filename string) string {
}
func (r *Recorder) peekStatus(path string) int {
content, err := os.ReadFile(path)
content, err := r.rootReadFile(path)
if err != nil {
return 0
}
@@ -733,16 +956,19 @@ func (r *Recorder) DeleteSession(sessionID string) error {
return fmt.Errorf("session ID is required")
}
sessionDir := filepath.Join(r.BaseDir, "interactions", sessionID)
sessionDir, err := r.safeJoin("interactions", sessionID)
if err != nil {
return err
}
return os.RemoveAll(sessionDir)
return r.rootRemoveAll(sessionDir)
}
// CleanupSessions deletes all but the most recent keepCount sessions.
func (r *Recorder) CleanupSessions(keepCount int) error {
interactionsDir := filepath.Join(r.BaseDir, "interactions")
entries, err := os.ReadDir(interactionsDir)
entries, err := r.rootReadDir(interactionsDir)
if err != nil {
if os.IsNotExist(err) {
return nil
@@ -771,7 +997,7 @@ func (r *Recorder) CleanupSessions(keepCount int) error {
for i := keepCount; i < len(sessions); i++ {
sessionDir := filepath.Join(interactionsDir, sessions[i].Name())
if err := os.RemoveAll(sessionDir); err != nil {
if err := r.rootRemoveAll(sessionDir); err != nil {
return fmt.Errorf("failed to delete session %s: %w", sessions[i].Name(), err)
}
}
@@ -781,15 +1007,22 @@ func (r *Recorder) CleanupSessions(keepCount int) error {
// GetInteractionContent returns the raw content of a recorded interaction.
func (r *Recorder) GetInteractionContent(relPath string) ([]byte, error) {
fullPath := filepath.Join(r.BaseDir, "interactions", relPath)
return os.ReadFile(fullPath)
fullPath, err := r.safeJoin("interactions", relPath)
if err != nil {
return nil, err
}
return r.rootReadFile(fullPath)
}
// ArchiveSession creates a .tar.gz archive of the specified session and writes it to w.
func (r *Recorder) ArchiveSession(sessionID string, w io.Writer) (err error) {
sessionDir := filepath.Join(r.BaseDir, "interactions", sessionID)
sessionDir, err := r.safeJoin("interactions", sessionID)
if err != nil {
return err
}
info, statErr := os.Stat(sessionDir)
info, statErr := r.rootStat(sessionDir)
if statErr != nil {
return statErr
}
@@ -839,11 +1072,12 @@ func (r *Recorder) ArchiveSession(sessionID string, w io.Writer) (err error) {
return nil
}
f, oErr := os.Open(path)
f, oErr := r.rootOpen(path)
if oErr != nil {
return oErr
}
defer f.Close()
defer func() { _ = f.Close() }()
_, cErr := io.Copy(tw, f)
+68
View File
@@ -0,0 +1,68 @@
package setup
import (
"testing"
)
func TestBuildServerHTTPSURL_PortResolution(t *testing.T) {
// HTTPS_PORT must be unset for the env-var path tests to be
// meaningful. t.Setenv("HTTPS_PORT", "") clears it for the duration
// of each subtest.
tests := []struct {
name string
targetURL string
envHTTPSPort string
want string
}{
{
name: "https with explicit port wins over HTTPS_PORT env",
targetURL: "https://soundtouch.fritz.box:443",
envHTTPSPort: "8443",
want: "https://soundtouch.fritz.box:443/health",
},
{
name: "https without explicit port uses 443",
targetURL: "https://soundtouch.fritz.box",
want: "https://soundtouch.fritz.box:443/health",
},
{
name: "http URL falls back to HTTPS_PORT env var",
targetURL: "http://aftertouch.local:8000",
envHTTPSPort: "9443",
want: "https://aftertouch.local:9443/health",
},
{
name: "http URL with no env var defaults to 8443",
targetURL: "http://aftertouch.local:8000",
want: "https://aftertouch.local:8443/health",
},
{
name: "invalid URL returns empty",
targetURL: "::not-a-url",
want: "",
},
{
name: "URL with no hostname returns empty",
targetURL: "http://",
want: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if tc.envHTTPSPort != "" {
t.Setenv("HTTPS_PORT", tc.envHTTPSPort)
} else {
t.Setenv("HTTPS_PORT", "")
}
m := &Manager{}
got := m.buildServerHTTPSURL(tc.targetURL)
if got != tc.want {
t.Errorf("buildServerHTTPSURL(%q) = %q, want %q", tc.targetURL, got, tc.want)
}
})
}
}
+73
View File
@@ -0,0 +1,73 @@
package setup
import (
"errors"
"fmt"
"strings"
)
// FactoryReset issues `sys factorydefault` over the device's port-17000
// diagnostic shell. The device wipes its persistent state (account
// pairing, Wi-Fi credentials, presets, source configuration) and reboots
// into setup mode — broadcasting its own `Bose SoundTouch XXXX` access
// point on 192.0.2.1.
//
// After this call the device is unreachable on the home network until
// the caller pushes new Wi-Fi credentials via PushWiFiCredentials (see
// wifi_provision.go).
func (m *Manager) FactoryReset(deviceIP string) (string, error) {
if m.NewTelnet == nil {
return "", errors.New("FactoryReset: Manager.NewTelnet is nil")
}
var logs strings.Builder
t := m.NewTelnet(deviceIP)
if err := t.Dial(); err != nil {
return logs.String(), fmt.Errorf("telnet dial %s:17000: %w", deviceIP, err)
}
defer func() { _ = t.Close() }()
banner, _ := t.Probe()
if banner != "" {
fmt.Fprintf(&logs, "Telnet banner: %q\n", strings.TrimSpace(banner))
}
resp, err := t.SendCommand("sys factorydefault")
if err != nil {
// A graceful close right after the command is normal — the device
// reboots immediately. We treat "connection closed" responses as
// success rather than failure.
if isExpectedDisconnect(err) {
fmt.Fprintf(&logs, "→ sys factorydefault\n(device disconnected — reset accepted)\n")
return logs.String(), nil
}
return logs.String(), fmt.Errorf("sys factorydefault: %w", err)
}
fmt.Fprintf(&logs, "→ sys factorydefault\n%s\n", strings.TrimRight(resp, "\r\n"))
if isCommandNotFound(resp) {
return logs.String(), fmt.Errorf("device rejected `sys factorydefault` (firmware does not expose this command)")
}
return logs.String(), nil
}
// isExpectedDisconnect reports whether an error from SendCommand is the
// normal "device closed the socket while rebooting" pattern, which we
// see during factory-reset.
func isExpectedDisconnect(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "eof") ||
strings.Contains(msg, "connection reset") ||
strings.Contains(msg, "connection closed") ||
strings.Contains(msg, "broken pipe")
}
+76
View File
@@ -0,0 +1,76 @@
package setup
import (
"errors"
"strings"
"testing"
)
func TestFactoryReset_HappyPath(t *testing.T) {
f := &fakeTelnet{
banner: "BoseDebug>",
responses: map[string]string{"sys factorydefault": "Rebooting...\n"},
}
m := newFakeTelnetManager(f)
logs, err := m.FactoryReset("192.0.2.10")
if err != nil {
t.Fatalf("FactoryReset: %v", err)
}
if len(f.commands) != 1 || f.commands[0] != "sys factorydefault" {
t.Errorf("commands = %v, want [sys factorydefault]", f.commands)
}
if !strings.Contains(logs, "Rebooting") {
t.Errorf("logs missing reboot output: %s", logs)
}
}
func TestFactoryReset_DisconnectIsAcceptedAsSuccess(t *testing.T) {
// Some firmwares drop the socket as soon as the reset starts, before
// they finish writing a response. That's not a failure.
f := &fakeTelnet{
fail: map[string]error{"sys factorydefault": errors.New("read EOF")},
}
m := newFakeTelnetManager(f)
logs, err := m.FactoryReset("192.0.2.10")
if err != nil {
t.Fatalf("disconnect during reset should be treated as success, got: %v", err)
}
if !strings.Contains(logs, "device disconnected") {
t.Errorf("logs should mention the expected disconnect, got: %s", logs)
}
}
func TestFactoryReset_RejectsFirmwareWithoutCommand(t *testing.T) {
// Default fakeTelnet response is "Command not found\n" for unmapped commands.
f := &fakeTelnet{}
m := newFakeTelnetManager(f)
_, err := m.FactoryReset("192.0.2.10")
if err == nil || !strings.Contains(err.Error(), "firmware does not expose") {
t.Errorf("err = %v, want firmware-rejection error", err)
}
}
func TestFactoryReset_NoTelnetClient(t *testing.T) {
m := &Manager{} // NewTelnet nil
_, err := m.FactoryReset("192.0.2.10")
if err == nil || !strings.Contains(err.Error(), "NewTelnet") {
t.Errorf("err = %v, want NewTelnet-nil error", err)
}
}
func TestFactoryReset_DialFailurePropagates(t *testing.T) {
f := &fakeTelnet{dialErr: errors.New("connection refused")}
m := newFakeTelnetManager(f)
_, err := m.FactoryReset("192.0.2.10")
if err == nil || !strings.Contains(err.Error(), "connection refused") {
t.Errorf("err = %v, want dial error", err)
}
}
+322
View File
@@ -0,0 +1,322 @@
package setup
import (
"context"
"errors"
"fmt"
"time"
)
// InitPlan describes everything required to take a factory-reset (or
// freshly-joined) speaker from "on the Wi-Fi" to "fully paired with a
// usable margeAccountUUID, pointing at AfterTouch."
//
// All fields are gathered upfront so the orchestrator can validate the
// plan before touching the device. AccountID may be left empty — the
// orchestrator either reuses the device's existing UUID (if it already
// has one) or generates a fresh 7-digit ID via GenerateAccountID.
type InitPlan struct {
DeviceIP string
ServiceURL string
AccountID string
Language int
DeviceName string
AuthToken string
// SkipURLRewrite skips the telnet envswitch step. The caller asserts
// the device's runtime marge URL already points at AfterTouch (e.g. a
// prior migration run, or a controlled test environment).
SkipURLRewrite bool
// StepTimeout overrides the per-WebSocket-step deadline.
StepTimeout time.Duration
}
// StepKind identifies a step for progress reporting.
type StepKind int
// Step kinds emitted by ExecuteInitPlan. Numbered explicitly so the wire
// format is stable for any future UI/JSON consumer.
const (
StepReadDeviceInfo StepKind = 1
StepURLRewrite StepKind = 2
StepGenerateAccountID StepKind = 3
StepDialWebSocket StepKind = 4
StepSetupStart StepKind = 5
StepIdentifyEnter StepKind = 6
StepLanguage StepKind = 7
StepSetupEnter StepKind = 8
StepIdentifyLeave StepKind = 9
StepName StepKind = 10
StepPairAccount StepKind = 11
StepSetupLeave StepKind = 12
StepPushTelemetry StepKind = 13
StepVerify StepKind = 14
)
// StepStatus is the per-step outcome surfaced via StepEvent.Status.
type StepStatus string
// Step statuses. "skipped" covers both caller-requested skips (e.g.
// SkipURLRewrite) and naturally-empty steps (e.g. SetName with no
// DeviceName change).
const (
StatusRunning StepStatus = "running"
StatusOK StepStatus = "ok"
StatusSkipped StepStatus = "skipped"
StatusFailed StepStatus = "failed"
)
// StepEvent is emitted before and after each step so callers can drive a UI.
type StepEvent struct {
Kind StepKind
Name string
Status StepStatus
Err error
}
// ProgressFunc receives StepEvents as the plan executes. May be nil.
type ProgressFunc func(StepEvent)
// ExecuteInitPlan runs the full speaker-initialization sequence described
// in docs/reference/DEVICE-PAIRING-FLOW.md:
//
// 1. read /info (so we know the device ID and current pairing state)
// 2. rewrite URLs via telnet envswitch (so the device's downstream POST
// after setMargeAccount lands on AfterTouch instead of dead Bose cloud)
// 3. resolve an account ID — reuse an existing margeAccountUUID, otherwise
// generate a fresh non-colliding 7-digit ID
// 4. open the WebSocket setup session
// 5. drive the state machine: SETUP_START → IDENTIFY_ENTER → language →
// SETUP_ENTER → IDENTIFY_LEAVE → name → setMargeAccount → SETUP_LEAVE
// → pushCustomerSupportInfoToMarge
// 6. verify by re-reading /info
//
// The returned InitPlan reflects any defaulting that happened (generated
// account ID, defaulted language, etc.) so callers can persist it.
func (m *Manager) ExecuteInitPlan(ctx context.Context, plan InitPlan, progress ProgressFunc) (InitPlan, error) {
plan, err := applyInitPlanDefaults(plan, m.ServerURL)
if err != nil {
return plan, err
}
emit := func(kind StepKind, name string, status StepStatus, err error) {
if progress != nil {
progress(StepEvent{Kind: kind, Name: name, Status: status, Err: err})
}
}
emit(StepReadDeviceInfo, "read /info", StatusRunning, nil)
info, err := m.GetLiveDeviceInfo(plan.DeviceIP)
if err != nil {
emit(StepReadDeviceInfo, "read /info", StatusFailed, err)
return plan, fmt.Errorf("read /info: %w", err)
}
emit(StepReadDeviceInfo, "read /info", StatusOK, nil)
if rewriteErr := m.runURLRewrite(plan, emit); rewriteErr != nil {
return plan, rewriteErr
}
plan, err = m.resolveAccountID(plan, info, emit)
if err != nil {
return plan, err
}
emit(StepDialWebSocket, "dial websocket", StatusRunning, nil)
if m.NewSession == nil {
nilErr := errors.New("Manager.NewSession is nil — call NewManager or set it explicitly")
emit(StepDialWebSocket, "dial websocket", StatusFailed, nilErr)
return plan, nilErr
}
session, err := m.NewSession(plan.DeviceIP, info.DeviceID, plan.StepTimeout)
if err != nil {
emit(StepDialWebSocket, "dial websocket", StatusFailed, err)
return plan, fmt.Errorf("dial websocket: %w", err)
}
defer func() { _ = session.Close() }()
emit(StepDialWebSocket, "dial websocket", StatusOK, nil)
type stepDef struct {
kind StepKind
name string
skip bool
fn func(context.Context) error
}
steps := []stepDef{
{kind: StepSetupStart, name: "SETUP_START", fn: session.Start},
{kind: StepIdentifyEnter, name: "SETUP_IDENTIFY_DEVICE_ENTER", fn: func(ctx context.Context) error {
// 300_000 ms matches the value captured from the official Bose
// app; the device flashes/beeps for that long while the user
// confirms identity. We pass it explicitly so the wire value
// is decided here rather than inside the session helper.
return session.IdentifyEnter(ctx, 300000)
}},
{kind: StepLanguage, name: fmt.Sprintf("sysLanguage=%d", plan.Language), fn: func(ctx context.Context) error {
return session.SetLanguage(ctx, plan.Language)
}},
{kind: StepSetupEnter, name: "SETUP_ENTER", fn: session.Enter},
{kind: StepIdentifyLeave, name: "SETUP_IDENTIFY_DEVICE_LEAVE", fn: session.IdentifyLeave},
{kind: StepName, name: "name=" + plan.DeviceName, skip: plan.DeviceName == "", fn: func(ctx context.Context) error {
return session.SetName(ctx, plan.DeviceName)
}},
{kind: StepPairAccount, name: "setMargeAccount=" + plan.AccountID, fn: func(ctx context.Context) error {
return session.SetMargeAccount(ctx, plan.AccountID, plan.AuthToken)
}},
{kind: StepSetupLeave, name: "SETUP_LEAVE", fn: session.Leave},
{kind: StepPushTelemetry, name: "pushCustomerSupportInfoToMarge", fn: session.PushCustomerSupportInfo},
}
for _, st := range steps {
if st.skip {
emit(st.kind, st.name+" (no change)", StatusSkipped, nil)
continue
}
emit(st.kind, st.name, StatusRunning, nil)
if stepErr := st.fn(ctx); stepErr != nil {
emit(st.kind, st.name, StatusFailed, stepErr)
return plan, fmt.Errorf("%s: %w", st.name, stepErr)
}
emit(st.kind, st.name, StatusOK, nil)
}
if err := m.verifyPairing(plan, emit); err != nil {
return plan, err
}
return plan, nil
}
// applyInitPlanDefaults validates required fields and fills in defaults
// from Manager.ServerURL / sysLanguage 2 / "Bearer aftertouch".
func applyInitPlanDefaults(plan InitPlan, serverURL string) (InitPlan, error) {
if plan.DeviceIP == "" {
return plan, errors.New("InitPlan.DeviceIP is required")
}
if plan.ServiceURL == "" {
plan.ServiceURL = serverURL
}
if plan.ServiceURL == "" {
return plan, errors.New("InitPlan.ServiceURL is required (and Manager.ServerURL is empty)")
}
if plan.Language == 0 {
plan.Language = LanguageEnglish
}
if plan.AuthToken == "" {
plan.AuthToken = "Bearer aftertouch"
}
return plan, nil
}
// runURLRewrite applies the telnet envswitch URL rewrite step unless the
// caller asked to skip it.
func (m *Manager) runURLRewrite(plan InitPlan, emit func(StepKind, string, StepStatus, error)) error {
if plan.SkipURLRewrite {
emit(StepURLRewrite, "telnet URL rewrite", StatusSkipped, nil)
return nil
}
emit(StepURLRewrite, "telnet URL rewrite", StatusRunning, nil)
urls := defaultTelnetURLs(plan.ServiceURL)
if _, rwErr := m.migrateViaTelnet(plan.DeviceIP, plan.ServiceURL, urls); rwErr != nil {
emit(StepURLRewrite, "telnet URL rewrite", StatusFailed, rwErr)
return fmt.Errorf("URL rewrite: %w", rwErr)
}
emit(StepURLRewrite, "telnet URL rewrite", StatusOK, nil)
return nil
}
// resolveAccountID populates plan.AccountID — reusing the device's
// existing margeAccountUUID, generating a fresh non-colliding 7-digit
// ID, or validating a user-supplied value.
func (m *Manager) resolveAccountID(plan InitPlan, info *DeviceInfoXML, emit func(StepKind, string, StepStatus, error)) (InitPlan, error) {
if plan.AccountID != "" {
if !IsValidAccountID(plan.AccountID) {
invalidErr := fmt.Errorf("invalid AccountID %q: must be exactly 7 digits", plan.AccountID)
emit(StepGenerateAccountID, "validate account ID", StatusFailed, invalidErr)
return plan, invalidErr
}
return plan, nil
}
if info.MargeAccountUUID != "" && IsValidAccountID(info.MargeAccountUUID) {
plan.AccountID = info.MargeAccountUUID
emit(StepGenerateAccountID, "reuse existing margeAccountUUID="+plan.AccountID, StatusOK, nil)
return plan, nil
}
emit(StepGenerateAccountID, "generate account ID", StatusRunning, nil)
id, genErr := GenerateAccountID(listKnownAccountIDs(m))
if genErr != nil {
emit(StepGenerateAccountID, "generate account ID", StatusFailed, genErr)
return plan, fmt.Errorf("generate account ID: %w", genErr)
}
plan.AccountID = id
emit(StepGenerateAccountID, "generate account ID="+id, StatusOK, nil)
return plan, nil
}
// verifyPairing re-reads /info after the state machine finished and
// confirms the device's margeAccountUUID matches what we asked for.
func (m *Manager) verifyPairing(plan InitPlan, emit func(StepKind, string, StepStatus, error)) error {
emit(StepVerify, "verify /info margeAccountUUID", StatusRunning, nil)
verify, err := m.GetLiveDeviceInfo(plan.DeviceIP)
if err != nil {
emit(StepVerify, "verify /info", StatusFailed, err)
return fmt.Errorf("verify /info: %w", err)
}
if verify.MargeAccountUUID != plan.AccountID {
mismatchErr := fmt.Errorf("post-init /info shows margeAccountUUID=%q, want %q", verify.MargeAccountUUID, plan.AccountID)
emit(StepVerify, "verify /info", StatusFailed, mismatchErr)
return mismatchErr
}
emit(StepVerify, "verify /info margeAccountUUID="+plan.AccountID, StatusOK, nil)
return nil
}
// listKnownAccountIDs collects account IDs already known to the local
// datastore so GenerateAccountID can avoid collisions. Returns nil when
// no datastore is configured or it errors — uniqueness is best-effort.
func listKnownAccountIDs(m *Manager) []string {
if m.DataStore == nil {
return nil
}
ids, err := m.DataStore.ListAccounts()
if err != nil {
return nil
}
return ids
}
+380
View File
@@ -0,0 +1,380 @@
package setup
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
)
// fakeSession is a StateMachine that records the order of
// invocations and lets each test inject per-step errors.
type fakeSession struct {
calls []string
errors map[string]error
closed bool
}
func (f *fakeSession) record(name string) error {
if e, ok := f.errors[name]; ok && e != nil {
return e
}
f.calls = append(f.calls, name)
return nil
}
func (f *fakeSession) Start(_ context.Context) error { return f.record("Start") }
func (f *fakeSession) Enter(_ context.Context) error { return f.record("Enter") }
func (f *fakeSession) Leave(_ context.Context) error { return f.record("Leave") }
func (f *fakeSession) IdentifyLeave(_ context.Context) error {
return f.record("IdentifyLeave")
}
func (f *fakeSession) IdentifyEnter(_ context.Context, timeoutMs int) error {
return f.record(fmt.Sprintf("IdentifyEnter(%d)", timeoutMs))
}
func (f *fakeSession) SetLanguage(_ context.Context, code int) error {
return f.record(fmt.Sprintf("SetLanguage(%d)", code))
}
func (f *fakeSession) SetName(_ context.Context, name string) error {
return f.record("SetName(" + name + ")")
}
func (f *fakeSession) SetMargeAccount(_ context.Context, accountID, token string) error {
return f.record(fmt.Sprintf("SetMargeAccount(%s,%s)", accountID, token))
}
func (f *fakeSession) PushCustomerSupportInfo(_ context.Context) error {
return f.record("PushCustomerSupportInfo")
}
func (f *fakeSession) Close() error {
f.closed = true
return nil
}
// fakeInfoResponder produces an http.Response carrying canned /info XML.
// pairedAccount toggles between "unpaired" and "paired with this UUID."
type fakeInfoResponder struct {
deviceID string
paired string // empty = unpaired
postInitPaired string // /info reading after the plan ran
reads int
}
func (f *fakeInfoResponder) get(_ string) (*http.Response, error) {
f.reads++
acct := f.paired
if f.reads >= 2 && f.postInitPaired != "" {
acct = f.postInitPaired
}
body := fmt.Sprintf(
`<info deviceID="%s"><name>Test</name><margeAccountUUID>%s</margeAccountUUID></info>`,
f.deviceID, acct,
)
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{},
}, nil
}
func newTestManagerWithFakes(t *testing.T, info *fakeInfoResponder, sess *fakeSession) *Manager {
t.Helper()
m := &Manager{
ServerURL: "http://aftertouch.local:8000",
HTTPGet: info.get,
NewSession: func(_, _ string, _ time.Duration) (StateMachine, error) {
return sess, nil
},
}
return m
}
func TestExecuteInitPlan_FactoryReset_GeneratesAccountAndRunsAllSteps(t *testing.T) {
info := &fakeInfoResponder{
deviceID: "AABBCCDDEEFF",
paired: "",
postInitPaired: "", // filled below after we know which ID was generated
}
sess := &fakeSession{}
m := newTestManagerWithFakes(t, info, sess)
// Intercept the generated account ID so we can prime the post-init
// /info read to return it. Easiest way: pre-supply a known AccountID.
plan := InitPlan{
DeviceIP: "192.0.2.10",
AccountID: "1234567",
DeviceName: "Living Room",
SkipURLRewrite: true,
}
info.postInitPaired = "1234567"
var events []StepEvent
got, err := m.ExecuteInitPlan(context.Background(), plan, func(e StepEvent) {
events = append(events, e)
})
if err != nil {
t.Fatalf("ExecuteInitPlan: %v", err)
}
if got.AccountID != "1234567" {
t.Errorf("AccountID = %q, want 1234567", got.AccountID)
}
if got.Language != LanguageEnglish {
t.Errorf("Language = %d, want %d (default English)", got.Language, LanguageEnglish)
}
wantCalls := []string{
"Start",
"IdentifyEnter(300000)",
"SetLanguage(2)",
"Enter",
"IdentifyLeave",
"SetName(Living Room)",
"SetMargeAccount(1234567,Bearer aftertouch)",
"Leave",
"PushCustomerSupportInfo",
}
if got, want := strings.Join(sess.calls, "|"), strings.Join(wantCalls, "|"); got != want {
t.Errorf("call order mismatch\n got: %s\nwant: %s", got, want)
}
if !sess.closed {
t.Error("expected session to be closed")
}
// Verify the URL-rewrite event was emitted as Skipped, not silently dropped.
if !hasEvent(events, StepURLRewrite, StatusSkipped) {
t.Errorf("expected StepURLRewrite Skipped event, got %v", eventSummary(events))
}
// Final verify step must report OK.
if !hasEvent(events, StepVerify, StatusOK) {
t.Errorf("expected StepVerify OK, got %v", eventSummary(events))
}
}
func TestExecuteInitPlan_ReusesExistingAccountUUID(t *testing.T) {
info := &fakeInfoResponder{
deviceID: "AABBCCDDEEFF",
paired: "9876543",
postInitPaired: "9876543",
}
sess := &fakeSession{}
m := newTestManagerWithFakes(t, info, sess)
plan := InitPlan{
DeviceIP: "192.0.2.10",
SkipURLRewrite: true,
}
got, err := m.ExecuteInitPlan(context.Background(), plan, nil)
if err != nil {
t.Fatalf("ExecuteInitPlan: %v", err)
}
if got.AccountID != "9876543" {
t.Errorf("AccountID = %q, want 9876543 (the device's existing UUID)", got.AccountID)
}
}
func TestExecuteInitPlan_GeneratesAccountWhenDeviceUUIDInvalid(t *testing.T) {
// Devices that report a non-7-digit UUID (e.g. a stale local value) must
// not be reused — we treat them as factory-reset for ID purposes.
info := &fakeInfoResponder{
deviceID: "AABBCCDDEEFF",
paired: "not-7-digits",
postInitPaired: "", // we'll learn the generated ID from the result
}
sess := &fakeSession{}
m := newTestManagerWithFakes(t, info, sess)
// Pre-generate so the post-init /info knows what to return.
plan := InitPlan{DeviceIP: "192.0.2.10", SkipURLRewrite: true}
// Track the generated AccountID and feed it back as the post-init /info value.
progress := func(e StepEvent) {
if e.Kind == StepGenerateAccountID && e.Status == StatusOK && strings.Contains(e.Name, "generate account ID=") {
info.postInitPaired = strings.TrimPrefix(e.Name, "generate account ID=")
}
}
got, err := m.ExecuteInitPlan(context.Background(), plan, progress)
if err != nil {
t.Fatalf("ExecuteInitPlan: %v", err)
}
if !IsValidAccountID(got.AccountID) {
t.Errorf("got.AccountID = %q, want a valid 7-digit ID", got.AccountID)
}
if got.AccountID == "not-7-digits" {
t.Error("orchestrator should not reuse an invalid UUID")
}
}
func TestExecuteInitPlan_RejectsInvalidSuppliedAccountID(t *testing.T) {
info := &fakeInfoResponder{deviceID: "X", paired: ""}
sess := &fakeSession{}
m := newTestManagerWithFakes(t, info, sess)
plan := InitPlan{
DeviceIP: "192.0.2.10",
AccountID: "abc",
SkipURLRewrite: true,
}
_, err := m.ExecuteInitPlan(context.Background(), plan, nil)
if err == nil {
t.Fatal("expected error for invalid AccountID")
}
if !strings.Contains(err.Error(), "invalid AccountID") {
t.Errorf("err = %v, want to mention invalid AccountID", err)
}
if len(sess.calls) != 0 {
t.Errorf("expected zero WS calls after rejection, got %v", sess.calls)
}
}
func TestExecuteInitPlan_StopsAtFirstFailedStep(t *testing.T) {
info := &fakeInfoResponder{deviceID: "X", paired: "", postInitPaired: "1234567"}
sess := &fakeSession{
errors: map[string]error{
"Enter": errors.New("device dropped the SETUP_ENTER frame"),
},
}
m := newTestManagerWithFakes(t, info, sess)
plan := InitPlan{
DeviceIP: "192.0.2.10",
AccountID: "1234567",
SkipURLRewrite: true,
}
var events []StepEvent
_, err := m.ExecuteInitPlan(context.Background(), plan, func(e StepEvent) { events = append(events, e) })
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "SETUP_ENTER") {
t.Errorf("err = %v, want to mention SETUP_ENTER", err)
}
// Steps after the failed one must not have been called.
for _, c := range sess.calls {
if c == "Leave" || c == "PushCustomerSupportInfo" {
t.Errorf("unexpected post-failure call %q", c)
}
}
if !hasEvent(events, StepSetupEnter, StatusFailed) {
t.Errorf("expected StepSetupEnter Failed event, got %v", eventSummary(events))
}
}
func TestExecuteInitPlan_EmptyDeviceNameSkipsNameStep(t *testing.T) {
info := &fakeInfoResponder{deviceID: "X", paired: "", postInitPaired: "1234567"}
sess := &fakeSession{}
m := newTestManagerWithFakes(t, info, sess)
plan := InitPlan{
DeviceIP: "192.0.2.10",
AccountID: "1234567",
SkipURLRewrite: true,
// DeviceName intentionally empty
}
if _, err := m.ExecuteInitPlan(context.Background(), plan, nil); err != nil {
t.Fatalf("ExecuteInitPlan: %v", err)
}
for _, c := range sess.calls {
if strings.HasPrefix(c, "SetName(") {
t.Errorf("SetName should be skipped when DeviceName is empty, but was called: %q", c)
}
}
}
func TestExecuteInitPlan_RequiresDeviceIP(t *testing.T) {
m := &Manager{ServerURL: "http://aftertouch.local:8000"}
_, err := m.ExecuteInitPlan(context.Background(), InitPlan{}, nil)
if err == nil || !strings.Contains(err.Error(), "DeviceIP") {
t.Errorf("err = %v, want to mention DeviceIP", err)
}
}
func TestExecuteInitPlan_RequiresServiceURL(t *testing.T) {
m := &Manager{}
_, err := m.ExecuteInitPlan(context.Background(), InitPlan{DeviceIP: "192.0.2.10"}, nil)
if err == nil || !strings.Contains(err.Error(), "ServiceURL") {
t.Errorf("err = %v, want to mention ServiceURL", err)
}
}
func TestExecuteInitPlan_FailsOnPostInitVerifyMismatch(t *testing.T) {
// Device's post-init /info still reports the old account — surface
// that as a verification failure rather than a silent success.
info := &fakeInfoResponder{
deviceID: "X",
paired: "",
postInitPaired: "9999999", // not equal to plan.AccountID
}
sess := &fakeSession{}
m := newTestManagerWithFakes(t, info, sess)
plan := InitPlan{
DeviceIP: "192.0.2.10",
AccountID: "1234567",
SkipURLRewrite: true,
}
_, err := m.ExecuteInitPlan(context.Background(), plan, nil)
if err == nil {
t.Fatal("expected verification error")
}
if !strings.Contains(err.Error(), "margeAccountUUID") {
t.Errorf("err = %v, want to mention margeAccountUUID mismatch", err)
}
}
func hasEvent(events []StepEvent, kind StepKind, status StepStatus) bool {
for _, e := range events {
if e.Kind == kind && e.Status == status {
return true
}
}
return false
}
func eventSummary(events []StepEvent) string {
parts := make([]string, 0, len(events))
for _, e := range events {
parts = append(parts, fmt.Sprintf("%d/%s", e.Kind, e.Status))
}
return strings.Join(parts, ",")
}
+157
View File
@@ -0,0 +1,157 @@
package setup
import (
"encoding/xml"
"errors"
"fmt"
"io"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
// InspectOptions controls how Manager.Inspect probes the speaker.
type InspectOptions struct {
// IncludeTelnet runs `getpdo CurrentSystemConfiguration` over telnet to
// capture the speaker's runtime URL configuration. Slower and not
// always reachable on hardened firmware, hence opt-in.
IncludeTelnet bool
}
// InspectSection is one slice of an InspectReport. Each section has an
// independent error so a partial failure (e.g. /presets refused) does not
// hide the rest of the report.
type InspectSection struct {
Name string
Err error
}
// InspectReport summarises everything we can learn about a speaker
// without writing to it. Used to populate UI before factory-reset / pair
// flows and to record the deviceID-suffix for later wait-online calls.
type InspectReport struct {
DeviceIP string
Info *DeviceInfoXML `json:"info,omitempty"`
InfoErr error `json:"-"`
Network *models.NetworkInformation `json:"network,omitempty"`
NetworkErr error `json:"-"`
Sources *models.Sources `json:"sources,omitempty"`
SourcesErr error `json:"-"`
Presets *PresetList `json:"presets,omitempty"`
PresetsErr error `json:"-"`
RuntimeURLs string `json:"runtime_urls,omitempty"`
RuntimeErr error `json:"-"`
}
// PresetList is a minimal preset summary — just enough to render a
// "preset N: <name>" overview. The full preset model lives in
// pkg/models, but we don't need it here.
type PresetList struct {
XMLName xml.Name `xml:"presets"`
Presets []struct {
ID string `xml:"id,attr"`
ContentItem struct {
Source string `xml:"source,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
Type string `xml:"type,attr"`
ItemName string `xml:"itemName"`
} `xml:"ContentItem"`
} `xml:"preset"`
}
// Inspect gathers a non-destructive snapshot of the speaker at deviceIP.
// Every probe is best-effort: individual section errors are recorded on
// the report rather than aborting the whole call.
func (m *Manager) Inspect(deviceIP string, opts InspectOptions) *InspectReport {
r := &InspectReport{DeviceIP: deviceIP}
r.Info, r.InfoErr = m.GetLiveDeviceInfo(deviceIP)
r.Network, r.NetworkErr = m.fetchNetworkInfo(deviceIP)
r.Sources, r.SourcesErr = m.fetchSources(deviceIP)
r.Presets, r.PresetsErr = m.fetchPresets(deviceIP)
if opts.IncludeTelnet {
r.RuntimeURLs, r.RuntimeErr = m.fetchRuntimeURLs(deviceIP)
}
return r
}
func (m *Manager) fetchXML(deviceIP, path string, out any) error {
url := buildDeviceURL(deviceIP, path)
resp, err := m.HTTPGet(url)
if err != nil {
return fmt.Errorf("GET %s: %w", url, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("GET %s returned %d", url, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read %s: %w", url, err)
}
return xml.Unmarshal(body, out)
}
func (m *Manager) fetchNetworkInfo(deviceIP string) (*models.NetworkInformation, error) {
var n models.NetworkInformation
if err := m.fetchXML(deviceIP, "/networkInfo", &n); err != nil {
return nil, err
}
return &n, nil
}
func (m *Manager) fetchSources(deviceIP string) (*models.Sources, error) {
var s models.Sources
if err := m.fetchXML(deviceIP, "/sources", &s); err != nil {
return nil, err
}
return &s, nil
}
func (m *Manager) fetchPresets(deviceIP string) (*PresetList, error) {
var p PresetList
if err := m.fetchXML(deviceIP, "/presets", &p); err != nil {
return nil, err
}
return &p, nil
}
// fetchRuntimeURLs reads the device's runtime URL configuration via the
// port-17000 diagnostic shell. The response is a multi-line text blob —
// we return it as-is so the caller can decide how to format it.
func (m *Manager) fetchRuntimeURLs(deviceIP string) (string, error) {
if m.NewTelnet == nil {
return "", errors.New("telnet probe disabled: Manager.NewTelnet is nil")
}
t := m.NewTelnet(deviceIP)
if err := t.Dial(); err != nil {
return "", fmt.Errorf("telnet dial %s:17000: %w", deviceIP, err)
}
defer func() { _ = t.Close() }()
_, _ = t.Probe()
resp, err := t.SendCommand("getpdo CurrentSystemConfiguration")
if err != nil {
return "", fmt.Errorf("getpdo: %w", err)
}
if isCommandNotFound(resp) {
return "", errors.New("device rejected `getpdo` (firmware does not expose this command)")
}
return strings.TrimRight(resp, "\r\n"), nil
}
+181
View File
@@ -0,0 +1,181 @@
package setup
import (
"errors"
"io"
"net/http"
"strings"
"testing"
)
// inspectFakes wires canned XML bodies into Manager.HTTPGet keyed by URL
// path. A missing path returns 404; an empty-string body returns the
// supplied err.
type inspectFakes struct {
responses map[string]string
errs map[string]error
}
func (f *inspectFakes) get(url string) (*http.Response, error) {
for path, body := range f.responses {
if strings.HasSuffix(url, path) {
if e := f.errs[path]; e != nil {
return nil, e
}
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{},
}, nil
}
}
return &http.Response{
StatusCode: 404,
Body: io.NopCloser(strings.NewReader("not found")),
Header: http.Header{},
}, nil
}
func TestInspect_HappyPath(t *testing.T) {
f := &inspectFakes{
responses: map[string]string{
"/info": `<info deviceID="506583DE4803">
<name>Bose SoundTouch DE4803</name>
<type>SoundTouch 10</type>
<margeAccountUUID>1234567</margeAccountUUID>
<margeURL>http://aftertouch.local:8000</margeURL>
<components>
<component>
<componentCategory>SCM</componentCategory>
<softwareVersion>27.0.6</softwareVersion>
<serialNumber>F23456789012</serialNumber>
</component>
</components>
</info>`,
"/networkInfo": `<networkInfo wifiProfileCount="1">
<interfaces>
<interface type="WIFI_INTERFACE" name="wlan0" macAddress="aa:bb:cc:dd:ee:ff" ipAddress="192.168.1.42" ssid="MyHomeNetwork" frequencyKHz="2452000" state="NETWORK_WIFI_CONNECTED" signal="GOOD_SIGNAL" mode="STATION"/>
</interfaces>
</networkInfo>`,
"/sources": `<sources><sourceItem source="TUNEIN" status="READY"/><sourceItem source="SPOTIFY" sourceAccount="user@example.com" status="READY"/></sources>`,
"/presets": `<presets><preset id="1"><ContentItem source="TUNEIN" type="stationurl"><itemName>1LIVE</itemName></ContentItem></preset></presets>`,
},
}
m := &Manager{HTTPGet: f.get}
r := m.Inspect("192.168.1.42", InspectOptions{})
if r.InfoErr != nil {
t.Errorf("InfoErr = %v, want nil", r.InfoErr)
}
if r.Info == nil || r.Info.DeviceID != "506583DE4803" {
t.Errorf("Info.DeviceID = %v, want 506583DE4803", r.Info)
}
if r.Info.MargeAccountUUID != "1234567" {
t.Errorf("MargeAccountUUID = %q, want 1234567", r.Info.MargeAccountUUID)
}
if r.Network == nil || len(r.Network.Interfaces.Interfaces) == 0 {
t.Fatalf("Network parse failed: %v / %v", r.Network, r.NetworkErr)
}
wifi := r.Network.Interfaces.Interfaces[0]
if wifi.SSID != "MyHomeNetwork" {
t.Errorf("SSID = %q, want MyHomeNetwork", wifi.SSID)
}
if r.Sources == nil || len(r.Sources.SourceItem) != 2 {
t.Errorf("Sources = %v, want 2 entries", r.Sources)
}
if r.Presets == nil || len(r.Presets.Presets) != 1 {
t.Errorf("Presets = %v, want 1 preset", r.Presets)
}
if r.Presets.Presets[0].ContentItem.ItemName != "1LIVE" {
t.Errorf("preset name = %q, want 1LIVE", r.Presets.Presets[0].ContentItem.ItemName)
}
}
func TestInspect_PartialFailureRecordsPerSectionErrors(t *testing.T) {
// /info ok, /presets returns network error — the rest of the report
// must still populate.
f := &inspectFakes{
responses: map[string]string{
"/info": `<info deviceID="X"><name>n</name></info>`,
"/networkInfo": `<networkInfo><interfaces></interfaces></networkInfo>`,
"/sources": `<sources/>`,
"/presets": "", // body unused — errs map below triggers error
},
errs: map[string]error{
"/presets": errors.New("connection reset"),
},
}
m := &Manager{HTTPGet: f.get}
r := m.Inspect("192.168.1.42", InspectOptions{})
if r.InfoErr != nil {
t.Errorf("InfoErr = %v, want nil", r.InfoErr)
}
if r.PresetsErr == nil {
t.Error("expected PresetsErr to be populated")
}
if r.Sources == nil {
t.Error("Sources should still populate despite PresetsErr")
}
}
func TestInspect_TelnetRuntimeURLs(t *testing.T) {
f := &inspectFakes{
responses: map[string]string{
"/info": `<info deviceID="X"><name>n</name></info>`,
},
}
tn := &fakeTelnet{
responses: map[string]string{
"getpdo CurrentSystemConfiguration": "margeServerUrl: http://aftertouch.local:8000\nstatsServerUrl: http://aftertouch.local:8000\n",
},
}
m := &Manager{
HTTPGet: f.get,
NewTelnet: func(string) TelnetClient { return tn },
}
r := m.Inspect("192.168.1.42", InspectOptions{IncludeTelnet: true})
if r.RuntimeErr != nil {
t.Errorf("RuntimeErr = %v, want nil", r.RuntimeErr)
}
if !strings.Contains(r.RuntimeURLs, "margeServerUrl") {
t.Errorf("RuntimeURLs missing expected content: %q", r.RuntimeURLs)
}
}
func TestInspect_TelnetSkippedWhenOptionDisabled(t *testing.T) {
f := &inspectFakes{
responses: map[string]string{
"/info": `<info deviceID="X"><name>n</name></info>`,
},
}
m := &Manager{HTTPGet: f.get}
r := m.Inspect("192.168.1.42", InspectOptions{IncludeTelnet: false})
if r.RuntimeURLs != "" || r.RuntimeErr != nil {
t.Errorf("telnet runtime fields should be zero when IncludeTelnet=false, got %q / %v",
r.RuntimeURLs, r.RuntimeErr)
}
}
+242
View File
@@ -0,0 +1,242 @@
package setup
import (
"crypto/rand"
"encoding/xml"
"errors"
"fmt"
"io"
"math/big"
"net"
"net/http"
"strings"
"time"
)
// PairAccountTimeouts bounds every step of the pairing call so a wedged
// device cannot stall the migration UI indefinitely.
const (
supportedURLsTimeout = 3 * time.Second
setMargeAccountConn = 5 * time.Second
setMargeAccountTotal = 12 * time.Second
)
// PairAccountResult records what was attempted, so the UI can show a
// breadcrumb of which path actually succeeded (or that both failed).
type PairAccountResult struct {
SetMargeAccountSupported bool `json:"set_marge_account_supported"`
HTTPAttempted bool `json:"http_attempted"`
HTTPError string `json:"http_error,omitempty"`
TelnetAttempted bool `json:"telnet_attempted"`
TelnetError string `json:"telnet_error,omitempty"`
Method string `json:"method"` // "http" | "telnet" | ""
}
// PairAccount associates the speaker at deviceIP with accountID. It tries
// the device's HTTP /setMargeAccount endpoint first; on missing endpoint or
// any time-bounded failure it falls back to a telnet
// `envswitch accountid set <id>` over the supplied client. If telnet is nil
// or also fails, PairAccount returns a structured error explaining the next
// step a user can take.
func (m *Manager) PairAccount(deviceIP, accountID string, t TelnetClient) (PairAccountResult, string, error) {
var (
result PairAccountResult
logs strings.Builder
)
if !IsValidAccountID(accountID) {
return result, "", fmt.Errorf("invalid account ID %q: must be exactly 7 digits", accountID)
}
supported, supportedErr := m.probeSetMargeAccount(deviceIP)
result.SetMargeAccountSupported = supported
switch {
case supportedErr != nil:
fmt.Fprintf(&logs, "supportedURLs probe failed: %v\n", supportedErr)
case supported:
logs.WriteString("supportedURLs lists /setMargeAccount — trying HTTP\n")
default:
logs.WriteString("supportedURLs does NOT list /setMargeAccount — skipping HTTP, going straight to telnet\n")
}
if supported {
result.HTTPAttempted = true
if err := m.postSetMargeAccount(deviceIP, accountID); err != nil {
result.HTTPError = err.Error()
fmt.Fprintf(&logs, "HTTP /setMargeAccount failed: %v\n", err)
} else {
result.Method = "http"
logs.WriteString("HTTP /setMargeAccount succeeded\n")
return result, logs.String(), nil
}
}
if t == nil {
return result, logs.String(), errors.New(
"pairing failed: HTTP /setMargeAccount unavailable and no telnet client supplied — " +
"open the official Bose app and pair manually before EOS, or use the SSH-based XML method")
}
result.TelnetAttempted = true
cmd := "envswitch accountid set " + accountID
resp, err := t.SendCommand(cmd)
if err != nil {
result.TelnetError = err.Error()
return result, logs.String(), fmt.Errorf("HTTP unavailable and telnet fallback failed: %w", err)
}
if isCommandNotFound(resp) {
result.TelnetError = "envswitch accountid: command not found on this firmware"
return result, logs.String(), errors.New(
"pairing failed: HTTP /setMargeAccount missing AND telnet `envswitch accountid` rejected — " +
"firmware does not expose either pairing path")
}
fmt.Fprintf(&logs, "Telnet %q → %s\n", cmd, strings.TrimRight(resp, "\r\n"))
result.Method = "telnet"
return result, logs.String(), nil
}
// probeSetMargeAccount fetches /supportedURLs and reports whether
// /setMargeAccount is in the listing.
func (m *Manager) probeSetMargeAccount(deviceIP string) (bool, error) {
url := buildDeviceURL(deviceIP, "/supportedURLs")
client := &http.Client{Timeout: supportedURLsTimeout}
resp, err := client.Get(url)
if err != nil {
return false, fmt.Errorf("GET %s: %w", url, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("GET %s returned %d", url, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return false, fmt.Errorf("read %s: %w", url, err)
}
var doc struct {
URLs []struct {
Location string `xml:"location,attr"`
} `xml:"URL"`
}
if err := xml.Unmarshal(body, &doc); err != nil {
// Fallback to substring match — some firmwares return a slightly
// different XML root that Go's strict parser refuses.
return strings.Contains(string(body), "/setMargeAccount"), nil
}
for _, u := range doc.URLs {
if u.Location == "/setMargeAccount" {
return true, nil
}
}
return false, nil
}
// postSetMargeAccount sends the pairing XML body to the device's
// /setMargeAccount endpoint with bounded timeouts.
func (m *Manager) postSetMargeAccount(deviceIP, accountID string) error {
url := buildDeviceURL(deviceIP, "/setMargeAccount")
body := fmt.Sprintf(
`<PairDeviceWithAccount><accountId>%s</accountId><userAuthToken>aftertouch</userAuthToken></PairDeviceWithAccount>`,
accountID,
)
client := &http.Client{
Timeout: setMargeAccountTotal,
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: setMargeAccountConn}).DialContext,
ResponseHeaderTimeout: setMargeAccountTotal - setMargeAccountConn,
},
}
resp, err := client.Post(url, "application/xml", strings.NewReader(body))
if err != nil {
return fmt.Errorf("POST %s: %w", url, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("POST %s returned %d: %s", url, resp.StatusCode, strings.TrimSpace(string(respBody)))
}
return nil
}
// buildDeviceURL builds a URL for a SoundTouch device's HTTP API. If
// deviceIP already includes a port (test scenarios using httptest) it is
// reused as-is; otherwise the canonical port 8090 is appended.
func buildDeviceURL(deviceIP, path string) string {
if _, _, err := net.SplitHostPort(deviceIP); err == nil {
return "http://" + deviceIP + path
}
return "http://" + deviceIP + ":8090" + path
}
// IsValidAccountID reports whether s is a syntactically valid SoundTouch
// account ID — exactly 7 numeric digits, the format used by every
// Bose-cloud-issued ID we have observed in captures.
func IsValidAccountID(s string) bool {
if len(s) != 7 {
return false
}
for _, ch := range s {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
// GenerateAccountID returns a fresh 7-digit account ID that does not collide
// with any value in known. It uses crypto/rand and re-rolls on collision.
func GenerateAccountID(known []string) (string, error) {
taken := make(map[string]bool, len(known))
for _, k := range known {
taken[k] = true
}
const maxAttempts = 32
for attempt := 0; attempt < maxAttempts; attempt++ {
// 7-digit space starts at 1_000_000 to avoid leading zeros, ending at
// 9_999_999. Range size is 9_000_000.
n, err := rand.Int(rand.Reader, big.NewInt(9_000_000))
if err != nil {
return "", fmt.Errorf("crypto/rand: %w", err)
}
candidate := fmt.Sprintf("%07d", n.Int64()+1_000_000)
if !taken[candidate] {
return candidate, nil
}
}
return "", errors.New("could not generate a non-colliding account ID after 32 attempts")
}
+305
View File
@@ -0,0 +1,305 @@
package setup
import (
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// fakeDevice spins up an httptest.Server that pretends to be the SoundTouch
// device's :8090 HTTP API. It records POSTs to /setMargeAccount so tests
// can assert on the body.
type fakeDevice struct {
srv *httptest.Server
addr string // "host:port" usable as deviceIP
supportsSetMarge bool
postStatus int // status code returned for POST /setMargeAccount
postDelay time.Duration
gotPostBody string
}
func newFakeDevice(t *testing.T) *fakeDevice {
t.Helper()
d := &fakeDevice{
supportsSetMarge: true,
postStatus: http.StatusOK,
}
mux := http.NewServeMux()
mux.HandleFunc("/supportedURLs", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/xml")
if d.supportsSetMarge {
_, _ = w.Write([]byte(`<supportedURLs><URL location="/setMargeAccount"/><URL location="/info"/></supportedURLs>`))
return
}
_, _ = w.Write([]byte(`<supportedURLs><URL location="/info"/></supportedURLs>`))
})
mux.HandleFunc("/setMargeAccount", func(w http.ResponseWriter, r *http.Request) {
if d.postDelay > 0 {
time.Sleep(d.postDelay)
}
body, _ := io.ReadAll(r.Body)
d.gotPostBody = string(body)
w.WriteHeader(d.postStatus)
})
d.srv = httptest.NewServer(mux)
u := d.srv.URL[len("http://"):]
host, port, err := net.SplitHostPort(u)
if err != nil {
t.Fatalf("split httptest URL: %v", err)
}
d.addr = host + ":" + port
t.Cleanup(d.srv.Close)
return d
}
func TestPairAccount_HappyPathHTTP(t *testing.T) {
d := newFakeDevice(t)
m := &Manager{}
res, _, err := m.PairAccount(d.addr, "1234567", nil)
if err != nil {
t.Fatalf("PairAccount: %v", err)
}
if res.Method != "http" {
t.Errorf("Method = %q, want http", res.Method)
}
if !res.SetMargeAccountSupported {
t.Error("SetMargeAccountSupported should be true")
}
if !res.HTTPAttempted {
t.Error("HTTPAttempted should be true")
}
if res.TelnetAttempted {
t.Error("TelnetAttempted should be false on the happy HTTP path")
}
if !strings.Contains(d.gotPostBody, "<accountId>1234567</accountId>") {
t.Errorf("device received %q, want <accountId>1234567</accountId>", d.gotPostBody)
}
}
func TestPairAccount_FallsBackWhenSetMargeAccountMissing(t *testing.T) {
d := newFakeDevice(t)
d.supportsSetMarge = false
f := &fakeTelnet{
responses: map[string]string{"envswitch accountid set 1234567": "OK\n"},
}
m := &Manager{}
res, _, err := m.PairAccount(d.addr, "1234567", f)
if err != nil {
t.Fatalf("PairAccount: %v", err)
}
if res.Method != "telnet" {
t.Errorf("Method = %q, want telnet", res.Method)
}
if res.SetMargeAccountSupported {
t.Error("SetMargeAccountSupported should be false")
}
if res.HTTPAttempted {
t.Error("HTTPAttempted should be false when supportedURLs reports the endpoint missing")
}
if !res.TelnetAttempted {
t.Error("TelnetAttempted should be true")
}
if len(f.commands) != 1 || f.commands[0] != "envswitch accountid set 1234567" {
t.Errorf("telnet commands = %v, want one envswitch accountid", f.commands)
}
}
func TestPairAccount_FallsBackWhenHTTPReturnsServerError(t *testing.T) {
d := newFakeDevice(t)
d.postStatus = http.StatusBadGateway
f := &fakeTelnet{
responses: map[string]string{"envswitch accountid set 7654321": "OK\n"},
}
m := &Manager{}
res, _, err := m.PairAccount(d.addr, "7654321", f)
if err != nil {
t.Fatalf("PairAccount: %v", err)
}
if res.Method != "telnet" {
t.Errorf("Method = %q, want telnet", res.Method)
}
if res.HTTPError == "" {
t.Error("HTTPError should be populated when POST returned 502")
}
if !res.TelnetAttempted {
t.Error("TelnetAttempted should be true after HTTP failure")
}
}
func TestPairAccount_HTTPSuccessSkipsTelnet(t *testing.T) {
d := newFakeDevice(t)
f := &fakeTelnet{}
m := &Manager{}
res, _, err := m.PairAccount(d.addr, "1234567", f)
if err != nil {
t.Fatalf("PairAccount: %v", err)
}
if res.Method != "http" {
t.Errorf("Method = %q, want http", res.Method)
}
if len(f.commands) != 0 {
t.Errorf("telnet should not have been used; commands = %v", f.commands)
}
}
func TestPairAccount_NoTelnetAndHTTPMissingReturnsClearError(t *testing.T) {
d := newFakeDevice(t)
d.supportsSetMarge = false
m := &Manager{}
_, _, err := m.PairAccount(d.addr, "1234567", nil)
if err == nil {
t.Fatal("expected error when both paths are unavailable")
}
if !strings.Contains(err.Error(), "no telnet client") {
t.Errorf("err = %v, want to mention missing telnet client", err)
}
}
func TestPairAccount_TelnetCommandNotFoundReportsBothPaths(t *testing.T) {
d := newFakeDevice(t)
d.supportsSetMarge = false
f := &fakeTelnet{
responses: map[string]string{"envswitch accountid set 1234567": "Command not found\n"},
}
m := &Manager{}
_, _, err := m.PairAccount(d.addr, "1234567", f)
if err == nil {
t.Fatal("expected error when telnet rejects the fallback")
}
if !strings.Contains(err.Error(), "envswitch") {
t.Errorf("err = %v, want to mention envswitch", err)
}
}
func TestPairAccount_RejectsInvalidAccountID(t *testing.T) {
m := &Manager{}
for _, badID := range []string{"", "12345", "12345678", "abcdefg", "12345 6"} {
_, _, err := m.PairAccount("127.0.0.1:9999", badID, nil)
if err == nil {
t.Errorf("PairAccount accepted invalid ID %q", badID)
}
}
}
func TestPairAccount_TelnetTransportErrorReturned(t *testing.T) {
d := newFakeDevice(t)
d.supportsSetMarge = false
f := &fakeTelnet{
fail: map[string]error{"envswitch accountid set 1234567": errors.New("connection reset")},
}
m := &Manager{}
_, _, err := m.PairAccount(d.addr, "1234567", f)
if err == nil {
t.Fatal("expected telnet transport error to be surfaced")
}
if !strings.Contains(err.Error(), "connection reset") {
t.Errorf("err = %v, want to wrap connection reset", err)
}
}
func TestIsValidAccountID(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"1234567", true},
{"0000000", true},
{"9999999", true},
{"", false},
{"123456", false},
{"12345678", false},
{"123456a", false},
{"-123456", false},
{" 123456", false},
}
for _, tc := range cases {
if got := IsValidAccountID(tc.in); got != tc.want {
t.Errorf("IsValidAccountID(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
func TestGenerateAccountID_AvoidsCollisions(t *testing.T) {
id, err := GenerateAccountID(nil)
if err != nil {
t.Fatalf("GenerateAccountID(nil): %v", err)
}
if !IsValidAccountID(id) {
t.Errorf("generated ID %q is not valid", id)
}
// Block out a fairly small space and check we still get a fresh ID.
known := []string{"1000000", "1000001", "1000002"}
for i := 0; i < 5; i++ {
got, err := GenerateAccountID(known)
if err != nil {
t.Fatalf("GenerateAccountID: %v", err)
}
for _, k := range known {
if got == k {
t.Errorf("generated %q collides with known list %v", got, known)
}
}
}
}
@@ -0,0 +1,156 @@
package setup
import (
"errors"
"testing"
)
func TestIsTelnetMigrated_TargetHostnamePresent(t *testing.T) {
m := &Manager{ServerURL: "http://example:8000"}
summary := &MigrationSummary{
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\nbmxRegistryUrl=http://example:8000/bmx/registry/v1/services\n",
}
if !m.isTelnetMigrated(summary) {
t.Error("isTelnetMigrated = false, want true when getpdo response contains our hostname")
}
}
func TestIsTelnetMigrated_DifferentHostname(t *testing.T) {
m := &Manager{ServerURL: "http://example:8000"}
summary := &MigrationSummary{
TelnetVerifiedConfig: "margeServerUrl=https://streaming.bose.com\n",
}
if m.isTelnetMigrated(summary) {
t.Error("isTelnetMigrated = true, want false when getpdo response points at the original cloud")
}
}
func TestIsTelnetMigrated_EmptyVerifiedConfig(t *testing.T) {
m := &Manager{ServerURL: "http://example:8000"}
summary := &MigrationSummary{} // TelnetVerifiedConfig empty
if m.isTelnetMigrated(summary) {
t.Error("isTelnetMigrated = true, want false when TelnetVerifiedConfig is empty")
}
}
// TestCheckIsMigrated_TelnetOnlyMigratedDevice covers the gap that motivated
// this iteration: SSH is unreachable, but the speaker has been pointed at
// our service via telnet (e.g. a firmware that refuses USB unlock). The
// migration UI must still report IsMigrated: true.
func TestCheckIsMigrated_TelnetOnlyMigratedDevice(t *testing.T) {
m := &Manager{ServerURL: "http://example:8000"}
summary := &MigrationSummary{
SSHSuccess: false,
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n",
}
m.checkIsMigrated(summary, "192.0.2.1")
if !summary.IsMigrated {
t.Error("IsMigrated = false, want true on a telnet-only migrated device with no SSH")
}
}
// TestCheckIsMigrated_NoTelnetNoSSH ensures we don't false-positive when
// neither transport sees the redirect.
func TestCheckIsMigrated_NoTelnetNoSSH(t *testing.T) {
m := &Manager{ServerURL: "http://example:8000"}
summary := &MigrationSummary{
SSHSuccess: false,
TelnetVerifiedConfig: "", // probe failed
}
m.checkIsMigrated(summary, "192.0.2.1")
if summary.IsMigrated {
t.Error("IsMigrated = true, want false when neither SSH nor telnet sees the redirect")
}
}
// TestCheckIsMigrated_PerAxisBooleansArePopulated locks in that each
// axis is reported individually so the UI can show partial-state cells.
// The mock SSH client claims /etc/hosts has Bose redirects; XML is
// unmigrated; resolv has no marker; telnet sees the redirected URL.
// All four axis flags must reflect their independent verdicts and
// IsMigrated must be the OR.
func TestCheckIsMigrated_PerAxisBooleansArePopulated(t *testing.T) {
m := &Manager{
ServerURL: "http://example:8000",
NewSSH: func(string) SSHClient {
return &mockSSH{
runFunc: func(cmd string) (string, error) {
if cmd == "cat /etc/hosts" {
return "192.0.2.1\tstreaming.bose.com\n", nil
}
return "", errors.New("not implemented in this mock")
},
}
},
}
summary := &MigrationSummary{
SSHSuccess: true,
CACertTrusted: true, // hosts migration requires CA trust
ParsedCurrentConfig: &PrivateCfg{
MargeServerUrl: "https://streaming.bose.com",
},
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n",
CurrentResolvConf: "nameserver 8.8.8.8\n",
}
m.checkIsMigrated(summary, "192.0.2.1")
if !summary.TelnetMigrated {
t.Error("TelnetMigrated = false, want true (verified config points at example)")
}
if summary.XMLMigrated {
t.Error("XMLMigrated = true, want false (parsed XML still points at streaming.bose.com)")
}
if !summary.HostsMigrated {
t.Error("HostsMigrated = false, want true (mock hosts content has Bose redirect + CA trusted)")
}
if summary.ResolvMigrated {
t.Error("ResolvMigrated = true, want false (no marker, no example hostname)")
}
if !summary.IsMigrated {
t.Error("IsMigrated = false, want true (TelnetMigrated || HostsMigrated)")
}
}
// TestCheckIsMigrated_TelnetSeesOriginalSSHSeesOriginal ensures we don't
// false-positive when both transports report unmigrated state.
func TestCheckIsMigrated_TelnetSeesOriginalSSHSeesOriginal(t *testing.T) {
m := &Manager{
ServerURL: "http://example:8000",
NewSSH: func(string) SSHClient {
return &mockSSH{runFunc: func(string) (string, error) { return "", errors.New("file not found") }}
},
}
summary := &MigrationSummary{
SSHSuccess: true,
TelnetVerifiedConfig: "margeServerUrl=https://streaming.bose.com\n",
ParsedCurrentConfig: &PrivateCfg{
MargeServerUrl: "https://streaming.bose.com",
},
CurrentResolvConf: "nameserver 8.8.8.8\n",
}
m.checkIsMigrated(summary, "192.0.2.1")
if summary.IsMigrated {
t.Error("IsMigrated = true, want false when both SSH and telnet see the original cloud URLs")
}
}
@@ -0,0 +1,187 @@
package setup
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// telnetSummaryEnv builds a Manager whose:
// - SSH client is the supplied mockSSH (or a no-op if nil).
// - Telnet client is the supplied fakeTelnet.
// - Live :8090/info call hits an httptest server returning a minimal XML.
//
// The deviceIP returned is the httptest server's listener addr ("host:port"),
// so the live-info call works; the SSH and telnet clients ignore the addr
// and return whatever the fakes are scripted to return.
func telnetSummaryEnv(t *testing.T, ssh *mockSSH, ft *fakeTelnet) (*Manager, string, func()) {
t.Helper()
return telnetSummaryEnvWithInfo(t, ssh, ft, `<info deviceID="123"><name>Test</name></info>`)
}
// telnetSummaryEnvWithInfo is telnetSummaryEnv with a caller-supplied
// :8090/info XML body, so individual tests can exercise device-info
// fields that affect summary state (e.g. margeAccountUUID for IsPaired).
func telnetSummaryEnvWithInfo(t *testing.T, ssh *mockSSH, ft *fakeTelnet, infoXML string) (*Manager, string, func()) {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
_, _ = fmt.Fprint(w, infoXML)
}))
m := NewManager("http://example:8000", nil, nil)
m.NewSSH = func(string) SSHClient {
if ssh != nil {
return ssh
}
return &mockSSH{runFunc: func(string) (string, error) { return "", errors.New("ssh disabled in test") }}
}
m.NewTelnet = func(string) TelnetClient { return ft }
return m, server.Listener.Addr().String(), server.Close
}
func TestGetMigrationSummary_TelnetSucceedsSSHFails(t *testing.T) {
target := "http://example:8000"
ft := &fakeTelnet{
banner: "BoseShell\n-> ",
responses: map[string]string{
"getpdo CurrentSystemConfiguration": "margeServerUrl=" + target + "\n",
},
}
m, host, cleanup := telnetSummaryEnv(t, nil, ft)
defer cleanup()
summary, err := m.GetMigrationSummary(host, "", "", nil)
if err != nil {
t.Fatalf("GetMigrationSummary: %v", err)
}
if summary.SSHSuccess {
t.Errorf("SSHSuccess = true, want false")
}
if !summary.TelnetReachable {
t.Errorf("TelnetReachable = false, want true")
}
if !strings.Contains(summary.TelnetBanner, "BoseShell") {
t.Errorf("TelnetBanner = %q, want it to contain BoseShell", summary.TelnetBanner)
}
if !strings.Contains(summary.TelnetVerifiedConfig, target) {
t.Errorf("TelnetVerifiedConfig = %q, want it to contain %q", summary.TelnetVerifiedConfig, target)
}
}
func TestGetMigrationSummary_TelnetFailsSSHFails(t *testing.T) {
ft := &fakeTelnet{dialErr: errors.New("connection refused")}
m, host, cleanup := telnetSummaryEnv(t, nil, ft)
defer cleanup()
summary, err := m.GetMigrationSummary(host, "", "", nil)
if err != nil {
t.Fatalf("GetMigrationSummary: %v", err)
}
if summary.SSHSuccess {
t.Errorf("SSHSuccess = true, want false")
}
if summary.TelnetReachable {
t.Errorf("TelnetReachable = true, want false")
}
if !strings.Contains(summary.TelnetProbeError, "connection refused") {
t.Errorf("TelnetProbeError = %q, want connection refused", summary.TelnetProbeError)
}
}
func TestGetMigrationSummary_IsPairedFromLiveInfo(t *testing.T) {
ft := &fakeTelnet{dialErr: errors.New("not the focus of this test")}
t.Run("with margeAccountUUID", func(t *testing.T) {
m, host, cleanup := telnetSummaryEnvWithInfo(t, nil, ft,
`<info deviceID="123"><name>Test</name><margeAccountUUID>3230304</margeAccountUUID></info>`,
)
defer cleanup()
summary, err := m.GetMigrationSummary(host, "", "", nil)
if err != nil {
t.Fatalf("GetMigrationSummary: %v", err)
}
if !summary.IsPaired {
t.Errorf("IsPaired = false, want true (margeAccountUUID present in :8090/info)")
}
if summary.AccountID != "3230304" {
t.Errorf("AccountID = %q, want 3230304 (live info should populate)", summary.AccountID)
}
})
t.Run("without margeAccountUUID", func(t *testing.T) {
m, host, cleanup := telnetSummaryEnvWithInfo(t, nil, ft,
`<info deviceID="123"><name>Test</name><margeAccountUUID></margeAccountUUID></info>`,
)
defer cleanup()
summary, err := m.GetMigrationSummary(host, "", "", nil)
if err != nil {
t.Fatalf("GetMigrationSummary: %v", err)
}
if summary.IsPaired {
t.Errorf("IsPaired = true, want false (factory-reset device with empty margeAccountUUID)")
}
})
}
func TestGetMigrationSummary_TelnetSucceedsSSHSucceeds(t *testing.T) {
target := "http://example:8000"
ft := &fakeTelnet{
responses: map[string]string{
"getpdo CurrentSystemConfiguration": "margeServerUrl=" + target + "\n",
},
}
// SSH mock returns enough for SSHSuccess to be true (cat /opt/Bose/etc/...).
ssh := &mockSSH{
runFunc: func(cmd string) (string, error) {
switch {
case strings.HasPrefix(cmd, "cat "+SoundTouchSdkPrivateCfgPath):
return `<?xml version="1.0"?><SoundTouchSdkPrivateCfg><margeServerUrl>` + target + `</margeServerUrl></SoundTouchSdkPrivateCfg>`, nil
case strings.HasPrefix(cmd, "[ -f"):
return "", errors.New("not found")
default:
return "", nil
}
},
}
m, host, cleanup := telnetSummaryEnv(t, ssh, ft)
defer cleanup()
summary, err := m.GetMigrationSummary(host, "", "", nil)
if err != nil {
t.Fatalf("GetMigrationSummary: %v", err)
}
if !summary.SSHSuccess {
t.Errorf("SSHSuccess = false, want true")
}
if !summary.TelnetReachable {
t.Errorf("TelnetReachable = false, want true")
}
if !strings.Contains(summary.TelnetVerifiedConfig, target) {
t.Errorf("TelnetVerifiedConfig = %q, want %q", summary.TelnetVerifiedConfig, target)
}
}
+94
View File
@@ -0,0 +1,94 @@
package setup
import (
"errors"
"fmt"
"time"
)
// PeerHit is the payload the observer middleware delivers to a probe
// waiter when a request from a registered peer IP lands on the service.
type PeerHit struct {
Path string
At time.Time
}
// PeerObserverHandle is the abstract view of the peer-observer registry
// the probe needs: register interest in an IP, eventually forget it.
// The handlers package's peerObserver satisfies this implicitly.
type PeerObserverHandle interface {
Register(ip string) <-chan PeerHit
Forget(ip string)
}
// PeerProbeResult is the JSON-serializable outcome of a passive
// reachability probe. Reached is the canonical success bit the UI keys
// off; ObservedPath and ElapsedMs are diagnostic.
type PeerProbeResult struct {
Reached bool `json:"reached"`
ObservedPath string `json:"observed_path,omitempty"`
ElapsedMs int64 `json:"elapsed_ms"`
}
// RunPeerReachabilityProbe is the post-migration reachability check
// that replaces the active swUpdateUrl round-trip. The sequence:
//
// 1. Register the device IP with the observer.
// 2. Nudge :8090/swUpdateCheck on the device to make the swUpdate
// daemon fan out *something* sooner than its own ~5min timer.
// 3. Wait up to timeout for any inbound from that IP.
//
// Any inbound counts as proof of reachability — on a migrated speaker,
// DNS interception means the daemon's outbounds (update fan-out, marge
// polls, BMX registry calls) all funnel through this service regardless
// of which URL the daemon resolved internally. We don't need a specific
// URL to land; we just need *the device* to dial us.
//
// The nudge is fire-and-forget. If :8090 is unreachable, the request
// returns quickly and we still wait for the daemon's own next fan-out
// (or time out). No state on the device is mutated; the probe is safe
// to re-run.
func (m *Manager) RunPeerReachabilityProbe(deviceIP string, observer PeerObserverHandle, timeout time.Duration) (*PeerProbeResult, error) {
if observer == nil {
return nil, errors.New("peer probe not configured: observer is nil")
}
if deviceIP == "" {
return nil, errors.New("peer probe: deviceIP is required")
}
hitCh := observer.Register(deviceIP)
defer observer.Forget(deviceIP)
// Nudge the device. Fire-and-forget — we don't gate on the response
// because the swUpdateCheck endpoint returns immediately after
// enqueuing, and the daemon's fan-out is what we actually want to
// observe. HTTPGet can be nil in test contexts.
if m.HTTPGet != nil {
swCheckURL := fmt.Sprintf("http://%s:8090/swUpdateCheck", deviceIP)
go func() {
resp, err := m.HTTPGet(swCheckURL)
if err != nil {
return
}
_ = resp.Body.Close()
}()
}
start := time.Now()
result := &PeerProbeResult{}
select {
case hit := <-hitCh:
result.Reached = true
result.ObservedPath = hit.Path
case <-time.After(timeout):
result.Reached = false
}
result.ElapsedMs = time.Since(start).Milliseconds()
return result, nil
}
+142
View File
@@ -0,0 +1,142 @@
package setup
import (
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
// fakePeerObserver is a deterministic PeerObserverHandle for unit
// tests. It exposes the channel returned from Register so the test can
// signal it manually to simulate a device inbound landing.
type fakePeerObserver struct {
mu sync.Mutex
channels map[string]chan PeerHit
forgotten []string
}
func newFakePeerObserver() *fakePeerObserver {
return &fakePeerObserver{channels: map[string]chan PeerHit{}}
}
func (o *fakePeerObserver) Register(ip string) <-chan PeerHit {
o.mu.Lock()
defer o.mu.Unlock()
ch := make(chan PeerHit, 1)
o.channels[ip] = ch
return ch
}
func (o *fakePeerObserver) Forget(ip string) {
o.mu.Lock()
defer o.mu.Unlock()
delete(o.channels, ip)
o.forgotten = append(o.forgotten, ip)
}
func (o *fakePeerObserver) signal(ip string, hit PeerHit) {
o.mu.Lock()
defer o.mu.Unlock()
ch, ok := o.channels[ip]
if !ok {
return
}
select {
case ch <- hit:
default:
}
}
func peerProbeManager(onTrigger func()) *Manager {
return &Manager{
HTTPGet: func(url string) (*http.Response, error) {
if onTrigger != nil {
onTrigger()
}
rr := httptest.NewRecorder()
rr.WriteHeader(200)
return rr.Result(), nil
},
}
}
func TestRunPeerReachabilityProbe_HappyPath(t *testing.T) {
obs := newFakePeerObserver()
// On nudge, simulate the device fanning out to /updates/soundtouch
// which the middleware would signal as a hit on this IP.
m := peerProbeManager(func() {
obs.signal("192.168.1.42", PeerHit{Path: "/updates/soundtouch", At: time.Now()})
})
result, err := m.RunPeerReachabilityProbe("192.168.1.42", obs, 2*time.Second)
if err != nil {
t.Fatalf("RunPeerReachabilityProbe error: %v", err)
}
if !result.Reached {
t.Error("Reached = false, want true")
}
if result.ObservedPath != "/updates/soundtouch" {
t.Errorf("ObservedPath = %q, want %q", result.ObservedPath, "/updates/soundtouch")
}
if len(obs.forgotten) != 1 || obs.forgotten[0] != "192.168.1.42" {
t.Errorf("Forget not called for IP: forgotten = %v", obs.forgotten)
}
}
func TestRunPeerReachabilityProbe_Timeout(t *testing.T) {
obs := newFakePeerObserver()
m := peerProbeManager(nil) // nudge fires but device never responds
start := time.Now()
result, err := m.RunPeerReachabilityProbe("192.168.1.42", obs, 200*time.Millisecond)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("RunPeerReachabilityProbe error: %v", err)
}
if result.Reached {
t.Error("Reached = true, want false (no hit)")
}
if elapsed < 200*time.Millisecond {
t.Errorf("returned early after %v; expected >= 200ms timeout", elapsed)
}
if len(obs.forgotten) != 1 {
t.Errorf("Forget not called after timeout: forgotten = %v", obs.forgotten)
}
}
func TestRunPeerReachabilityProbe_NilObserver(t *testing.T) {
m := peerProbeManager(nil)
_, err := m.RunPeerReachabilityProbe("192.168.1.42", nil, time.Second)
if err == nil {
t.Error("expected error for nil observer, got nil")
}
}
func TestRunPeerReachabilityProbe_EmptyIP(t *testing.T) {
m := peerProbeManager(nil)
obs := newFakePeerObserver()
_, err := m.RunPeerReachabilityProbe("", obs, time.Second)
if err == nil {
t.Error("expected error for empty deviceIP, got nil")
}
}
func TestRunPeerReachabilityProbe_NilHTTPGetTimesOut(t *testing.T) {
// With nil HTTPGet the nudge is skipped entirely; the probe just
// waits for the device to dial in on its own. Useful in tests and
// in environments where the trigger isn't safe to fire.
m := &Manager{} // HTTPGet nil
obs := newFakePeerObserver()
result, err := m.RunPeerReachabilityProbe("192.168.1.42", obs, 100*time.Millisecond)
if err != nil {
t.Fatalf("error: %v", err)
}
if result.Reached {
t.Error("Reached = true with no nudge and no signal")
}
}
+143
View File
@@ -0,0 +1,143 @@
package setup
import (
"fmt"
"strings"
)
// crossCheckPreflights compares the URL fields visible via SSH (from the
// parsed SoundTouchSdkPrivateCfg.xml) with the same fields visible via
// telnet (from `getpdo CurrentSystemConfiguration`). Any field that is
// reported by both transports but with different values is recorded as
// a non-fatal warning.
//
// In practice the two sources can diverge briefly: `sys configuration …`
// writes the runtime fields, while `envswitch boseurls set …` writes a
// parallel persistence layer that wins on next boot — and the XML file
// is only re-rendered after a reboot. A warning here is therefore not an
// error per se; it usually means "reboot the device to make the two
// layers agree."
func (m *Manager) crossCheckPreflights(summary *MigrationSummary) {
if summary.ParsedCurrentConfig == nil || summary.TelnetVerifiedConfig == "" {
return
}
telnet := parseGetpdoConfig(summary.TelnetVerifiedConfig)
xml := summary.ParsedCurrentConfig
pairs := []struct {
name string
xmlValue string
}{
{"margeServerUrl", xml.MargeServerUrl},
{"statsServerUrl", xml.StatsServerUrl},
{"swUpdateUrl", xml.SwUpdateUrl},
{"bmxRegistryUrl", xml.BmxRegistryUrl},
}
for _, p := range pairs {
telnetValue, hasTelnet := telnet[p.name]
if !hasTelnet || p.xmlValue == "" {
continue
}
if telnetValue == p.xmlValue {
continue
}
summary.Warnings = append(summary.Warnings, fmt.Sprintf(
"%s differs between transports: SSH-XML=%q telnet-getpdo=%q (a reboot usually re-syncs the runtime layer with the persisted XML)",
p.name, p.xmlValue, telnetValue,
))
}
}
// parseGetpdoConfig extracts field values from a `getpdo
// CurrentSystemConfiguration` reply. Two formats are accepted:
//
// 1. Protobuf-text-like nested blocks (the format observed on FW
// 27.0.6 ST 10/20/300 in the wild):
//
// margeServerUrl {
// text: "https://streaming.bose.com"
// }
//
// 2. Flat key=value lines (kept as a tolerance path for firmware
// variants that report differently or for hand-crafted test
// fixtures).
//
// Any line that doesn't match either shape is silently ignored, so the
// parser tolerates banner text, prompt characters (`->`, `->OK`),
// blank lines, and unrelated fields.
func parseGetpdoConfig(text string) map[string]string {
out := map[string]string{}
var currentKey string
for _, raw := range strings.Split(text, "\n") {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
// Block open: "<key> {".
if strings.HasSuffix(line, "{") {
head := strings.TrimSpace(strings.TrimSuffix(line, "{"))
if head != "" && isIdentifier(head) {
currentKey = head
}
continue
}
// Block close.
if line == "}" {
currentKey = ""
continue
}
// "text: ..." inside a block is the field value.
if currentKey != "" && strings.HasPrefix(line, "text:") {
val := strings.TrimSpace(strings.TrimPrefix(line, "text:"))
val = strings.Trim(val, `"`)
out[currentKey] = val
continue
}
// Flat key=value, only if the key is a bare identifier (so we
// don't misread protobuf "text: value" as a key=value pair via
// some other separator).
if i := strings.IndexByte(line, '='); i > 0 {
key := strings.TrimSpace(line[:i])
if key != "" && isIdentifier(key) {
out[key] = strings.TrimSpace(line[i+1:])
}
}
}
return out
}
// isIdentifier reports whether s looks like a configuration field name —
// alphanumeric or underscore only. Used to keep parseGetpdoConfig from
// promoting random "x: y" or "x = y" lines (with spaces, punctuation,
// arrows) into the result map.
func isIdentifier(s string) bool {
if s == "" {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
case r == '_':
default:
return false
}
}
return true
}
@@ -0,0 +1,186 @@
package setup
import (
"strings"
"testing"
)
func TestParseGetpdoConfig_StandardLines(t *testing.T) {
in := "margeServerUrl=http://example:8000\nbmxRegistryUrl=http://example:8000/bmx/registry/v1/services\n"
got := parseGetpdoConfig(in)
if got["margeServerUrl"] != "http://example:8000" {
t.Errorf("margeServerUrl = %q, want http://example:8000", got["margeServerUrl"])
}
if got["bmxRegistryUrl"] != "http://example:8000/bmx/registry/v1/services" {
t.Errorf("bmxRegistryUrl = %q", got["bmxRegistryUrl"])
}
}
func TestParseGetpdoConfig_TolerantToNoise(t *testing.T) {
in := "BoseShell\n-> getpdo CurrentSystemConfiguration\nmargeServerUrl=http://example:8000\nrandom line without equals\n statsServerUrl = http://example:8000 \n-> "
got := parseGetpdoConfig(in)
if got["margeServerUrl"] != "http://example:8000" {
t.Errorf("margeServerUrl = %q, want http://example:8000", got["margeServerUrl"])
}
if got["statsServerUrl"] != "http://example:8000" {
t.Errorf("statsServerUrl = %q, want trimmed http://example:8000", got["statsServerUrl"])
}
if _, exists := got["random line without equals"]; exists {
t.Errorf("non-key=value line should not be parsed")
}
}
// TestParseGetpdoConfig_ProtobufTextRealDevice pins the parser to the
// live response captured from a SoundTouch 20 (FW 27.0.6.46330.5043500)
// against http://mac.fritz.box:8000/setup/summary. This is the format
// the parser actually has to handle in production — the prior
// key=value-only implementation returned an empty map for this input,
// which surfaced as empty "Current on Device" cells in the migration
// UI.
func TestParseGetpdoConfig_ProtobufTextRealDevice(t *testing.T) {
in := `margeServerUrl {
text: "https://streaming.bose.com"
}
statsServerUrl {
text: "https://events.api.bosecm.com"
}
swUpdateUrl {
text: "https://worldwide.bose.com/updates/soundtouch"
}
isZeroconfEnabled {
text: true
}
usePandoraProductionServer {
text: true
}
saveMargeCustomerReport {
text: false
}
bmxRegistryUrl {
text: "https://content.api.bose.io/bmx/registry/v1/services"
}
->OK
->`
got := parseGetpdoConfig(in)
want := map[string]string{
"margeServerUrl": "https://streaming.bose.com",
"statsServerUrl": "https://events.api.bosecm.com",
"swUpdateUrl": "https://worldwide.bose.com/updates/soundtouch",
"bmxRegistryUrl": "https://content.api.bose.io/bmx/registry/v1/services",
"isZeroconfEnabled": "true",
"usePandoraProductionServer": "true",
"saveMargeCustomerReport": "false",
}
for k, v := range want {
if got[k] != v {
t.Errorf("%s = %q, want %q", k, got[k], v)
}
}
}
func TestCrossCheckPreflights_AgreementProducesNoWarnings(t *testing.T) {
m := &Manager{ServerURL: "http://example:8000"}
summary := &MigrationSummary{
ParsedCurrentConfig: &PrivateCfg{
MargeServerUrl: "http://example:8000",
StatsServerUrl: "http://example:8000",
SwUpdateUrl: "http://example:8000/updates/soundtouch",
BmxRegistryUrl: "http://example:8000/bmx/registry/v1/services",
},
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n" +
"statsServerUrl=http://example:8000\n" +
"swUpdateUrl=http://example:8000/updates/soundtouch\n" +
"bmxRegistryUrl=http://example:8000/bmx/registry/v1/services\n",
}
m.crossCheckPreflights(summary)
if len(summary.Warnings) != 0 {
t.Errorf("Warnings = %v, want none when both transports agree", summary.Warnings)
}
}
func TestCrossCheckPreflights_MismatchProducesWarning(t *testing.T) {
m := &Manager{ServerURL: "http://example:8000"}
// SSH-XML still shows the original cloud URL (envswitch wrote the
// runtime layer but the on-device file hasn't been re-rendered).
summary := &MigrationSummary{
ParsedCurrentConfig: &PrivateCfg{
MargeServerUrl: "https://streaming.bose.com",
},
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n",
}
m.crossCheckPreflights(summary)
if len(summary.Warnings) != 1 {
t.Fatalf("Warnings = %v, want exactly one warning", summary.Warnings)
}
w := summary.Warnings[0]
if !strings.Contains(w, "margeServerUrl") {
t.Errorf("warning %q should name the field", w)
}
if !strings.Contains(w, "streaming.bose.com") || !strings.Contains(w, "example:8000") {
t.Errorf("warning %q should quote both values", w)
}
}
func TestCrossCheckPreflights_NoWarningWhenTelnetMissesField(t *testing.T) {
m := &Manager{ServerURL: "http://example:8000"}
summary := &MigrationSummary{
ParsedCurrentConfig: &PrivateCfg{
MargeServerUrl: "http://example:8000",
StatsServerUrl: "http://example:8000",
},
// getpdo only echoes margeServerUrl — statsServerUrl is silently
// absent on this firmware. Absence is not a disagreement.
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n",
}
m.crossCheckPreflights(summary)
if len(summary.Warnings) != 0 {
t.Errorf("Warnings = %v, want none when a field is missing from one transport", summary.Warnings)
}
}
func TestCrossCheckPreflights_OnlyOneTransportPresent(t *testing.T) {
m := &Manager{ServerURL: "http://example:8000"}
t.Run("telnet only", func(t *testing.T) {
summary := &MigrationSummary{
TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n",
}
m.crossCheckPreflights(summary)
if len(summary.Warnings) != 0 {
t.Errorf("Warnings = %v, want none when SSH didn't read the XML", summary.Warnings)
}
})
t.Run("ssh only", func(t *testing.T) {
summary := &MigrationSummary{
ParsedCurrentConfig: &PrivateCfg{MargeServerUrl: "http://example:8000"},
}
m.crossCheckPreflights(summary)
if len(summary.Warnings) != 0 {
t.Errorf("Warnings = %v, want none when telnet didn't respond", summary.Warnings)
}
})
}
+94
View File
@@ -0,0 +1,94 @@
package setup
import (
"errors"
"strings"
"testing"
)
func TestReboot_DefaultIsSSH(t *testing.T) {
var ranCmds []string
m := &Manager{
NewSSH: func(host string) SSHClient {
return &mockSSH{runFunc: func(cmd string) (string, error) {
ranCmds = append(ranCmds, cmd)
return "ok\n", nil
}}
},
}
if _, err := m.Reboot("192.0.2.1", ""); err != nil {
t.Fatalf("Reboot: %v", err)
}
found := false
for _, c := range ranCmds {
if strings.Contains(c, "reboot") {
found = true
break
}
}
if !found {
t.Errorf("expected SSH `reboot` command, got %v", ranCmds)
}
}
func TestReboot_TelnetSendsSysReboot(t *testing.T) {
f := &fakeTelnet{
responses: map[string]string{"sys reboot": "OK\n"},
}
m := &Manager{
NewTelnet: func(host string) TelnetClient { return f },
}
if _, err := m.Reboot("192.0.2.1", RebootMethodTelnet); err != nil {
t.Fatalf("Reboot: %v", err)
}
if len(f.commands) != 1 || f.commands[0] != "sys reboot" {
t.Errorf("commands = %v, want [sys reboot]", f.commands)
}
}
func TestReboot_TelnetTreatsCloseAsSuccess(t *testing.T) {
// The device closes the socket as part of rebooting. SendCommand surfaces
// that as an EOF/closed error; the reboot path must absorb it.
f := &fakeTelnet{
fail: map[string]error{"sys reboot": errors.New("EOF")},
}
m := &Manager{
NewTelnet: func(host string) TelnetClient { return f },
}
out, err := m.Reboot("192.0.2.1", RebootMethodTelnet)
if err != nil {
t.Fatalf("Reboot should swallow socket-close after sys reboot, got %v", err)
}
if !strings.Contains(out, "connection closed by reboot") {
t.Errorf("output should annotate the close, got %q", out)
}
}
func TestReboot_TelnetSurfacesDialError(t *testing.T) {
f := &fakeTelnet{dialErr: errors.New("connection refused")}
m := &Manager{
NewTelnet: func(host string) TelnetClient { return f },
}
if _, err := m.Reboot("192.0.2.1", RebootMethodTelnet); err == nil {
t.Fatal("expected dial error, got nil")
}
}
func TestReboot_UnknownMethodErrors(t *testing.T) {
m := &Manager{}
if _, err := m.Reboot("192.0.2.1", RebootMethod("ftp")); err == nil {
t.Fatal("expected error for unsupported reboot method")
}
}
+357 -93
View File
@@ -3,6 +3,7 @@ package setup
import (
"encoding/xml"
"errors"
"fmt"
"io"
"log"
@@ -13,6 +14,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
@@ -20,6 +22,7 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/ssh"
"github.com/gesellix/bose-soundtouch/pkg/telnet"
)
// MigrationMethod represents the method used to migrate a speaker.
@@ -32,6 +35,9 @@ const (
MigrationMethodHosts MigrationMethod = "hosts"
// MigrationMethodResolvConf redirects services by injecting a priority DNS hook into the DHCP logic and updating the CA trust store.
MigrationMethodResolvConf MigrationMethod = "resolv"
// MigrationMethodTelnet redirects services by driving the device's diagnostic
// shell on TCP port 17000. Requires no SSH access on the device.
MigrationMethodTelnet MigrationMethod = "telnet"
)
// SoundTouchSdkPrivateCfgPath is the path to the speaker's private configuration file on device.
@@ -72,11 +78,41 @@ type MigrationSummary struct {
CurrentResolvConf string `json:"current_resolv_conf,omitempty"`
PlannedResolv string `json:"planned_resolv,omitempty"`
IsMigrated bool `json:"is_migrated"`
ResolveIPError string `json:"resolve_ip_error,omitempty"`
MirrorEnabled bool `json:"mirror_enabled"`
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
SkipMirrorEndpoints []string `json:"skip_mirror_endpoints,omitempty"`
PreferredSource string `json:"preferred_source,omitempty"`
// Per-axis migration signals — IsMigrated is the OR of these. The UI
// displays them individually so users can see partial states (e.g.
// URLs flipped via telnet but the on-disk XML hasn't caught up, or
// DNS interception in place but no CA installed).
XMLMigrated bool `json:"xml_migrated"`
HostsMigrated bool `json:"hosts_migrated"`
ResolvMigrated bool `json:"resolv_migrated"`
TelnetMigrated bool `json:"telnet_migrated"`
// IsPaired reports whether the device's live :8090/info advertises a
// non-empty margeAccountUUID. Surfaced separately so the wizard can
// flag pairing as a precondition independently of the URL flip.
IsPaired bool `json:"is_paired"`
ResolveIPError string `json:"resolve_ip_error,omitempty"`
MirrorEnabled bool `json:"mirror_enabled"`
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
SkipMirrorEndpoints []string `json:"skip_mirror_endpoints,omitempty"`
PreferredSource string `json:"preferred_source,omitempty"`
// Telnet (port 17000) preflight state — populated when the user is about to
// or has just used MigrationMethodTelnet.
TelnetReachable bool `json:"telnet_reachable"`
TelnetBanner string `json:"telnet_banner,omitempty"`
TelnetVerifiedConfig string `json:"telnet_verified_config,omitempty"`
TelnetProbeError string `json:"telnet_probe_error,omitempty"`
// KnownAccountIDs are accountIDs already present in the local datastore;
// the UI offers them as choices when pairing a fresh device.
KnownAccountIDs []string `json:"known_account_ids,omitempty"`
// Warnings holds non-fatal advisories emitted during summary
// construction — currently the cross-check between SSH-XML and
// telnet-getpdo readings of the device's URL configuration. The UI
// should display them as informational hints, not errors.
Warnings []string `json:"warnings,omitempty"`
}
// SSHClient defines the interface for SSH operations.
@@ -85,12 +121,28 @@ type SSHClient interface {
UploadContent(content []byte, remotePath string) error
}
// TelnetClient defines the interface for the device's port-17000 diagnostic
// shell. The concrete implementation lives in github.com/gesellix/bose-soundtouch/pkg/telnet;
// the interface exists so tests can substitute a mock.
type TelnetClient interface {
Dial() error
Probe() (string, error)
SendCommand(cmd string) (string, error)
Close() error
}
// Manager handles the migration of speakers to the service.
type Manager struct {
ServerURL string
DataStore *datastore.DataStore
Crypto *certmanager.CertificateManager
NewSSH func(host string) SSHClient
NewTelnet func(host string) TelnetClient
// NewSession opens the WebSocket setup state-machine session used
// by ExecuteInitPlan. Tests inject an in-memory fake; the production
// default is DialSession.
NewSession func(deviceIP, deviceID string, stepTimeout time.Duration) (StateMachine, error)
// GetDNSRunning is an optional callback to check the actual state of the DNS server.
GetDNSRunning func() (bool, string)
@@ -112,6 +164,12 @@ func NewManager(serverURL string, ds *datastore.DataStore, cm *certmanager.Certi
NewSSH: func(host string) SSHClient {
return ssh.NewClient(host)
},
NewTelnet: func(host string) TelnetClient {
return telnet.NewClient(host)
},
NewSession: func(deviceIP, deviceID string, stepTimeout time.Duration) (StateMachine, error) {
return DialSession(deviceIP, deviceID, SessionConfig{StepTimeout: stepTimeout})
},
HTTPGet: http.Get,
MgmtUsername: "admin",
MgmtPassword: "change_me!",
@@ -214,6 +272,21 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
SSHSuccess: false,
}
// Run the telnet preflight in parallel with the SSH-based probes below.
// Both transports are queried independently: SSH gives access to
// /etc/hosts, /etc/resolv.conf and the on-device XML config; telnet's
// `getpdo CurrentSystemConfiguration` reports the live URL set without
// needing root. They are complementary, so we wait for both and merge
// the results — total wall time = max(ssh, telnet).
telnetCh := make(chan MigrationSummary, 1)
go func() {
var local MigrationSummary
m.telnetPreflight(&local, deviceIP)
telnetCh <- local
}()
// Populate device info from datastore and live info
m.populateDeviceInfo(summary, deviceIP)
@@ -228,28 +301,19 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
BmxRegistryUrl: fmt.Sprintf("%s/bmx/registry/v1/services", targetURL),
}
// 2. Check SSH and read current config
currentConfig, err := m.checkCurrentConfig(summary, deviceIP)
if err == nil && currentConfig != "" {
summary.CurrentConfig = currentConfig
fmt.Printf("Current config from %s (length: %d):\n%q\n", deviceIP, len(currentConfig), currentConfig)
// 2. One batched SSH round-trip collects every file/existence probe
// we need. Without this, the legacy per-helper path issued ~8 fresh
// SSH dials in sequence — each pkg/ssh.Run() opens a brand-new
// TCP+SSH handshake on legacy crypto, ~500 ms1 s each.
probe := m.probeSpeakerSSH(deviceIP)
// Parse current config
var currentCfg PrivateCfg
if xml.Unmarshal([]byte(currentConfig), &currentCfg) == nil {
summary.ParsedCurrentConfig = &currentCfg
m.applyProbeToSummary(summary, probe, &plannedCfg, proxyURL, targetURL, options)
if proxyURL == "" {
proxyURL = targetURL
}
// Apply options if provided
if options != nil {
m.applyProxyOptions(&plannedCfg, proxyURL, options, &currentCfg)
}
}
}
// Note: CurrentConfig is set by checkCurrentConfig in all cases (success or failure)
// Per-field literal URL overrides win over both the canonical
// derivation and any self/proxied/original mode applied above —
// the user picked a URL, so the planned preview reflects exactly
// what the XML migration will write.
applyURLOverrides(&plannedCfg, options)
xmlContent, err := xml.MarshalIndent(plannedCfg, "", " ")
if err != nil {
@@ -261,25 +325,12 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
// 2b. Planned network config (hosts entries, resolv.conf preview, resolve error)
m.populatePlannedNetworkConfig(summary, deviceIP, targetURL)
// 3. Check for remote services files
m.checkRemoteServices(summary, deviceIP)
// 4. Check if CA certificate is trusted
m.checkCACertTrusted(summary, deviceIP)
// 4b. Check current /etc/resolv.conf
if summary.SSHSuccess {
client := m.NewSSH(deviceIP)
if resolvConf, err := client.Run("cat /etc/resolv.conf"); err == nil {
summary.CurrentResolvConf = resolvConf
}
}
// 5. Provide HTTPS URL for testing
// 3. Provide HTTPS URL for testing (consumed by the migration UI)
summary.ServerHTTPSURL = m.buildServerHTTPSURL(targetURL)
// 6. Check if migrated
m.checkIsMigrated(summary, deviceIP)
// 4. Check if migrated (telnet axis uses the parallel preflight;
// XML/hosts/resolv axes use the probe data already gathered above).
m.checkIsMigratedFromProbe(summary, probe)
// 7. Mirroring settings
if m.DataStore != nil {
@@ -292,10 +343,21 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
}
}
// 8. Merge telnet preflight results (started in parallel at the top).
telnetResult := <-telnetCh
summary.TelnetReachable = telnetResult.TelnetReachable
summary.TelnetBanner = telnetResult.TelnetBanner
summary.TelnetVerifiedConfig = telnetResult.TelnetVerifiedConfig
summary.TelnetProbeError = telnetResult.TelnetProbeError
// 9. Cross-check SSH-XML and telnet-getpdo readings; surface any
// divergence as a non-fatal warning.
m.crossCheckPreflights(summary)
return summary, nil
}
func (m *Manager) populatePlannedNetworkConfig(summary *MigrationSummary, deviceIP, targetURL string) {
func (m *Manager) populatePlannedNetworkConfig(summary *MigrationSummary, _, targetURL string) {
parsedURL, err := url.Parse(targetURL)
if err != nil {
return
@@ -306,9 +368,13 @@ func (m *Manager) populatePlannedNetworkConfig(summary *MigrationSummary, device
return
}
client := m.NewSSH(deviceIP)
hostIP, resolveErr := m.resolveIP(hostName, client)
// Resolve locally only. The "from-device" lookup that resolveIP can
// do via SSH (`ping -c 1 host`) costs another fresh SSH handshake
// plus the ping's own runtime — easily 25 s on firmware-27 devices
// — and the result feeds only the PlannedResolv/PlannedHosts preview.
// For the actual apply paths (migrateViaHosts/migrateViaResolv) the
// device-side resolution is still used; this is only the preview.
hostIP, resolveErr := m.resolveIP(hostName, nil)
if resolveErr != nil {
summary.ResolveIPError = resolveErr.Error()
}
@@ -342,13 +408,36 @@ func (m *Manager) populatePlannedNetworkConfig(summary *MigrationSummary, device
summary.PlannedHosts = strings.Join(hostsLines, "\n")
}
// buildServerHTTPSURL composes the AfterTouch /health probe URL.
//
// Port resolution order (first non-empty wins):
// 1. The port from targetURL when it is already an https:// URL — the
// caller supplied an HTTPS endpoint, so it owns the port choice.
// 2. The implicit https:// default 443 when targetURL is https:// with
// no explicit port.
// 3. The HTTPS_PORT env var — back-compat for deployments where
// targetURL is http://…:8000 and HTTPS_PORT names the separate TLS
// listener.
// 4. The legacy default 8443.
func (m *Manager) buildServerHTTPSURL(targetURL string) string {
parsedURL, err := url.Parse(targetURL)
if err != nil || parsedURL.Hostname() == "" {
return ""
}
httpsPort := os.Getenv("HTTPS_PORT")
var httpsPort string
if parsedURL.Scheme == "https" {
httpsPort = parsedURL.Port()
if httpsPort == "" {
httpsPort = "443"
}
}
if httpsPort == "" {
httpsPort = os.Getenv("HTTPS_PORT")
}
if httpsPort == "" {
httpsPort = "8443"
}
@@ -356,17 +445,57 @@ func (m *Manager) buildServerHTTPSURL(targetURL string) string {
return fmt.Sprintf("https://%s:%s/health", parsedURL.Hostname(), httpsPort)
}
// checkIsMigrated determines if the device is already migrated to AfterTouch.
// checkIsMigrated determines if the device is already migrated to
// AfterTouch and which mechanism is in place.
//
// Each axis is recorded as a separate boolean so the UI can show
// partial-state cells (e.g. URLs flipped via telnet but the on-disk XML
// hasn't been re-rendered, or DNS interception present but no CA
// installed). IsMigrated is the OR — if any mechanism reports the
// device pointing at our service, the device is "migrated."
//
// The telnet-based check runs unconditionally because it is the only
// migration-state signal available on devices that do not expose SSH
// (USB-unlock-refusing firmware on SA-5, ST520, recent ST Portable).
// The SSH-based checks need a working shell and cover the /etc/hosts
// and /etc/resolv.conf interception variants, neither of which shows
// up in `getpdo CurrentSystemConfiguration`.
func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) {
if !summary.SSHSuccess {
return
summary.TelnetMigrated = m.isTelnetMigrated(summary)
if summary.SSHSuccess {
client := m.NewSSH(deviceIP)
summary.XMLMigrated = m.isXMLMigrated(summary)
summary.HostsMigrated = m.isHostsMigrated(client, summary)
summary.ResolvMigrated = m.isResolvConfMigrated(client, summary)
}
client := m.NewSSH(deviceIP)
summary.IsMigrated = summary.TelnetMigrated ||
summary.XMLMigrated ||
summary.HostsMigrated ||
summary.ResolvMigrated
}
if m.isXMLMigrated(summary) || m.isHostsMigrated(client, summary) || m.isResolvConfMigrated(client, summary) {
summary.IsMigrated = true
// isTelnetMigrated reports whether the live device config (read via the
// telnet preflight's `getpdo CurrentSystemConfiguration`) already points
// at our service. Mirrors isXMLMigrated's substring-match semantics — any
// occurrence of our hostname in the response is enough.
func (m *Manager) isTelnetMigrated(summary *MigrationSummary) bool {
if summary.TelnetVerifiedConfig == "" {
return false
}
parsedTarget, err := url.Parse(m.ServerURL)
if err != nil {
return false
}
targetHost := parsedTarget.Hostname()
if targetHost == "" {
return false
}
return strings.Contains(summary.TelnetVerifiedConfig, targetHost)
}
// isXMLMigrated checks whether current XML config already points to our server.
@@ -495,6 +624,12 @@ func (m *Manager) populateDeviceInfo(summary *MigrationSummary, deviceIP string)
summary.AccountID = infoXML.MargeAccountUUID
}
}
// Pairing state is derived from the live :8090/info value above
// (which clobbers the stale datastore copy if both are present).
// An empty AccountID at this point means a fresh / factory-reset
// device that needs pairing before presets and streaming work.
summary.IsPaired = summary.AccountID != ""
}
// checkCurrentConfig reads and validates the current speaker configuration
@@ -577,43 +712,61 @@ func (m *Manager) applyProxyOptions(plannedCfg *PrivateCfg, proxyURL string, opt
}
}
// checkRemoteServices checks for remote services files on the device
func (m *Manager) checkRemoteServices(summary *MigrationSummary, deviceIP string) {
client := m.NewSSH(deviceIP)
locations := []string{
"/etc/remote_services",
"/mnt/nv/remote_services",
"/tmp/remote_services",
}
for _, loc := range locations {
if _, err := client.Run(fmt.Sprintf("[ -e %s ]", loc)); err == nil {
summary.RemoteServicesFound = append(summary.RemoteServicesFound, loc)
summary.RemoteServicesEnabled = true
if loc != "/tmp/remote_services" {
summary.RemoteServicesPersistent = true
}
}
}
}
// checkCACertTrusted checks if the local CA certificate is already in the device's trust store.
func (m *Manager) checkCACertTrusted(summary *MigrationSummary, deviceIP string) {
if m.Crypto == nil {
// applyURLOverrides applies per-field literal URL overrides from the
// migration options map (marge_url / stats_url / sw_update_url /
// bmx_url) on top of an already-populated PrivateCfg. Empty or missing
// entries leave the field unchanged.
//
// These overrides win over the legacy "self/proxied/original" semantic
// applied by applyProxyOptions: if the user picked a literal URL, the
// migration honors it verbatim. The XML and Telnet write paths and
// the GetMigrationSummary read path all call this so the planned
// preview matches what migration actually writes.
func applyURLOverrides(cfg *PrivateCfg, options map[string]string) {
if cfg == nil || options == nil {
return
}
if v := options["marge_url"]; v != "" {
cfg.MargeServerUrl = v
}
if v := options["stats_url"]; v != "" {
cfg.StatsServerUrl = v
}
if v := options["sw_update_url"]; v != "" {
cfg.SwUpdateUrl = v
}
if v := options["bmx_url"]; v != "" {
cfg.BmxRegistryUrl = v
}
}
// checkCACertTrusted checks if the local CA certificate is already in
// the device's trust store. The CALabel grep works regardless of whether
// Manager.Crypto is configured — only the secondary "match cert payload"
// fallback needs it. CLI callers without Crypto can therefore still
// detect a previously-trusted CA.
func (m *Manager) checkCACertTrusted(summary *MigrationSummary, deviceIP string) {
client := m.NewSSH(deviceIP)
bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
// First, check for the label
// Primary check: our injected label.
output, err := client.Run(fmt.Sprintf("grep -F %q %s", CALabel, bundlePath))
if err == nil && strings.Contains(output, CALabel) {
summary.CACertTrusted = true
return
}
// Secondary check (only when Manager.Crypto is configured): match
// the actual cert payload — covers older injections that lack the
// label.
if m.Crypto == nil {
return
}
caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
if err != nil {
return
@@ -653,6 +806,14 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
method = MigrationMethodXML
}
// Telnet is SSH-free by design — skip the SSH-based off-device backup and
// rw pre-flight, both of which would fail on devices that haven't been
// rooted via remote_services.
if method == MigrationMethodTelnet {
urls := telnetURLsFromOptions(targetURL, options)
return m.migrateViaTelnet(deviceIP, targetURL, urls)
}
var logs string
// 0. Off-device backup for safety
@@ -697,6 +858,14 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
}
func (m *Manager) checkDNSPreFlight() error {
// CLI / remote callers construct a Manager without a DataStore — they
// can't introspect AfterTouch's settings from here. Skip the local
// check in that case; the caller is responsible for verifying the
// remote service's DNS state (the CLI hits GET /setup/settings).
if m.DataStore == nil {
return nil
}
// Pre-flight check: DNS server must be enabled and bound to port 53
settings, err := m.DataStore.GetSettings()
if err != nil {
@@ -770,6 +939,10 @@ func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options ma
}
}
// Per-field literal URL overrides take precedence over the
// proxy/original modes applied above — see applyURLOverrides.
applyURLOverrides(&cfg, options)
xmlContent, err := xml.MarshalIndent(cfg, "", " ")
if err != nil {
return logs, fmt.Errorf("failed to marshal XML: %w", err)
@@ -985,18 +1158,38 @@ func (m *Manager) EnsureRemoteServices(deviceIP string) (string, error) {
return logs, fmt.Errorf("failed to enable remote services in any of the locations: %v", locations)
}
// TrustCACert injects the local CA certificate into the device's shared trust store.
// TrustCACert injects the local CA certificate into the device's shared
// trust store. The cert is read from disk via Manager.Crypto — used by
// the in-process migration flow where the CLI and the certmanager share
// a filesystem. Remote/CLI callers without Crypto should fetch the cert
// over HTTP and use TrustCACertFromBytes instead.
func (m *Manager) TrustCACert(deviceIP string) (string, error) {
client := m.NewSSH(deviceIP)
rwCmd := "(rw || mount -o remount,rw /)"
var logs string
if m.Crypto == nil {
return "", errors.New("TrustCACert: Manager.Crypto is nil — remote callers should fetch the CA via /setup/ca.crt and call TrustCACertFromBytes (e.g. `soundtouch-cli setup install-ca`)")
}
caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
if err != nil {
return "", fmt.Errorf("failed to read CA certificate: %w", err)
}
return m.TrustCACertFromBytes(deviceIP, caCertPEM)
}
// TrustCACertFromBytes injects the supplied PEM-encoded CA bundle into
// the speaker's shared trust store. Identical to TrustCACert except the
// cert bytes come from the caller — used by the remote CLI which fetches
// /setup/ca.crt over HTTP and never touches Manager.Crypto.
func (m *Manager) TrustCACertFromBytes(deviceIP string, caCertPEM []byte) (string, error) {
if !strings.Contains(string(caCertPEM), "BEGIN CERTIFICATE") {
return "", fmt.Errorf("CA payload does not contain a PEM certificate")
}
client := m.NewSSH(deviceIP)
rwCmd := "(rw || mount -o remount,rw /)"
var logs string
bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
out, _ := client.Run(rwCmd)
logs += rwCmd + ": " + out + "\n"
@@ -1016,12 +1209,8 @@ func (m *Manager) TrustCACert(deviceIP string) (string, error) {
}
if strings.Contains(bundleContent, CALabel) {
// Label found, let's replace the whole block between labels if we used them,
// or just remove the lines containing the label and re-append.
// For simplicity, let's remove everything between CALabel tags if we had them,
// but since we only had one line before, let's just remove lines containing CALabel
// and the cert data if possible.
// A better way is to rebuild the bundle without our CA.
// Rebuild the bundle without our previously-injected CA so the
// fresh one replaces the old.
lines := strings.Split(bundleContent, "\n")
var newLines []string
@@ -1047,7 +1236,6 @@ func (m *Manager) TrustCACert(deviceIP string) (string, error) {
bundleContent += "\n"
}
// Append with labels
labeledCert := fmt.Sprintf("\n%s\n%s%s\n", CALabel, string(caCertPEM), CALabel)
newBundleContent := bundleContent + labeledCert
@@ -1778,12 +1966,40 @@ func (m *Manager) RemoveRemoteServices(deviceIP string) (string, error) {
return logs, nil
}
// Reboot reboots the speaker at the given IP.
func (m *Manager) Reboot(deviceIP string) (string, error) {
// RebootMethod selects the transport used to reboot a speaker.
type RebootMethod string
const (
// RebootMethodSSH reboots via SSH `reboot` (the original behavior). Requires
// a rooted device (remote_services unlocked).
RebootMethodSSH RebootMethod = "ssh"
// RebootMethodTelnet reboots via the device's port-17000 diagnostic shell
// using `sys reboot`. Requires no SSH access.
RebootMethodTelnet RebootMethod = "telnet"
)
// Reboot reboots the speaker at the given IP using the requested transport.
// An empty method defaults to RebootMethodSSH, preserving prior behavior.
func (m *Manager) Reboot(deviceIP string, method RebootMethod) (string, error) {
if method == "" {
method = RebootMethodSSH
}
switch method {
case RebootMethodSSH:
return m.rebootViaSSH(deviceIP)
case RebootMethodTelnet:
return m.rebootViaTelnet(deviceIP)
default:
return "", fmt.Errorf("unsupported reboot method: %s", method)
}
}
func (m *Manager) rebootViaSSH(deviceIP string) (string, error) {
client := m.NewSSH(deviceIP)
rwCmd := "(rw || mount -o remount,rw /)"
fmt.Printf("Rebooting speaker at %s\n", deviceIP)
fmt.Printf("Rebooting speaker at %s via SSH\n", deviceIP)
out, err := client.Run(fmt.Sprintf("%s && reboot", rwCmd))
if err != nil {
@@ -1793,6 +2009,54 @@ func (m *Manager) Reboot(deviceIP string) (string, error) {
return out, nil
}
func (m *Manager) rebootViaTelnet(deviceIP string) (string, error) {
if m.NewTelnet == nil {
return "", errors.New("telnet reboot not configured: Manager.NewTelnet is nil")
}
fmt.Printf("Rebooting speaker at %s via telnet\n", deviceIP)
t := m.NewTelnet(deviceIP)
if err := t.Dial(); err != nil {
return "", fmt.Errorf("telnet dial %s:17000 failed: %w", deviceIP, err)
}
defer func() { _ = t.Close() }()
// We deliberately don't wait for a response — the device closes the socket
// as part of rebooting, and SendCommand would surface that as an error
// even though the reboot itself succeeded. Treat any short read or close
// as "command was accepted".
resp, err := t.SendCommand("sys reboot")
if err != nil {
// A read error after the write is the expected case (socket dies on
// reboot). Only surface real transport failures; treat the rest as
// success and let the caller verify by polling :8090/info.
if isLikelyRebootCloseError(err) {
return resp + "\n[connection closed by reboot]", nil
}
return resp, fmt.Errorf("failed to send sys reboot: %w", err)
}
return resp, nil
}
// isLikelyRebootCloseError returns true if err looks like the socket closed
// because the device started rebooting, rather than a real connectivity
// problem. We are intentionally generous here: the user already opted into
// rebooting, so a closed socket is expected.
func isLikelyRebootCloseError(err error) bool {
msg := err.Error()
for _, marker := range []string{"EOF", "closed", "connection reset", "broken pipe", "timed out"} {
if strings.Contains(msg, marker) {
return true
}
}
return false
}
// TestDomain is the fake domain used for preliminary redirection tests.
const TestDomain = "custom-test-api.bose.fake"

Some files were not shown because too many files have changed in this diff Show More