feat(telnet): add port-17000 migration method and account pairing

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>
This commit is contained in:
Tobias Gesellchen
2026-05-10 12:43:36 +02:00
co-authored by Claude Opus 4.7
parent d9894be7db
commit fb47807f70
15 changed files with 1768 additions and 22 deletions
+2
View File
@@ -1070,6 +1070,8 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
r.Post("/revert/{deviceId}", server.HandleRevertMigration)
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
r.Get("/account-id-suggestions/{deviceId}", server.HandleAccountIDSuggestions)
r.Post("/pair-account/{deviceId}", server.HandlePairAccount)
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services/{deviceId}", server.HandleRemoveRemoteServices)
+2
View File
@@ -48,6 +48,7 @@ GET /mgmt/spotify/callback handlers.(
GET /mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
GET /oauth/* handlers.(*Server).HandleBoseProxy-fm
GET /proxy/* handlers.(*Server).HandleProxyRequest-fm
GET /setup/account-id-suggestions/{deviceId} handlers.(*Server).HandleAccountIDSuggestions-fm
GET /setup/ca.crt handlers.(*Server).HandleGetCACert-fm
GET /setup/devices handlers.(*Server).HandleListDiscoveredDevices-fm
GET /setup/devices/{deviceId}/events handlers.(*Server).HandleGetDeviceEvents-fm
@@ -121,6 +122,7 @@ POST /setup/devices handlers.(
POST /setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
POST /setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
POST /setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
POST /setup/pair-account/{deviceId} handlers.(*Server).HandlePairAccount-fm
POST /setup/proxy-settings handlers.(*Server).HandleUpdateProxySettings-fm
POST /setup/reboot/{deviceId} handlers.(*Server).HandleRebootDevice-fm
POST /setup/remove-remote-services/{deviceId} handlers.(*Server).HandleRemoveRemoteServices-fm
+31 -17
View File
@@ -60,9 +60,13 @@ sys configuration margeServerUrl http://<service-host>:8000
sys configuration swUpdateUrl http://<service-host>:8000/updates/soundtouch
envswitch boseurls set http://<service-host>:8000 http://<service-host>:8000/updates/soundtouch
getpdo CurrentSystemConfiguration
sys reboot
```
`sys reboot` is **not** part of this sequence. The migration flow only writes
configuration — the reboot is user-initiated via the existing reboot button in
the web UI, mirroring what XML/DNS migration already does. See §6.2 for how
that button gains a `?method=ssh|telnet` selector.
Three important details from the discussion:
1. **`sys configuration` alone is not enough.** `stephan48` reported that
@@ -152,7 +156,8 @@ Per the user's brief, the migration logic must:
2. **Time-bound** the POST aggressively (e.g. ≤5s connect + ≤10s read) and treat
anything over the budget as a failure rather than waiting indefinitely.
3. On either failure mode, **fall back** to the telnet equivalent
(`envswitch accountid set <id>` + `sys reboot`).
`envswitch accountid set <id>` over the same `pkg/telnet` connection used
for the URL flip. Reboot stays a user-initiated action (§6.2).
4. If telnet:17000 is **also** unreachable, surface a clear "your firmware does
not support unattended pairing — please pair manually via the official Bose
app *before* it goes EOS, or open SSH and use the XML method" error rather
@@ -248,8 +253,8 @@ Each migration is a single goroutine driving one device. The client must:
- enforce per-command response deadlines so a wedged device cannot stall the
migration UI (mirrors the `/setMargeAccount` requirement);
- never send `sys reboot` until **all** preceding `OK` acks have arrived (so
partial config doesn't get persisted);
- abort the rest of the sequence on the first non-`OK` response so we don't
half-write configuration;
- always close the socket on error.
### 5.5 Testing strategy
@@ -259,7 +264,7 @@ in the test, scripting it to consume our commands and emit canned `OK`/error
responses. That gives us deterministic coverage for:
- happy path (all four URLs accepted),
- single-command failure → no `sys reboot` sent,
- single-command failure → sequence aborts, no further commands sent,
- "command not found" on `envswitch …` → fallback path exercised,
- TCP closed mid-stream → migration aborts cleanly,
- read deadline triggers when the device hangs (the broken-state simulation).
@@ -308,11 +313,13 @@ this; we just add a `telnet` option next to `xml`/`resolv`.
Otherwise the UI offers (a) pick from `DataStore.ListAccounts()`,
(b) manual entry validated as 7 numeric digits, (c) a "Generate" button
that randomizes a 7-digit number and re-rolls on collision.
2. **Reboot policy.** Migration is automatic, but the UI shows a **modal
confirmation** before the final `sys reboot` is sent ("Speaker will reboot
now to apply changes — continue?"). This matches the XML path, which
already reboots automatically, while preventing surprise reboots from a
stray click on a half-filled form.
2. **Reboot policy.** Migration writes configuration only — it does **not**
issue `sys reboot` itself. Reboot stays user-initiated via the existing
reboot button in the web UI, the same way XML/DNS migration already works.
That button's endpoint (`POST /setup/reboot/{deviceId}`,
`Manager.Reboot(deviceIP)`) gains an optional `?method=ssh|telnet` query
parameter; default stays `ssh` so existing behavior is preserved. The
button itself uses a plain `confirm()` dialog before firing.
3. **CA / HTTPS story.** Telnet has no way to install a custom CA. Documented
as an explicit limitation: telnet method = HTTP-only redirect to our
service. Users who need end-to-end TLS must use the XML or DNS method.
@@ -323,21 +330,28 @@ this; we just add a `telnet` option next to `xml`/`resolv`.
## 7. Summary of what changes when this lands
- **New reusable package `pkg/telnet`**line-oriented TCP client with
`Dial`, `SendCommand`, `Probe`, `Close`, all deadline-driven. No external
dependencies, usable from CLI, service, and tests.
- **New reusable package `pkg/telnet`**sibling of `pkg/ssh`, line-oriented
TCP client with `Dial`, `SendCommand`, `Probe`, `Close`, all deadline-driven.
No external dependencies, usable from CLI, service, and tests.
- **New `MigrationMethodTelnet = "telnet"`** constant in `pkg/service/setup/setup.go`
plus a `migrateViaTelnet` branch in `Manager.MigrateSpeaker`.
- **New `pkg/service/setup/telnet_migration.go`** orchestrating the URL
configuration sequence (§2.1) on top of `pkg/telnet`.
configuration sequence (§2.1) on top of `pkg/telnet`. Configuration only —
no `sys reboot` here.
- **New `pkg/service/setup/marge_pairing.go`** with `PairAccount(deviceIP, id)`:
probes `/supportedURLs`, time-bounded `POST /setMargeAccount`, falls back to
telnet `envswitch accountid set <id>` on missing/wedged endpoint.
- **`Manager.Reboot` and `HandleRebootDevice` gain a method selector** —
signature changes to `Reboot(deviceIP string, method RebootMethod) (string, error)`
with `RebootMethodSSH` (default, today's behavior) and `RebootMethodTelnet`
(sends `sys reboot` over a fresh `pkg/telnet` connection). Handler reads
`?method=ssh|telnet` from the query string.
- **`MigrationSummary` gains** `TelnetReachable`, `TelnetBanner`,
`TelnetCommandsAccepted`, `SetMargeAccountSupported`, `CurrentAccountID`,
`KnownAccountIDs` so the UI can show preflight outcomes and offer reuse.
- **UI**`web/index.html` dropdown gets a `telnet` option (greyed out when
preflight fails) and a new pane for picking/entering/randomizing a 7-digit
account ID when `:8090/info` reports an empty `margeAccountUUID`. A modal
confirmation gates the final `sys reboot`. The legacy `hosts` option stays
out of the dropdown (deprecated).
account ID when `:8090/info` reports an empty `margeAccountUUID`. The
existing reboot button gets a method selector (radio or dropdown) wired to
the new query param, with `confirm()` before firing. The legacy `hosts`
option stays out of the dropdown (deprecated).
+138
View File
@@ -0,0 +1,138 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/go-chi/chi/v5"
)
// accountIDSuggestionsResponse is the body of GET /setup/account-id-suggestions/{deviceId}.
// `current` is the device's existing margeAccountUUID (empty when the device is fresh / factory-reset).
// `known` is the list of accountIDs already present in the local datastore, so the UI can offer
// the user a way to re-attach a fresh device to an existing account.
type accountIDSuggestionsResponse struct {
Current string `json:"current"`
Known []string `json:"known"`
}
// HandleAccountIDSuggestions returns the device's current account ID (from
// :8090/info, empty if unset) plus the list of account IDs already present
// in the local datastore.
func (s *Server) HandleAccountIDSuggestions(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
}
resp := accountIDSuggestionsResponse{}
if info, err := s.sm.GetLiveDeviceInfo(deviceIP); err == nil {
resp.Current = info.MargeAccountUUID
}
if known, err := s.ds.ListAccounts(); err == nil {
resp.Known = known
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// pairAccountResponse is the body of POST /setup/pair-account/{deviceId}.
type pairAccountResponse struct {
OK bool `json:"ok"`
Result setup.PairAccountResult `json:"result"`
Output string `json:"output"`
Error string `json:"error,omitempty"`
}
// HandlePairAccount associates the device with the supplied 7-digit account ID,
// trying HTTP /setMargeAccount first and falling back to telnet
// `envswitch accountid set`.
//
// Query params:
// - account_id (required) — must pass setup.IsValidAccountID
func (s *Server) HandlePairAccount(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
writeJSONError(w, http.StatusBadRequest, "Device ID is required")
return
}
accountID := r.URL.Query().Get("account_id")
if !setup.IsValidAccountID(accountID) {
writeJSONError(w, http.StatusBadRequest, "account_id must be exactly 7 digits")
return
}
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
if err != nil {
writeJSONError(w, http.StatusNotFound, err.Error())
return
}
var t setup.TelnetClient
if s.sm.NewTelnet != nil {
t = s.sm.NewTelnet(deviceIP)
if dialErr := t.Dial(); dialErr != nil {
// Telnet not reachable — fall through with t=nil so PairAccount
// can decide based on HTTP availability alone.
t = nil
} else {
defer func() { _ = t.Close() }()
}
}
result, output, err := s.sm.PairAccount(deviceIP, accountID, t)
w.Header().Set("Content-Type", "application/json")
body := pairAccountResponse{
OK: err == nil,
Result: result,
Output: output,
}
if err != nil {
body.Error = err.Error()
w.WriteHeader(http.StatusInternalServerError)
}
if encErr := json.NewEncoder(w).Encode(body); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// jsonErrorBody is the static shape of error responses from this file.
// Avoiding map[string]interface{} keeps errchkjson satisfied: the typed
// struct guarantees encoding can't fail with a runtime type error.
type jsonErrorBody struct {
OK bool `json:"ok"`
Message string `json:"message"`
}
// writeJSONError is a small helper for the handlers in this file to keep
// error wiring out of the happy path. It mirrors what the rest of the
// package does inline.
func writeJSONError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(jsonErrorBody{OK: false, Message: message}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
+3 -1
View File
@@ -1066,7 +1066,9 @@ func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) {
return
}
output, err := s.sm.Reboot(deviceIP)
method := setup.RebootMethod(r.URL.Query().Get("method"))
output, err := s.sm.Reboot(deviceIP, method)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
+2
View File
@@ -127,6 +127,8 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
r.Post("/revert/{deviceId}", server.HandleRevertMigration)
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
r.Get("/account-id-suggestions/{deviceId}", server.HandleAccountIDSuggestions)
r.Post("/pair-account/{deviceId}", server.HandlePairAccount)
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
+242
View File
@@ -0,0 +1,242 @@
package setup
import (
"crypto/rand"
"encoding/xml"
"errors"
"fmt"
"io"
"math/big"
"net"
"net/http"
"strings"
"time"
)
// PairAccountTimeouts bounds every step of the pairing call so a wedged
// device cannot stall the migration UI indefinitely.
const (
supportedURLsTimeout = 3 * time.Second
setMargeAccountConn = 5 * time.Second
setMargeAccountTotal = 12 * time.Second
)
// PairAccountResult records what was attempted, so the UI can show a
// breadcrumb of which path actually succeeded (or that both failed).
type PairAccountResult struct {
SetMargeAccountSupported bool `json:"set_marge_account_supported"`
HTTPAttempted bool `json:"http_attempted"`
HTTPError string `json:"http_error,omitempty"`
TelnetAttempted bool `json:"telnet_attempted"`
TelnetError string `json:"telnet_error,omitempty"`
Method string `json:"method"` // "http" | "telnet" | ""
}
// PairAccount associates the speaker at deviceIP with accountID. It tries
// the device's HTTP /setMargeAccount endpoint first; on missing endpoint or
// any time-bounded failure it falls back to a telnet
// `envswitch accountid set <id>` over the supplied client. If telnet is nil
// or also fails, PairAccount returns a structured error explaining the next
// step a user can take.
func (m *Manager) PairAccount(deviceIP, accountID string, t TelnetClient) (PairAccountResult, string, error) {
var (
result PairAccountResult
logs strings.Builder
)
if !IsValidAccountID(accountID) {
return result, "", fmt.Errorf("invalid account ID %q: must be exactly 7 digits", accountID)
}
supported, supportedErr := m.probeSetMargeAccount(deviceIP)
result.SetMargeAccountSupported = supported
switch {
case supportedErr != nil:
fmt.Fprintf(&logs, "supportedURLs probe failed: %v\n", supportedErr)
case supported:
logs.WriteString("supportedURLs lists /setMargeAccount — trying HTTP\n")
default:
logs.WriteString("supportedURLs does NOT list /setMargeAccount — skipping HTTP, going straight to telnet\n")
}
if supported {
result.HTTPAttempted = true
if err := m.postSetMargeAccount(deviceIP, accountID); err != nil {
result.HTTPError = err.Error()
fmt.Fprintf(&logs, "HTTP /setMargeAccount failed: %v\n", err)
} else {
result.Method = "http"
logs.WriteString("HTTP /setMargeAccount succeeded\n")
return result, logs.String(), nil
}
}
if t == nil {
return result, logs.String(), errors.New(
"pairing failed: HTTP /setMargeAccount unavailable and no telnet client supplied — " +
"open the official Bose app and pair manually before EOS, or use the SSH-based XML method")
}
result.TelnetAttempted = true
cmd := "envswitch accountid set " + accountID
resp, err := t.SendCommand(cmd)
if err != nil {
result.TelnetError = err.Error()
return result, logs.String(), fmt.Errorf("HTTP unavailable and telnet fallback failed: %w", err)
}
if isCommandNotFound(resp) {
result.TelnetError = "envswitch accountid: command not found on this firmware"
return result, logs.String(), errors.New(
"pairing failed: HTTP /setMargeAccount missing AND telnet `envswitch accountid` rejected — " +
"firmware does not expose either pairing path")
}
fmt.Fprintf(&logs, "Telnet %q → %s\n", cmd, strings.TrimRight(resp, "\r\n"))
result.Method = "telnet"
return result, logs.String(), nil
}
// probeSetMargeAccount fetches /supportedURLs and reports whether
// /setMargeAccount is in the listing.
func (m *Manager) probeSetMargeAccount(deviceIP string) (bool, error) {
url := buildDeviceURL(deviceIP, "/supportedURLs")
client := &http.Client{Timeout: supportedURLsTimeout}
resp, err := client.Get(url)
if err != nil {
return false, fmt.Errorf("GET %s: %w", url, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("GET %s returned %d", url, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return false, fmt.Errorf("read %s: %w", url, err)
}
var doc struct {
URLs []struct {
Location string `xml:"location,attr"`
} `xml:"URL"`
}
if err := xml.Unmarshal(body, &doc); err != nil {
// Fallback to substring match — some firmwares return a slightly
// different XML root that Go's strict parser refuses.
return strings.Contains(string(body), "/setMargeAccount"), nil
}
for _, u := range doc.URLs {
if u.Location == "/setMargeAccount" {
return true, nil
}
}
return false, nil
}
// postSetMargeAccount sends the pairing XML body to the device's
// /setMargeAccount endpoint with bounded timeouts.
func (m *Manager) postSetMargeAccount(deviceIP, accountID string) error {
url := buildDeviceURL(deviceIP, "/setMargeAccount")
body := fmt.Sprintf(
`<PairDeviceWithAccount><accountId>%s</accountId><userAuthToken>aftertouch</userAuthToken></PairDeviceWithAccount>`,
accountID,
)
client := &http.Client{
Timeout: setMargeAccountTotal,
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: setMargeAccountConn}).DialContext,
ResponseHeaderTimeout: setMargeAccountTotal - setMargeAccountConn,
},
}
resp, err := client.Post(url, "application/xml", strings.NewReader(body))
if err != nil {
return fmt.Errorf("POST %s: %w", url, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("POST %s returned %d: %s", url, resp.StatusCode, strings.TrimSpace(string(respBody)))
}
return nil
}
// buildDeviceURL builds a URL for a SoundTouch device's HTTP API. If
// deviceIP already includes a port (test scenarios using httptest) it is
// reused as-is; otherwise the canonical port 8090 is appended.
func buildDeviceURL(deviceIP, path string) string {
if _, _, err := net.SplitHostPort(deviceIP); err == nil {
return "http://" + deviceIP + path
}
return "http://" + deviceIP + ":8090" + path
}
// IsValidAccountID reports whether s is a syntactically valid SoundTouch
// account ID — exactly 7 numeric digits, the format used by every
// Bose-cloud-issued ID we have observed in captures.
func IsValidAccountID(s string) bool {
if len(s) != 7 {
return false
}
for _, ch := range s {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
// GenerateAccountID returns a fresh 7-digit account ID that does not collide
// with any value in known. It uses crypto/rand and re-rolls on collision.
func GenerateAccountID(known []string) (string, error) {
taken := make(map[string]bool, len(known))
for _, k := range known {
taken[k] = true
}
const maxAttempts = 32
for attempt := 0; attempt < maxAttempts; attempt++ {
// 7-digit space starts at 1_000_000 to avoid leading zeros, ending at
// 9_999_999. Range size is 9_000_000.
n, err := rand.Int(rand.Reader, big.NewInt(9_000_000))
if err != nil {
return "", fmt.Errorf("crypto/rand: %w", err)
}
candidate := fmt.Sprintf("%07d", n.Int64()+1_000_000)
if !taken[candidate] {
return candidate, nil
}
}
return "", errors.New("could not generate a non-colliding account ID after 32 attempts")
}
+305
View File
@@ -0,0 +1,305 @@
package setup
import (
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// fakeDevice spins up an httptest.Server that pretends to be the SoundTouch
// device's :8090 HTTP API. It records POSTs to /setMargeAccount so tests
// can assert on the body.
type fakeDevice struct {
srv *httptest.Server
addr string // "host:port" usable as deviceIP
supportsSetMarge bool
postStatus int // status code returned for POST /setMargeAccount
postDelay time.Duration
gotPostBody string
}
func newFakeDevice(t *testing.T) *fakeDevice {
t.Helper()
d := &fakeDevice{
supportsSetMarge: true,
postStatus: http.StatusOK,
}
mux := http.NewServeMux()
mux.HandleFunc("/supportedURLs", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/xml")
if d.supportsSetMarge {
_, _ = w.Write([]byte(`<supportedURLs><URL location="/setMargeAccount"/><URL location="/info"/></supportedURLs>`))
return
}
_, _ = w.Write([]byte(`<supportedURLs><URL location="/info"/></supportedURLs>`))
})
mux.HandleFunc("/setMargeAccount", func(w http.ResponseWriter, r *http.Request) {
if d.postDelay > 0 {
time.Sleep(d.postDelay)
}
body, _ := io.ReadAll(r.Body)
d.gotPostBody = string(body)
w.WriteHeader(d.postStatus)
})
d.srv = httptest.NewServer(mux)
u := d.srv.URL[len("http://"):]
host, port, err := net.SplitHostPort(u)
if err != nil {
t.Fatalf("split httptest URL: %v", err)
}
d.addr = host + ":" + port
t.Cleanup(d.srv.Close)
return d
}
func TestPairAccount_HappyPathHTTP(t *testing.T) {
d := newFakeDevice(t)
m := &Manager{}
res, _, err := m.PairAccount(d.addr, "1234567", nil)
if err != nil {
t.Fatalf("PairAccount: %v", err)
}
if res.Method != "http" {
t.Errorf("Method = %q, want http", res.Method)
}
if !res.SetMargeAccountSupported {
t.Error("SetMargeAccountSupported should be true")
}
if !res.HTTPAttempted {
t.Error("HTTPAttempted should be true")
}
if res.TelnetAttempted {
t.Error("TelnetAttempted should be false on the happy HTTP path")
}
if !strings.Contains(d.gotPostBody, "<accountId>1234567</accountId>") {
t.Errorf("device received %q, want <accountId>1234567</accountId>", d.gotPostBody)
}
}
func TestPairAccount_FallsBackWhenSetMargeAccountMissing(t *testing.T) {
d := newFakeDevice(t)
d.supportsSetMarge = false
f := &fakeTelnet{
responses: map[string]string{"envswitch accountid set 1234567": "OK\n"},
}
m := &Manager{}
res, _, err := m.PairAccount(d.addr, "1234567", f)
if err != nil {
t.Fatalf("PairAccount: %v", err)
}
if res.Method != "telnet" {
t.Errorf("Method = %q, want telnet", res.Method)
}
if res.SetMargeAccountSupported {
t.Error("SetMargeAccountSupported should be false")
}
if res.HTTPAttempted {
t.Error("HTTPAttempted should be false when supportedURLs reports the endpoint missing")
}
if !res.TelnetAttempted {
t.Error("TelnetAttempted should be true")
}
if len(f.commands) != 1 || f.commands[0] != "envswitch accountid set 1234567" {
t.Errorf("telnet commands = %v, want one envswitch accountid", f.commands)
}
}
func TestPairAccount_FallsBackWhenHTTPReturnsServerError(t *testing.T) {
d := newFakeDevice(t)
d.postStatus = http.StatusBadGateway
f := &fakeTelnet{
responses: map[string]string{"envswitch accountid set 7654321": "OK\n"},
}
m := &Manager{}
res, _, err := m.PairAccount(d.addr, "7654321", f)
if err != nil {
t.Fatalf("PairAccount: %v", err)
}
if res.Method != "telnet" {
t.Errorf("Method = %q, want telnet", res.Method)
}
if res.HTTPError == "" {
t.Error("HTTPError should be populated when POST returned 502")
}
if !res.TelnetAttempted {
t.Error("TelnetAttempted should be true after HTTP failure")
}
}
func TestPairAccount_HTTPSuccessSkipsTelnet(t *testing.T) {
d := newFakeDevice(t)
f := &fakeTelnet{}
m := &Manager{}
res, _, err := m.PairAccount(d.addr, "1234567", f)
if err != nil {
t.Fatalf("PairAccount: %v", err)
}
if res.Method != "http" {
t.Errorf("Method = %q, want http", res.Method)
}
if len(f.commands) != 0 {
t.Errorf("telnet should not have been used; commands = %v", f.commands)
}
}
func TestPairAccount_NoTelnetAndHTTPMissingReturnsClearError(t *testing.T) {
d := newFakeDevice(t)
d.supportsSetMarge = false
m := &Manager{}
_, _, err := m.PairAccount(d.addr, "1234567", nil)
if err == nil {
t.Fatal("expected error when both paths are unavailable")
}
if !strings.Contains(err.Error(), "no telnet client") {
t.Errorf("err = %v, want to mention missing telnet client", err)
}
}
func TestPairAccount_TelnetCommandNotFoundReportsBothPaths(t *testing.T) {
d := newFakeDevice(t)
d.supportsSetMarge = false
f := &fakeTelnet{
responses: map[string]string{"envswitch accountid set 1234567": "Command not found\n"},
}
m := &Manager{}
_, _, err := m.PairAccount(d.addr, "1234567", f)
if err == nil {
t.Fatal("expected error when telnet rejects the fallback")
}
if !strings.Contains(err.Error(), "envswitch") {
t.Errorf("err = %v, want to mention envswitch", err)
}
}
func TestPairAccount_RejectsInvalidAccountID(t *testing.T) {
m := &Manager{}
for _, badID := range []string{"", "12345", "12345678", "abcdefg", "12345 6"} {
_, _, err := m.PairAccount("127.0.0.1:9999", badID, nil)
if err == nil {
t.Errorf("PairAccount accepted invalid ID %q", badID)
}
}
}
func TestPairAccount_TelnetTransportErrorReturned(t *testing.T) {
d := newFakeDevice(t)
d.supportsSetMarge = false
f := &fakeTelnet{
fail: map[string]error{"envswitch accountid set 1234567": errors.New("connection reset")},
}
m := &Manager{}
_, _, err := m.PairAccount(d.addr, "1234567", f)
if err == nil {
t.Fatal("expected telnet transport error to be surfaced")
}
if !strings.Contains(err.Error(), "connection reset") {
t.Errorf("err = %v, want to wrap connection reset", err)
}
}
func TestIsValidAccountID(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"1234567", true},
{"0000000", true},
{"9999999", true},
{"", false},
{"123456", false},
{"12345678", false},
{"123456a", false},
{"-123456", false},
{" 123456", false},
}
for _, tc := range cases {
if got := IsValidAccountID(tc.in); got != tc.want {
t.Errorf("IsValidAccountID(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
func TestGenerateAccountID_AvoidsCollisions(t *testing.T) {
id, err := GenerateAccountID(nil)
if err != nil {
t.Fatalf("GenerateAccountID(nil): %v", err)
}
if !IsValidAccountID(id) {
t.Errorf("generated ID %q is not valid", id)
}
// Block out a fairly small space and check we still get a fresh ID.
known := []string{"1000000", "1000001", "1000002"}
for i := 0; i < 5; i++ {
got, err := GenerateAccountID(known)
if err != nil {
t.Fatalf("GenerateAccountID: %v", err)
}
for _, k := range known {
if got == k {
t.Errorf("generated %q collides with known list %v", got, known)
}
}
}
}
+94
View File
@@ -0,0 +1,94 @@
package setup
import (
"errors"
"strings"
"testing"
)
func TestReboot_DefaultIsSSH(t *testing.T) {
var ranCmds []string
m := &Manager{
NewSSH: func(host string) SSHClient {
return &mockSSH{runFunc: func(cmd string) (string, error) {
ranCmds = append(ranCmds, cmd)
return "ok\n", nil
}}
},
}
if _, err := m.Reboot("192.0.2.1", ""); err != nil {
t.Fatalf("Reboot: %v", err)
}
found := false
for _, c := range ranCmds {
if strings.Contains(c, "reboot") {
found = true
break
}
}
if !found {
t.Errorf("expected SSH `reboot` command, got %v", ranCmds)
}
}
func TestReboot_TelnetSendsSysReboot(t *testing.T) {
f := &fakeTelnet{
responses: map[string]string{"sys reboot": "OK\n"},
}
m := &Manager{
NewTelnet: func(host string) TelnetClient { return f },
}
if _, err := m.Reboot("192.0.2.1", RebootMethodTelnet); err != nil {
t.Fatalf("Reboot: %v", err)
}
if len(f.commands) != 1 || f.commands[0] != "sys reboot" {
t.Errorf("commands = %v, want [sys reboot]", f.commands)
}
}
func TestReboot_TelnetTreatsCloseAsSuccess(t *testing.T) {
// The device closes the socket as part of rebooting. SendCommand surfaces
// that as an EOF/closed error; the reboot path must absorb it.
f := &fakeTelnet{
fail: map[string]error{"sys reboot": errors.New("EOF")},
}
m := &Manager{
NewTelnet: func(host string) TelnetClient { return f },
}
out, err := m.Reboot("192.0.2.1", RebootMethodTelnet)
if err != nil {
t.Fatalf("Reboot should swallow socket-close after sys reboot, got %v", err)
}
if !strings.Contains(out, "connection closed by reboot") {
t.Errorf("output should annotate the close, got %q", out)
}
}
func TestReboot_TelnetSurfacesDialError(t *testing.T) {
f := &fakeTelnet{dialErr: errors.New("connection refused")}
m := &Manager{
NewTelnet: func(host string) TelnetClient { return f },
}
if _, err := m.Reboot("192.0.2.1", RebootMethodTelnet); err == nil {
t.Fatal("expected dial error, got nil")
}
}
func TestReboot_UnknownMethodErrors(t *testing.T) {
m := &Manager{}
if _, err := m.Reboot("192.0.2.1", RebootMethod("ftp")); err == nil {
t.Fatal("expected error for unsupported reboot method")
}
}
+116 -3
View File
@@ -3,6 +3,7 @@ package setup
import (
"encoding/xml"
"errors"
"fmt"
"io"
"log"
@@ -20,6 +21,7 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/ssh"
"github.com/gesellix/bose-soundtouch/pkg/telnet"
)
// MigrationMethod represents the method used to migrate a speaker.
@@ -32,6 +34,9 @@ const (
MigrationMethodHosts MigrationMethod = "hosts"
// MigrationMethodResolvConf redirects services by injecting a priority DNS hook into the DHCP logic and updating the CA trust store.
MigrationMethodResolvConf MigrationMethod = "resolv"
// MigrationMethodTelnet redirects services by driving the device's diagnostic
// shell on TCP port 17000. Requires no SSH access on the device.
MigrationMethodTelnet MigrationMethod = "telnet"
)
// SoundTouchSdkPrivateCfgPath is the path to the speaker's private configuration file on device.
@@ -77,6 +82,17 @@ type MigrationSummary struct {
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
SkipMirrorEndpoints []string `json:"skip_mirror_endpoints,omitempty"`
PreferredSource string `json:"preferred_source,omitempty"`
// Telnet (port 17000) preflight state — populated when the user is about to
// or has just used MigrationMethodTelnet.
TelnetReachable bool `json:"telnet_reachable"`
TelnetBanner string `json:"telnet_banner,omitempty"`
TelnetVerifiedConfig string `json:"telnet_verified_config,omitempty"`
TelnetProbeError string `json:"telnet_probe_error,omitempty"`
// KnownAccountIDs are accountIDs already present in the local datastore;
// the UI offers them as choices when pairing a fresh device.
KnownAccountIDs []string `json:"known_account_ids,omitempty"`
}
// SSHClient defines the interface for SSH operations.
@@ -85,12 +101,23 @@ type SSHClient interface {
UploadContent(content []byte, remotePath string) error
}
// TelnetClient defines the interface for the device's port-17000 diagnostic
// shell. The concrete implementation lives in github.com/gesellix/bose-soundtouch/pkg/telnet;
// the interface exists so tests can substitute a mock.
type TelnetClient interface {
Dial() error
Probe() (string, error)
SendCommand(cmd string) (string, error)
Close() error
}
// Manager handles the migration of speakers to the service.
type Manager struct {
ServerURL string
DataStore *datastore.DataStore
Crypto *certmanager.CertificateManager
NewSSH func(host string) SSHClient
NewTelnet func(host string) TelnetClient
// GetDNSRunning is an optional callback to check the actual state of the DNS server.
GetDNSRunning func() (bool, string)
@@ -112,6 +139,9 @@ func NewManager(serverURL string, ds *datastore.DataStore, cm *certmanager.Certi
NewSSH: func(host string) SSHClient {
return ssh.NewClient(host)
},
NewTelnet: func(host string) TelnetClient {
return telnet.NewClient(host)
},
HTTPGet: http.Get,
MgmtUsername: "admin",
MgmtPassword: "change_me!",
@@ -653,6 +683,13 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
method = MigrationMethodXML
}
// Telnet is SSH-free by design — skip the SSH-based off-device backup and
// rw pre-flight, both of which would fail on devices that haven't been
// rooted via remote_services.
if method == MigrationMethodTelnet {
return m.migrateViaTelnet(deviceIP, targetURL)
}
var logs string
// 0. Off-device backup for safety
@@ -1778,12 +1815,40 @@ func (m *Manager) RemoveRemoteServices(deviceIP string) (string, error) {
return logs, nil
}
// Reboot reboots the speaker at the given IP.
func (m *Manager) Reboot(deviceIP string) (string, error) {
// RebootMethod selects the transport used to reboot a speaker.
type RebootMethod string
const (
// RebootMethodSSH reboots via SSH `reboot` (the original behavior). Requires
// a rooted device (remote_services unlocked).
RebootMethodSSH RebootMethod = "ssh"
// RebootMethodTelnet reboots via the device's port-17000 diagnostic shell
// using `sys reboot`. Requires no SSH access.
RebootMethodTelnet RebootMethod = "telnet"
)
// Reboot reboots the speaker at the given IP using the requested transport.
// An empty method defaults to RebootMethodSSH, preserving prior behavior.
func (m *Manager) Reboot(deviceIP string, method RebootMethod) (string, error) {
if method == "" {
method = RebootMethodSSH
}
switch method {
case RebootMethodSSH:
return m.rebootViaSSH(deviceIP)
case RebootMethodTelnet:
return m.rebootViaTelnet(deviceIP)
default:
return "", fmt.Errorf("unsupported reboot method: %s", method)
}
}
func (m *Manager) rebootViaSSH(deviceIP string) (string, error) {
client := m.NewSSH(deviceIP)
rwCmd := "(rw || mount -o remount,rw /)"
fmt.Printf("Rebooting speaker at %s\n", deviceIP)
fmt.Printf("Rebooting speaker at %s via SSH\n", deviceIP)
out, err := client.Run(fmt.Sprintf("%s && reboot", rwCmd))
if err != nil {
@@ -1793,6 +1858,54 @@ func (m *Manager) Reboot(deviceIP string) (string, error) {
return out, nil
}
func (m *Manager) rebootViaTelnet(deviceIP string) (string, error) {
if m.NewTelnet == nil {
return "", errors.New("telnet reboot not configured: Manager.NewTelnet is nil")
}
fmt.Printf("Rebooting speaker at %s via telnet\n", deviceIP)
t := m.NewTelnet(deviceIP)
if err := t.Dial(); err != nil {
return "", fmt.Errorf("telnet dial %s:17000 failed: %w", deviceIP, err)
}
defer func() { _ = t.Close() }()
// We deliberately don't wait for a response — the device closes the socket
// as part of rebooting, and SendCommand would surface that as an error
// even though the reboot itself succeeded. Treat any short read or close
// as "command was accepted".
resp, err := t.SendCommand("sys reboot")
if err != nil {
// A read error after the write is the expected case (socket dies on
// reboot). Only surface real transport failures; treat the rest as
// success and let the caller verify by polling :8090/info.
if isLikelyRebootCloseError(err) {
return resp + "\n[connection closed by reboot]", nil
}
return resp, fmt.Errorf("failed to send sys reboot: %w", err)
}
return resp, nil
}
// isLikelyRebootCloseError returns true if err looks like the socket closed
// because the device started rebooting, rather than a real connectivity
// problem. We are intentionally generous here: the user already opted into
// rebooting, so a closed socket is expected.
func isLikelyRebootCloseError(err error) bool {
msg := err.Error()
for _, marker := range []string{"EOF", "closed", "connection reset", "broken pipe", "timed out"} {
if strings.Contains(msg, marker) {
return true
}
}
return false
}
// TestDomain is the fake domain used for preliminary redirection tests.
const TestDomain = "custom-test-api.bose.fake"
+1 -1
View File
@@ -946,7 +946,7 @@ func TestReboot(t *testing.T) {
}
}
_, err := m.Reboot("192.168.1.10")
_, err := m.Reboot("192.168.1.10", "")
if err != nil {
t.Fatalf("Reboot failed: %v", err)
}
+92
View File
@@ -0,0 +1,92 @@
package setup
import (
"errors"
"fmt"
"strings"
)
// telnetURLConfigCommands returns the canonical sequence of telnet commands
// that point a SoundTouch device at the given local-service base URL.
//
// Order matters: `sys configuration …` writes the runtime URL, while
// `envswitch boseurls set …` writes a parallel persistence layer that
// otherwise wins on the next reboot. See docs/analysis/TELNET-MIGRATION-METHOD.md
// §2.1 for the discussion this is derived from.
func telnetURLConfigCommands(targetURL string) []string {
return []string{
"sys configuration bmxRegistryUrl " + targetURL + "/bmx/registry/v1/services",
"sys configuration statsServerUrl " + targetURL,
"sys configuration margeServerUrl " + targetURL,
"sys configuration swUpdateUrl " + targetURL + "/updates/soundtouch",
"envswitch boseurls set " + targetURL + " " + targetURL + "/updates/soundtouch",
}
}
// migrateViaTelnet runs the URL-configuration sequence over the device's
// port-17000 diagnostic shell. It writes configuration only — reboot is left
// to the user, who triggers it via the existing reboot button (which now
// accepts a method=telnet|ssh selector).
//
// The sequence aborts on the first non-OK response so we never half-write the
// configuration; the caller can retry safely after fixing the underlying
// issue (closed port, hardened firmware, etc.).
func (m *Manager) migrateViaTelnet(deviceIP, targetURL string) (string, error) {
if m.NewTelnet == nil {
return "", errors.New("telnet migration not configured: Manager.NewTelnet is nil")
}
var logs strings.Builder
t := m.NewTelnet(deviceIP)
if err := t.Dial(); err != nil {
return logs.String(), fmt.Errorf("telnet dial %s:17000 failed: %w", deviceIP, err)
}
defer func() { _ = t.Close() }()
banner, _ := t.Probe()
if banner != "" {
fmt.Fprintf(&logs, "Telnet banner: %q\n", strings.TrimSpace(banner))
}
for _, cmd := range telnetURLConfigCommands(targetURL) {
resp, err := t.SendCommand(cmd)
if err != nil {
return logs.String(), fmt.Errorf("telnet command %q failed: %w", cmd, err)
}
fmt.Fprintf(&logs, "→ %s\n%s\n", cmd, strings.TrimRight(resp, "\r\n"))
if isCommandNotFound(resp) {
return logs.String(), fmt.Errorf("device rejected %q (firmware does not expose this command)", cmd)
}
}
verify, err := t.SendCommand("getpdo CurrentSystemConfiguration")
if err != nil {
return logs.String(), fmt.Errorf("verification command failed: %w", err)
}
fmt.Fprintf(&logs, "→ getpdo CurrentSystemConfiguration\n%s\n", strings.TrimRight(verify, "\r\n"))
if !strings.Contains(verify, targetURL) {
return logs.String(), fmt.Errorf("verification failed: getpdo response does not contain %q (device may have rejected the new URLs)", targetURL)
}
logs.WriteString("Telnet migration succeeded. Reboot the device to apply.\n")
return logs.String(), nil
}
// isCommandNotFound returns true if the device's response to a command
// indicates the command is not available on this firmware. Different firmware
// builds use slightly different wording; we accept any of the observed
// variants.
func isCommandNotFound(resp string) bool {
low := strings.ToLower(resp)
return strings.Contains(low, "command not found") ||
strings.Contains(low, "unknown command") ||
strings.Contains(low, "not implemented")
}
+197
View File
@@ -0,0 +1,197 @@
package setup
import (
"errors"
"strings"
"testing"
)
// fakeTelnet is a deterministic TelnetClient for unit tests. The responses
// map keys on the exact command string; the value is what SendCommand
// returns. Commands not in the map return "Command not found\n".
type fakeTelnet struct {
dialErr error
banner string
responses map[string]string
// fail returns this error from SendCommand for the named command.
fail map[string]error
// commands records every command actually sent, in order, so tests can
// assert on sequencing.
commands []string
}
func (f *fakeTelnet) Dial() error { return f.dialErr }
func (f *fakeTelnet) Probe() (string, error) { return f.banner, nil }
func (f *fakeTelnet) Close() error { return nil }
func (f *fakeTelnet) SendCommand(cmd string) (string, error) {
f.commands = append(f.commands, cmd)
if err, ok := f.fail[cmd]; ok {
return "", err
}
if resp, ok := f.responses[cmd]; ok {
return resp, nil
}
return "Command not found\n", nil
}
func newFakeTelnetManager(f *fakeTelnet) *Manager {
m := &Manager{
ServerURL: "http://example:8000",
NewTelnet: func(host string) TelnetClient { return f },
}
return m
}
func happyResponses(targetURL string) map[string]string {
return map[string]string{
"sys configuration bmxRegistryUrl " + targetURL + "/bmx/registry/v1/services": "OK\n",
"sys configuration statsServerUrl " + targetURL: "OK\n",
"sys configuration margeServerUrl " + targetURL: "OK\n",
"sys configuration swUpdateUrl " + targetURL + "/updates/soundtouch": "OK\n",
"envswitch boseurls set " + targetURL + " " + targetURL + "/updates/soundtouch": "OK\n",
"getpdo CurrentSystemConfiguration": "margeServerUrl=" + targetURL + "\nbmxRegistryUrl=" + targetURL + "/bmx/registry/v1/services\n",
}
}
func TestMigrateViaTelnet_HappyPath(t *testing.T) {
target := "http://example:8000"
f := &fakeTelnet{
banner: "BoseShell\n-> ",
responses: happyResponses(target),
}
m := newFakeTelnetManager(f)
logs, err := m.migrateViaTelnet("192.0.2.1", target)
if err != nil {
t.Fatalf("migrateViaTelnet: %v", err)
}
wantOrder := []string{
"sys configuration bmxRegistryUrl " + target + "/bmx/registry/v1/services",
"sys configuration statsServerUrl " + target,
"sys configuration margeServerUrl " + target,
"sys configuration swUpdateUrl " + target + "/updates/soundtouch",
"envswitch boseurls set " + target + " " + target + "/updates/soundtouch",
"getpdo CurrentSystemConfiguration",
}
if len(f.commands) != len(wantOrder) {
t.Fatalf("sent %d commands, want %d:\n%v", len(f.commands), len(wantOrder), f.commands)
}
for i, want := range wantOrder {
if f.commands[i] != want {
t.Errorf("command[%d] = %q, want %q", i, f.commands[i], want)
}
}
if !strings.Contains(logs, "succeeded") {
t.Errorf("logs missing success marker:\n%s", logs)
}
if !strings.Contains(logs, "BoseShell") {
t.Errorf("logs missing banner echo:\n%s", logs)
}
}
func TestMigrateViaTelnet_DialFailureReturnsError(t *testing.T) {
f := &fakeTelnet{dialErr: errors.New("connection refused")}
m := newFakeTelnetManager(f)
_, err := m.migrateViaTelnet("192.0.2.1", "http://example:8000")
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)
}
if len(f.commands) != 0 {
t.Errorf("expected no commands sent on dial failure, got %v", f.commands)
}
}
func TestMigrateViaTelnet_CommandNotFoundAborts(t *testing.T) {
target := "http://example:8000"
resp := happyResponses(target)
// The ST20-Portable case: `envswitch` is not implemented.
delete(resp, "envswitch boseurls set "+target+" "+target+"/updates/soundtouch")
f := &fakeTelnet{responses: resp}
m := newFakeTelnetManager(f)
_, err := m.migrateViaTelnet("192.0.2.1", target)
if err == nil {
t.Fatal("expected error when envswitch is rejected, got nil")
}
if !strings.Contains(err.Error(), "envswitch") {
t.Errorf("err = %v, want to mention the rejected command", err)
}
// The verification command must NOT have been sent — the run aborts on
// the first rejection.
for _, c := range f.commands {
if c == "getpdo CurrentSystemConfiguration" {
t.Errorf("verification was sent after a rejected command: %v", f.commands)
}
}
}
func TestMigrateViaTelnet_VerifyMismatchFails(t *testing.T) {
target := "http://example:8000"
resp := happyResponses(target)
// Device echoes the OLD URLs (envswitch/sys configuration silently dropped).
resp["getpdo CurrentSystemConfiguration"] = "margeServerUrl=https://streaming.bose.com\n"
f := &fakeTelnet{responses: resp}
m := newFakeTelnetManager(f)
_, err := m.migrateViaTelnet("192.0.2.1", target)
if err == nil {
t.Fatal("expected verification mismatch error, got nil")
}
if !strings.Contains(err.Error(), "verification failed") {
t.Errorf("err = %v, want to mention verification failure", err)
}
}
func TestMigrateViaTelnet_TransportErrorAborts(t *testing.T) {
target := "http://example:8000"
f := &fakeTelnet{
responses: happyResponses(target),
fail: map[string]error{
"sys configuration margeServerUrl " + target: errors.New("write: broken pipe"),
},
}
m := newFakeTelnetManager(f)
_, err := m.migrateViaTelnet("192.0.2.1", target)
if err == nil {
t.Fatal("expected transport error, got nil")
}
if !strings.Contains(err.Error(), "broken pipe") {
t.Errorf("err = %v, want to wrap broken pipe", err)
}
}
func TestMigrateViaTelnet_MissingNewTelnetIsClearError(t *testing.T) {
m := &Manager{ServerURL: "http://example:8000"} // NewTelnet deliberately nil
_, err := m.migrateViaTelnet("192.0.2.1", "http://example:8000")
if err == nil {
t.Fatal("expected error when NewTelnet is nil")
}
if !strings.Contains(err.Error(), "NewTelnet") {
t.Errorf("err = %v, want a configuration error mentioning NewTelnet", err)
}
}
+172
View File
@@ -0,0 +1,172 @@
// Package telnet provides a minimal line-oriented client for the SoundTouch
// device's diagnostic shell on TCP port 17000.
//
// The protocol observed in the wild is a plain TCP stream with no Telnet
// option negotiation (no IAC sequences), so the client uses the standard
// library's net package directly. All I/O is deadline-driven so a wedged
// device can never stall the caller indefinitely.
package telnet
import (
"bytes"
"errors"
"fmt"
"net"
"os"
"strconv"
"time"
)
// Default values for a fresh Client.
const (
DefaultPort = 17000
DefaultDialTimeout = 2 * time.Second
DefaultReadTimeout = 5 * time.Second
DefaultWriteTimeout = 2 * time.Second
// idleWindow is how long we wait for further bytes after the first
// byte of a response before treating the response as complete.
idleWindow = 400 * time.Millisecond
)
// Client is a connected (or about-to-be-connected) session to a SoundTouch
// diagnostic shell. A Client is not safe for concurrent use; create one per
// device interaction.
type Client struct {
Host string
Port int
DialTimeout time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
conn net.Conn
}
// NewClient returns a Client targeting host:17000 with the default timeouts.
func NewClient(host string) *Client {
return &Client{
Host: host,
Port: DefaultPort,
DialTimeout: DefaultDialTimeout,
ReadTimeout: DefaultReadTimeout,
WriteTimeout: DefaultWriteTimeout,
}
}
// Dial establishes the TCP connection. Subsequent calls are a no-op as long
// as the existing connection is still open.
func (c *Client) Dial() error {
if c.conn != nil {
return nil
}
addr := net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
conn, err := net.DialTimeout("tcp", addr, c.DialTimeout)
if err != nil {
return fmt.Errorf("dial %s: %w", addr, err)
}
c.conn = conn
return nil
}
// Close terminates the TCP connection. Calling Close on a closed Client is a
// no-op.
func (c *Client) Close() error {
if c.conn == nil {
return nil
}
err := c.conn.Close()
c.conn = nil
return err
}
// Probe reads any banner the device emits immediately after connect. It
// returns whatever bytes arrive within a short window; an empty banner is
// not treated as an error because some firmware revisions stay silent until
// the first command.
func (c *Client) Probe() (string, error) {
if c.conn == nil {
return "", errors.New("telnet: not connected")
}
if err := c.conn.SetReadDeadline(time.Now().Add(idleWindow * 2)); err != nil {
return "", fmt.Errorf("set read deadline: %w", err)
}
buf := make([]byte, 1024)
n, err := c.conn.Read(buf)
if err != nil && !errors.Is(err, os.ErrDeadlineExceeded) {
return "", fmt.Errorf("read banner: %w", err)
}
return string(buf[:n]), nil
}
// SendCommand writes cmd followed by CRLF and reads the device's response.
// The read terminates when the connection has been idle for idleWindow after
// the first byte arrived, or when the overall ReadTimeout is reached.
//
// Returns the raw response text (callers decide what counts as success — the
// device's textual conventions vary by firmware: some commands return "OK",
// others echo state, others return nothing).
func (c *Client) SendCommand(cmd string) (string, error) {
if c.conn == nil {
return "", errors.New("telnet: not connected")
}
if err := c.conn.SetWriteDeadline(time.Now().Add(c.WriteTimeout)); err != nil {
return "", fmt.Errorf("set write deadline: %w", err)
}
if _, err := c.conn.Write([]byte(cmd + "\r\n")); err != nil {
return "", fmt.Errorf("write %q: %w", cmd, err)
}
overall := time.Now().Add(c.ReadTimeout)
var buf bytes.Buffer
chunk := make([]byte, 1024)
haveBytes := false
for {
deadline := overall
if haveBytes {
d := time.Now().Add(idleWindow)
if d.Before(overall) {
deadline = d
}
}
if err := c.conn.SetReadDeadline(deadline); err != nil {
return buf.String(), fmt.Errorf("set read deadline: %w", err)
}
n, err := c.conn.Read(chunk)
if n > 0 {
buf.Write(chunk[:n])
haveBytes = true
}
if err == nil {
continue
}
if errors.Is(err, os.ErrDeadlineExceeded) {
if haveBytes {
return buf.String(), nil
}
return buf.String(), fmt.Errorf("timed out waiting for response to %q", cmd)
}
return buf.String(), fmt.Errorf("read after %q: %w", cmd, err)
}
}
+371
View File
@@ -0,0 +1,371 @@
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)
}
}