package setup import ( "errors" "fmt" "strings" ) // telnetURLs holds the four URLs the migration writes via telnet. Most // users keep all four pointing at the same service base; per-field // overrides exist mainly so soundcork users can append /marge to the // marge URL. type telnetURLs struct { Marge string Stats string SwUpdate string BmxRegistry string } // defaultTelnetURLs returns the canonical URL set derived from the // soundtouch-service base targetURL. func defaultTelnetURLs(targetURL string) telnetURLs { return telnetURLs{ Marge: targetURL, Stats: targetURL, SwUpdate: targetURL + "/updates/soundtouch", BmxRegistry: targetURL + "/bmx/registry/v1/services", } } // telnetURLsFromOptions resolves the four URLs from targetURL plus // per-field overrides supplied via the migration options map. Recognised // keys are marge_url, stats_url, sw_update_url, bmx_url; missing or empty // entries fall back to the canonical default. // // We deliberately do not expose a "proxied"/"original" semantic here // (unlike the XML method's applyProxyOptions): per the discussion that // motivated this iteration, the goal is to keep the user model simple — // one base URL plus optional path suffixes — and let the service layer // hold any non-trivial logic. func telnetURLsFromOptions(targetURL string, options map[string]string) telnetURLs { u := defaultTelnetURLs(targetURL) if v := options["marge_url"]; v != "" { u.Marge = v } if v := options["stats_url"]; v != "" { u.Stats = v } if v := options["sw_update_url"]; v != "" { u.SwUpdate = v } if v := options["bmx_url"]; v != "" { u.BmxRegistry = v } return u } // Commands returns the canonical sequence of telnet commands. Order // matters: `sys configuration …` writes the runtime layer; the closing // `envswitch boseurls set …` writes the parallel persistence layer that // otherwise wins on the next reboot. // // Envswitch derivation rule: arg1 mirrors u.Marge verbatim, arg2 mirrors // u.SwUpdate verbatim. Soundcork users who set Marge to "/marge" // therefore get "envswitch boseurls set /marge /updates/soundtouch" // without any extra plumbing — the parallel layer stays consistent with // the runtime layer. func (u telnetURLs) Commands() []string { return []string{ "sys configuration bmxRegistryUrl " + u.BmxRegistry, "sys configuration statsServerUrl " + u.Stats, "sys configuration margeServerUrl " + u.Marge, "sys configuration swUpdateUrl " + u.SwUpdate, "envswitch boseurls set " + u.Marge + " " + u.SwUpdate, } } // 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.). // // targetURL is kept as a separate verification anchor: most users have // every URL share that base, so substring-matching it against the // device's `getpdo` reply is the simplest "did the writes stick?" check // that still works for the soundcork "/marge on one field" case. func (m *Manager) migrateViaTelnet(deviceIP, targetURL string, urls telnetURLs) (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 urls.Commands() { 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 (runtime layer only — confirms the writes were accepted, not that they'll survive a reboot)\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 writes accepted (runtime layer). Reboot the device so the envswitch-persisted layer takes over.\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") }