From 62dd53777d02634dcaa5dd24a69a0cef2878160d Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Mon, 11 May 2026 18:42:29 +0200 Subject: [PATCH] remove(service): delete deprecated telnet round-trip probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hard-deletes everything marked DEPRECATED in the previous commit: Files: - pkg/service/setup/telnet_probe.go - pkg/service/setup/telnet_probe_test.go - pkg/service/handlers/handlers_telnet_probe.go - pkg/service/handlers/probe_registry.go - pkg/service/handlers/probe_registry_test.go Edits: - Server.probes field + initialization (server.go). - Routes /probe/{token}, /probe/{token}/*, and /setup/telnet-probe/{deviceId} (main.go). - checkTelnetRoundTrip() in script.js. The passive observer (peer_probe.go + handlers_peer_probe.go) is now the only reachability check for migrated speakers; unmigrated/partial states surface a skip row pointing at the Apply + reboot cycle, as documented in TELNET-MIGRATION-METHOD.md §9.8. isCommandNotFound and parseGetpdoConfig remain — they are used by telnet_migration, telnet_preflight, marge_pairing, and preflight_crosscheck. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/soundtouch-service/main.go | 13 - pkg/service/handlers/handlers_telnet_probe.go | 91 ------ pkg/service/handlers/probe_registry.go | 68 ---- pkg/service/handlers/probe_registry_test.go | 46 --- pkg/service/handlers/server.go | 8 +- pkg/service/handlers/web/js/script.js | 33 -- pkg/service/setup/telnet_probe.go | 205 ------------ pkg/service/setup/telnet_probe_test.go | 305 ------------------ 8 files changed, 1 insertion(+), 768 deletions(-) delete mode 100644 pkg/service/handlers/handlers_telnet_probe.go delete mode 100644 pkg/service/handlers/probe_registry.go delete mode 100644 pkg/service/handlers/probe_registry_test.go delete mode 100644 pkg/service/setup/telnet_probe.go delete mode 100644 pkg/service/setup/telnet_probe_test.go diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index b7e12a1..7536726 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -872,17 +872,6 @@ 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 - // because firmware may append path components (e.g. /index.xml). - r.Get("/probe/{token}", server.HandleProbeInbound) - r.Get("/probe/{token}/*", server.HandleProbeInbound) // Passive peer-reachability probe. Registers a device IP with the // in-process observer, nudges :8090/swUpdateCheck, and waits for // any inbound from that IP. Used post-migration where the daemon @@ -1108,8 +1097,6 @@ 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) r.Post("/proxy-settings", server.HandleUpdateProxySettings) diff --git a/pkg/service/handlers/handlers_telnet_probe.go b/pkg/service/handlers/handlers_telnet_probe.go deleted file mode 100644 index 8c20d25..0000000 --- a/pkg/service/handlers/handlers_telnet_probe.go +++ /dev/null @@ -1,91 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "time" - - "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 -// XML stub so the speaker's swUpdateCheck doesn't error out on a -// missing structure. -func (s *Server) HandleProbeInbound(w http.ResponseWriter, r *http.Request) { - token := chi.URLParam(r, "token") - if token != "" { - s.probes.Signal(token) - } - - w.Header().Set("Content-Type", "application/xml") - _, _ = w.Write([]byte(``)) -} - -// telnetProbeResponse is the body of POST /setup/telnet-probe/{deviceId}. -type telnetProbeResponse struct { - OK bool `json:"ok"` - Result any `json:"result,omitempty"` - 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 -// whether the device's outbound landed on our service within -// telnetProbeTimeout. -// -// Query params: -// - target_url (optional) — defaults to the configured server URL. -func (s *Server) HandleTelnetProbe(w http.ResponseWriter, r *http.Request) { - deviceID := chi.URLParam(r, "deviceId") - if deviceID == "" { - writeJSONError(w, http.StatusBadRequest, "Device ID is required") - return - } - - deviceIP, err := s.resolveDeviceIDToIP(deviceID) - if err != nil { - writeJSONError(w, http.StatusNotFound, err.Error()) - return - } - - targetURL := r.URL.Query().Get("target_url") - if targetURL == "" { - targetURL = s.sm.ServerURL - } - - result, err := s.sm.RunTelnetRoundTripProbe(deviceIP, targetURL, s.probes, telnetProbeTimeout) - - w.Header().Set("Content-Type", "application/json") - - body := telnetProbeResponse{ - OK: err == nil && result != nil && result.Reached, - Result: result, - } - if err != nil { - body.Error = err.Error() - } - - if err := json.NewEncoder(w).Encode(body); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - } -} diff --git a/pkg/service/handlers/probe_registry.go b/pkg/service/handlers/probe_registry.go deleted file mode 100644 index 697faff..0000000 --- a/pkg/service/handlers/probe_registry.go +++ /dev/null @@ -1,68 +0,0 @@ -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 -// matching channel when the speaker's swUpdateCheck fan-out lands). -type probeRegistry struct { - mu sync.Mutex - pending map[string]chan struct{} -} - -func newProbeRegistry() *probeRegistry { - return &probeRegistry{pending: make(map[string]chan struct{})} -} - -// Register creates a one-shot channel keyed by token. The caller waits -// on the returned channel for the matching inbound; the channel is -// closed by Signal. Must be paired with Forget to release the entry. -func (r *probeRegistry) Register(token string) <-chan struct{} { - r.mu.Lock() - defer r.mu.Unlock() - - ch := make(chan struct{}) - r.pending[token] = ch - - return ch -} - -// Signal closes the channel for token (idempotent — repeated hits on -// the same probe path are tolerated, the device sometimes retries). -// Returns true when a matching registration existed. -func (r *probeRegistry) Signal(token string) bool { - r.mu.Lock() - defer r.mu.Unlock() - - ch, ok := r.pending[token] - if !ok { - return false - } - - select { - case <-ch: - // already closed; nothing to do - default: - close(ch) - } - - return true -} - -// Forget removes the entry. Safe to call after Register's channel has -// been closed (or never signalled); does not affect already-returned -// channels. -func (r *probeRegistry) Forget(token string) { - r.mu.Lock() - defer r.mu.Unlock() - - delete(r.pending, token) -} diff --git a/pkg/service/handlers/probe_registry_test.go b/pkg/service/handlers/probe_registry_test.go deleted file mode 100644 index 07f21fe..0000000 --- a/pkg/service/handlers/probe_registry_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package handlers - -import ( - "testing" - "time" -) - -func TestProbeRegistry_RegisterSignalForget(t *testing.T) { - r := newProbeRegistry() - - ch := r.Register("abc123") - if ch == nil { - t.Fatal("Register returned nil channel") - } - - if !r.Signal("abc123") { - t.Error("Signal returned false for registered token") - } - - select { - case <-ch: - // channel closed as expected - case <-time.After(100 * time.Millisecond): - t.Error("Signal did not close the channel") - } - - // Signal again on the same token must be idempotent (no panic on - // double close). - if !r.Signal("abc123") { - t.Error("second Signal returned false") - } - - r.Forget("abc123") - - // After Forget, Signal returns false. - if r.Signal("abc123") { - t.Error("Signal returned true after Forget") - } -} - -func TestProbeRegistry_UnknownToken(t *testing.T) { - r := newProbeRegistry() - if r.Signal("never-registered") { - t.Error("Signal returned true for unregistered token") - } -} diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index 5b9b02b..2652e01 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -61,12 +61,7 @@ type Server struct { amazonClientSecret string amazonRedirectURI string amazonService *amazon.Service - // 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 + peerObserver *peerObserver } // RequestSnapshot represents an immutable snapshot of an HTTP request. @@ -101,7 +96,6 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, pro recordEnabled: recordEnabled, discoveryInterval: 5 * time.Minute, discoveryEnabled: true, - probes: newProbeRegistry(), peerObserver: newPeerObserver(), } diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js index 7e30170..86394fb 100644 --- a/pkg/service/handlers/web/js/script.js +++ b/pkg/service/handlers/web/js/script.js @@ -3047,39 +3047,6 @@ async function checkPeerReachability(deviceId) { } } -// checkTelnetRoundTrip is the SSH-less alternative to the curl-from- -// device HTTPS test: temporarily flips the speaker's swUpdateUrl via -// telnet, triggers :8090/swUpdateCheck, and reports whether the -// device's outbound landed on our /probe/{token} catch-all. See -// pkg/service/setup/telnet_probe.go for the orchestration details. -// -// DEPRECATED: scheduled for removal. The swUpdate daemon caches its -// URL at startup and ignores live `sys configuration` writes, so the -// flip never reaches the running daemon. On a migrated speaker, the -// passive observer (checkPeerReachability) is the honest test; on an -// unmigrated speaker, no round-trip via swUpdateUrl is possible -// without a reboot — Apply + reboot is the only path. The orchestrator -// no longer calls this function; it is retained only until the -// deletion commit lands. -async function checkTelnetRoundTrip(deviceId, targetUrl) { - try { - const q = `?target_url=${encodeURIComponent(targetUrl)}`; - const resp = await fetch(`/setup/telnet-probe/${encodeURIComponent(deviceId)}${q}`, {method: "POST"}); - const result = await resp.json(); - if (result.ok) { - const ms = result.result && result.result.elapsed_ms; - return {status: "ok", message: ms ? `reached in ${ms}ms` : undefined}; - } - if (result.error) return {status: "fail", message: result.error.split("\n")[0]}; - if (result.result && result.result.reached === false) { - return {status: "fail", message: "probe inbound not observed before timeout"}; - } - return {status: "fail", message: "probe failed"}; - } catch (e) { - return {status: "fail", message: String(e)}; - } -} - // --- Pre-flight panel: orchestrator --------------------------------- // runApplyPreflight runs the checks visible in the pre-flight panel. diff --git a/pkg/service/setup/telnet_probe.go b/pkg/service/setup/telnet_probe.go deleted file mode 100644 index b54be18..0000000 --- a/pkg/service/setup/telnet_probe.go +++ /dev/null @@ -1,205 +0,0 @@ -package setup - -import ( - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "net/url" - "strings" - "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 -// matching /probe/{token}/* path). The handlers package wires its -// probeRegistry into this interface. -type ProbeRegistrar interface { - Register(token string) <-chan struct{} - Forget(token string) -} - -// TelnetProbeResult records what RunTelnetRoundTripProbe observed. -// Reached reports whether the device's outbound landed on our service -// within the configured timeout; Restored reports whether the -// temporary swUpdateUrl override was reverted to the captured -// original. The orchestrator always attempts the restore even on the -// failure path, so a Reached=false + Restored=true is the common -// "couldn't reach us, device is back to its old configuration" state. -type TelnetProbeResult struct { - Reached bool `json:"reached"` - Restored bool `json:"restored"` - OriginalURL string `json:"original_url,omitempty"` - ProbeURL string `json:"probe_url,omitempty"` - ElapsedMs int64 `json:"elapsed_ms"` - Logs string `json:"logs,omitempty"` -} - -// generateProbeToken returns a random hex token suitable for use in a -// URL path. 12 bytes → 24 hex chars; collision probability is -// negligible for the dozens-of-probes-per-session scope. -func generateProbeToken() (string, error) { - b := make([]byte, 12) - if _, err := rand.Read(b); err != nil { - return "", err - } - - 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: -// -// 1. Telnet `getpdo CurrentSystemConfiguration` to capture the -// speaker's current swUpdateUrl. -// 2. Generate a token, register a one-shot signal channel under it. -// 3. Telnet `sys configuration swUpdateUrl /probe/` -// to point the runtime layer at our service. Deliberately NOT -// `envswitch boseurls set …` — the persistence layer keeps the -// original, so a reboot heals the device naturally if our -// restore step fails. -// 4. HTTP GET `:8090/swUpdateCheck` to make the speaker -// fan out a request to the new swUpdateUrl. -// 5. Wait on the registered channel up to timeout. -// 6. Telnet `sys configuration swUpdateUrl ` to revert. -// -// Returns Reached=true only if the inbound landed before the timeout -// fired. Restore runs in a deferred call so it executes even when -// earlier steps fail. -func (m *Manager) RunTelnetRoundTripProbe(deviceIP, targetURL string, registrar ProbeRegistrar, timeout time.Duration) (*TelnetProbeResult, error) { - if m.NewTelnet == nil { - return nil, errors.New("telnet probe not configured: Manager.NewTelnet is nil") - } - - if registrar == nil { - return nil, errors.New("telnet probe not configured: registrar is nil") - } - - parsedTarget, err := url.Parse(strings.TrimSpace(targetURL)) - if err != nil || parsedTarget.Host == "" { - return nil, fmt.Errorf("invalid target URL %q: hostname required", targetURL) - } - - result := &TelnetProbeResult{} - - var logs strings.Builder - - t := m.NewTelnet(deviceIP) - if dialErr := t.Dial(); dialErr != nil { - return nil, fmt.Errorf("telnet dial %s:17000 failed: %w", deviceIP, dialErr) - } - - defer func() { _ = t.Close() }() - - // 1. Capture the current swUpdateUrl from getpdo. If the device - // refuses getpdo we cannot safely flip the URL — abort. - verify, err := t.SendCommand("getpdo CurrentSystemConfiguration") - if err != nil { - return nil, fmt.Errorf("getpdo CurrentSystemConfiguration failed: %w", err) - } - - if isCommandNotFound(verify) { - return nil, errors.New("device rejected getpdo CurrentSystemConfiguration — cannot capture original URL") - } - - parsed := parseGetpdoConfig(verify) - - originalURL := parsed["swUpdateUrl"] - if originalURL == "" { - return nil, errors.New("could not parse original swUpdateUrl from getpdo response") - } - - result.OriginalURL = originalURL - fmt.Fprintf(&logs, "Original swUpdateUrl: %s\n", originalURL) - - // 2. Token + registration. - token, err := generateProbeToken() - if err != nil { - return nil, fmt.Errorf("generate probe token: %w", err) - } - - probeCh := registrar.Register(token) - defer registrar.Forget(token) - - probeURL := fmt.Sprintf("%s://%s/probe/%s", parsedTarget.Scheme, parsedTarget.Host, token) - result.ProbeURL = probeURL - - fmt.Fprintf(&logs, "Probe URL: %s\n", probeURL) - - // 3. Set swUpdateUrl to the probe URL via telnet. Deferred restore - // runs regardless of subsequent failures. - setCmd := "sys configuration swUpdateUrl " + probeURL - - resp, err := t.SendCommand(setCmd) - if err != nil { - return result, fmt.Errorf("telnet set swUpdateUrl failed: %w", err) - } - - if isCommandNotFound(resp) { - return result, fmt.Errorf("device rejected %q (firmware does not expose this command)", setCmd) - } - - fmt.Fprintf(&logs, "→ %s\n%s\n", setCmd, strings.TrimRight(resp, "\r\n")) - - defer func() { - restoreCmd := "sys configuration swUpdateUrl " + originalURL - if rresp, rerr := t.SendCommand(restoreCmd); rerr == nil && !isCommandNotFound(rresp) { - result.Restored = true - - fmt.Fprintf(&logs, "→ %s (restored)\n%s\n", restoreCmd, strings.TrimRight(rresp, "\r\n")) - } else if rerr != nil { - fmt.Fprintf(&logs, "Restore failed: %v (envswitch persistence will heal on next reboot)\n", rerr) - } - - result.Logs = logs.String() - }() - - // 4. Trigger the device's outbound via :8090/swUpdateCheck. The - // HTTP call is fire-and-forget — we don't need its response, only - // that the device fans out to the probe URL we just set. - swCheckURL := fmt.Sprintf("http://%s:8090/swUpdateCheck", deviceIP) - - go func() { - if m.HTTPGet == nil { - return - } - - resp, err := m.HTTPGet(swCheckURL) - if err != nil { - return - } - - _ = resp.Body.Close() - }() - - // 5. Wait for the inbound. - start := time.Now() - - select { - case <-probeCh: - result.Reached = true - - fmt.Fprintf(&logs, "Probe inbound observed after %v\n", time.Since(start)) - case <-time.After(timeout): - result.Reached = false - - fmt.Fprintf(&logs, "Probe timed out after %v\n", timeout) - } - - result.ElapsedMs = time.Since(start).Milliseconds() - - return result, nil -} diff --git a/pkg/service/setup/telnet_probe_test.go b/pkg/service/setup/telnet_probe_test.go deleted file mode 100644 index a57a04b..0000000 --- a/pkg/service/setup/telnet_probe_test.go +++ /dev/null @@ -1,305 +0,0 @@ -package setup - -import ( - "errors" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - "time" -) - -// fakeRegistrar is a deterministic ProbeRegistrar for unit tests. It -// exposes the channel it returned from Register so the test can -// signal it manually to simulate the device's outbound landing on our -// service. -type fakeRegistrar struct { - mu sync.Mutex - channels map[string]chan struct{} - registered []string - forgotten []string -} - -func newFakeRegistrar() *fakeRegistrar { - return &fakeRegistrar{channels: map[string]chan struct{}{}} -} - -func (r *fakeRegistrar) Register(token string) <-chan struct{} { - r.mu.Lock() - defer r.mu.Unlock() - ch := make(chan struct{}) - r.channels[token] = ch - r.registered = append(r.registered, token) - return ch -} - -func (r *fakeRegistrar) Forget(token string) { - r.mu.Lock() - defer r.mu.Unlock() - delete(r.channels, token) - r.forgotten = append(r.forgotten, token) -} - -// fire closes the channel for the most-recently-registered token so -// the orchestrator's select wakes. -func (r *fakeRegistrar) fire() { - r.mu.Lock() - defer r.mu.Unlock() - if len(r.registered) == 0 { - return - } - last := r.registered[len(r.registered)-1] - ch, ok := r.channels[last] - if !ok { - return - } - select { - case <-ch: - default: - close(ch) - } -} - -// telnetProbeManager builds a Manager pre-wired for probe tests: -// fakeTelnet supplies getpdo and sys configuration responses, and -// HTTPGet is overridden so the :8090/swUpdateCheck trigger doesn't -// reach out to anything real. The httptest server simulates the -// device's swUpdateCheck so we observe the request landing. -func telnetProbeManager(ft *fakeTelnet, onTrigger func()) *Manager { - m := &Manager{ - ServerURL: "http://example:8000", - NewTelnet: func(string) TelnetClient { return ft }, - HTTPGet: func(url string) (*http.Response, error) { - if onTrigger != nil { - onTrigger() - } - rr := httptest.NewRecorder() - rr.WriteHeader(200) - return rr.Result(), nil - }, - } - return m -} - -func TestRunTelnetRoundTripProbe_HappyPath(t *testing.T) { - target := "http://example:8000" - ft := &fakeTelnet{ - responses: map[string]string{ - "getpdo CurrentSystemConfiguration": `swUpdateUrl { - text: "https://worldwide.bose.com/updates/soundtouch" -} -`, - }, - } - registrar := newFakeRegistrar() - - // The :8090 trigger should cause the device to fan out to the - // probe URL. In the test we simulate by closing the channel from - // the trigger goroutine. - m := telnetProbeManager(ft, func() { registrar.fire() }) - - // fakeTelnet returns "Command not found\n" for unmapped commands. - // We need `sys configuration swUpdateUrl …` (any value) to look - // like a success. Pre-populate the map with the canonical happy - // response — the test will fill in the actual command after - // generateProbeToken runs, but we can pattern-match instead. - // Trick: keep the responses map empty for the set command and - // override the fakeTelnet behaviour. - ft.responses = map[string]string{ - "getpdo CurrentSystemConfiguration": `swUpdateUrl { - text: "https://worldwide.bose.com/updates/soundtouch" -} -`, - } - // The set/restore commands aren't in the responses map; the - // fakeTelnet defaults to "Command not found\n" which would fail - // the run. Override by injecting an OK response for any command - // starting with "sys configuration swUpdateUrl ". - origSendCommand := ft.SendCommand - _ = origSendCommand // unused — fakeTelnet uses a method, not a field. - - // Use a custom telnet client that returns OK for sys configuration. - customTelnet := &probeFakeTelnet{ - responses: ft.responses, - } - m.NewTelnet = func(string) TelnetClient { return customTelnet } - - result, err := m.RunTelnetRoundTripProbe("192.0.2.1", target, registrar, 2*time.Second) - if err != nil { - t.Fatalf("RunTelnetRoundTripProbe: %v", err) - } - - if !result.Reached { - t.Errorf("Reached = false, want true") - } - - if !result.Restored { - t.Errorf("Restored = false, want true (restore command should have succeeded)") - } - - if result.OriginalURL != "https://worldwide.bose.com/updates/soundtouch" { - t.Errorf("OriginalURL = %q, want the captured value", result.OriginalURL) - } - - if !strings.Contains(result.ProbeURL, "/probe/") { - t.Errorf("ProbeURL = %q, want a /probe/ path", result.ProbeURL) - } - - if len(registrar.forgotten) != 1 { - t.Errorf("Forget calls = %d, want 1", len(registrar.forgotten)) - } -} - -// probeFakeTelnet returns OK for any "sys configuration swUpdateUrl …" -// command and falls back to the responses map for everything else. -type probeFakeTelnet struct { - responses map[string]string - commands []string -} - -func (f *probeFakeTelnet) Dial() error { return nil } -func (f *probeFakeTelnet) Close() error { return nil } -func (f *probeFakeTelnet) Probe() (string, error) { return "", nil } -func (f *probeFakeTelnet) SendCommand(cmd string) (string, error) { - f.commands = append(f.commands, cmd) - if resp, ok := f.responses[cmd]; ok { - return resp, nil - } - if strings.HasPrefix(cmd, "sys configuration swUpdateUrl ") { - return "OK\n", nil - } - return "Command not found\n", nil -} - -func TestRunTelnetRoundTripProbe_TimeoutWhenInboundNeverArrives(t *testing.T) { - target := "http://example:8000" - registrar := newFakeRegistrar() - - // Do NOT fire the registrar — simulate the device not making the - // outbound (e.g. firewall, hung firmware). - m := telnetProbeManager(nil, nil) - m.NewTelnet = func(string) TelnetClient { - return &probeFakeTelnet{ - responses: map[string]string{ - "getpdo CurrentSystemConfiguration": `swUpdateUrl { - text: "https://worldwide.bose.com/updates/soundtouch" -} -`, - }, - } - } - - result, err := m.RunTelnetRoundTripProbe("192.0.2.1", target, registrar, 100*time.Millisecond) - if err != nil { - t.Fatalf("expected nil error on timeout, got %v", err) - } - - if result.Reached { - t.Errorf("Reached = true, want false (no inbound was fired)") - } - - if !result.Restored { - t.Errorf("Restored = false, want true even on the timeout path") - } -} - -func TestRunTelnetRoundTripProbe_AbortsWhenGetpdoMissesSwUpdateURL(t *testing.T) { - target := "http://example:8000" - registrar := newFakeRegistrar() - m := telnetProbeManager(nil, nil) - m.NewTelnet = func(string) TelnetClient { - return &probeFakeTelnet{ - responses: map[string]string{ - // No swUpdateUrl key — older firmware variant. We refuse - // to flip anything because we wouldn't know what to - // restore to. - "getpdo CurrentSystemConfiguration": `margeServerUrl { - text: "https://streaming.bose.com" -} -`, - }, - } - } - - _, err := m.RunTelnetRoundTripProbe("192.0.2.1", target, registrar, 100*time.Millisecond) - if err == nil { - t.Fatal("expected error when getpdo response has no swUpdateUrl, got nil") - } - - if !strings.Contains(err.Error(), "swUpdateUrl") { - t.Errorf("err = %v, want it to mention the missing field", err) - } -} - -func TestRunTelnetRoundTripProbe_AbortsWhenDeviceRejectsSysConfiguration(t *testing.T) { - target := "http://example:8000" - registrar := newFakeRegistrar() - m := telnetProbeManager(nil, nil) - m.NewTelnet = func(string) TelnetClient { - return &probeFakeTelnetReject{ - responses: map[string]string{ - "getpdo CurrentSystemConfiguration": `swUpdateUrl { - text: "https://worldwide.bose.com/updates/soundtouch" -} -`, - }, - } - } - - _, err := m.RunTelnetRoundTripProbe("192.0.2.1", target, registrar, 100*time.Millisecond) - if err == nil { - t.Fatal("expected error when device rejects sys configuration, got nil") - } - - if !strings.Contains(err.Error(), "firmware does not expose") { - t.Errorf("err = %v, want a firmware-rejection message", err) - } -} - -type probeFakeTelnetReject struct { - responses map[string]string -} - -func (f *probeFakeTelnetReject) Dial() error { return nil } -func (f *probeFakeTelnetReject) Close() error { return nil } -func (f *probeFakeTelnetReject) Probe() (string, error) { return "", nil } -func (f *probeFakeTelnetReject) SendCommand(cmd string) (string, error) { - if resp, ok := f.responses[cmd]; ok { - return resp, nil - } - // Any other command, including sys configuration, is rejected. - return "Command not found\n", nil -} - -func TestRunTelnetRoundTripProbe_DialFailure(t *testing.T) { - registrar := newFakeRegistrar() - m := &Manager{ - ServerURL: "http://example:8000", - NewTelnet: func(string) TelnetClient { - return &fakeTelnet{dialErr: errors.New("connection refused")} - }, - } - - _, err := m.RunTelnetRoundTripProbe("192.0.2.1", "http://example:8000", registrar, 100*time.Millisecond) - if err == nil { - t.Fatal("expected dial error, got nil") - } - - if !strings.Contains(err.Error(), "connection refused") { - t.Errorf("err = %v, want to wrap connection refused", err) - } -} - -func TestRunTelnetRoundTripProbe_InvalidTargetURL(t *testing.T) { - registrar := newFakeRegistrar() - m := &Manager{ - ServerURL: "http://example:8000", - NewTelnet: func(string) TelnetClient { return &fakeTelnet{} }, - } - - _, err := m.RunTelnetRoundTripProbe("192.0.2.1", "not-a-url", registrar, 100*time.Millisecond) - if err == nil { - t.Fatal("expected error on invalid target URL, got nil") - } -}