mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +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
@@ -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