Adds the #471 "secure" steps as opt-in flags on `setup enable-ssh`, off by
default (per the decision that closing 17000 must be opt-in):
- --close-17000: blocks port 17000 from the LAN. Manager.Close17000 remounts /
read-write, persists an idempotent iptables rule in
/etc/init.d/Firewalls/update_iptables (keyed on a marker), and applies it
immediately; loopback access is kept.
- --authorized-key <pubkey>: Manager.InstallAuthorizedKey writes the key to
/home/root/.ssh/authorized_keys so root SSH no longer relies on the
empty-password login.
Both run over the SSH the enable step just opened. Default output reminds the
user that 17000 is left open and how to close it. Unit tests cover the
firewall command sequence and the key upload path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds `soundtouch-cli setup enable-ssh`, the first iteration of foob61451's #471:
turn on SSH on a speaker that has no prior SSH access and without a USB
recovery stick, then fall into the migration / CA-install flow we already have.
Mechanism (new Manager methods, reusing the existing telnet :17000 client):
- EnableSSHViaTelnet sends `envswitch boseurls set "<url>;touch
/tmp/remote_services;/etc/init.d/sshd start" "<url>/update"`. The injected
shell commands run when the speaker next parses its boseurls (~60s), starting
sshd. The URL is only the vehicle for the injection — it does NOT need a live
server, so this works before any AfterTouch service exists.
- WaitForSSHPort polls :22 until sshd is up.
- ResetBoseURLs restores a clean marge URL afterwards.
- Persistence reuses the existing EnsureRemoteServices (writes the marker over
the now-open SSH so it survives reboot).
CLI flow: inject → wait for :22 → reset clean URLs → persist. `--service-url`
is optional (placeholder used otherwise; set real URLs later via migration).
Securing/closing port 17000 is deliberately OPT-IN and not done here. Unit
tests pin the exact injected/reset command strings and the double-quote guard.
This lands in -cli first (cheapest to iterate); the future soundtouch-app can
reuse the same Manager methods.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The embedded player's TTS proxy made a server-side call back to the
service over the public ServiceURL. When that URL is HTTPS with the
service's self-signed CA, the call failed with "x509: certificate
signed by unknown authority" — the service didn't trust its own CA.
Route the player's own server-side self-calls to the service's loopback
HTTP listener instead (new WebApp.InternalServiceURL, used via
proxyServiceURL()). Loopback is plain HTTP, so it needs no CA and works
on HTTP and HTTPS deployments alike, including before the CA is
generated, and it doesn't depend on the public URL being routable from
inside the service. ServiceURL stays public: Play URL bakes it into the
stream URLs the speaker fetches, and the UI displays it.
config.port is always the plain-HTTP listener (http.Serve); TLS lives
on a separate httpsAddr, so the loopback URL can never hit a TLS-only
socket.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The set_clock quick-fix pushed the time via POST /clockTime and reported
success unconditionally. On real hardware (ST10, observed live) the firmware
dispatches POST /clockTime to its read handler (HandleClockGetTime) and
ignores the value: it returns 200 but the clock never moves, so the fix was a
silent no-op that still claimed success.
Now setSpeakerClock:
- tries HTTP POST /clockTime (works on firmware that honours it), then
- verifies by re-reading /clockTime; if the clock did not move, it
- sets the clock over SSH (`date -u -s …`, with a BusyBox positional
fallback) on an SSH-reachable speaker (root, empty password), and
- verifies again. It only reports success when the clock actually changed;
otherwise it returns an honest error pointing at the real root cause
(the speaker can't resolve/reach NTP, so the clock is stuck — restore
DNS/NTP reachability; a wrong clock breaks HTTPS/TLS).
The HTTP request format itself was already correct (the device's own GET uses
`utcTime`); the problem was never the payload, only that some firmware has no
HTTP setter at all. Durable NTP-side fix (AfterTouch resolving/serving NTP) is
tracked separately.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The soundtouch-player image (and its transitional soundtouch-web alias) ran
as root for no reason: the player is stateless, binds an unprivileged port
(8080), and its mDNS/SSDP discovery uses unprivileged multicast. Drop to
USER nobody. Verified the image starts, binds 8080, and discovers as uid
65534.
The soundtouch-service image is left as root for now: it persists to
/app/data (commonly a host-mounted volume whose ownership we can't assume)
and its optional built-in DNS server binds the privileged :53. Making it
non-root needs a chowned data dir plus NET_BIND_SERVICE (or moving DNS off
:53), so it's handled separately.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lands the groundwork to run the service container as non-root, but keeps it
running as root by default so this is NOT a breaking change yet. Enabling it
(BREAKING) is planned for v1.0.0 and reduced to a one-line flip.
Image prep (all harmless while running as root):
- A fixed non-root user, uid/gid 65532 (aftertouch), with /app chowned to it.
- A cap_net_bind_service file capability on the binary so the optional DNS
server can still bind :53 as non-root (NET_BIND_SERVICE is in Docker's
default cap set; no --cap-add needed). Applied after chown so it survives.
- USER ${APP_USER} with ARG APP_USER=root: still root by default. To enable
non-root, flip the default to "aftertouch" (one line) or build with
--build-arg APP_USER=aftertouch.
Startup safety net (active now, no-op while writable):
- warnIfDataDirNotWritable probes DATA_DIR and, if it can't write, logs the
exact `chown -R 65532:65532 <dir>` fix (with the process uid) instead of
failing later with a cryptic permission error. This is the common snag when
a non-root container meets a bind-mounted host dir owned by someone else.
Verified: default build runs as root; --build-arg APP_USER=aftertouch runs as
65532, serves /health, writes the data dir; a read-only data dir triggers the
warning + chown hint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The community Markdown conversion of the official SoundTouch Web API PDF moved
from jaas666/bose-soundtouch-player-api to jaas666/bose-soundtouch-web-api.
Update the links in the community-tools comparison and related-resources list
so the docs link check passes. (Supersedes an earlier mistaken removal; the
repo was renamed, not deleted.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The web player is intrinsically LAN-resident: it reaches speakers directly
and only delegates cloud-only features (e.g. TTS) to a possibly-remote
AfterTouch service via --service-url. That is exactly what a cloud-hosted
soundtouch-service cannot do, so the standalone player binary stays useful
and is not being deprecated. Rename it to state its purpose, with a
transition window so existing downloads keep working.
- cmd/soundtouch-web -> cmd/soundtouch-player; CLI name is now
soundtouch-player. When the binary is invoked under its old name it prints
a one-line rename notice (filepath.Base(os.Args[0])).
- Build/release both names from the same source: Makefile (build-player +
build-web alias, dev-player* targets), Dockerfile (soundtouch-player image
+ transitional soundtouch-web image), release.yml and ci.yml (player +
web artifacts, checksums, Docker images; release notes announce the
rename). The soundtouch-web binary, image, and install script remain a
transitional alias to be dropped in a future release (which will break
stale fetch scripts and nudge users to the release notes).
- scripts/raspberry-pi/install-player.sh is canonical; install-web.sh keeps
working but warns.
- Sweep docs, code comments, user-facing strings, and assets
(soundtouch-web-ui.png, soundtouch-web-tunein.png, soundtouch-web-roadmap.md)
to soundtouch-player; README documents the rename and why the player
remains separate from the embedded /app.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The header bars are intentionally monochrome, but the logo was recoloured
along with them (admin forced it white; the player and chooser whitened it
in light mode). Drop the filter on the brand mark only so it stays in its
blue/yellow brand colours as the single accent, while the mono nav icons
still recolour via --nav-icon-filter. Removes the now-unused --logo-filter
var from the chooser.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Unify the three surfaces' footers and rework the documentation link:
- All footers now show the same version-only line (AfterTouch <version>
(<commit>) • <date>), centered and full-width. The chooser footer no
longer caps its width or carries a docs link; the admin footer uses the
same "•" separator as the player and chooser instead of "-".
- The chooser gets a prominent in-body Documentation link with a book
icon, distinct from the two destination rows (and removed from the top
bar, which is now brand-only).
- That same book icon becomes a small docs button in the player navbar
and the admin header bar, so documentation is one click away from every
surface.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With default_landing set to app or admin, "/" redirects straight there,
which made the chooser (and through it the other surface) unreachable
from the "home" link. Add a "?chooser" override: "/" always serves the
chooser when that query is present, regardless of the configured default.
Point the "home" brand links on the player, the admin console, and the
chooser itself at /?chooser, so "home" always lands on the hub instead of
bouncing back through the default redirect. The bare "/" still honours the
default for direct hits and bookmarks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause of the "navigating away from /admin waits ~30s" report: the
device list refreshed every device's live /info at once. Over HTTP/1.1 a
browser opens only ~6 connections per origin, and it keeps the current
document's in-flight requests (and their sockets) alive until a new
navigation's response begins. With several offline speakers each holding
an /info socket until timeout, all ~6 connections were occupied, so the
next navigation (GET /) could not get a socket until a probe freed one.
The page genuinely waited the full timeout before painting.
Cap the live-info probes at LIVE_INFO_CONCURRENCY (3) via a small mapLimit
helper, leaving sockets free for navigation and other requests. Combined
with the 5s GET timeout, an offline-heavy datastore no longer stalls the
UI. The device table still renders immediately from the datastore; only
the live enrichment is throttled.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Manager.HTTPGet defaulted to http.Get, which uses http.DefaultClient with
no timeout. An offline speaker therefore hung the caller for the OS-level
TCP timeout (~30 s). The admin device list refreshes every device's live
/info on each load (updateDeviceInfo per row), so a handful of offline
speakers each held a request for 30 s. Server-side those run concurrently
and never blocked other routes, but the browser's ~6-connections-per-origin
limit got saturated by the long-held /info requests, which made the whole
admin page (and navigating away from it) feel stuck.
Give HTTPGet a 5 s timeout (liveDeviceHTTPTimeout): ample for a healthy
speaker on the LAN, quick to fail a dead one. Applies to the /info,
/presets, /recents, /sources, inspect, and peer-probe GETs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous commit gated the on-load discovery sweep to a cold start,
but a second, redundant DOMContentLoaded handler still called
triggerDiscovery() ungated on every admin load, so /admin kept kicking
off a full sweep (and its reseed) even with devices already known. The
second handler only duplicated fetchDevices + fetchSettings + the
ungated trigger, all of which the first (gated) handler already does, so
remove it outright. That also drops the duplicate per-device live /info
refresh the second handler caused.
Also drop two em dashes (a code comment and the landing meta description).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The admin console ran a full discovery sweep on every page load whenever
discovery was enabled (DOMContentLoaded -> triggerDiscovery). With devices
already in the datastore, that re-probed every host (including offline
ones) on each visit, which felt slow and surprising.
Gate the on-load sweep on a cold start only: fetch the cached device list
first, and trigger discovery just when it is empty. With devices known,
rely on the cached list, the periodic sweep, and the explicit Discover
button. fetchDevices now returns the device count for that check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SeedExtraDevices probed each datastore host serially via AddDeviceByHost,
whose /info call blocks up to its 10 s timeout for an unknown host. With
offline speakers in the datastore, a re-sync (e.g. the admin page's
discovery sweep on load, or the periodic discovery) stalled for 10 s per
offline device, one after another.
Fan the per-host probes out across goroutines and wait for all of them,
so the seed costs roughly a single timeout regardless of how many devices
are offline. AddDeviceByHost is already registry-safe under concurrency
(covered by TestRegistryConcurrent).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Post-merge, "/" was the admin console with a small text link to the
player. This makes "/" a neutral chooser and unifies the chrome across
all three surfaces (landing, player, admin).
- "/" now serves a lean chooser page (web/landing.html): a calm, self-
contained page (no framework, inline CSS) that routes to the Player
(/app) or the Admin & Setup console (/admin), with the console framed
as the privileged surface. API/speaker clients (non-HTML Accept) still
get the version JSON from "/" unchanged.
- The admin console moved to /admin (HandleAdmin); its assets and APIs
are absolute, so it works unchanged at the new path.
- New persisted setting default_landing (chooser|app|admin): when set to
app or admin, "/" 302-redirects straight there. Exposed in the admin
Settings tab; defaults to the chooser.
- Shared header: all three carry the same accent bar (braille mark +
"AfterTouch" + "Bose SoundTouch Toolkit"); the mark is the home link
back to "/". Shared footer: all three show the same version line
(the landing fetches /api/setup/version with a tiny vanilla script).
Light/dark and mobile refinements are deliberately left for a later
pass; the admin keeps its existing light-only styling for now.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The merge of soundtouch-web into soundtouch-service was asymmetric:
manual device *adds* propagated to the player UI (HandleAddManualDevice
notifies, the hook re-seeds + broadcasts), but *removals* did not. The
datastore-removal handler never notified, and the web registry's sync
only ever added entries — its map was append-only, so a removed device
lingered in the player UI until restart.
This adds the missing removal path:
- DELETE /api/control/devices/{id} (HandleDeleteDevice). The registry is
keyed by host/IP; the datastore by device ID (MAC), so the handler
resolves one to the other via the connection's DeviceInfo, cascades to
the datastore through a new RemoveDeviceHook (embedded build only),
prunes the in-memory entry, and broadcasts the updated list.
- WebApp.RemoveDevice prunes the registry and stops the per-device
goroutines (status poller + WebSocket reconnect loop) via a new
done-channel + Close() on DeviceConnection — previously both ran for
the life of the process.
- Server.RemoveDeviceByID extracts the cross-account lookup + remove from
HandleRemoveDevice and now fires notifyDevicesChanged, so the admin
Devices tab removal also propagates to the player UI.
- Player UI: a quiet per-card Remove control (visible on hover), a
confirm dialog, optimistic prune, and a note that a still-online
device may reappear after the next discovery scan (honest v1 — no
ignore-list).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the datastore the single source of truth for the embedded web UI and
stop running a second mDNS/UPnP stack inside the same process.
- The embedded web app no longer creates its own discovery service. Its
"discover" action (POST /api/control/discover) now triggers the service's
own sweep via a new WebApp.TriggerDiscovery hook (wired to
server.DiscoverDevices), which writes results to the shared datastore.
- DiscoverDevices: when TriggerDiscovery is set it runs the external sweep
and re-syncs from ExtraDeviceHosts (the datastore) without any own mDNS;
it only runs its own sweep when given a non-nil discovery service
(standalone soundtouch-web, unchanged).
- Liveness: server.SetDevicesChangedHook fires after a discovery sweep
(server.DiscoverDevices) and after a manual add (HandleAddManualDevice);
the embedded build re-seeds the web registry and broadcasts the updated
device list, so speakers found by the service's periodic discovery or
added via /setup appear in the UI without a manual refresh.
- setupRouter no longer takes a web discovery service (it was always nil
for the service); MountWeb is mounted with a nil discovery service.
Removing devices live still needs a web-registry delete path (the registry
only adds today); that is a separate follow-up. Routes are unchanged, so
the router golden file is untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fold soundtouch-web into soundtouch-service as an additive mount, so a
single process serves both the speaker/cloud-replacement API and the LAN
control UI. No new auth and no opt-in flag: the web surface sits at the
same LAN-trust tier as /setup (which -web already calls without
credentials), and -web is LAN-only by nature.
- newEmbeddedWebApp builds the web app with release metadata, a loopback
ServiceURL (plain HTTP, no CA needed) for the TTS / Play URL proxy, and
an initial discovery sweep. setupRouter gains the web app + discovery
service and mounts the portable surface (MountWeb) additively:
/api/control/* and /app/* (+ /app/static/*). The service keeps its own
/, /health and /static; nothing collides. webApp is optional so the
router unit tests that only exercise the service surface pass nil.
- Manual devices with discovery off: the web app's ExtraDeviceHosts hook
is pointed at the service datastore (ListAllDevices), and
SeedExtraDevices (run from DiscoverDevices, i.e. at startup and on each
/api/control/discover) registers them via the existing AddDeviceByHost.
So speakers added via /setup show up in the UI even when periodic
discovery is disabled.
- The admin page at / now links to the player UI at /app; the speaker /
JSON contract is unchanged.
- Router golden file regenerated: the diff is purely the additive
/api/control + /app routes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Prepare soundtouch-web to be folded into soundtouch-service as an additive
mount. Two changes, no behaviour change for the standalone binary:
- Move the embedded assets from /static/* to /app/static/*, so the whole
web UI lives under /api/control + /app and nothing contends with a host
router's own /static (e.g. the optional Stockholm bridge's root catch-all).
index.html and app.js asset references are updated in lockstep.
- Split Mount into a portable core and a standalone wrapper. MountWeb
registers only the portable surface (/app/static/*, /api/control/*,
/app/*) and nothing outside those subtrees (no /, no /health), so it can
be mounted into another router additively. Mount (used by cmd/soundtouch-web)
now calls MountWeb and adds the standalone-only /health and /->/app redirect.
mount_test.go exercises MountWeb (asserts the portable surface owns nothing
outside /api/control + /app) and Mount (asserts it adds / and /health).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the web UI's app-wide event stream (device list, discovery status,
per-device status updates) from top-level /ws to /api/control/ws. It is
the read/event half of the control surface, so it belongs under the same
namespace as the rest of the web API (the per-device socket already sits
at /api/control/devices/{id}/ws). The bundled app.js WebSocket URL is
updated in lockstep.
This brings soundtouch-web's entire HTTP surface under two clean subtrees
(/api/control/* for the API, /app/* for the SPA), so folding -web into
-service becomes a near-additive mount.
mount_test.go now asserts /api/control/ws is registered and top-level /ws
is gone.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Model tunein, radiobrowser, playurl and tts as content "providers" and
give them a uniform /providers namespace, so the surface is consistent
and extensible (Spotify/Amazon slot in later as new providers).
Two kinds of provider operation fall out naturally:
- Browsable providers (a catalog you search/navigate) expose global
browse routes:
GET /api/control/providers/tunein/{search,search/next,navigate,navigate/*}
GET /api/control/providers/radiobrowser/search
- Every provider plays on a device via a uniform `play` verb:
POST /api/control/devices/{id}/providers/tunein/play
POST /api/control/devices/{id}/providers/radiobrowser/play
POST /api/control/devices/{id}/providers/url/play (was play-url)
POST /api/control/devices/{id}/providers/tts/play (was speak)
Input providers (url, tts) have no catalog, so they appear only as a
device play. The generic POST /devices/{id}/play (raw ContentItem) stays
the low-level primitive, not a provider. /providers stays a literal
namespace with literal provider children (no {provider} param), so there
is still zero static-vs-param ambiguity.
The bundled api.js is updated in lockstep. mount_test.go now asserts the
provider routes exist and the pre-infix flat paths are gone.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the soundtouch-web single-page app from top-level page paths
(/devices, /tunein, ...) under one /app subtree, so the whole web UI
lives under /app/* and folding -web into -service stays an additive
mount. The client navigates via component state rather than the URL and
all assets are referenced absolutely (/static/...), so this is a pure
routing change: no frontend edits needed.
The bare root / now redirects into the app (standalone convenience).
When -web is folded into -service, / instead serves a landing page
(admin vs app) and this redirect is replaced.
Extend mount_test.go with TestMountSPARoutes: the SPA resolves under
/app, the old top-level page paths are gone, and / remains only as the
redirect.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Restructure soundtouch-web's control API to the post-merge canonical
shape so folding -web into -service later is a near-additive mount.
Device-scoped actions now nest under /api/control/devices/{id}/...,
making every direct child of /api/control a literal namespace (devices,
tunein, radiobrowser, version, discover) with no static-vs-param sibling
ambiguity. Browse/search endpoints (tunein, radiobrowser) stay global.
This is a direct migration (no dual-mount, no deprecation middleware):
-web's only client is its own bundled frontend, so a reload picks up the
new paths. The bundled api.js/app.js are updated in lockstep.
Add mount_test.go: the first test that exercises Mount() itself. It
walks the registered routes to assert (a) registration never panics and
(b) the invariant that every web /api/* route lives under /api/control/*
so no flat route is left behind. Handler unit tests call handlers
directly with injected params, so their request-path literals were
cosmetic; updated to the new nested shape for accurate documentation.
SPA routes and the main /ws socket are unchanged here; they move in
follow-up steps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verified the actual overlap between the service and soundtouch-web routers; the
doc's "/, /health, /ws are all collisions" was too broad:
- `/` is the only true collision -> resolve with a landing page (Admin/Setup vs App).
- `/health` is a merge (both define it; standardise on the service's richer body,
and check nothing depends on the web's {"status":"ok","version"} shape).
- `/ws` and `/static/*` are additive -- the service registers neither.
Sequence the merge to mirror the proven service approach but adapted to -web:
- Migrate `-web` in place to the target shape (`/api/control/*`, `/app/*`) FIRST,
as a direct restructure -- no dual-mount, no deprecation signal -- because its
only client is its own bundled frontend (reload-to-fix). The careful
add-alias-then-deprecate dance stays reserved for the central `-service`.
- The subsequent fold-in is then a near-additive mount plus the `/` landing page
and `/health` standardisation.
Also: resolve overlaps structurally before merging (a flag that conditionally
registers routes hides a collision, it does not fix it; do not rely on chi to
warn); ship the merged variant behind an opt-in flag whose purpose is optional
testing/feedback (default-off also keeps the surface unexposed until auth lands),
not a collision guard. Note the deprecation signal is already implemented for
/setup and /mgmt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
So the eventual 1.x removal of the legacy admin paths can be data-driven (cut a
route only once it has gone quiet across real deployments), record usage of the
pre-/api paths without changing their behavior.
- New DeprecatedRouteMiddleware: after serving, counts the hit keyed by
"METHOD <route-pattern>" and logs a one-time warning per route pointing at the
/api equivalent. Wired onto the legacy /setup and /mgmt mounts only — NOT the
/api/* twins, NOT the externally-pinned OAuth callbacks, NOT the Stockholm
setup-wizard catch-all.
- Counts are exposed in the diagnostic export (deprecated_route_hits), so the
shared bundles show whether the old paths are still in use.
Legacy paths keep working unchanged. make test-http-client: 95 requests, 0
failed (the suite still exercises /mgmt directly and now emits the one-time
warnings). go test + golangci-lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Point soundtouch-web's TTS proxy at the new canonical /api/setup/tts/speak path
(request URL and doc comment). No behavior change; the legacy path still works.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Point the CLI's service calls at the new canonical paths: tts speak
(/api/setup/tts/speak) and the CA bundle fetch (/api/setup/ca.crt), plus the
user-facing message and doc comment. No behavior change; legacy paths still work.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the internal self-reachability probe onto the new /api/setup/version path
(updating the doc comment and the unit test accordingly). No behavior change
(the legacy path still works); keeps our own code off the soon-to-be-legacy
/setup/* surface.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Switch the bundled admin SPA's requests from the legacy /setup/* and /mgmt/*
paths to the new canonical /api/setup/* and /api/mgmt/* aliases. Behaviour is
unchanged (the aliases serve the same handlers; TestDualRouteEquivalence pins
that), and the legacy paths stay live, so this is a no-break move. The OAuth
callback URLs are not referenced by the SPA and stay at /mgmt regardless.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Route-transition step 1: add /api/setup/* and /api/mgmt/* as purely additive
aliases of the existing /setup/* and /mgmt/* admin-tier routes, registered from
one shared closure so the legacy and new paths stay byte-identical. The old
paths remain live (no-break upgrade); the admin-SPA repoint and the
old-route deprecation signal are deliberate follow-ups.
- /api/mgmt carries the same Basic Auth as /mgmt. The browser OAuth callbacks
(/mgmt/{spotify,amazon}/callback) are externally-pinned (provider redirect
URIs) and stay at /mgmt only — not aliased.
- /api/setup serves data only; the Stockholm setup-wizard static catch-all
(/setup/*) stays under /setup.
- peer-probe is now part of the shared setup registration, so it is served at
both /setup/peer-probe and /api/setup/peer-probe (previously a one-off
top-level /setup/peer-probe route).
- New TestDualRouteEquivalence fires the same request at the old and new path
and asserts identical status + body — the harness that guards each
dual-routing step.
Frozen speaker contract untouched. Router golden updated.
make test-http-client: 95 requests, 0 failed. go test + golangci-lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Close the speaker/service-contract gaps found comparing against a reference
implementation — three real Bose routes we did not serve:
- DELETE /streaming/account/{account}/source/{sourceID} — removes a configured
source from every device of the account (HandleMargeDeleteSource +
marge.RemoveSourceFromAccount), mirroring the account-level POST add-source.
Bare 200, empty body. Previously source removal was only reachable via the
admin /setup surface.
- GET /bmx/tunein — bare TuneIn service descriptor (the registry's `self` link),
HandleTuneInService. chi routes both /bmx/tunein and /bmx/tunein/.
- GET /core02/svc-bmx-adapter-orion/prod/orion — bare Orion (LOCAL_INTERNET_RADIO)
adapter descriptor, HandleOrionService.
The two descriptors reuse the existing extractBMXService + applyBMXTemplate
helpers (same {BMX_SERVER}/{MEDIA_SERVER} substitution the registry applies).
Contract tests added (delete_source.http, get_bmx_service_descriptors.http);
router + frozen-coverage goldens updated.
make test-http-client: 95 requests, 0 failed. go test + golangci-lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Shrink the route surface the #451 refactor must preserve by retiring the
/accounts/{account}/* compatibility mirror. Across the full recording corpus
(all _/backup/*, _/mitm, _/i195, _/issue-94, captures + data/ + tests/, 139k+
.http files) no speaker or app uses the /accounts prefix, and every operation it
offered is served by the /streaming/account/* paths real clients actually use.
- New HandleUnsupported: returns 501 and logs the full request + client IP + a
"please report this" message, so any real-world use surfaces instead of being
silently dropped, and the prefix becomes a clean removal candidate.
- Re-point every /accounts/* route to it. The frozen /streaming/* contract is
left entirely on its real handlers (those stay even where our corpus didn't
exercise them — absence of capture is not proof of disuse).
- Migrate the integration tests off the /accounts mirror onto their recorded
/streaming/account/* equivalents (register/unregister/spotify_full_flow), then
pin the mirror's 501 contract in unsupported_routes.http.
- Router + frozen-route-coverage golden files updated accordingly.
make test-http-client: 91 requests, 0 failed. go test + golangci-lint clean.
Note for release time: call out the intentional /accounts/* 501 breakage in the
release notes' Noteworthy section (use /streaming/account/* instead).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Revert an over-eager sanitization: this paragraph explains *why* RFC-1918 ranges
make poor placeholders, and deliberately uses 192.168.1.10 as the
non-conformant counter-example. Rewriting it to an RFC-5737 address defeated the
point (192.0.2.10 is obviously a documentation placeholder). Restore the
illustrative bad example.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The integration suite flaked in CI: with three `go run` mocks now compiling
concurrently, the spotify/amazon mocks weren't listening within the fixed
`sleep 10`, so the registration requests at the start of the suite hit a
connection-refused and the "Account exists" assertions (and the cascading amazon
oauth token test) failed. Locally it passed because the mock builds were warm.
Replace the fixed sleep with real readiness gating:
- Add a /healthz endpoint to the spotify, amazon and tunein mocks.
- Give all four CI services (the three mocks + soundtouch-service) a compose
healthcheck (busybox wget; all images are alpine-based), and make the service
depend_on the mocks being service_healthy.
- `docker compose up -d --build --wait` blocks until everything is healthy, so
the JetBrains client only runs against a fully-ready stack.
Also clear the two semgrep advisories on the new TuneIn mock:
- cmd/mock-*: annotate the intentional plaintext ListenAndServe with nosemgrep
(throwaway loopback/CI test servers, never production).
- pkg/testutils/tunein: sanitize the query-supplied guide id to a safe charset
before interpolating it into the JSON/XML response (raw-html-format).
make test-http-client: 73 requests, 0 failed (clean testdata, healthcheck-gated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TestFrozenRouteContractCoverage walks the service router for frozen speaker/app
contract routes (the /streaming, /accounts, /customer, /bmx, /core02, /oauth,
/custom, /media, /updates, /v1, /alexa, /ced prefixes) and checks each is hit by
at least one .http integration test. The set of uncovered frozen routes is
golden-filed (testdata/frozen_routes_uncovered.txt), mirroring the existing
router_routes.txt pattern: adding a frozen route without a test, or a test that
newly covers one, changes the set and fails the guard, forcing a conscious
update. This makes COVERAGE.md a machine-checked invariant rather than a doc
that can silently drift.
Restricted to GET/POST/PUT/DELETE (chi HandleFunc-registered routes otherwise
add CONNECT/TRACE/... noise). golangci-lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per the repo's no-real-data rule (CLAUDE.md), scrub committed files only (the
gitignored _/ local captures are left as-is):
- Real Bose-OUI device ID 08DF1F0BA325 -> placeholder AABBCCDDEE0A across 4 docs
and 8 Go test files (consistent 1:1 rename; affected packages tested green).
- Personal/topology LAN IPs -> RFC-5737: the lab runbook's AP subnet
192.168.10.x -> 198.51.100.x (192.0.2.x is already used contrastively there)
and 192.168.100.1 -> 203.0.113.1; illustrative example IPs in
ANONYMIZATION-SUMMARY / spotify-overview / TROUBLESHOOTING -> 192.0.2.x.
- Kept factual RFC-1918 range citations (10.0.0.0/8 trusted-proxy example,
192.168.0.0/16 "all private subnets") since they name the ranges themselves.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The device-API coverage docs had drifted from the code. Verified each claim
against pkg/client and corrected:
- UNIMPLEMENTED-ENDPOINTS.md: re-marked endpoints now implemented but still
listed as candidates — setMusicServiceAccount / removeMusicServiceAccount and
the stereo-pair group set (getGroup/addGroup/removeGroup/updateGroup); added a
reconciliation note and clarified this tracks the speaker :8090 API, not the
service router.
- SUPPORTED-URLS.md: fixed the "Not Yet Implemented" lists (music services,
presets, stations, navigate, speaker, requestToken/notification/playNotification
are all implemented), the contradictory storePreset double-listing, the native
group section, and the System Info over-claim (trackInfo non-functional,
bluetoothInfo not implemented).
- API-COVERAGE.md: fixed the exec-summary count (18/19 -> 20/21) to match its own
table and refreshed the date.
Also sanitised a real device ID (08DF1F0BA325 -> placeholder) found in
SUPPORTED-URLS.md, per the repo's no-real-MACs rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two deliberately-unimplemented routes, pinned as "currently ignored" so a future
change to them is conscious:
- GET /v1/blacklist/{deviceId} -> 405 (inline stub)
- POST /alexa/certificate -> 501 (no AWS IoT integration)
App / provisioning surface (app-called, not the speaker data-plane). Shapes come
from the _/mitm capture where one exists, otherwise from the handler (canned /
stub responses):
- GET /streaming/account/{a}/emailaddress -> 200 (<emailAddress>, _/mitm)
- GET /customer/account/{a} -> 200 (<customer> profile, canned)
- POST /customer/account/{a} -> 200 (profile update, stub)
- POST /customer/account/{a}/password -> 200 (password change, stub)
COVERAGE.md gains an app/provisioning section and records the source (mitm vs
handler) for each. make test-http-client: 73 requests, 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two more frozen GET routes that return a static 200:
- GET /bmx/registry/v1/servicesAvailability (embedded availability registry JSON)
- GET /ced/soundtouch/mr4_22097fe2/index.xml (CED firmware-update config; a
present static file is 200, absent paths 404)
COVERAGE.md: correct the rows that were already covered by the first batch but
left marked as gaps (/v1/auth, /v1/scmudc, orion station, custom playback,
ding, bmx-icons), and record the two new routes. Remaining gaps are the ones
that need an upstream fixture (tunein episode), prior TTS state (media/tts), or
are quirky-status edges.
make test-http-client: 67 requests, 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The speaker re-polls /full and the device presets with the ETag it last saw and
expects 304 Not Modified when nothing changed. Two self-contained flows capture
the current ETag and replay it via If-None-Match, asserting 304. This pins the
conditional-GET behaviour and the case-sensitive ETag header path (CLAUDE.md).
make test-http-client: 65 requests, 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the BMX TuneIn integration tests independent of the live TuneIn
(radiotime.com) service, the same way Spotify/Amazon are already mocked.
- pkg/service/bmx: the TuneIn upstream base URLs become configurable vars with
a SetTuneInEndpoints(opmlBase, apiBase) setter that also registers the host in
the outbound allowlist. Defaults are unchanged (real radiotime hosts), so
production behaviour is identical; tests can redirect to a mock.
- cmd/soundtouch-service: new --tunein-opml-url / --tunein-api-url flags
(TUNEIN_OPML_URL / TUNEIN_API_URL) wired through to SetTuneInEndpoints.
- cmd/mock-tunein + pkg/testutils/tunein: a mock TuneIn server serving Tune.ashx
(stream URLs) and describe.ashx (name/logo) with RFC-5737 values; unmocked
endpoints 404 so a test needing them fails loudly.
- docker-compose.ci.yml: add the tunein-mock service and point the service at it.
- tunein_playback_station.http now asserts the mock-served stream URL + name,
proving the path is offline. tunein_favorite.http covers the local-only
favorite add/remove (202).
- TUNEIN-MOCK-MISSING.md lists the upstream captures still needed (episode /
navigate / search) before those routes can be mocked + tested.
make test-http-client: 61 requests, 0 failed. golangci-lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
create_group.http now captures the new group id from the Location header into
{{groupId}}; delete_group.http then completes the lifecycle by removing that
group (DELETE /group/{groupId} -> 200 with <status>) and exercises the no-id,
account-level teardown form a speaker sends on factory reset
(DELETE /group/ -> 200). Inserted after get_group.http, before device teardown.
make test-http-client: 59 requests, 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build the regression net the API route layout note calls for, before any
route refactoring: mine real recorded speaker traffic (Bose_Lisa UA) into a
coverage checklist and fill the high-priority, dependency-free gaps.
- COVERAGE.md: inventory of frozen speaker routes (method + status) mapped to
covering .http files, with the remaining gaps classified by priority.
- New flows, all asserting status/content-type/structure with the firmware UA:
- GET /v1/auth (app-key probe)
- POST /v1/scmudc/{deviceId} (telemetry upload)
- GET /core02/.../orion/station (Orion custom-stream adapter)
- GET /custom/v1/playback/{encodedURL} (LOCAL_INTERNET_RADIO / ding)
- POST /bmx/tunein/v1/report (STOP -> {}, START -> nextReportIn)
- GET /media/aftertouch-ding.wav (binary: status + content-type)
- GET /media/bmx-icons/{provider}/{file} (binary: status + content-type)
All request/response values use placeholder / RFC-5737 data; no recorded
bodies are committed. make test-http-client: 57 requests, 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- architecture/_index.md: list the section's docs with links.
- reference/CLOUD-API.md: "See also" pointer (service cloud-emulation routes).
- reference/API-ENDPOINTS.md: note distinguishing the speaker device API from
the service route layout, with a link.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Architectural reference for the staged API refactoring that precedes the
soundtouch-web / soundtouch-service merge:
- Route classification by client audience and what pins each path (frozen
firmware contract vs externally-pinned OAuth callbacks vs our movable
admin/control surface), with service + web route tables.
- Actors model (speaker / app / cloud) and deployment topologies; speaker-direct
vs data-plane reachability.
- deployment-mode parameter (private/shared/public), trust tiers, auth posture
(opt-none -> opt-in -> opt-out?), and auth mechanisms (Marge as one auth
provider like EntraID; native/headless clients via RFC 8252 loopback or a
headless token; identity in logs).
- /app/* single role-gated app with code-splitting for on-device size.
- Versioning policy: no path versioning; semver with 0.x dual-routing and a 1.x
cutover that removes obsolete routes.
- Staged migration (add+alias, fold in web, deprecate the binary, observable
old-route warnings) with a "before 1.x" definition of done.
- Regression safety: contract tests from the frozen recordings, building on the
existing tests/integration/http-client suite.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>