remove(service): delete deprecated telnet round-trip probe

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) <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 f0de4864b6
commit 62dd53777d
8 changed files with 1 additions and 768 deletions
-13
View File
@@ -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)
@@ -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(`<?xml version="1.0" encoding="utf-8"?><swUpdateIndex/>`))
}
// 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)
}
}
-68
View File
@@ -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)
}
@@ -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")
}
}
+1 -7
View File
@@ -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(),
}
-33
View File
@@ -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.
-205
View File
@@ -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 <targetURL>/probe/<token>`
// 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 `<deviceIP>: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 <originalURL>` 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
}
-305
View File
@@ -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/<token> 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")
}
}