Commit Graph
247 Commits
Author SHA1 Message Date
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
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 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
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
969bdf8704 feat(service): add --discovery-enabled CLI flag and treat 0 interval as disabled (#229)
Why: Operators need to control device discovery from the command line
without touching the persisted settings file, and a zero discovery
interval should be unambiguously off rather than running an
immediate-fire scan loop.

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:18:15 +02:00
ac5e67d198 fix(client): default sourceAccount to "AUX" for AUX source selection (#228)
The speaker rejects /select with source="AUX" and an empty sourceAccount
as INVALID_SOURCE, so the audio path never reaches APAuxSrc. Default the
sourceAccount to "AUX" inside SelectSource and align ItemName to "AUX
IN" to match the device's own button-press payload.

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:11:00 +02:00
ea4d8bacac revert(setup): revert OverrideSdkPrivateCfg.xml migration approach (#220)
The OverrideSdkPrivateCfg.xml override path introduced in #209 does not
work on SoundTouch 10 (and likely other models): the firmware ignores
the override file, leaving the device pointing at the original Bose
cloud URLs. Revert to editing SoundTouchSdkPrivateCfg.xml directly with
a .original backup, which is the approach known to work.

Relates to #214

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 08:43:34 +02:00
bcffbc7719 fix(setup): test override file existence before treating cat output as config (#215)
client.Run uses CombinedOutput, so when
`/mnt/nv/OverrideSdkPrivateCfg.xml` is absent (the default for devices
migrated with pre-0.71.0 code) the cat stderr is returned as the
override config and surfaced to the migration page UI as "Current Config
(on Speaker)". Gate the branch on `[ -f ... ]` first, mirroring the
legacy .original check.

Relates to #209
Relates to #214

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:06:31 +02:00
d14f4691a6 fix(amazon): use email and AMAZON type to match old Bose cloud format (#212)
Store the user's email address (not Amazon account ID) in
sourceKey.account and set source type to "AMAZON" so the speaker
firmware recognises Amazon Music sources the same way as the original
Bose cloud.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 08:23:04 +02:00
a1d0add5a1 feat(ui): add CA certificate download to Settings tab and Migration tab (#210)
Adds a "Download CA Certificate" button in the Settings tab
(system-level convenience for importing the cert into browsers, curl,
Python clients, etc.) and a "Download CA cert" link next to the existing
"Trust CA Now" button in the Migration tab. Both link to the existing
/setup/ca.crt endpoint.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 20:51:40 +02:00
8b196a8260 fix(setup): write XML migration to OverrideSdkPrivateCfg.xml instead of editing original (#209)
Use /mnt/nv/OverrideSdkPrivateCfg.xml (the firmware's override path)
rather than editing /opt/Bose/etc/SoundTouchSdkPrivateCfg.xml directly.
A malformed override cannot cause a reboot loop because the device falls
back to the untouched original.

Revert now removes the override file; legacy .original backups are still
restored for devices migrated with older code. checkCurrentConfig reads
the override path first so IsMigrated detection works correctly with the
new approach.

Credit: Ueberbose team, discovered via [soundcork
documentation](https://github.com/deborahgu/soundcork#configuring-the-bose-speaker-to-use-the-soundcork-server).

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:15:48 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ac44fdace6 perf(certmanager): reduce CA key size from RSA-4096 to RSA-2048
RSA-4096 CA generation blocks service startup for minutes on slow ARM
hardware. The CA key is only used to sign server certs, never in TLS
handshakes, so 2048 bits provides sufficient security for a local CA
while being ~4-8x faster to generate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:11:41 +02:00
ca19bb32f7 feat(setup): harden hostname resolution before migration (#204)
- resolveIP now returns (string, error): error when result did not come
  from the device's own SSH ping (service-side fallback or total
failure)
- migrateViaResolvConf and parseTargetURLAndResolveIP abort on error,
  preventing a bad IP from being written to the device
- GetMigrationSummary captures the error in ResolveIPError and falls
back
  to the hostname for the preview display; XML migration is unaffected
- Web UI shows a warning box with the error and a docs link when
resolution
  is uncertain; migrate button stays enabled for the XML method
- Add hostname resolution troubleshooting section to TROUBLESHOOTING.md

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 20:00:24 +02:00
c931510384 feat(setup): add 'original' option and harden backup before migration (#202)
- Rename proxy option values: 'upstream' → 'proxied', 'official' →
'original'
- Add 'original' option to preserve current device URL as-is per field
- Drop proxyURL guard in applyProxyOptions so 'original' works without a
proxy
- Abort migration if on-device backup cannot be created (was
warning-only)

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

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

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

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

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

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

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

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

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

Example flow:

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 14:00:23 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ee44526d25 docs(amazon): confirm amazon_music:access scope requires device client ID
Attempting to request amazon_music:access with a standard application
client ID (amzn1.application-oa2-client.*) returns HTTP 400
lwa-invalid-parameter-bad-scope from the LWA authorization endpoint.
The scope is gated to Amazon Music partner device client IDs.

Revert scope to "profile" (working state) and document the confirmed
blocker with the exact error. Path forward: Amazon Music partner
registration for a device client ID; one-line change to AmazonScopes
when available.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:30:06 +02:00
Tobias GesellchenandClaude Sonnet 4.6 50c40be763 feat(amazon): fix bridge fallback, source display name, and document streaming blocker
- Amazon bridge: fall back to sync/legacy on any error from
  SetMusicServiceOAuthAccount (not only error 1029); timeouts from
  unresponsive speakers no longer silently skip the fallback chain
- Amazon bridge: reduce speaker client timeout from 30s to 5s for
  faster failure on local network calls
- marge: resolveSourceName now prefers SourceName/DisplayName over
  SourceKeyAccount, so Amazon (and Spotify) sources show the account
  holder's name instead of the raw account ID
- docs: update amazon-music-oauth.md with real-world test results;
  music-api.amazon.com returns 401 because standard LWA apps lack
  music::* partner scopes — infrastructure is complete but streaming
  is blocked pending Amazon partner access
- docs: add SELF-HOSTING.md and MUSIC-SERVICES.md user guides; link
  both in SUMMARY.md

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:30:06 +02:00
Tobias GesellchenandClaude Sonnet 4.6 70d05554a0 feat: add Amazon LWA mock server (testutils + integration mocks)
Mirror the Spotify equivalents: pkg/testutils/amazon/handlers.go provides
HandleToken and HandleProfile for use in unit tests; tests/integration/mocks/amazon.go
wraps them in an AmazonMock with TokenURL() and ProfileURL() accessors.

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:30:06 +02:00
Tobias GesellchenandClaude Sonnet 4.6 466e9eca97 feat: extract shared ZeroConf package and add Amazon Music OAuth service
- Extract DH key exchange crypto from pkg/service/spotify into new
  pkg/service/zeroconf package with exported functions and
  AuthTypeOAuthToken constant (both Spotify and Amazon use auth type 4)
- Reduce pkg/service/spotify/zeroconf.go to thin wrappers around the
  shared package; public API (PushSpotifyCredentials, ZeroConfGetInfo)
  is preserved
- Add pkg/service/amazon package mirroring the Spotify service with
  Amazon-specific differences: LWA endpoints, POST body credentials
  (not Basic Auth), user_id/name profile fields, amazon/accounts.json
- Add PushAmazonCredentials delegating to shared zeroconf.PushCredentials

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:30:06 +02:00
Tobias GesellchenandClaude Sonnet 4.6 5fbad7d315 feat: add Amazon Music source classification and fix ETag caching
- Recognize Amazon Music in learned sources (classifyAsAmazon) and
  AddSource dispatch, using CredentialTypeToken (cs1) not cs3
- Exclude Amazon from default sources: an empty-credential Amazon entry
  triggers the speaker's AmazonController to fail JSON parsing with
  MUSIC_SERVICE_ACCOUNT_LOGIN_FAILED; Amazon must only appear once a
  real OAuth token is present
- Merge missing defaults into stored sources at request time so devices
  with older Sources.xml still receive all current defaults
- Fix source providers ETag: was time.Now().UnixMilli() (always new),
  now a content hash so If-None-Match/304 works correctly
- Include default sources fingerprint in GetETagForAccount so adding a
  new default invalidates cached /full responses on speakers
- Refactor createLearnedSource into classifyLearnedSource +
  classifyAsX helpers to reduce cyclomatic complexity below linter limit
- Add regression test for two-device scenario matching production setup

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

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

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:34:05 +02:00
9412b5ffa0 feat: add group CRUD endpoints (POST add, POST modify, DELETE delete) (#191)
Groups (stereo pairs of ST10 speakers) were read-only — the GET endpoint
always returned an empty <group/>. Add POST /account/{account}/group,
POST /account/{account}/group/{groupId}, and DELETE
/account/{account}/group/{groupId} with datastore persistence, matching
the API shape observed in soundcork. The GET endpoint now reads live
group state from the datastore.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:28:38 +02:00
ff6edc5383 feat: log [UNHANDLED] for routes with no local handler (#190)
feat: log [UNHANDLED] for routes with no local handler

Every request that falls through to HandleNotFound now emits an
[UNHANDLED] METHOD path log line, making it immediately visible when a
speaker calls an endpoint we have not implemented. When proxyLogBody is
enabled the request body is also included (truncated to 512 bytes) and
restored before forwarding, so the proxy still sees the full payload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:28:25 +02:00
a2f952495e fix: use proper URL manipulation for TuneIn render=json parameter (#189)
Naive string concatenation (`rawURL + "&render=json"`) produced
malformed URLs when the input had no query string yet, or already
contained render=json. Replace with tuneInRenderJSONURI which parses and
sets the parameter cleanly. Also fix TuneIn search query encoding in the
self link and section href, and replace the http-prefix check for OPML
URIs with a proper host comparison.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:24:43 +02:00
Tobias Gesellchen 174d087b8e Do not duplicate existing sources with default sources 2026-04-23 22:08:54 +02:00
Tobias Gesellchen 066e381737 Fix/beautify the account overview 2026-04-23 22:08:54 +02:00
Tobias GesellchenandGitHub 885967aafc The RADIOPLAYER source is deprecated (#180)
See https://www.radioplayer.de/apps/bose.html

> Der Radioplayer in BOSE Lautsprechersystemen (ARCHIV)
>
> Bose Soundbar und Bose Soundtouch
>
> ACHTUNG: BOSE steht seit jeher für glasklaren Sound. Im Jahr 2018
wurden daher auch sämtliche Sender des Radioplayers in den SoundBar und
SoundTouch Geräten des Audio-Herstellers aus Massachussets verfügbar
gemacht. Trotz des großen Erfolges der Geräte, besondern auch in
Deutschland, hat sich BOSE jedoch dazu entschieden die Linie der
SoundTouch-Geräte nicht mehr fortzuführen. Die letzte Aktualisierung der
BOSE SoundTouch-App (in der der Radioplayer integriert war, siehe unten)
erfolgte in den App-Stores in 2021. Seither sind einige (neuere) Sender
nicht mehr wie gewohnt verfügbar. BOSE hat zudem verkündet, den Support
der SoundTouch-Geräte zum 18. Februar 2026 komplett einzustellen, was
den Zugriff auf Musikdienste wie den Radioplayer vollends beendet.
2026-04-22 18:26:14 +02:00
Tobias GesellchenandGitHub c6748eda41 Serialize all WebSocket writes (#179) 2026-04-21 21:57:19 +02:00
Tobias GesellchenandGitHub 469a91ad80 Fix logo filenames (#178) 2026-04-21 21:49:11 +02:00
Tobias GesellchenandGitHub ceb08cd6bf Fix ETag for account-level endpoints (#177) 2026-04-20 21:09:00 +02:00
Tobias Gesellchen 5e6885cfe8 Add missing RADIO_BROWSER default source 2026-04-20 19:18:39 +02:00
Tobias Gesellchen 88c83b6131 Fix security issues 2026-04-19 21:59:55 +02:00
Tobias Gesellchen 56e82d5a01 Add TuneIn search/browse/playback
We might peek into https://github.com/core-hacked/tunein-api for more advanced use cases
2026-04-19 21:59:55 +02:00
Tobias GesellchenandGitHub d0ce48ef03 Fix a mismatch where the local service was incorrectly wrapping the single preset in a <presets> element (#172) 2026-04-18 21:49:16 +02:00
Tobias GesellchenandGitHub aa5a25b382 Enhance version-info (#170) 2026-04-17 22:16:47 +02:00
Tobias GesellchenandGitHub f14cb45680 Enhance and group device discovery settings in web UI (#169) 2026-04-17 21:51:11 +02:00
Tobias GesellchenandGitHub 1fecb3948e Refactor constants for sources and source providers (#168) 2026-04-17 19:08:50 +02:00
Tobias GesellchenandGitHub 0e2f05e6e5 Improve source sync by adding deduction of known source IDs (#167) 2026-04-17 18:50:51 +02:00
Tobias GesellchenandGitHub ffe61dd7a6 Prevent loops for proxied requests on unknown endpoints (#166)
Follow-up for https://github.com/gesellix/Bose-SoundTouch/issues/161
2026-04-17 18:20:46 +02:00