deprecate(service): mark active telnet round-trip probe for removal

The swUpdate daemon caches its target URL at boot and ignores live
`sys configuration` writes, so the active flip in
RunTelnetRoundTripProbe never reaches the running daemon — confirmed
empirically on a fully-migrated speaker (FW 27.0.6) where both the
runtime and persistence layers were flipped and the device still
dialed the previously-cached `/updates/soundtouch` URL plus
DNS-intercepted `/streaming/software/update/account/*`. The probe URL
was never observed.

Marks DEPRECATED:
  - pkg/service/setup/telnet_probe.go: ProbeRegistrar,
    TelnetProbeResult, generateProbeToken, RunTelnetRoundTripProbe.
  - pkg/service/handlers/handlers_telnet_probe.go: HandleTelnetProbe,
    HandleProbeInbound, telnetProbeTimeout, telnetProbeResponse.
  - pkg/service/handlers/probe_registry.go: probeRegistry.
  - Server.probes field.
  - /probe/{token}[/*] and /setup/telnet-probe/{deviceId} routes.

Adds §9.8 to docs/analysis/TELNET-MIGRATION-METHOD.md documenting the
daemon-cache finding, the diagnostic that confirmed it, the passive
observer replacement, the pre-flight branch on migration state, and
the canonical telnet flow (Apply config → reboot → passive
validation). All code symbols remain in place this commit; the
follow-up commit performs the hard delete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-11 20:31:24 +02:00
co-authored by Claude Opus 4.7
parent 9a7646bf58
commit f0de4864b6
6 changed files with 137 additions and 3 deletions
+6
View File
@@ -872,6 +872,11 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/", server.HandleRoot)
r.Get("/health", server.HandleHealth)
// DEPRECATED: /probe/{token}[/*] backs the deprecated round-trip
// probe (RunTelnetRoundTripProbe). Removed in a follow-up commit
// alongside the rest of the active-probe code path; see
// docs/analysis/TELNET-MIGRATION-METHOD.md §9.8.
//
// Telnet round-trip probe inbound. The orchestrator temporarily
// sets the speaker's swUpdateUrl to /probe/{token}; the speaker
// then fans out a request that we observe here. Catch-all suffix
@@ -1103,6 +1108,7 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
r.Post("/test-dns/{deviceId}", server.HandleTestDNSRedirection)
// DEPRECATED: see §9.8; removed in the follow-up commit.
r.Post("/telnet-probe/{deviceId}", server.HandleTelnetProbe)
r.Get("/ca.crt", server.HandleGetCACert)
r.Get("/proxy-settings", server.HandleGetProxySettings)
+96 -1
View File
@@ -625,4 +625,99 @@ configured `swUpdateUrl`.
implemented.
- Running the round-trip probe on SSH-capable speakers too (as
additional validation alongside the curl-from-device HTTPS test),
not just as the SSH-less fallback it is today.
not just as the SSH-less fallback it is today. **Subsumed by §9.8
— the round-trip probe is being removed; the passive observer is
transport-agnostic and replaces it for migrated speakers.**
### 9.8 The swUpdate daemon-cache finding and removal of §9.5
The §9.5 round-trip probe was retired after empirical testing on a
fully-migrated speaker (FW 27.0.6) revealed that the `swUpdate`
daemon **caches its target URL at boot and ignores live config
writes**. The diagnostic sequence:
1. Manual telnet flip of both layers — `sys configuration swUpdateUrl
<probe-url>` (runtime) **and** `envswitch boseurls set <marge>
<probe-url>` (persistence). `getpdo CurrentSystemConfiguration`
confirmed both writes stuck.
2. HTTP GET `:8090/swUpdateCheck` to trigger fan-out.
3. Service access log showed the device outbound landed on
`/updates/soundtouch` (the **previous** `swUpdateUrl` value, current
at the last daemon boot) and `/streaming/software/update/account/<id>`
(a separate Bose URL the daemon hits, routed to this service by DNS
interception). The probe URL was never dialed.
This falsifies the original NEXT.md hypothesis that the persistence
layer would override the runtime layer for the daemon's fan-out, and
points instead at daemon-level URL caching. Two consequences:
- **The §9.5 probe cannot work on migrated speakers without a
reboot.** The cached URL is set when the daemon starts; flipping
config after that point has no effect on what the daemon dials.
- **The §9.5 probe likely cannot work on unmigrated speakers
either**, for the same reason — the daemon caches whatever URL it
read at startup, which on an unmigrated speaker is the Bose cloud
URL. We have no service running with the probe URL registered on
unmigrated speakers, so the original "it worked in testing" claim
has no empirical basis; it likely failed silently because nothing
was watching.
The honest replacement is a **passive observer** (see
`pkg/service/setup/peer_probe.go`):
1. Register the device IP with an in-process observer
(`handlers.peerObserver`, wired via `PeerObserverMiddleware`).
2. Nudge `:8090/swUpdateCheck` to make the daemon fan out *something*
sooner than its ~5min timer.
3. Wait up to 30s for any inbound from that IP. On a migrated
speaker, DNS interception means the daemon's outbounds (update
fan-out, marge polls, BMX registry calls) all funnel through this
service regardless of which URL the daemon resolved internally —
so reachability reduces to *"did the device dial us at all."*
Endpoint: `POST /setup/peer-probe/{deviceId}`. No device-state
mutation; safe to re-run. Returns `{ok, result: {reached,
observed_path, elapsed_ms}, error}` with the same UI keying as the
old probe (`result.reached`).
#### 9.8.1 The pre-flight panel branch
The web UI's pre-flight orchestrator (`runApplyPreflight` in
`script.js`) branches on `summary.is_migrated`:
| Migration state | Reachability row |
|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------|
| Migrated (`is_migrated=true`) | "Reachability check (passive observer)" — calls `POST /setup/peer-probe/{deviceId}`. |
| Not migrated (incl. partial) | Skip row "Round-trip validation runs after Apply + reboot" with the rationale "daemon caches swUpdateUrl at boot". |
Per-axis booleans (`xml_migrated`, `hosts_migrated`, `resolv_migrated`,
`telnet_migrated`) remain visible in the State card, so the user can
see which parts of the migration are already in place even when the
overall flag is false. The skip row does not attempt the active probe
on unmigrated speakers — the canonical telnet flow is:
```
Apply telnet config → user-initiated reboot → re-run pre-flight on
the now-migrated speaker → passive observer confirms fan-out.
```
#### 9.8.2 Removal trail
Removed (or scheduled for removal in a follow-up commit) at the time
of §9.8 landing:
- `pkg/service/setup/telnet_probe.go` — `RunTelnetRoundTripProbe`,
`ProbeRegistrar`, `TelnetProbeResult`, `generateProbeToken`.
- `pkg/service/handlers/handlers_telnet_probe.go` — `HandleTelnetProbe`,
`HandleProbeInbound`, `telnetProbeTimeout`, `telnetProbeResponse`.
- `pkg/service/handlers/probe_registry.go` — `probeRegistry` + tests.
- `Server.probes` field.
- Routes `/probe/{token}`, `/probe/{token}/*`, `/setup/telnet-probe/{deviceId}`.
- The `target_url` query-param plumbing on the deprecated endpoint.
- `script.js` — `checkTelnetRoundTrip` (orchestrator call site removed
in the commit that added the branch; function itself removed later).
`isCommandNotFound` and `parseGetpdoConfig` stay — they are also used
by the migration writer (`telnet_migration.go`), preflight reader
(`telnet_preflight.go`), pairing path (`marge_pairing.go`), and
cross-check (`preflight_crosscheck.go`).
@@ -8,12 +8,21 @@ import (
"github.com/go-chi/chi/v5"
)
// DEPRECATED: handlers_telnet_probe.go (HandleTelnetProbe,
// HandleProbeInbound, telnetProbeTimeout, telnetProbeResponse) is
// scheduled for removal. The swUpdate daemon caches its target URL
// at boot; the active flip in RunTelnetRoundTripProbe never reaches
// the running daemon. See docs/analysis/TELNET-MIGRATION-METHOD.md
// §9.8. Replaced by HandlePeerProbe (handlers_peer_probe.go).
// telnetProbeTimeout caps how long the orchestrator waits for the
// device's outbound swUpdateCheck fan-out to land on /probe/{token}.
// 6s lines up with the existing telnet preflight budgets and is well
// above the median observed round-trip (<1s on FW 27.0.6).
const telnetProbeTimeout = 6 * time.Second
// DEPRECATED: see the file-level note above.
//
// HandleProbeInbound is the catch-all for /probe/{token}/* — the path
// the round-trip orchestrator sets as the speaker's swUpdateUrl. Any
// hit signals the registered channel; the response body is a minimal
@@ -36,6 +45,8 @@ type telnetProbeResponse struct {
Error string `json:"error,omitempty"`
}
// DEPRECATED: see the file-level note above.
//
// HandleTelnetProbe runs the SSH-less round-trip reachability check.
// Generates a token, temporarily points the speaker's swUpdateUrl at
// /probe/{token} via telnet, triggers :8090/swUpdateCheck, and reports
+7
View File
@@ -2,6 +2,13 @@ package handlers
import "sync"
// DEPRECATED: probeRegistry is scheduled for removal. The active
// round-trip probe it backs cannot work without a reboot (the swUpdate
// daemon caches its URL at boot). See
// docs/analysis/TELNET-MIGRATION-METHOD.md §9.8. Replaced by
// peerObserver (peer_observer.go) which keys on device IP and supports
// passive post-migration reachability checks.
// probeRegistry is the rendezvous between the telnet round-trip probe
// orchestrator (which registers a one-shot token and waits for an
// inbound) and the /probe/{token}/* HTTP handler (which closes the
+6 -2
View File
@@ -61,8 +61,12 @@ type Server struct {
amazonClientSecret string
amazonRedirectURI string
amazonService *amazon.Service
probes *probeRegistry
peerObserver *peerObserver
// DEPRECATED: probes (probeRegistry) is removed in a follow-up
// commit alongside the round-trip probe handlers; see
// docs/analysis/TELNET-MIGRATION-METHOD.md §9.8. peerObserver is
// the replacement substrate.
probes *probeRegistry
peerObserver *peerObserver
}
// RequestSnapshot represents an immutable snapshot of an HTTP request.
+11
View File
@@ -10,6 +10,15 @@ import (
"time"
)
// DEPRECATED: ProbeRegistrar, TelnetProbeResult, generateProbeToken,
// and RunTelnetRoundTripProbe are scheduled for removal. The swUpdate
// daemon caches its target URL at boot and ignores live `sys
// configuration` writes, so the temporary flip never reaches the
// running daemon. See docs/analysis/TELNET-MIGRATION-METHOD.md §9.8.
// Replaced by Manager.RunPeerReachabilityProbe in peer_probe.go for
// the migrated case; pre-migration validation relies on the other
// pre-flight rows plus the Apply + reboot cycle.
// ProbeRegistrar is the rendezvous between the round-trip probe
// orchestrator (which registers a token and waits) and an HTTP layer
// (which signals the channel when the device's outbound lands on the
@@ -48,6 +57,8 @@ func generateProbeToken() (string, error) {
return hex.EncodeToString(b), nil
}
// DEPRECATED: see the package-level note above.
//
// RunTelnetRoundTripProbe is the SSH-less reachability check that
// fills the gap the curl-from-device HTTPS test leaves on USB-
// unlock-refusing speakers. The sequence: