mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
Speakers paired via non-AfterTouch tooling (e.g. the USB-stick SSH-enable method) can report a margeAccountUUID that isn't Bose's own 7-digit numeric format, such as "stick@local". Discovery persisted this value unvalidated, and the datastore's identifier check rejected it outright, so the device was silently never saved. Widens datastore.IsSafeIdentifier to accept any identifier that's safe as a path component, XML value, and telnet-command token (still excluding whitespace, control characters, and HTML/XML/shell metacharacters), and makes it the single account-ID validator, replacing setup's separate, stricter 7-digit-only IsValidAccountID. Also closes related gaps found while widening the validator: - postSetMargeAccount now XML-escapes the account ID instead of raw string interpolation. - SaveAccountInfo/HandleMargeCreateAccount now validate the account ID the same way SaveDeviceInfo already did. - handlers_export.go URL-escapes account/device IDs before building outbound diagnostic-fetch URLs. - pkg/service/health gained the sanitizeLog helper every other package already has, applied to log lines carrying speaker-reported values. - The admin web UI (script.js) renders account/device IDs via DOM APIs instead of innerHTML/inline event-handler string interpolation, closing a stored-XSS path, and a duplicate escape helper was consolidated into one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
356 lines
12 KiB
Go
356 lines
12 KiB
Go
package setup
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
|
)
|
|
|
|
// 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 !datastore.IsSafeIdentifier(accountID) {
|
|
return result, "", fmt.Errorf("invalid account ID %q: must be a non-empty, path-safe identifier", 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
|
|
|
|
// Safe to concatenate: datastore.IsSafeIdentifier (checked above) rejects
|
|
// any whitespace or control characters, so accountID can't smuggle extra
|
|
// tokens into this single-line telnet command.
|
|
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 !datastore.IsSafeIdentifier(target) {
|
|
return "", false, "", fmt.Errorf("invalid account id %q: must be a non-empty, path-safe identifier", 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")
|
|
|
|
// accountID is XML-escaped rather than interpolated raw:
|
|
// datastore.IsSafeIdentifier already excludes '<', '>', '&', '\'', '"'
|
|
// (see #634), but escaping here too means this stays well-formed even
|
|
// if that gate is ever bypassed.
|
|
var escapedAccountID bytes.Buffer
|
|
if err := xml.EscapeText(&escapedAccountID, []byte(accountID)); err != nil {
|
|
return fmt.Errorf("escape account ID: %w", err)
|
|
}
|
|
|
|
body := fmt.Sprintf(
|
|
`<PairDeviceWithAccount><accountId>%s</accountId><userAuthToken>aftertouch</userAuthToken></PairDeviceWithAccount>`,
|
|
escapedAccountID.String(),
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
// 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")
|
|
}
|