mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
Adds an SSH-free third migration path that drives the SoundTouch device's diagnostic shell on TCP port 17000, plus a hardened replacement for the fragile /setMargeAccount HTTP pairing call. * `pkg/telnet` — new reusable, dependency-free client (sibling of `pkg/ssh`) with deadline-driven Dial / Probe / SendCommand / Close. Mock-server tests cover happy path, command-not-found, mid-stream close, and the wedged-device read-timeout scenario. * `setup.MigrationMethodTelnet` — runs `sys configuration` for all four URLs plus the parallel `envswitch boseurls set` persistence layer that otherwise wins on reboot, then verifies with `getpdo CurrentSystemConfiguration`. Aborts on the first non-OK response so configuration is never half-written. No SSH backup or rw pre-flight (the path is SSH-free by design). * `setup.PairAccount` — probes :8090/supportedURLs first, time-bounds POST /setMargeAccount aggressively (5s connect / 12s total) to avoid the hangs reported in #236, and falls back to `envswitch accountid set <id>` over telnet when the HTTP endpoint is missing or wedged. Returns a PairAccountResult breadcrumb so the UI can show which path actually succeeded. * `setup.Reboot(deviceIP, method)` — gains a RebootMethod selector; RebootMethodSSH stays the default (preserving prior behavior), RebootMethodTelnet sends `sys reboot` over a fresh telnet session and treats the inevitable socket-close as success. * New endpoints on `/setup`: - GET /account-id-suggestions/{deviceId} — returns the device's current margeAccountUUID (from :8090/info) plus known account IDs from the datastore, so the UI can offer reuse. - POST /pair-account/{deviceId}?account_id=NNNNNNN — invokes PairAccount; the existing reboot endpoint reads ?method=ssh|telnet from the query string. * Helpers `IsValidAccountID` (exactly 7 digits) and `GenerateAccountID` (crypto/rand, retries on collision against a known-IDs list). Documentation in docs/analysis/TELNET-MIGRATION-METHOD.md is updated to match the implementation: bare-URL convention for `soundtouch-service`, no automatic `sys reboot` (user-initiated via the existing button with a method selector), and the realised package layout. The /etc/hosts method is intentionally not exposed in the new flow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
372 lines
7.9 KiB
Go
372 lines
7.9 KiB
Go
package telnet
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// scriptedServer is a minimal mock of the device's port-17000 shell. It
|
|
// returns the supplied banner on connect, then for each line read it emits
|
|
// the corresponding entry from responses (or "Command not found" if the line
|
|
// is not in the map).
|
|
type scriptedServer struct {
|
|
t *testing.T
|
|
listener net.Listener
|
|
banner string
|
|
responses map[string]string
|
|
// hangAfter, if non-empty, names a command after which the server stops
|
|
// responding (to exercise the read-timeout path).
|
|
hangAfter string
|
|
// closeAfter, if non-empty, names a command after which the server closes
|
|
// the connection mid-stream.
|
|
closeAfter string
|
|
|
|
stop chan struct{}
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
func newScriptedServer(t *testing.T, banner string, responses map[string]string) *scriptedServer {
|
|
t.Helper()
|
|
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
|
|
s := &scriptedServer{
|
|
t: t,
|
|
listener: l,
|
|
banner: banner,
|
|
responses: responses,
|
|
stop: make(chan struct{}),
|
|
}
|
|
|
|
s.wg.Add(1)
|
|
|
|
go s.serve()
|
|
|
|
return s
|
|
}
|
|
|
|
func (s *scriptedServer) addr() string {
|
|
return s.listener.Addr().String()
|
|
}
|
|
|
|
func (s *scriptedServer) hostPort() (string, int) {
|
|
host, portStr, err := net.SplitHostPort(s.addr())
|
|
if err != nil {
|
|
s.t.Fatalf("split host/port: %v", err)
|
|
}
|
|
|
|
port := 0
|
|
|
|
if _, err := parseInt(portStr, &port); err != nil {
|
|
s.t.Fatalf("parse port %q: %v", portStr, err)
|
|
}
|
|
|
|
return host, port
|
|
}
|
|
|
|
func (s *scriptedServer) close() {
|
|
close(s.stop)
|
|
_ = s.listener.Close()
|
|
s.wg.Wait()
|
|
}
|
|
|
|
func (s *scriptedServer) serve() {
|
|
defer s.wg.Done()
|
|
|
|
conn, err := s.listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
if s.banner != "" {
|
|
_, _ = conn.Write([]byte(s.banner))
|
|
}
|
|
|
|
r := bufio.NewReader(conn)
|
|
|
|
for {
|
|
line, err := r.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
cmd := strings.TrimRight(line, "\r\n")
|
|
if cmd == "" {
|
|
continue
|
|
}
|
|
|
|
if cmd == s.closeAfter {
|
|
return
|
|
}
|
|
|
|
resp, ok := s.responses[cmd]
|
|
if !ok {
|
|
resp = "Command not found\n"
|
|
}
|
|
|
|
_, _ = conn.Write([]byte(resp))
|
|
|
|
if cmd == s.hangAfter {
|
|
// Block until the server is closed; the client's read deadline
|
|
// must fire before then.
|
|
<-s.stop
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// parseInt is a tiny strconv.Atoi wrapper so we don't drag strconv into this file.
|
|
func parseInt(s string, out *int) (int, error) {
|
|
n := 0
|
|
|
|
for _, ch := range s {
|
|
if ch < '0' || ch > '9' {
|
|
return 0, errors.New("not a number")
|
|
}
|
|
|
|
n = n*10 + int(ch-'0')
|
|
}
|
|
|
|
*out = n
|
|
|
|
return n, nil
|
|
}
|
|
|
|
func newClientFor(t *testing.T, s *scriptedServer) *Client {
|
|
t.Helper()
|
|
|
|
host, port := s.hostPort()
|
|
|
|
c := NewClient(host)
|
|
c.Port = port
|
|
// Tighten the timeouts so tests fail fast if the implementation regresses.
|
|
c.DialTimeout = 500 * time.Millisecond
|
|
c.ReadTimeout = 1500 * time.Millisecond
|
|
c.WriteTimeout = 500 * time.Millisecond
|
|
|
|
return c
|
|
}
|
|
|
|
func TestNewClient_Defaults(t *testing.T) {
|
|
c := NewClient("192.168.1.10")
|
|
if c.Host != "192.168.1.10" {
|
|
t.Errorf("Host = %q, want 192.168.1.10", c.Host)
|
|
}
|
|
|
|
if c.Port != DefaultPort {
|
|
t.Errorf("Port = %d, want %d", c.Port, DefaultPort)
|
|
}
|
|
|
|
if c.DialTimeout != DefaultDialTimeout {
|
|
t.Errorf("DialTimeout = %v, want %v", c.DialTimeout, DefaultDialTimeout)
|
|
}
|
|
}
|
|
|
|
func TestDial_Failure(t *testing.T) {
|
|
// A reserved-for-test address that nothing should be listening on.
|
|
c := NewClient("127.0.0.1")
|
|
c.Port = 1 // privileged port, will not connect from a test
|
|
c.DialTimeout = 200 * time.Millisecond
|
|
|
|
if err := c.Dial(); err == nil {
|
|
t.Error("expected dial failure, got nil")
|
|
}
|
|
}
|
|
|
|
func TestProbe_ReturnsBanner(t *testing.T) {
|
|
s := newScriptedServer(t, "BoseShell v1\n-> ", nil)
|
|
defer s.close()
|
|
|
|
c := newClientFor(t, s)
|
|
|
|
if err := c.Dial(); err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
|
|
defer func() { _ = c.Close() }()
|
|
|
|
got, err := c.Probe()
|
|
if err != nil {
|
|
t.Fatalf("Probe: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(got, "BoseShell v1") {
|
|
t.Errorf("Probe = %q, want to contain banner", got)
|
|
}
|
|
}
|
|
|
|
func TestProbe_NoBannerIsOK(t *testing.T) {
|
|
s := newScriptedServer(t, "", nil)
|
|
defer s.close()
|
|
|
|
c := newClientFor(t, s)
|
|
|
|
if err := c.Dial(); err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
|
|
defer func() { _ = c.Close() }()
|
|
|
|
got, err := c.Probe()
|
|
if err != nil {
|
|
t.Fatalf("Probe: %v", err)
|
|
}
|
|
|
|
if got != "" {
|
|
t.Errorf("Probe = %q, want empty when no banner is sent", got)
|
|
}
|
|
}
|
|
|
|
func TestSendCommand_HappyPath(t *testing.T) {
|
|
s := newScriptedServer(t, "", map[string]string{
|
|
"sys configuration bmxRegistryUrl http://example:8000/bmx/registry/v1/services": "OK\n",
|
|
"sys configuration margeServerUrl http://example:8000": "OK\n",
|
|
"getpdo CurrentSystemConfiguration": "margeServerUrl=http://example:8000\nbmxRegistryUrl=http://example:8000/bmx/registry/v1/services\n",
|
|
})
|
|
defer s.close()
|
|
|
|
c := newClientFor(t, s)
|
|
|
|
if err := c.Dial(); err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
|
|
defer func() { _ = c.Close() }()
|
|
|
|
resp, err := c.SendCommand("sys configuration margeServerUrl http://example:8000")
|
|
if err != nil {
|
|
t.Fatalf("SendCommand: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(resp, "OK") {
|
|
t.Errorf("response = %q, want to contain OK", resp)
|
|
}
|
|
|
|
resp, err = c.SendCommand("getpdo CurrentSystemConfiguration")
|
|
if err != nil {
|
|
t.Fatalf("SendCommand getpdo: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(resp, "margeServerUrl=http://example:8000") {
|
|
t.Errorf("getpdo response = %q, want to echo configured url", resp)
|
|
}
|
|
}
|
|
|
|
func TestSendCommand_CommandNotFound(t *testing.T) {
|
|
s := newScriptedServer(t, "", map[string]string{})
|
|
defer s.close()
|
|
|
|
c := newClientFor(t, s)
|
|
|
|
if err := c.Dial(); err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
|
|
defer func() { _ = c.Close() }()
|
|
|
|
resp, err := c.SendCommand("definitely not a real command")
|
|
if err != nil {
|
|
t.Fatalf("SendCommand: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(resp, "Command not found") {
|
|
t.Errorf("response = %q, want to contain 'Command not found'", resp)
|
|
}
|
|
}
|
|
|
|
func TestSendCommand_DeadlineFiresWhenDeviceHangs(t *testing.T) {
|
|
s := newScriptedServer(t, "", map[string]string{
|
|
"first": "OK\n",
|
|
"second": "",
|
|
})
|
|
s.hangAfter = "second"
|
|
|
|
defer s.close()
|
|
|
|
c := newClientFor(t, s)
|
|
c.ReadTimeout = 600 * time.Millisecond
|
|
|
|
if err := c.Dial(); err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
|
|
defer func() { _ = c.Close() }()
|
|
|
|
if _, err := c.SendCommand("first"); err != nil {
|
|
t.Fatalf("first SendCommand: %v", err)
|
|
}
|
|
|
|
start := time.Now()
|
|
|
|
_, err := c.SendCommand("second")
|
|
if err == nil {
|
|
t.Fatal("expected timeout error, got nil")
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), "timed out") {
|
|
t.Errorf("err = %v, want timed-out wording", err)
|
|
}
|
|
|
|
// The error must arrive within roughly the ReadTimeout, not after several
|
|
// times that — guards against an accidental infinite read loop.
|
|
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
|
t.Errorf("SendCommand returned after %v, want under 2s", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestSendCommand_ConnectionClosedMidStream(t *testing.T) {
|
|
s := newScriptedServer(t, "", map[string]string{})
|
|
s.closeAfter = "trigger close"
|
|
|
|
defer s.close()
|
|
|
|
c := newClientFor(t, s)
|
|
|
|
if err := c.Dial(); err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
|
|
defer func() { _ = c.Close() }()
|
|
|
|
_, err := c.SendCommand("trigger close")
|
|
if err == nil {
|
|
t.Fatal("expected error after server closes mid-stream, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSendCommand_FailsWithoutDial(t *testing.T) {
|
|
c := NewClient("127.0.0.1")
|
|
if _, err := c.SendCommand("anything"); err == nil {
|
|
t.Error("SendCommand without Dial should fail, got nil")
|
|
}
|
|
}
|
|
|
|
func TestClose_IsIdempotent(t *testing.T) {
|
|
s := newScriptedServer(t, "", nil)
|
|
defer s.close()
|
|
|
|
c := newClientFor(t, s)
|
|
|
|
if err := c.Dial(); err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
|
|
if err := c.Close(); err != nil {
|
|
t.Errorf("first Close: %v", err)
|
|
}
|
|
|
|
if err := c.Close(); err != nil {
|
|
t.Errorf("second Close: %v", err)
|
|
}
|
|
}
|