mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
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:
co-authored by
Claude Opus 4.7
parent
d9894be7db
commit
fb47807f70
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user