Expose telnet-only URL restore through the CLI, setup API, and web UI. Validate command-safe URLs, serialize per-speaker mutations, and verify runtime readback while reporting partial-state failures.
SOURCE-SELECTION.md listed TUNEIN, RADIO_BROWSER and STORED_MUSIC among the
sources selectable with a source and account alone, and showed SelectTuneIn
as an example. That is the trap: a speaker answers 200 for such a select,
parks on a stub now-playing, and carries on playing whatever it was playing,
so callers checking /now_playing see the source they asked for while the
audio is something else.
Splits the list into sources a bare select works for and sources that need a
ContentItem with a Location, replaces the SelectTuneIn example with
SelectContentItem, and carries the same warning into the doc comments on
SelectSource and SelectTuneIn, where a developer reaching for them will
actually see it. Also points the player's README at the new reference.
No behaviour change; comments and documentation only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Selecting a source cost up to ~21 speaker requests. Two multipliers, both
removed here.
A match on the first readback only marked the command provisional and left
the later deadlines running, so the happy path always spent all three. The
reason to keep watching is real: /select answers 200 even for a source the
speaker rejects seconds later, surfacing as a transition to an error source.
But the event stream already reports that transition as it happens, and the
effect watching nowPlayingUpdated already turns it into a failure. Polling
on top is re-asking a question we are subscribed to the answer of. The
remaining readbacks are now kept only when the readback itself reports no
live event stream, which is the case they are actually needed for.
Each readback also fetched the whole device, and HandleAPIDevice runs a full
UpdateDeviceStatus: six sequential speaker calls plus /getGroup on a
stereo-capable model, to answer one question, against a device the readback
may be checking on precisely because it is slow. GET
/devices/{id}/now-playing refreshes only /now_playing and returns the same
shape, under FieldNowPlaying's generation so it still orders against push
events and concurrent polls, and reporting to the health tracker like any
other HTTP round.
A confirmed selection on a speaker with a live event stream now costs one
speaker request instead of about twenty-one. A speaker whose events are not
arriving keeps the full three-readback window, at one request each.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same fix as -service and -player, for consistency: newGroupCoordinator
proactively pushed group cleanup/rename to an external Marge backend
the CLI doesn't own. A speaker's own firmware already self-reports
that create/rename/teardown to whatever Marge backend it's configured
with -- that's the entire reason HandleMargeAddGroup/HandleMargeModifyGroup/
HandleMargeDeleteGroup exist, they're only ever called by speakers.
Extracted the wiring into cliStereoPairGenerationPersistence (mirrors
the -service/-player equivalents): cleanup and rename are now no-ops,
and preflight's read-only dangling-generation check stays but its
failure no longer blocks Create -- it's printed as a warning instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A speaker's own firmware self-reports its group create/rename/teardown
to whatever Marge backend it's configured with -- that's the entire
reason HandleMargeAddGroup/HandleMargeModifyGroup/HandleMargeDeleteGroup
exist, they're only ever called by speakers, never by us. Coordinator
cleanup/rename proactively pushing the same update to an external
(non-local) Marge target duplicated that self-report against a backend
we generally can't authenticate to anyway (real Bose cloud, another
instance, ...), for zero benefit.
The one part of the external path with a real, distinct purpose --
preflight's read-only check for a dangling stale generation before a
new Create -- stays, but its failure (network error, wrong
credentials, an unreachable backend) no longer blocks Create. It's a
best-effort safety net on top of the coordinator's own physical
preflight (capability/zone/reachability checks against the live
speakers), not the primary guard, and the live speakers' own state is
the authoritative signal either way.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A speaker migrated at the DNS level keeps reporting its own MargeURL as
the literal Bose cloud hostname (e.g. https://streaming.bose.com) --
DNS migration only changes how that hostname resolves on the network,
never the device's own advertised URL. embeddedStereoPairGenerationPersistence's
isLocal() only matched against this service's own advertised server
URL, so it misclassified such a speaker as "external" and sent the
stereo-pair Create preflight's generation-conflict check out over the
real internet instead of checking the local datastore. Bose's cloud is
still live enough to answer (just not to authenticate us), so the
check failed with HTTP 401 and hard-blocked Create for an otherwise
perfectly normal DNS-migrated setup.
Added a DNS-hijack-aware check: if this service's own DNS hijack is
active and the reported MargeURL's host is one of the known redirected
Bose hostnames (discovery.InterceptedBoseHosts), treat it as local too.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TestPrintRoutes compares against a checked-in route list; the new
GET .../zone/candidates route (added for Zone.js's candidate source
fix) needs to be reflected there too.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CodeQL alert 313 (go/log-injection). The access-log middleware logged
r.URL.Path and SOAP-body-derived objectID/browseFlag verbatim, without
stripping newlines -- an attacker-controlled request could inject fake
log lines or control characters. Add the same sanitizeLog helper this
repo already uses in ~18 other packages for exactly this class of
finding.
Alert 312 (go/reflected-xss, same file/area) was investigated and left
open deliberately: objectID is only ever used as a lookup key in
pkg/dlna/dlnatest, never echoed into the response, and every actual
output field goes through xmlEsc/xmlAttr (encoding/xml.EscapeText)
before being written -- looks like a CodeQL false positive rather than
a real gap, but not dismissing it yet per discussion.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fixes correctness issues found reviewing the bounded device-seed retry
loop before merging: a datastore read failure could make the readiness
check trivially pass; stale-host pruning only considered hosts inserted
in the current attempt and only ran inside the retry loop, not the
plain SeedExtraDevices path; the retry loop and a devices-changed-hook
seed could probe the same offline host concurrently; and a zero-change
startup window silently dropped the previously-unconditional device-list
broadcast. Also makes the retry interval/window configurable instead of
hardcoded, following the existing discovery-interval flag pattern.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Five findings from reviewing gesellix/Bose-SoundTouch#644
(Retry configured player devices during discovery):
1. Source-labeling used exact string equality
(device.DiscoveryMethod == "Configuration"), which breaks once a
configured host is also found via mDNS/UPnP in the same sweep:
mergeDeviceData concatenates methods into e.g.
"Configuration+mDNS/Bonjour", so the check silently failed and the
device got labeled "discovered" instead of "manual". Extracted the
decision into classifySource() and switched to a substring match.
Added TestClassifySource, which fails against the old exact-equality
logic on exactly the composite-string cases (verified) and would
have caught this before merge -- the PR's own test disables
mDNS/UPnP, so it never exercised this path.
2. Manually configured devices no longer registered immediately at
startup -- they now wait for the full mDNS/UPnP sweep (up to the
10s discovery timeout) to complete, since the PR removed the
synchronous registration loop and relies entirely on
PreferredDevices. Restored the immediate loop alongside (not
instead of) folding manualHosts into PreferredDevices, so a
currently-online configured device registers immediately as
before, while an offline one still gets retried on every
subsequent discovery pass -- the actual value this PR adds.
3. The new PreferredDevices-seeding loop didn't dedupe against hosts
already loaded from PREFERRED_DEVICES, so setting both for the same
host produced duplicate entries. Currently harmless (absorbed by
AddDeviceByHost's fast path) but fragile. Added dedup by host.
4. NewDiscoveryService's doc comment didn't mention the new
configuredHosts parameter or its retry-on-every-sweep behavior.
Documented.
5. The new test's second DiscoverDevices call spawns a one-shot
status-update goroutine and a 30s-ticker poll loop with no
guaranteed drain before the deferred server.Close(), risking
benign but real -race/CI flakiness. Added a bounded settle delay
after RemoveDevice.
Verified: full build/vet/race test suite/lint clean; the new
TestClassifySource fails against the pre-fix logic and passes with
it; TestDiscoverDevicesRetriesConfiguredHosts re-run 20x under -race
with no flakiness.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Go 1.27 changed how runtime.FuncForPC reports the symbol for
HandleWeb()'s returned closure: it now correctly attributes it to its
defining function (handlers.(*Server).HandleWeb) instead of leaking the
inlining call site's enclosing function name (setupRouter) the way
older Go versions did. The registered route itself is unchanged -- this
is purely a difference in the introspected debug name.
The test's cleanup logic had a dedicated special case for stripping a
leading "setupRouter" prefix, added to work around exactly that
inlining artifact. Verified empirically (temporarily instrumented with
the raw runtime.FuncForPC output, then diffed the full 299-route table
with the special case removed) that the general prefix-stripping loop
already produces an identical result for the remaining legitimate
cases (closures actually defined inline in setupRouter, e.g.
/favicon.ico) -- so the dedicated case was already redundant before
this Go bump and can be dropped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Speakers paired via non-AfterTouch tooling (e.g. the USB-stick SSH-enable
method) can report a margeAccountUUID that isn't Bose's own 7-digit
numeric format, such as "stick@local". Discovery persisted this value
unvalidated, and the datastore's identifier check rejected it outright,
so the device was silently never saved.
Widens datastore.IsSafeIdentifier to accept any identifier that's safe
as a path component, XML value, and telnet-command token (still
excluding whitespace, control characters, and HTML/XML/shell
metacharacters), and makes it the single account-ID validator,
replacing setup's separate, stricter 7-digit-only IsValidAccountID.
Also closes related gaps found while widening the validator:
- postSetMargeAccount now XML-escapes the account ID instead of raw
string interpolation.
- SaveAccountInfo/HandleMargeCreateAccount now validate the account ID
the same way SaveDeviceInfo already did.
- handlers_export.go URL-escapes account/device IDs before building
outbound diagnostic-fetch URLs.
- pkg/service/health gained the sanitizeLog helper every other package
already has, applied to log lines carrying speaker-reported values.
- The admin web UI (script.js) renders account/device IDs via DOM APIs
instead of innerHTML/inline event-handler string interpolation,
closing a stored-XSS path, and a duplicate escape helper was
consolidated into one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A speaker can be reachable, named, and already account-paired yet still
report SOUNDTOUCH_NOT_CONFIGURED, leaving the "install the Bose app"
prompt on screen (reported for ST30 Series II/III in #615). Only a full
pass through the WebSocket setup state machine clears it, but running
that unconditionally risks re-running the bracket on speakers that
don't need or support it.
Add Manager.PreflightInitPlan: checks /supportedURLs for
/setMargeAccount, then requires /soundTouchConfigurationStatus to read
exactly SOUNDTOUCH_NOT_CONFIGURED before ExecuteInitPlan runs.
Already-configured devices are a no-op; an unsupported route or an
unrecognised status value aborts instead of guessing.
On an on-device install, soundtouch-service defaulted its server URL to
os.Hostname() when --server-url wasn't set. Since the service runs on the
speaker's own Linux, that returns the speaker's internal variant codename
(e.g. "spotty", "mojo") -- never resolvable, not even by the speaker itself
-- breaking TuneIn/BMX playback with CURL ErrorCode 6 (issue #546).
Add a --deployment-mode/DEPLOYMENT_MODE flag (on-device, private-network,
public-network) so the fallback is chosen deliberately instead of guessed:
on-device defaults to localhost, public-network refuses to start rather
than guess a public address, and the previous hostname-guessing behavior
is kept for private-network/unset installs, now with a startup warning.
The on-device init script sets DEPLOYMENT_MODE=on-device automatically and
now auto-exports aftertouch.conf into the daemon's environment generally,
which also unblocks discussion #610 (setting MGMT_USERNAME/MGMT_PASSWORD
on-device) without any further code change.
Verified end-to-end on real ST20 hardware: service now resolves
http://localhost:8000, a re-migrate updates the speaker's own runtime
config to match, and TuneIn playback works again.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Prompted by recovering a real speaker (192.168.178.28) that had gone
stressed/unresponsive: it had previously been through `setup enable-ssh`
without --service-url (leaving margeServerUrl persisted as the
aftertouch.invalid placeholder, firmware retry-looping a failing DNS/curl
lookup against it, #546-shaped), compounded by a burst of SSH connections
from a `setup revert` attempt (see the dial-storm finding, tracked
separately, not fixed here).
Added --marge-url/--stats-url/--sw-update-url/--bmx-url override flags to
`setup migrate`. No new backend logic: telnetURLsFromOptions
(pkg/service/setup/telnet_migration.go) already supported per-field
overrides end-to-end, shared with the XML method's applyURLOverrides --
the CLI just never exposed it. Lets a speaker's URLs be pointed anywhere
(back to AfterTouch, or back to the genuine original Bose cloud) via a
single telnet connection, no SSH and no .original backup required --
confirmed recovering the real speaker above (migration committed, a
previously-timing-out plain SSH command returned instantly afterward).
Documented in TROUBLESHOOTING.md: the enable-ssh placeholder-persistence
gotcha (now with the escape hatch above) and the setup revert dial-storm
risk as a known, not-yet-fixed issue with a workaround (prefer this
lighter telnet-only migrate over repeated revert attempts). Documented the
new flags in CLI-REFERENCE.md's setup migrate section.
Prompted by writing a #614 self-test guide (on-device install walkthrough)
and by helping fully revert a real speaker a factory reset didn't fully
clean up.
New soundtouch-cli commands (cmd/soundtouch-cli/cmd_setup.go):
- `setup sync` — wraps POST /api/setup/sync/{deviceId}, the same operation
as the web UI's Devices -> Sync Data button. Read-only towards the
speaker (presets/recents/sources into the datastore); never writes back.
- `setup revert` — wraps setup.Manager.RevertMigration, the same operation
as the web UI's "Revert to Defaults" button. Restores
SoundTouchSdkPrivateCfg.xml/hosts/resolv.conf from their .original
backups and strips the AfterTouch CA cert from the trust bundle. No
--service-url needed; pure SSH against the speaker. Deliberately leaves
SSH persistence and account pairing untouched, matching the web UI
button (use `setup remote-services --remove` / `account unpair` for
those).
Both are thin wrappers with no new business logic, matching the existing
migrate/pair/reboot pattern. Tests added for setup sync's HTTP plumbing
(auth-retry, device-scoped URL, error propagation); no CLI-level test for
setup revert, consistent with reboot/migrate/pair also having none --
RevertMigration itself is already tested in pkg/service/setup/setup_test.go.
Documentation gaps closed:
- ON-DEVICE-INSTALL-WALKTHROUGH.md never showed the Migrate step at all --
jumped from install/reboot straight to the pairing QuickFix as if the
speaker were already pointed at itself. Added an explicit Migrate step
(web-UI and CLI paths), a CLI alternative for the pairing QuickFix, a
no-USB-stick `enable-ssh` (#471) alternative to the physical stick
procedure, and a "testing a pre-release build" section for cross-
compiling and manually swapping an unreleased binary (soundtouch-cli
deploy step included, mirroring the already-covered soundtouch-service
swap).
- MIGRATION-GUIDE.md's "never use localhost" Target Domain warning had no
on-device exception, even though loopback is exactly correct there since
the speaker and the service are the same machine. Added the callout, and
the same enable-ssh alternative to its SSH-enablement step.
- DEVICE-INITIAL-SETUP.md's AP-mode Wi-Fi provisioning commands were
macOS-only (networksetup, dns-sd) with no Linux/Windows equivalents,
unlike the rest of the docs. Added nmcli/netsh wlan alongside.
- CLI-REFERENCE.md's entire `setup <subcommand>` group was undocumented
(--help was the only reference) -- wrote a full "Setup & Migration"
section covering all 16 subcommands, and added the also-undocumented
`account unpair` to the existing Music Service Account Management
section.
Answers #591's open question 2: CLI-only users get no update notice
from soundtouch-service's periodic background check. Both binaries
gain a soundtouch-cli/soundtouch-backup update-check command that
does a single, on-demand GitHub Releases check via the existing
pkg/service/updatecheck package. Running the command is itself the
opt-in, so unlike the service there's no config flag or persisted
state.
pkg/service/updatecheck.Checker was already designed decoupled from
handlers.Server/main.go specifically so other binaries could import
it directly; this is that follow-through.
Follow-up to #591: UpdateCheckEnabled/UpdateCheckInterval are now
persisted, live-reloaded Settings fields (mirroring the discovery
enabled/interval pattern), editable from the admin Settings page
without a restart. The env var/CLI flag remains the seed value for a
fresh install with no settings.json yet.
The background goroutine now always runs and polls the live settings
every minute (updateCheckPollTick), instead of being started only if
enabled at process launch, so flipping the toggle takes effect within
a minute rather than requiring a restart.
Community hardware testing (bitranox, JRpersonal) on 2026-08-09 retracted
the earlier "inter-command delay is necessary" theory and established that
envswitch boseurls set commits the whole runtime layer (not just its two
arguments), has no read form, and doesn't ack with "OK". Corrects
TELNET-MIGRATION-METHOD.md and TELNET-COMMAND-REFERENCE.md accordingly,
retracts the stale "confirmed necessary" command-delay claim in
enable_ssh.go/cmd_setup.go, and lowers DefaultTelnetCommandDelay 5s -> 3s
as a smaller hedge now that the delay itself is known not to be the
mechanism.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
#515 comment 5230833551: on a genuinely unpaired (factory-reset) device,
margeServerUrl is reportedly never polled at all, so the boseurls
SSH-enable injection has no read cycle to fire on regardless of any
command delay. enable-ssh now checks /info first and, if
margeAccountUUID is empty, pairs the device via the existing
PairAccount helper (HTTP /setMargeAccount, telnet fallback) before
running the injection.
Adds setup.Manager.EnsureMargeAccountPaired plus --no-auto-pair (skip
entirely) and --account (use a specific 7-digit ID instead of a
generated one, e.g. to match one already in the datastore) flags on
enable-ssh. Pairing failure is a warning, not fatal, since the claim
is unconfirmed on this specific hardware and existing working flows
must not regress.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per #515 comment 5228449448: on a real Lifestyle console, the same 6
commands (5 sys configuration/envswitch + reboot) sent back-to-back left
sshd down after reboot, but succeeded sent one at a time with ~7s gaps —
same commands, same order, same device, minutes apart. Sending fast may
not let the device fully process one command before the next arrives.
Adds --command-delay (setup.DefaultTelnetCommandDelay, 3s), threaded
through EnableSSHViaTelnetFullConfig/runTelnetInjection (pause after each
of the 5 commands) and runEnableSSHInjection (one more pause before the
reboot). 0 restores the old back-to-back behavior. The reporter didn't
try to find the true minimum, just confirmed ~7s works and speculated
"a second or two may well be enough" — 3s is a middle ground, tunable via
the flag if a specific device needs more.
Also prints an approximate total for the injection phase up front (6
steps x delay, ~18s at the default) so the command doesn't look hung —
separate from the existing --wait message for sshd coming up after
reboot, which can take much longer.
Refs #515
setup ssh-check's failure message claimed "those commands were removed"
on FW 27.x and jumped straight to the USB-stick fallback — flatly
contradicted by setup enable-ssh, which exists specifically to bootstrap
SSH over telnet via the port-17000 envswitch trick (#471), and by
TELNET-COMMAND-REFERENCE.md's own notes that the injection is
field-confirmed on several FW 27.x models. Success is model/build-
dependent, not universally impossible — some devices (ST Portable, some
CineMate 520 units) need --full-config instead of the default injection.
Reordered the message to point at `setup enable-ssh` (with the
--full-config caveat) first, USB stick as the fallback if that doesn't
work on a given device — this was the exact point where a user hitting a
closed port 22 would previously be told to go find a USB stick without
ever learning the telnet route exists.
Refs #598
Addresses the Semgrep finding on PR #599
(go.lang.security.audit.crypto.math_random.math-random-used) on the
update-check jitter delay, and applies the same treatment to the #419
activity-log filename suffix, which has the same non-security shape but
predates this PR's diff so it wasn't flagged.
Neither value is ever compared, kept secret, or otherwise security-
sensitive (a sleep duration and a filename-uniqueness suffix), so
crypto/rand would only add error-handling overhead for no real benefit.
Suppressed with the same // nosemgrep: <rule-id> pattern already used in
the mock-amazon/mock-spotify/mock-tunein servers, mirroring the existing
//nolint:gosec on the same lines.
Refs #591
Third piece of #591. --update-check-enabled/--update-check-interval
(UPDATE_CHECK_ENABLED/UPDATE_CHECK_INTERVAL), default off/24h, following
the same local main.go flag pattern as discovery-enabled — not pkg/config,
which soundtouch-service doesn't import at all (correction to the issue's
proposed location, see the design doc).
Background goroutine modeled on startDeviceDiscovery: startup jitter
(0-5min), skips the immediate check if the persisted last-check is still
fresh, backs off retries to no sooner than 1h after a failure, logs once
per newly-detected version. The decision logic (shouldCheckImmediately,
shouldSkipDueToBackoff, logUpdateIfNewlyAvailable) is split into pure,
directly-testable functions rather than living inline in the goroutine.
Server gets a SetUpdateChecker/UpdateCheckResult pair (nil-safe) so the
next two pieces (announcement, /api/setup/version) can read the current
state without importing updatecheck's construction details.
Manually verified against a running instance: enabled via flags, no panic,
service stays responsive (jitter means the actual first check can take up
to 5 minutes to fire, so this only confirms the wiring, not a live
GitHub response — that's covered by the previous commit's httptest-backed
unit tests).
Refs #591
Fourth piece of #419: a small in-code (not admin-authored) announcement
list, target-scoped ("app"/"admin", "chooser" reserved but not wired since
the landing page has no JS yet) and filterable by live server state via
ShowWhile. First entry: the admin-area-gate heads-up, shown on the admin
target while AdminAreaAuth is unset.
GET /api/announcements?target=... and POST /api/announcements/{id}/dismiss
are deliberately NOT behind BasicAuthAdmin — the whole point of the gate
notice is to reach operators who haven't set up credentials yet, the exact
audience an admin-only endpoint would exclude. The dismiss endpoint
validates id against the known announcement list before it reaches
RecordActivity, since this is the one call site where an id comes from an
HTTP request rather than a compile-time constant.
Updated the router snapshot (testdata/router_routes.txt) for the two new
routes.
Not wired into any UI yet — nothing calls these endpoints.
Refs #419
Third piece of #419. BasicAuthAdmin() mirrors BasicAuthMgmt but reads the
live AdminAreaAuth mode and credentials on every request instead of
capturing them once at router-setup time, so toggling the Settings-UI
switch takes effect immediately.
Split mountSetupAPI into mountSetupAPIShared (ca.crt, tts/speak, tts/config
— used directly by soundtouch-cli and soundtouch-player, must stay reachable
regardless of the gate) and mountSetupAPIAdmin (everything else). Wired the
gate around /admin and both mountSetupAPIAdmin mounts (/setup, /api/setup).
Stockholm's optional legacy setup wizard is intentionally left out of scope.
Also fixes two lint issues introduced in the prior commit (unchecked
json.Marshal in tests, HandleUpdateSettings over the cyclomatic complexity
threshold) since `make lint` wasn't run before that commit landed.
Refs #419
First piece of the #419 admin-area gate: a persisted, live-reloadable
tri-state setting ("" unset / "enabled" / "disabled") so a later release
can flip the default from open to gated without breaking an explicit
opt-out. Rejects enabling while MGMT_USERNAME/MGMT_PASSWORD are still the
published default, since that would give a false sense of security.
No behavior change yet — nothing reads this field to actually gate
anything. That's the next chunk.
Refs #419
chi 5.3.1 recognizes the HTTP QUERY method, so chi.Walk now expands the
all-methods HandleFunc registrations for the SiriusXM live-adapter routes
to include QUERY. The routes are functionally unchanged; only the walk
output grew two lines.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two follow-ups to the derive/show/override settings work:
- The "advanced override" affordance reused the .info-toggle style with a
text label, which is an 18px circular icon badge — the label rendered as
a broken blue circle. Use the icon-toggle pattern like TLS extra hosts:
a small ⓘ that reveals a details block containing the explanation and the
override input.
- Existing installs persist their old effective HTTPS URL in the (now
override) https_server_url field, so the UI showed "(override)" even when
the value equals what we would derive. On load, treat an override that
exactly matches the derived URL as "derive" (clear it), so default
installs show "(derived from Target Domain)"; genuinely custom values are
kept as overrides.
Verified live: an existing settings.json with https_server_url equal to the
derived value now reports an empty override, and the served admin HTML uses
the ⓘ toggle.
refs #355
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The HTTPS URL AfterTouch advertises (and points speakers at for the
DNS-redirect, OAuth, install-ca and cert-trust flows) was a separate,
internally-tracked value: sourced only from --https-server-url /
HTTPS_SERVER_URL / the settings file, defaulting to the machine hostname,
and never shown or editable in the web UI. So it could silently diverge
from the Target Domain (e.g. a different host, or a port-less value that
fell back to 443 while the listener was on 8443 — the root of #355), with
no way to see or fix it in the UI.
Make it derive + show + override:
- DeriveHTTPSURL resolves the effective HTTPS URL: an explicit override
wins; otherwise it follows the Target Domain (same host, https, on the
configured HTTPS port); an already-https Target Domain is honoured
verbatim (its port is not second-guessed); empty falls back to the
hostname default. So changing the Target Domain updates the HTTPS URL
automatically for the common single-host case.
- The persisted https_server_url is now the *override* (empty = derive).
Existing installs carry their old value here, so it is preserved as an
override — no silent change on upgrade; clearing it opts into derive.
- The server keeps httpsServerURL as the effective value, so all
consumers (cert SANs, migration, export, health) are unchanged; it is
recomputed whenever the Target Domain or override changes.
- Settings API returns https_server_url (effective) plus
https_server_url_override; the Settings page shows the effective URL
with a derived/override note and an "advanced" override field.
Verified live on a clean data dir: derive from an http Target Domain,
auto-follow when the Target Domain changes, explicit override, an https
Target Domain kept verbatim, and override persistence across restart.
Unit tests cover DeriveHTTPSURL including the already-https cases.
refs #355
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the previous commit. The cert-chain check dialed the
advertised HTTPS URL, whose port defaults to 443 when the URL omits it
(splitHTTPSHostPort). The advertised URL comes from
--https-server-url / HTTPS_SERVER_URL / the settings file and is not
editable in the web UI, so when it lost its port it silently pointed the
check (and speakers) at 443 while the real listener was on 8443 — the
exact "port 443" complaint in issue #355.
Thread the actual HTTPS listener port into the check (new
Server.SetHTTPSListenAddr, wired from config.httpsAddr). When the dial
fails and the advertised port differs from the listener port, emit a
mismatch-specific warning that names both ports and offers the corrected
HTTPS_SERVER_URL, while still deferring to reverse-proxy setups. A
reachable endpoint never reaches this branch.
Reproduced locally on a clean data dir: seeding a port-less
https_server_url with the listener on 8443 previously errored on
:443; it now warns with both ports and the fix. Regression tests added.
refs #355
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Startup treated an empty server_url as "first run" and wrote a fresh
default settings.json via createDefaultSettings, which builds the struct
from CLI flags and does not merge the existing file. A hand-authored
settings.json that sets, say, trust_forwarded_headers but leaves
server_url to the --server-url flag has no server_url, so it was
silently clobbered on first start (losing the operator's keys).
Gate the default-seed (and the lost-volume "first run" notice) on the
ABSENCE of settings.json instead. An existing file is now always
respected; a genuinely empty data dir still gets defaults and the
notice. This also fixes a latent loop where a never-set server_url made
every start look like a first run.
Adds regression tests: settingsFileExists, plus first-run seed both
preserving a hand-authored file and writing defaults when absent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chi v5.3.0 deprecates middleware.RealIP (IP-spoofing advisories), which
failed the Lint and Static Security Analysis CI jobs (SA1019). Replace the
RealIP wrapper with chi's middleware.ClientIP: ClientIPFromRemoteAddr is
always applied so middleware.GetClientIP is populated, and when
trust_forwarded_headers is set and the immediate peer is a trusted-proxy
CIDR, ClientIPFromXFF resolves the real client from X-Forwarded-For
(rightmost entry outside the trusted CIDRs). The immediate-peer trust gate
is preserved, so a non-trusted peer's XFF is ignored. CIDR strings are
validated with netip.ParsePrefix first to avoid ClientIPFromXFF's panic.
Behavior change: only X-Forwarded-For is honored now (RealIP also read
X-Real-IP / True-Client-IP). Docs and a release note follow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The help text still listed only tts/url/beep/notify. Add the UPnP
AVTransport option (no app key, no DNS; http:// only, replaces source).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a third way to push a clip to a speaker, surfaced by @dagrider in
#517: POST SetAVTransportURI + Play to the speaker's UPnP MediaRenderer
control endpoint (port 8091). Unlike /speaker play_info it needs no
app_key and no DNS interception, so it works on a plain LAN; the
trade-off is it switches the speaker to the UPNP source and replaces the
current playback (no duck-and-resume).
- pkg/client: SetAVTransportURI, AVTransportPlay, PlayURLViaUPnP (+ the
:8091 control-URL derivation and SOAP plumbing), with tests.
- cmd/soundtouch-cli: `speaker url-upnp --url <url>`.
- docs: document the UPnP/AVTransport option under POST /speaker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The walkthrough mounted the volume at /data, but the image's DATA_DIR is
/app/data, so the documented docker run never actually persisted the
datastore, settings or CA; a recreated container silently lost all state.
Correct the mount path, document what lives under /app/data and the cost
of losing it, and add a Windows/macOS Docker Desktop note (host
networking is Linux-only; publish ports; DNS interception needs :53/:443).
The service also logs a clear notice on startup when the data dir looks
empty.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v0.114.0 binaries reported version 0.0.0 in the web UI. Two root causes,
both fixed here.
1. The release build relied solely on Go's VCS stamping of
info.Main.Version and never injected a version. When v0.114.0 was
re-released via workflow_dispatch from `main` (one commit past the
tag) with a shallow checkout, no tag was reachable, so Go stamped a
v0.0.0-<ts>-<sha> pseudo-version. The asset filenames used the
validated input version, so the files were named v0.114.0 but
reported 0.0.0 at runtime.
2. The `release` and `workflow_dispatch` triggers followed two distinct
patterns. On `release` every job's checkout landed on the tagged
commit (GITHUB_SHA == tag); on `workflow_dispatch` they all built
whatever branch the run started from. So a manual dispatch built the
wrong source entirely (binaries and Docker images alike).
Changes:
- Unify both triggers on the git tag. `validate` resolves the tag once
(inputs.tag on dispatch, release.tag_name on a release event), verifies
it exists in git, and exposes it as an output. Every other job checks
out `ref: needs.validate.outputs.tag`, so the build is always the
tagged commit regardless of trigger. The dispatch path now re-releases
an existing tag (push the tag first) instead of creating one from a
branch; it fails fast if the tag is missing.
- Inject -X main.version/commit/date into the release binaries, mirroring
the Dockerfile (which has done this since #422). version/commit no
longer depend on git stamping; commit is read from the checked-out HEAD
(not github.sha, which on dispatch is the branch HEAD). Both binaries
and Docker images take the v-prefixed tag (needs.validate.outputs.tag)
so the displayed version stays "v0.114.0", matching prior releases.
- Guard updateBuildInfo() in all four cmd/*/main.go so an injected
version (version != "dev") is never clobbered by a VCS pseudo-version.
`go install …@vX.Y.Z` still resolves the tag via build info as before.
- Collapse the duplicated `if event_name == workflow_dispatch` tag
derivations and route tag/version through needs.validate.outputs.*.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
soundtouch-web was a transitional alias of soundtouch-player. Stop
building and publishing it everywhere, and refresh the release notes
while at it:
- release.yml: remove the soundtouch-web binary, its individual and
combined checksums, and its release assets (EXPECTED_COUNT 35 -> 28);
drop the ghcr.io/...-web Docker image steps. Also slim the
workflow_dispatch release notes to an accurate AfterTouch header plus
GitHub's auto-generated changelog, with the bare tag as the title.
- Dockerfile: drop the soundtouch-web image stage.
- Makefile: remove WEB_NAME and the build-web target (and its use in
build/install).
- Delete scripts/raspberry-pi/install-web.sh (it fetched a release asset
that is no longer published) and point the docs at install-player.sh.
- Correct README, CLAUDE.md, and main.go wording that claimed the alias
was still published.
The runtime notice for a binary still run under the soundtouch-web name
is kept, so anyone who renamed the binary is nudged to soundtouch-player.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The default `setup enable-ssh` injects the remote_services/sshd payload
only via `envswitch boseurls set` and relies on the speaker re-reading its
boseurls (~60s) without a reboot. On the SoundTouch Portable (Series I,
FW 27.0.6.46330.5043500) and some CineMate 520 units the device accepts and
persists that injection (getpdo confirms) but sshd never comes up, so :22
stays "Connection refused".
@Henri-be got root on the ST Portable by typing a different sequence by hand
over telnet :17000: the injection rides `sys configuration margeServerUrl`
(the runtime layer) as well as `envswitch`, all four URL keys are written,
and the device is rebooted so it re-parses the config at boot.
Add an opt-in `--full-config` flag that replicates that exact sequence
(EnableSSHViaTelnetFullConfig + telnet reboot via the existing
RebootMethodTelnet). The default single-envswitch path is unchanged, so the
field-confirmed flow on the Wireless Link Adapter and CineMate 520 `lisa`
variant does not regress. Docs (TELNET-COMMAND-REFERENCE, DEVICE-LOGGING)
document both paths and which device models/firmware need `--full-config`.
The flag automation is candidate behaviour awaiting reporter confirmation:
the manual sequence is confirmed on the ST Portable, the flag is not yet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two lint fixes on the DLNA test server, no behaviour change:
- nilerr: the "skip unreadable file, keep walking" branch in the
--media-dir WalkDir callback returns nil after a non-nil read error
by design; annotate it with //nolint:nilerr, matching the existing
skip-entry branch above it.
- revive (redefines-builtin-id): rename between()'s `close` parameter
(and `open` for symmetry) to closeTag/openTag so it no longer shadows
the builtin `close`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
--media-dir flattened all tracks into a single container named after --name, so
browsing showed one "<--name>" dir (and a doubled breadcrumb) instead of the
real album folder, and every track's artist was the hardcoded "Test Artist".
- Group tracks by their containing directory; each becomes its own browsable +
playable container titled after that directory (e.g. "Sunday at Devil Dirt").
- Derive the artist from the directory above the album
(<root>/<artist>/<album>/track), falling back to "Unknown Artist"; album stays
the track's own folder name.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tooling to reproduce "album cover broken in the player" (disc #499) and to debug
STORED_MUSIC playback against a controllable DLNA source.
- dlnatest.Item gains ArtPayload/ArtMime; the Browse DIDL emits
<upnp:albumArtURI> and the art bytes are served at /AlbumArt/<id>.<ext>. The
built-in tracks carry a tiny PNG cover so the repro works with zero setup.
- audio + art are served via http.ServeContent (adds the byte-range support real
speakers use when streaming).
- example-dlna-server gains --media-dir: serve real .mp3/.wav/.flac/.m4a/.ogg
files, searched recursively so an artist/album tree works. Art per track: a
sibling <name>.jpg/.png, else cover.jpg/cover.png/folder.jpg in the album
folder. Files are read into memory (point it at an album, not a whole library).
- BrowseMetadata: serveContentDir now honours BrowseFlag and returns single-object
metadata (with the track's <res>). Speakers issue Browse(BrowseMetadata) to
resolve a track before playing; returning empty caused INVALID_SOURCE.
- fix SSDP discoverability on multi-interface hosts: join the multicast group on
the interface that owns the LAN IP (macOS lists lo0 first, so the old "first
multicast interface" join landed on loopback and never heard the LAN M-SEARCH).
- add an HTTP access log (method/path/status/bytes/peer, plus ObjectID+BrowseFlag
for Browse) so the speaker's request sequence is visible while debugging.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adding a DLNA media server (setMusicServiceAccount) can leave the new
STORED_MUSIC source not fully registered on the speaker, so playing a track
fails with INVALID_SOURCE until a power-cycle. AfterTouch's health
diagnostic already recommends the no-reboot fix: a sourcesUpdated
notification makes the speaker re-fetch its account /full and re-register
its source list.
Fire that nudge automatically right after a successful registration, in
both the player (HandleAddLibraryServer) and the CLI (account add-nas), via
the existing client.NotifySourcesUpdated. It is best-effort: registration
already succeeded, so a failed nudge never fails the request (the handler
returns {account, refreshed}, the CLI prints a warning that a power-cycle
may still be needed). The handler resolves the Bose device ID from the
cached DeviceConnection.DeviceInfo, falling back to GetDeviceInfo.
Note: per the diagnostic, a power-cycle is still occasionally required, so
the nudge is an improvement, not a guarantee.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TestPrintRoutes is a golden snapshot of the full chi route tree. The new
DLNA Music Library routes (device-scoped /library/{servers,browse,play} and
the global /providers/library/servers, plus the /app/library SPA deep link)
legitimately extend the tree, so refresh the snapshot. Diff is exactly the
seven new library routes mapping to the new handlers; no other routes change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hardware testing (ST10 FW27.0.6 + FRITZ!Box 6490) showed the previous
raw-URL play modes do not play DLNA content: a raw stream URL sent as a
LOCAL_INTERNET_RADIO location is rejected by the speaker (APServer
"REJECT: TransportControl: Wrong Client", nothing plays). The native
mechanism is STORED_MUSIC: register the server, then select a ContentItem
carrying the media server's object ID as location.
`library play` now takes --source-account (<UDN>/0) and --location
(object ID from a browse), plus optional --name/--type/--art, and selects
a STORED_MUSIC ContentItem with type="track" via SelectContentItem (so the
type is set, which SelectStoredMusic does not do). It first checks /sources
for a READY STORED_MUSIC entry with that account and, if absent, prints a
ready-to-copy `account add-nas` hint and stops instead of failing opaquely.
The old --url/--mode raw-URL flags are removed.
Validated end to end: browse -> play -> now_playing source=STORED_MUSIC
status=PLAY_STATE. The speaker streams from the media server directly; no
AfterTouch proxy involved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>