diff --git a/pkg/service/setup/telnet_preflight.go b/pkg/service/setup/telnet_preflight.go new file mode 100644 index 0000000..b87aa7c --- /dev/null +++ b/pkg/service/setup/telnet_preflight.go @@ -0,0 +1,57 @@ +package setup + +import ( + "fmt" + "strings" +) + +// telnetPreflight performs a read-only check of the device's port-17000 +// diagnostic shell and populates the Telnet* fields on summary. +// +// It exists so the migration UI can decide whether to offer the telnet +// method, and so a telnet-only (SSH-less) device can still tell us whether +// it is already pointing at our service. The probe is deliberately scoped +// to safe, non-mutating commands: +// +// 1. TCP dial :17000 (Manager.NewTelnet handles the timeouts). +// 2. Read whatever banner the shell emits. +// 3. `getpdo CurrentSystemConfiguration` — a read-only command; if the +// device answers with "command not found" we record that too so the UI +// can disable the telnet option with a reason. +// +// Errors are recorded on summary.TelnetProbeError rather than returned, so +// preflight is best-effort and never breaks the rest of GetMigrationSummary. +func (m *Manager) telnetPreflight(summary *MigrationSummary, deviceIP string) { + if m.NewTelnet == nil { + summary.TelnetProbeError = "telnet client not configured" + return + } + + t := m.NewTelnet(deviceIP) + + if err := t.Dial(); err != nil { + summary.TelnetProbeError = fmt.Sprintf("dial %s:17000: %v", deviceIP, err) + return + } + + defer func() { _ = t.Close() }() + + summary.TelnetReachable = true + + if banner, _ := t.Probe(); banner != "" { + summary.TelnetBanner = strings.TrimSpace(banner) + } + + resp, err := t.SendCommand("getpdo CurrentSystemConfiguration") + if err != nil { + summary.TelnetProbeError = fmt.Sprintf("getpdo CurrentSystemConfiguration: %v", err) + return + } + + if isCommandNotFound(resp) { + summary.TelnetProbeError = "device rejected getpdo CurrentSystemConfiguration (firmware does not expose it)" + return + } + + summary.TelnetVerifiedConfig = strings.TrimRight(resp, "\r\n") +} diff --git a/pkg/service/setup/telnet_preflight_test.go b/pkg/service/setup/telnet_preflight_test.go new file mode 100644 index 0000000..01c3ab6 --- /dev/null +++ b/pkg/service/setup/telnet_preflight_test.go @@ -0,0 +1,117 @@ +package setup + +import ( + "errors" + "strings" + "testing" +) + +func TestTelnetPreflight_HappyPath(t *testing.T) { + target := "http://example:8000" + f := &fakeTelnet{ + banner: "BoseShell\n-> ", + responses: map[string]string{ + "getpdo CurrentSystemConfiguration": "margeServerUrl=" + target + "\nbmxRegistryUrl=" + target + "/bmx/registry/v1/services\n", + }, + } + m := newFakeTelnetManager(f) + + summary := &MigrationSummary{} + m.telnetPreflight(summary, "192.0.2.1") + + 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) + } + + if summary.TelnetProbeError != "" { + t.Errorf("TelnetProbeError = %q, want empty on happy path", summary.TelnetProbeError) + } +} + +func TestTelnetPreflight_DialFailureRecorded(t *testing.T) { + f := &fakeTelnet{dialErr: errors.New("connection refused")} + m := newFakeTelnetManager(f) + + summary := &MigrationSummary{} + m.telnetPreflight(summary, "192.0.2.1") + + if summary.TelnetReachable { + t.Errorf("TelnetReachable = true, want false on dial failure") + } + + if !strings.Contains(summary.TelnetProbeError, "connection refused") { + t.Errorf("TelnetProbeError = %q, want it to wrap connection refused", summary.TelnetProbeError) + } + + if len(f.commands) != 0 { + t.Errorf("commands sent on dial failure: %v, want none", f.commands) + } +} + +func TestTelnetPreflight_GetpdoCommandNotFoundRecorded(t *testing.T) { + f := &fakeTelnet{ + responses: map[string]string{ + // Default fakeTelnet behaviour returns "Command not found\n" for + // any command not in the map. We rely on that here. + }, + } + m := newFakeTelnetManager(f) + + summary := &MigrationSummary{} + m.telnetPreflight(summary, "192.0.2.1") + + if !summary.TelnetReachable { + t.Errorf("TelnetReachable = false, want true (TCP dial succeeded)") + } + + if summary.TelnetVerifiedConfig != "" { + t.Errorf("TelnetVerifiedConfig = %q, want empty when getpdo is rejected", summary.TelnetVerifiedConfig) + } + + if !strings.Contains(summary.TelnetProbeError, "getpdo") { + t.Errorf("TelnetProbeError = %q, want it to mention the rejected command", summary.TelnetProbeError) + } +} + +func TestTelnetPreflight_TransportErrorRecorded(t *testing.T) { + f := &fakeTelnet{ + fail: map[string]error{ + "getpdo CurrentSystemConfiguration": errors.New("read: broken pipe"), + }, + } + m := newFakeTelnetManager(f) + + summary := &MigrationSummary{} + m.telnetPreflight(summary, "192.0.2.1") + + if !summary.TelnetReachable { + t.Errorf("TelnetReachable = false, want true (dial succeeded before send)") + } + + if !strings.Contains(summary.TelnetProbeError, "broken pipe") { + t.Errorf("TelnetProbeError = %q, want it to wrap broken pipe", summary.TelnetProbeError) + } +} + +func TestTelnetPreflight_NoNewTelnetRecordsConfigurationError(t *testing.T) { + m := &Manager{ServerURL: "http://example:8000"} // NewTelnet deliberately nil + + summary := &MigrationSummary{} + m.telnetPreflight(summary, "192.0.2.1") + + if summary.TelnetReachable { + t.Errorf("TelnetReachable = true, want false when NewTelnet is nil") + } + + if !strings.Contains(summary.TelnetProbeError, "not configured") { + t.Errorf("TelnetProbeError = %q, want it to mention configuration", summary.TelnetProbeError) + } +}