From 09c8b916aef6ed5475ea998d33767fac6565e7db Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 10 May 2026 23:48:08 +0200 Subject: [PATCH] feat(setup,handlers): SSH-less reachability via telnet round-trip probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills the SSH-less gap the curl-from-device test leaves in the pre-flight panel: instead of skipping connectivity verification on USB-unlock-refusing speakers, we drive a round-trip from the device itself using only telnet:17000 and the device's own :8090 API. Sequence (Manager.RunTelnetRoundTripProbe): 1. telnet `getpdo CurrentSystemConfiguration` — capture the speaker's current swUpdateUrl so we can restore it. 2. Generate a random hex token; register a one-shot signal channel under it via the new probeRegistry on Server. 3. telnet `sys configuration swUpdateUrl /probe/` — runtime layer only, no envswitch boseurls set, so the persistence layer keeps the original and a reboot heals the device naturally if our restore step fails. 4. HTTP GET :8090/swUpdateCheck — the cleanest :8090 endpoint that triggers exactly one outbound to the configured swUpdateUrl. Read-only on the cloud side, doesn't depend on margeAccountUUID, doesn't start an actual update. 5. Wait on the registered channel up to telnetProbeTimeout (6s). 6. telnet `sys configuration swUpdateUrl ` — restore in a deferred call so it runs even on the failure path. New /probe/{token}[/*] catch-all on the root router signals the matching channel when the speaker's outbound lands; the response is a minimal `` so the device's swUpdateCheck doesn't choke on a missing structure. The {token}/* sub-path is registered because some firmware appends a path component to the configured swUpdateUrl. POST /setup/telnet-probe/{deviceId}?target_url=… exposes the orchestrator as a single REST call returning {ok, result: {reached, restored, original_url, probe_url, elapsed_ms, logs}, error?}. Tests cover: happy path with channel signalled by the fake registrar when the :8090 trigger fires, timeout when no inbound arrives, abort when getpdo doesn't expose swUpdateUrl, abort when the firmware rejects sys configuration, dial failure, invalid target URL. Frontend wiring (visible pre-flight panel) lands in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/soundtouch-service/main.go | 7 + pkg/service/handlers/handlers_telnet_probe.go | 80 +++++ pkg/service/handlers/probe_registry.go | 61 ++++ pkg/service/handlers/probe_registry_test.go | 46 +++ pkg/service/handlers/server.go | 2 + pkg/service/setup/telnet_probe.go | 189 +++++++++++ pkg/service/setup/telnet_probe_test.go | 305 ++++++++++++++++++ 7 files changed, 690 insertions(+) create mode 100644 pkg/service/handlers/handlers_telnet_probe.go create mode 100644 pkg/service/handlers/probe_registry.go create mode 100644 pkg/service/handlers/probe_registry_test.go create mode 100644 pkg/service/setup/telnet_probe.go create mode 100644 pkg/service/setup/telnet_probe_test.go diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 9d19eb1..c041e04 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -871,6 +871,12 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Get("/", server.HandleRoot) r.Get("/health", server.HandleHealth) + // 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) r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { r.URL.Path = "/media/favicon-braille.svg" server.HandleMedia()(w, r) @@ -1090,6 +1096,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) + 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 new file mode 100644 index 0000000..eb38042 --- /dev/null +++ b/pkg/service/handlers/handlers_telnet_probe.go @@ -0,0 +1,80 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/go-chi/chi/v5" +) + +// 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 + +// 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"` +} + +// 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 new file mode 100644 index 0000000..30b2d73 --- /dev/null +++ b/pkg/service/handlers/probe_registry.go @@ -0,0 +1,61 @@ +package handlers + +import "sync" + +// 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 new file mode 100644 index 0000000..07f21fe --- /dev/null +++ b/pkg/service/handlers/probe_registry_test.go @@ -0,0 +1,46 @@ +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 817a6f3..08e6e5a 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -61,6 +61,7 @@ type Server struct { amazonClientSecret string amazonRedirectURI string amazonService *amazon.Service + probes *probeRegistry } // RequestSnapshot represents an immutable snapshot of an HTTP request. @@ -95,6 +96,7 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, pro recordEnabled: recordEnabled, discoveryInterval: 5 * time.Minute, discoveryEnabled: true, + probes: newProbeRegistry(), } return s diff --git a/pkg/service/setup/telnet_probe.go b/pkg/service/setup/telnet_probe.go new file mode 100644 index 0000000..fb0021d --- /dev/null +++ b/pkg/service/setup/telnet_probe.go @@ -0,0 +1,189 @@ +package setup + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "net/url" + "strings" + "time" +) + +// 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 +} + +// 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 err := t.Dial(); err != nil { + return nil, fmt.Errorf("telnet dial %s:17000 failed: %w", deviceIP, err) + } + + 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 new file mode 100644 index 0000000..a57a04b --- /dev/null +++ b/pkg/service/setup/telnet_probe_test.go @@ -0,0 +1,305 @@ +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") + } +}