diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index ebdd787..0af86ed 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -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) diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt index a2ca267..9c137a3 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -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 diff --git a/docs/analysis/TELNET-MIGRATION-METHOD.md b/docs/analysis/TELNET-MIGRATION-METHOD.md index 2484fdb..8f68c20 100644 --- a/docs/analysis/TELNET-MIGRATION-METHOD.md +++ b/docs/analysis/TELNET-MIGRATION-METHOD.md @@ -60,9 +60,13 @@ sys configuration margeServerUrl http://:8000 sys configuration swUpdateUrl http://:8000/updates/soundtouch envswitch boseurls set http://:8000 http://: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 ` + `sys reboot`). + `envswitch accountid set ` 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 ` 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). diff --git a/pkg/service/handlers/handlers_pairing.go b/pkg/service/handlers/handlers_pairing.go new file mode 100644 index 0000000..b7e099d --- /dev/null +++ b/pkg/service/handlers/handlers_pairing.go @@ -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) + } +} diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go index 32f4bea..e853879 100644 --- a/pkg/service/handlers/handlers_setup.go +++ b/pkg/service/handlers/handlers_setup.go @@ -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) diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go index 4d870b4..41221e2 100644 --- a/pkg/service/handlers/main_test.go +++ b/pkg/service/handlers/main_test.go @@ -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) diff --git a/pkg/service/setup/marge_pairing.go b/pkg/service/setup/marge_pairing.go new file mode 100644 index 0000000..e4c8e9b --- /dev/null +++ b/pkg/service/setup/marge_pairing.go @@ -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 ` 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( + `%saftertouch`, + 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") +} diff --git a/pkg/service/setup/marge_pairing_test.go b/pkg/service/setup/marge_pairing_test.go new file mode 100644 index 0000000..b5c14b0 --- /dev/null +++ b/pkg/service/setup/marge_pairing_test.go @@ -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(``)) + return + } + + _, _ = w.Write([]byte(``)) + }) + + 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, "1234567") { + t.Errorf("device received %q, want 1234567", 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) + } + } + } +} diff --git a/pkg/service/setup/reboot_test.go b/pkg/service/setup/reboot_test.go new file mode 100644 index 0000000..6bec990 --- /dev/null +++ b/pkg/service/setup/reboot_test.go @@ -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") + } +} diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go index af7d44a..e2aff8f 100644 --- a/pkg/service/setup/setup.go +++ b/pkg/service/setup/setup.go @@ -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" diff --git a/pkg/service/setup/setup_test.go b/pkg/service/setup/setup_test.go index f7d61bb..1df35d8 100644 --- a/pkg/service/setup/setup_test.go +++ b/pkg/service/setup/setup_test.go @@ -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) } diff --git a/pkg/service/setup/telnet_migration.go b/pkg/service/setup/telnet_migration.go new file mode 100644 index 0000000..8a0fa0e --- /dev/null +++ b/pkg/service/setup/telnet_migration.go @@ -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") +} diff --git a/pkg/service/setup/telnet_migration_test.go b/pkg/service/setup/telnet_migration_test.go new file mode 100644 index 0000000..e854482 --- /dev/null +++ b/pkg/service/setup/telnet_migration_test.go @@ -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) + } +} diff --git a/pkg/telnet/telnet.go b/pkg/telnet/telnet.go new file mode 100644 index 0000000..0865442 --- /dev/null +++ b/pkg/telnet/telnet.go @@ -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) + } +} diff --git a/pkg/telnet/telnet_test.go b/pkg/telnet/telnet_test.go new file mode 100644 index 0000000..7985d98 --- /dev/null +++ b/pkg/telnet/telnet_test.go @@ -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) + } +}