mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 16:46:17 +00:00
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>
93 lines
3.3 KiB
Go
93 lines
3.3 KiB
Go
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")
|
|
}
|