mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 09:06:14 +00:00
feat(setup,handlers): SSH-less reachability via telnet round-trip probe
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 <targetURL>/probe/<token>`
— 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 <original>` — 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 `<swUpdateIndex/>` 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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
102770e301
commit
09c8b916ae
@@ -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)
|
||||
|
||||
@@ -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(`<?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"`
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 <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 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
|
||||
}
|
||||
@@ -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/<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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user