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 } // EnsureMargeAccountPaired reads the device's /info and, if margeAccountUUID // is empty (a genuinely unpaired, factory-reset device), pairs it via // PairAccount using wantAccountID if given, otherwise a freshly generated ID. // See #515 comment 5230833551: on an unpaired device, margeServerUrl is // reportedly never polled at all, so the boseurls SSH-enable injection has no // read cycle to fire on regardless of command delay — pairing first gives it // one. accountID is empty when GetLiveDeviceInfo itself fails; otherwise it // is either the device's existing margeAccountUUID (alreadyPaired=true) or // the account ID just paired with. func (m *Manager) EnsureMargeAccountPaired(deviceIP, wantAccountID string, t TelnetClient) (accountID string, alreadyPaired bool, logs string, err error) { info, infoErr := m.GetLiveDeviceInfo(deviceIP) if infoErr != nil { return "", false, "", fmt.Errorf("read /info: %w", infoErr) } if info.MargeAccountUUID != "" { return info.MargeAccountUUID, true, "", nil } target := wantAccountID if target == "" { generated, genErr := GenerateAccountID(nil) if genErr != nil { return "", false, "", fmt.Errorf("generate account id: %w", genErr) } target = generated } else if !IsValidAccountID(target) { return "", false, "", fmt.Errorf("invalid account id %q: must be exactly 7 digits", target) } _, pairLogs, pairErr := m.PairAccount(deviceIP, target, t) if pairErr != nil { return target, false, pairLogs, pairErr } return target, false, pairLogs, 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 } // ConfigurationStatus values reported by GET /soundTouchConfigurationStatus. // See issue #615: a speaker can be reachable, named, and already // account-paired yet still report SOUNDTOUCH_NOT_CONFIGURED, which leaves // the firmware nagging the owner to install the Bose app. Only a full pass // through the WebSocket setup state machine (ExecuteInitPlan) clears it. const ( ConfigurationStatusConfigured = "SOUNDTOUCH_CONFIGURED" ConfigurationStatusNotConfigured = "SOUNDTOUCH_NOT_CONFIGURED" ) // ReadConfigurationStatus fetches /soundTouchConfigurationStatus and returns // its raw status attribute (e.g. "SOUNDTOUCH_CONFIGURED"). func (m *Manager) ReadConfigurationStatus(deviceIP string) (string, error) { url := buildDeviceURL(deviceIP, "/soundTouchConfigurationStatus") client := &http.Client{Timeout: supportedURLsTimeout} resp, err := client.Get(url) if err != nil { return "", fmt.Errorf("GET %s: %w", url, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("GET %s returned %d", url, resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("read %s: %w", url, err) } var doc struct { Status string `xml:"status,attr"` } if err := xml.Unmarshal(body, &doc); err != nil { return "", fmt.Errorf("parse %s: %w", url, err) } return doc.Status, nil } // PreflightInitPlan reports whether ExecuteInitPlan should be run against // deviceIP, gated on the two conditions from issue #615: /setMargeAccount // must be listed in /supportedURLs, and the device's current // /soundTouchConfigurationStatus must be exactly SOUNDTOUCH_NOT_CONFIGURED. // needed=false with a nil error means "already configured, nothing to do." // Any other outcome (unsupported route, unrecognised status value) is // treated as unknown and returned as an error rather than guessed at. func (m *Manager) PreflightInitPlan(deviceIP string) (needed bool, status string, err error) { supported, probeErr := m.probeSetMargeAccount(deviceIP) if probeErr != nil { return false, "", fmt.Errorf("supportedURLs probe: %w", probeErr) } if !supported { return false, "", errors.New("/setMargeAccount is not listed in /supportedURLs — device does not support this pairing path") } status, err = m.ReadConfigurationStatus(deviceIP) if err != nil { return false, "", fmt.Errorf("read /soundTouchConfigurationStatus: %w", err) } switch status { case ConfigurationStatusConfigured: return false, status, nil case ConfigurationStatusNotConfigured: return true, status, nil default: return false, status, fmt.Errorf("unexpected /soundTouchConfigurationStatus value %q", status) } } // 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") }