From 91ba28c52e387270aecfc6ffa6188db21fec648f Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 10 May 2026 18:42:26 +0200 Subject: [PATCH] feat(setup): run telnet preflight in parallel with SSH probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetMigrationSummary now kicks off telnetPreflight in a goroutine at entry and merges the four Telnet* fields into the main summary just before returning. Wall time becomes max(ssh, telnet); the two transports are queried independently and their results combined — SSH retains visibility into /etc/hosts, /etc/resolv.conf and the on-device XML config, while telnet contributes the live URL set readable via `getpdo CurrentSystemConfiguration` without root. Race-free by construction: the goroutine writes to its own MigrationSummary instance and only the four telnet fields are copied back. Verified with `go test -race`. Tests cover telnet-only, ssh-only, and both-succeed paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../setup/migration_summary_telnet_test.go | 139 ++++++++++++++++++ pkg/service/setup/setup.go | 21 +++ 2 files changed, 160 insertions(+) create mode 100644 pkg/service/setup/migration_summary_telnet_test.go diff --git a/pkg/service/setup/migration_summary_telnet_test.go b/pkg/service/setup/migration_summary_telnet_test.go new file mode 100644 index 0000000..b7651cd --- /dev/null +++ b/pkg/service/setup/migration_summary_telnet_test.go @@ -0,0 +1,139 @@ +package setup + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// telnetSummaryEnv builds a Manager whose: +// - SSH client is the supplied mockSSH (or a no-op if nil). +// - Telnet client is the supplied fakeTelnet. +// - Live :8090/info call hits an httptest server returning a minimal XML. +// +// The deviceIP returned is the httptest server's listener addr ("host:port"), +// so the live-info call works; the SSH and telnet clients ignore the addr +// and return whatever the fakes are scripted to return. +func telnetSummaryEnv(t *testing.T, ssh *mockSSH, ft *fakeTelnet) (*Manager, string, func()) { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + _, _ = fmt.Fprint(w, `Test`) + })) + + m := NewManager("http://example:8000", nil, nil) + m.NewSSH = func(string) SSHClient { + if ssh != nil { + return ssh + } + return &mockSSH{runFunc: func(string) (string, error) { return "", errors.New("ssh disabled in test") }} + } + m.NewTelnet = func(string) TelnetClient { return ft } + + return m, server.Listener.Addr().String(), server.Close +} + +func TestGetMigrationSummary_TelnetSucceedsSSHFails(t *testing.T) { + target := "http://example:8000" + ft := &fakeTelnet{ + banner: "BoseShell\n-> ", + responses: map[string]string{ + "getpdo CurrentSystemConfiguration": "margeServerUrl=" + target + "\n", + }, + } + + m, host, cleanup := telnetSummaryEnv(t, nil, ft) + defer cleanup() + + summary, err := m.GetMigrationSummary(host, "", "", nil) + if err != nil { + t.Fatalf("GetMigrationSummary: %v", err) + } + + if summary.SSHSuccess { + t.Errorf("SSHSuccess = true, want false") + } + + if !summary.TelnetReachable { + t.Errorf("TelnetReachable = false, want true") + } + + if !strings.Contains(summary.TelnetBanner, "BoseShell") { + t.Errorf("TelnetBanner = %q, want it to contain BoseShell", summary.TelnetBanner) + } + + if !strings.Contains(summary.TelnetVerifiedConfig, target) { + t.Errorf("TelnetVerifiedConfig = %q, want it to contain %q", summary.TelnetVerifiedConfig, target) + } +} + +func TestGetMigrationSummary_TelnetFailsSSHFails(t *testing.T) { + ft := &fakeTelnet{dialErr: errors.New("connection refused")} + + m, host, cleanup := telnetSummaryEnv(t, nil, ft) + defer cleanup() + + summary, err := m.GetMigrationSummary(host, "", "", nil) + if err != nil { + t.Fatalf("GetMigrationSummary: %v", err) + } + + if summary.SSHSuccess { + t.Errorf("SSHSuccess = true, want false") + } + + if summary.TelnetReachable { + t.Errorf("TelnetReachable = true, want false") + } + + if !strings.Contains(summary.TelnetProbeError, "connection refused") { + t.Errorf("TelnetProbeError = %q, want connection refused", summary.TelnetProbeError) + } +} + +func TestGetMigrationSummary_TelnetSucceedsSSHSucceeds(t *testing.T) { + target := "http://example:8000" + ft := &fakeTelnet{ + responses: map[string]string{ + "getpdo CurrentSystemConfiguration": "margeServerUrl=" + target + "\n", + }, + } + + // SSH mock returns enough for SSHSuccess to be true (cat /opt/Bose/etc/...). + ssh := &mockSSH{ + runFunc: func(cmd string) (string, error) { + switch { + case strings.HasPrefix(cmd, "cat "+SoundTouchSdkPrivateCfgPath): + return `` + target + ``, nil + case strings.HasPrefix(cmd, "[ -f"): + return "", errors.New("not found") + default: + return "", nil + } + }, + } + + m, host, cleanup := telnetSummaryEnv(t, ssh, ft) + defer cleanup() + + summary, err := m.GetMigrationSummary(host, "", "", nil) + if err != nil { + t.Fatalf("GetMigrationSummary: %v", err) + } + + if !summary.SSHSuccess { + t.Errorf("SSHSuccess = false, want true") + } + + if !summary.TelnetReachable { + t.Errorf("TelnetReachable = false, want true") + } + + if !strings.Contains(summary.TelnetVerifiedConfig, target) { + t.Errorf("TelnetVerifiedConfig = %q, want %q", summary.TelnetVerifiedConfig, target) + } +} diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go index e2aff8f..90a6d07 100644 --- a/pkg/service/setup/setup.go +++ b/pkg/service/setup/setup.go @@ -244,6 +244,20 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti SSHSuccess: false, } + // Run the telnet preflight in parallel with the SSH-based probes below. + // Both transports are queried independently: SSH gives access to + // /etc/hosts, /etc/resolv.conf and the on-device XML config; telnet's + // `getpdo CurrentSystemConfiguration` reports the live URL set without + // needing root. They are complementary, so we wait for both and merge + // the results — total wall time = max(ssh, telnet). + telnetCh := make(chan MigrationSummary, 1) + + go func() { + var local MigrationSummary + m.telnetPreflight(&local, deviceIP) + telnetCh <- local + }() + // Populate device info from datastore and live info m.populateDeviceInfo(summary, deviceIP) @@ -322,6 +336,13 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti } } + // 8. Merge telnet preflight results (started in parallel at the top). + telnetResult := <-telnetCh + summary.TelnetReachable = telnetResult.TelnetReachable + summary.TelnetBanner = telnetResult.TelnetBanner + summary.TelnetVerifiedConfig = telnetResult.TelnetVerifiedConfig + summary.TelnetProbeError = telnetResult.TelnetProbeError + return summary, nil }