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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
* 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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
Adds a "Docker Compose (recommended for home servers and VMs)" section
to Step 1, pointing users to the existing docker-compose.yml and
.env.example. Clarifies the purpose of docker-compose.ci.yml (CI tests
only) and docker-compose.override.yml (local modifications, not in VCS).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
On constrained hardware (e.g. ARMv7), RSA key generation can block
startup for minutes. HTTP now starts immediately; HTTPS is brought up
in a background goroutine once cert generation completes. A log message
informs the user that HTTPS will be available shortly after startup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>