Files
Tobias GesellchenandClaude Sonnet 5 401a546482 docs+fix(telnet): label getpdo output as runtime-layer-only, not proof of persistence
Addresses recommendations 4 and 7 from #515 comment 5231931569: a
green-looking getpdo readback only confirms the sys configuration
writes were accepted, not that they'll survive a reboot (that's what
the envswitch-persisted layer decides). Labels the getpdo line in both
migrateViaTelnet and runTelnetInjection's CLI/log output accordingly,
softens migrateViaTelnet's "succeeded" wording to "accepted", and adds
the same one-line caveat to TELNET-MIGRATION-METHOD.md #2.3 (previously
only in TELNET-COMMAND-REFERENCE.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 20:46:51 +02:00

155 lines
5.3 KiB
Go

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 "<base>/marge"
// therefore get "envswitch boseurls set <base>/marge <base>/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")
}