Files
Bose-SoundTouch/pkg/service/setup/marge_pairing.go
T
Tobias GesellchenandClaude Opus 4.7 fb47807f70 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>
2026-05-10 12:43:36 +02:00

243 lines
7.0 KiB
Go

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")
}