From e3450ffd009817cc8716bd24d5393b553f2e950a Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Wed, 13 May 2026 08:27:43 +0200 Subject: [PATCH] refactor(setup): split high-complexity functions into per-axis helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the five remaining gocyclo > 20 warnings to zero by extracting cohesive sub-functions; same observable behaviour, smaller surface to read at each call site. Bonus: the new helpers are individually testable. - pkg/models/clockdisplay.go: split ClockDisplay.UnmarshalXML attr handling into applyClockDisplayOuterAttrs (legacy flat shape) and applyClockConfigAttrs (current nested shape). - pkg/service/setup/ssh_probe_apply.go: split applyProbeToSummary into applyProbeCurrentConfig / applyProbeResolvConf / applyProbeRemoteServices / applyProbeCACert — one helper per MigrationSummary axis the probe populates. - pkg/service/setup/init_plan.go: split ExecuteInitPlan into applyInitPlanDefaults, runURLRewrite, resolveAccountID, and verifyPairing. Cleans up several shadowed err variables in the process. - cmd/soundtouch-cli/cmd_setup.go: split renderInspectReport into renderInspectIdentityAndPairing / renderInspectNetwork / renderInspectSources / renderInspectPresets / renderInspectRuntimeURLs, and buildPlanSteps into resetSteps + migrationSteps helpers. golangci-lint run ./pkg/service/setup/... ./pkg/models/... ./cmd/soundtouch-cli/... now reports zero findings. Tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/soundtouch-cli/cmd_setup.go | 434 +++++++++++++++------------ pkg/models/clockdisplay.go | 99 +++--- pkg/service/setup/init_plan.go | 166 ++++++---- pkg/service/setup/ssh_probe_apply.go | 95 ++++-- 4 files changed, 465 insertions(+), 329 deletions(-) diff --git a/cmd/soundtouch-cli/cmd_setup.go b/cmd/soundtouch-cli/cmd_setup.go index bab9ab0..b134bc8 100644 --- a/cmd/soundtouch-cli/cmd_setup.go +++ b/cmd/soundtouch-cli/cmd_setup.go @@ -86,115 +86,156 @@ func renderInspectReport(r *setup.InspectReport) { fmt.Printf("Speaker @ %s\n", r.DeviceIP) fmt.Println(strings.Repeat("─", 40)) + renderInspectIdentityAndPairing(r) + renderInspectNetwork(r) + renderInspectSources(r) + renderInspectPresets(r) + renderInspectRuntimeURLs(r) +} + +func renderInspectIdentityAndPairing(r *setup.InspectReport) { if r.InfoErr != nil { PrintError(fmt.Sprintf("/info: %v", r.InfoErr)) - } else if r.Info != nil { - i := r.Info - - fmt.Println("Identity") - fmt.Printf(" deviceID : %s\n", i.DeviceID) - - if suffix := deviceIDSuffix(i.DeviceID); suffix != "" { - fmt.Printf(" → use as --match suffix for wait-online: %s\n", suffix) - } - - fmt.Printf(" name : %s\n", i.Name) - fmt.Printf(" type : %s\n", i.Type) - - for _, comp := range i.Components { - if comp.SoftwareVersion != "" { - fmt.Printf(" softwareVersion : %s (component %s)\n", comp.SoftwareVersion, comp.Category) - } - - if comp.SerialNumber != "" { - fmt.Printf(" serialNumber : %s (component %s)\n", comp.SerialNumber, comp.Category) - } - } - - fmt.Println() - fmt.Println("Pairing") - - if i.MargeAccountUUID == "" { - PrintWarning("margeAccountUUID is empty — device is unpaired (factory-reset state)") - } else { - fmt.Printf(" margeAccountUUID : %s\n", i.MargeAccountUUID) - } - - fmt.Printf(" margeURL : %s\n", i.MargeURL) - fmt.Println() + return } + if r.Info == nil { + return + } + + i := r.Info + + fmt.Println("Identity") + fmt.Printf(" deviceID : %s\n", i.DeviceID) + + if suffix := deviceIDSuffix(i.DeviceID); suffix != "" { + fmt.Printf(" → use as --match suffix for wait-online: %s\n", suffix) + } + + fmt.Printf(" name : %s\n", i.Name) + fmt.Printf(" type : %s\n", i.Type) + + for _, comp := range i.Components { + if comp.SoftwareVersion != "" { + fmt.Printf(" softwareVersion : %s (component %s)\n", comp.SoftwareVersion, comp.Category) + } + + if comp.SerialNumber != "" { + fmt.Printf(" serialNumber : %s (component %s)\n", comp.SerialNumber, comp.Category) + } + } + + fmt.Println() + fmt.Println("Pairing") + + if i.MargeAccountUUID == "" { + PrintWarning("margeAccountUUID is empty — device is unpaired (factory-reset state)") + } else { + fmt.Printf(" margeAccountUUID : %s\n", i.MargeAccountUUID) + } + + fmt.Printf(" margeURL : %s\n", i.MargeURL) + fmt.Println() +} + +func renderInspectNetwork(r *setup.InspectReport) { if r.NetworkErr != nil { PrintError(fmt.Sprintf("/networkInfo: %v", r.NetworkErr)) - } else if r.Network != nil { - fmt.Println("Network") - - for i := range r.Network.Interfaces.Interfaces { - iface := &r.Network.Interfaces.Interfaces[i] - - fmt.Printf(" %s\n", iface.Type) - fmt.Printf(" state : %s\n", iface.State) - - if iface.IPAddress != "" { - fmt.Printf(" ipAddress : %s\n", iface.IPAddress) - } - - if iface.MacAddress != "" { - fmt.Printf(" macAddress : %s\n", iface.MacAddress) - } - - if iface.SSID != "" { - fmt.Printf(" ssid : %s\n", iface.SSID) - fmt.Printf(" → use as --ssid for wifi-push: %s\n", iface.SSID) - } - - if iface.Signal != "" { - fmt.Printf(" signal : %s\n", iface.Signal) - } - - if iface.FrequencyKHz != 0 { - fmt.Printf(" frequency : %d kHz\n", iface.FrequencyKHz) - } - } - - fmt.Println() + return } + if r.Network == nil { + return + } + + fmt.Println("Network") + + for i := range r.Network.Interfaces.Interfaces { + iface := &r.Network.Interfaces.Interfaces[i] + + fmt.Printf(" %s\n", iface.Type) + fmt.Printf(" state : %s\n", iface.State) + + if iface.IPAddress != "" { + fmt.Printf(" ipAddress : %s\n", iface.IPAddress) + } + + if iface.MacAddress != "" { + fmt.Printf(" macAddress : %s\n", iface.MacAddress) + } + + if iface.SSID != "" { + fmt.Printf(" ssid : %s\n", iface.SSID) + fmt.Printf(" → use as --ssid for wifi-push: %s\n", iface.SSID) + } + + if iface.Signal != "" { + fmt.Printf(" signal : %s\n", iface.Signal) + } + + if iface.FrequencyKHz != 0 { + fmt.Printf(" frequency : %d kHz\n", iface.FrequencyKHz) + } + } + + fmt.Println() +} + +func renderInspectSources(r *setup.InspectReport) { if r.SourcesErr != nil { PrintError(fmt.Sprintf("/sources: %v", r.SourcesErr)) - } else if r.Sources != nil { - fmt.Printf("Sources (%d)\n", len(r.Sources.SourceItem)) - renderSourceTable(r.Sources.SourceItem) - fmt.Println() + return } + if r.Sources == nil { + return + } + + fmt.Printf("Sources (%d)\n", len(r.Sources.SourceItem)) + renderSourceTable(r.Sources.SourceItem) + fmt.Println() +} + +func renderInspectPresets(r *setup.InspectReport) { if r.PresetsErr != nil { PrintError(fmt.Sprintf("/presets: %v", r.PresetsErr)) - } else if r.Presets != nil { - fmt.Printf("Presets (%d)\n", len(r.Presets.Presets)) - - for _, p := range r.Presets.Presets { - fmt.Printf(" [%s] %s (source=%s)\n", p.ID, p.ContentItem.ItemName, p.ContentItem.Source) - } - - if len(r.Presets.Presets) == 0 { - fmt.Println(" (none)") - } - - fmt.Println() + return } + if r.Presets == nil { + return + } + + fmt.Printf("Presets (%d)\n", len(r.Presets.Presets)) + + for _, p := range r.Presets.Presets { + fmt.Printf(" [%s] %s (source=%s)\n", p.ID, p.ContentItem.ItemName, p.ContentItem.Source) + } + + if len(r.Presets.Presets) == 0 { + fmt.Println(" (none)") + } + + fmt.Println() +} + +func renderInspectRuntimeURLs(r *setup.InspectReport) { if r.RuntimeErr != nil { PrintError(fmt.Sprintf("telnet getpdo: %v", r.RuntimeErr)) - } else if r.RuntimeURLs != "" { - fmt.Println("Runtime URL configuration (telnet getpdo)") - - for _, line := range strings.Split(r.RuntimeURLs, "\n") { - fmt.Printf(" %s\n", line) - } - - fmt.Println() + return } + + if r.RuntimeURLs == "" { + return + } + + fmt.Println("Runtime URL configuration (telnet getpdo)") + + for _, line := range strings.Split(r.RuntimeURLs, "\n") { + fmt.Printf(" %s\n", line) + } + + fmt.Println() } // sourceLine is the per-source row before any width-padding decisions @@ -1092,60 +1133,7 @@ func buildPlanSteps( var steps []planStep if reset { - steps = append(steps, planStep{ - title: "Factory-reset the speaker", - cmd: fmt.Sprintf("soundtouch-cli setup factory-reset --host=%s", host), - reason: "Wipes account pairing, presets, Wi-Fi — gives a clean baseline for the SETUP state machine.", - }) - - steps = append(steps, planStep{ - title: "Connect this host to the speaker's setup AP", - cmd: `# macOS: networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"`, - reason: "After reset the speaker broadcasts its own Wi-Fi at 192.0.2.1.", - manual: true, - }) - - steps = append(steps, planStep{ - title: "Wait for the speaker's setup-mode IP to respond", - cmd: "soundtouch-cli setup wait-ap", - }) - - ssidArg := wifiSSID - if ssidArg == "" { - if currentSSID := inspectedSSID(inspect); currentSSID != "" { - ssidArg = currentSSID - } else { - ssidArg = "" - } - } - - steps = append(steps, planStep{ - title: "Push home Wi-Fi credentials to the speaker", - cmd: fmt.Sprintf(`soundtouch-cli setup wifi-push --ssid=%q --pass=`, ssidArg), - reason: "Speaker leaves AP mode and joins your home network within ~30 s.", - }) - - steps = append(steps, planStep{ - title: "Switch this host back to home Wi-Fi", - cmd: fmt.Sprintf(`# macOS: networksetup -setairportnetwork en0 %q `, ssidArg), - reason: "Required so wait-online's mDNS browse reaches the right network segment.", - manual: true, - }) - - match := "" - if inspect.Info != nil { - match = deviceIDSuffix(inspect.Info.DeviceID) - } - - if match == "" { - match = "" - } - - steps = append(steps, planStep{ - title: "Discover the speaker's new IP via mDNS", - cmd: fmt.Sprintf("soundtouch-cli setup wait-online --match=%s", match), - }) - + steps = append(steps, resetSteps(host, wifiSSID, inspect)...) host = "" // subsequent commands target the discovered IP } @@ -1153,60 +1141,8 @@ func buildPlanSteps( return steps } - if !reset && (summary == nil || !summary.IsMigrated) { - method, methodReason := recommendMigrationMethod(serviceURL, summary) - if method == "" { - steps = append(steps, planStep{ - title: "Enable SSH on the speaker (USB-stick procedure)", - cmd: "# See `soundtouch-cli setup ssh-check` output for the USB-stick steps.", - reason: "Telnet won't respond and SSH is closed — no transport available to apply a migration.", - manual: true, - }) - } else { - if method == setup.MigrationMethodResolvConf || method == setup.MigrationMethodHosts { - if summary != nil && !summary.CACertTrusted { - steps = append(steps, planStep{ - title: "Install AfterTouch's CA cert on the speaker", - cmd: fmt.Sprintf("soundtouch-cli setup install-ca --host=%s --service-url=%s", - host, serviceURL), - reason: "DNS-redirect methods keep using https://*.bose.com URLs — the device needs to trust AfterTouch's cert.", - }) - } - } - - steps = append(steps, planStep{ - title: fmt.Sprintf("Apply URL migration using method=%s", method), - cmd: fmt.Sprintf("soundtouch-cli setup migrate --host=%s --service-url=%s --method=%s", - host, serviceURL, method), - reason: methodReason, - }) - - steps = append(steps, planStep{ - title: "Reboot the speaker", - cmd: fmt.Sprintf("soundtouch-cli setup reboot --host=%s", host), - reason: "The envswitch parallel-persistence layer only fully wins on next boot; reboot now to lock the new URLs in before pairing.", - }) - } - } else if reset { - // Reset path always re-applies a migration after wifi-push. - method, methodReason := recommendMigrationMethod(serviceURL, nil) - if method == "" { - method = setup.MigrationMethodTelnet - methodReason = "Default: envswitch — works on most firmware-27 devices without SSH." - } - - steps = append(steps, planStep{ - title: fmt.Sprintf("Apply URL migration using method=%s", method), - cmd: fmt.Sprintf("soundtouch-cli setup migrate --host=%s --service-url=%s --method=%s", - host, serviceURL, method), - reason: methodReason, - }) - - steps = append(steps, planStep{ - title: "Reboot the speaker", - cmd: fmt.Sprintf("soundtouch-cli setup reboot --host=%s", host), - reason: "The envswitch parallel-persistence layer only fully wins on next boot; reboot now to lock the new URLs in before pairing.", - }) + if reset || summary == nil || !summary.IsMigrated { + steps = append(steps, migrationSteps(host, serviceURL, summary, reset)...) } if includePair && (reset || (summary != nil && !summary.IsPaired)) { @@ -1220,6 +1156,108 @@ func buildPlanSteps( return steps } +// resetSteps composes the factory-reset → AP-switch → wait-ap → +// wifi-push → home-switch → wait-online prefix that --reset adds. +func resetSteps(host, wifiSSID string, inspect *setup.InspectReport) []planStep { + ssidArg := wifiSSID + if ssidArg == "" { + if currentSSID := inspectedSSID(inspect); currentSSID != "" { + ssidArg = currentSSID + } else { + ssidArg = "" + } + } + + match := "" + if inspect != nil && inspect.Info != nil { + match = deviceIDSuffix(inspect.Info.DeviceID) + } + + if match == "" { + match = "" + } + + return []planStep{ + { + title: "Factory-reset the speaker", + cmd: fmt.Sprintf("soundtouch-cli setup factory-reset --host=%s", host), + reason: "Wipes account pairing, presets, Wi-Fi — gives a clean baseline for the SETUP state machine.", + }, + { + title: "Connect this host to the speaker's setup AP", + cmd: `# macOS: networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"`, + reason: "After reset the speaker broadcasts its own Wi-Fi at 192.0.2.1.", + manual: true, + }, + { + title: "Wait for the speaker's setup-mode IP to respond", + cmd: "soundtouch-cli setup wait-ap", + }, + { + title: "Push home Wi-Fi credentials to the speaker", + cmd: fmt.Sprintf(`soundtouch-cli setup wifi-push --ssid=%q --pass=`, ssidArg), + reason: "Speaker leaves AP mode and joins your home network within ~30 s.", + }, + { + title: "Switch this host back to home Wi-Fi", + cmd: fmt.Sprintf(`# macOS: networksetup -setairportnetwork en0 %q `, ssidArg), + reason: "Required so wait-online's mDNS browse reaches the right network segment.", + manual: true, + }, + { + title: "Discover the speaker's new IP via mDNS", + cmd: fmt.Sprintf("soundtouch-cli setup wait-online --match=%s", match), + }, + } +} + +// migrationSteps composes the migrate + reboot pair (plus optional +// install-ca prelude for DNS-redirect methods). In --reset mode the +// summary is nil and we default to method=telnet; otherwise we let the +// recommender pick based on the speaker's current capabilities. +func migrationSteps(host, serviceURL string, summary *setup.MigrationSummary, reset bool) []planStep { + var steps []planStep + + method, methodReason := recommendMigrationMethod(serviceURL, summary) + + if method == "" { + if reset { + method = setup.MigrationMethodTelnet + methodReason = "Default: envswitch — works on most firmware-27 devices without SSH." + } else { + return []planStep{{ + title: "Enable SSH on the speaker (USB-stick procedure)", + cmd: "# See `soundtouch-cli setup ssh-check` output for the USB-stick steps.", + reason: "Telnet won't respond and SSH is closed — no transport available to apply a migration.", + manual: true, + }} + } + } + + dnsRedirect := method == setup.MigrationMethodResolvConf || method == setup.MigrationMethodHosts + if dnsRedirect && summary != nil && !summary.CACertTrusted { + steps = append(steps, planStep{ + title: "Install AfterTouch's CA cert on the speaker", + cmd: fmt.Sprintf("soundtouch-cli setup install-ca --host=%s --service-url=%s", host, serviceURL), + reason: "DNS-redirect methods keep using https://*.bose.com URLs — the device needs to trust AfterTouch's cert.", + }) + } + + steps = append(steps, planStep{ + title: fmt.Sprintf("Apply URL migration using method=%s", method), + cmd: fmt.Sprintf("soundtouch-cli setup migrate --host=%s --service-url=%s --method=%s", host, serviceURL, method), + reason: methodReason, + }) + + steps = append(steps, planStep{ + title: "Reboot the speaker", + cmd: fmt.Sprintf("soundtouch-cli setup reboot --host=%s", host), + reason: "The envswitch parallel-persistence layer only fully wins on next boot; reboot now to lock the new URLs in before pairing.", + }) + + return steps +} + // recommendMigrationMethod picks a migration method from the speaker's // current capabilities. Returns "" when no transport is available. // diff --git a/pkg/models/clockdisplay.go b/pkg/models/clockdisplay.go index 26472ea..2631a5c 100644 --- a/pkg/models/clockdisplay.go +++ b/pkg/models/clockdisplay.go @@ -76,7 +76,46 @@ func mapFromWireFormat(wire string) string { // either because it appears in legacy captures or for forward-compat with // firmwares that may revert. func (c *ClockDisplay) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { - for _, attr := range start.Attr { + applyClockDisplayOuterAttrs(c, start.Attr) + + for { + tok, err := d.Token() + if errors.Is(err, io.EOF) { + break + } + + if err != nil { + return err + } + + switch t := tok.(type) { + case xml.StartElement: + if t.Name.Local == "clockConfig" { + applyClockConfigAttrs(c, t.Attr) + } + + if err := d.Skip(); err != nil { + return err + } + + case xml.CharData: + if text := strings.TrimSpace(string(t)); text != "" { + c.Value = text + } + + case xml.EndElement: + return nil + } + } + + return nil +} + +// applyClockDisplayOuterAttrs handles the legacy flat-attribute format +// (deviceID, enabled, format, brightness, autoDim, timeZone) that older +// fixtures used directly on the element. +func applyClockDisplayOuterAttrs(c *ClockDisplay, attrs []xml.Attr) { + for _, attr := range attrs { switch attr.Name.Local { case "deviceID": c.DeviceID = attr.Value @@ -92,52 +131,26 @@ func (c *ClockDisplay) UnmarshalXML(d *xml.Decoder, start xml.StartElement) erro c.TimeZone = attr.Value } } +} - for { - tok, err := d.Token() - if errors.Is(err, io.EOF) { - break - } - - if err != nil { - return err - } - - switch t := tok.(type) { - case xml.StartElement: - if t.Name.Local == "clockConfig" { - for _, attr := range t.Attr { - switch attr.Name.Local { - case "timezoneInfo": - c.TimeZone = attr.Value - case "userEnable": - c.Enabled = attr.Value == "true" - case "timeFormat": - if mapped := mapFromWireFormat(attr.Value); mapped != "" { - c.Format = mapped - } - case "brightnessLevel": - c.Brightness, _ = strconv.Atoi(attr.Value) - } - } +// applyClockConfigAttrs handles the nested attributes +// (timezoneInfo, userEnable, timeFormat, brightnessLevel) — the shape +// FW 27 emits and accepts. +func applyClockConfigAttrs(c *ClockDisplay, attrs []xml.Attr) { + for _, attr := range attrs { + switch attr.Name.Local { + case "timezoneInfo": + c.TimeZone = attr.Value + case "userEnable": + c.Enabled = attr.Value == "true" + case "timeFormat": + if mapped := mapFromWireFormat(attr.Value); mapped != "" { + c.Format = mapped } - - if err := d.Skip(); err != nil { - return err - } - - case xml.CharData: - text := strings.TrimSpace(string(t)) - if text != "" { - c.Value = text - } - - case xml.EndElement: - return nil + case "brightnessLevel": + c.Brightness, _ = strconv.Atoi(attr.Value) } } - - return nil } // ClockFormat represents supported clock display formats diff --git a/pkg/service/setup/init_plan.go b/pkg/service/setup/init_plan.go index 07d0579..2a8df26 100644 --- a/pkg/service/setup/init_plan.go +++ b/pkg/service/setup/init_plan.go @@ -95,24 +95,9 @@ type ProgressFunc func(StepEvent) // The returned InitPlan reflects any defaulting that happened (generated // account ID, defaulted language, etc.) so callers can persist it. func (m *Manager) ExecuteInitPlan(ctx context.Context, plan InitPlan, progress ProgressFunc) (InitPlan, error) { - if plan.DeviceIP == "" { - return plan, errors.New("InitPlan.DeviceIP is required") - } - - if plan.ServiceURL == "" { - plan.ServiceURL = m.ServerURL - } - - if plan.ServiceURL == "" { - return plan, errors.New("InitPlan.ServiceURL is required (and Manager.ServerURL is empty)") - } - - if plan.Language == 0 { - plan.Language = LanguageEnglish - } - - if plan.AuthToken == "" { - plan.AuthToken = "Bearer aftertouch" + plan, err := applyInitPlanDefaults(plan, m.ServerURL) + if err != nil { + return plan, err } emit := func(kind StepKind, name string, status StepStatus, err error) { @@ -131,44 +116,13 @@ func (m *Manager) ExecuteInitPlan(ctx context.Context, plan InitPlan, progress P emit(StepReadDeviceInfo, "read /info", StatusOK, nil) - if plan.SkipURLRewrite { - emit(StepURLRewrite, "telnet URL rewrite", StatusSkipped, nil) - } else { - emit(StepURLRewrite, "telnet URL rewrite", StatusRunning, nil) - - urls := defaultTelnetURLs(plan.ServiceURL) - if _, rwErr := m.migrateViaTelnet(plan.DeviceIP, plan.ServiceURL, urls); rwErr != nil { - emit(StepURLRewrite, "telnet URL rewrite", StatusFailed, rwErr) - return plan, fmt.Errorf("URL rewrite: %w", rwErr) - } - - emit(StepURLRewrite, "telnet URL rewrite", StatusOK, nil) + if rewriteErr := m.runURLRewrite(plan, emit); rewriteErr != nil { + return plan, rewriteErr } - if plan.AccountID == "" { - if info.MargeAccountUUID != "" && IsValidAccountID(info.MargeAccountUUID) { - plan.AccountID = info.MargeAccountUUID - emit(StepGenerateAccountID, "reuse existing margeAccountUUID="+plan.AccountID, StatusOK, nil) - } else { - emit(StepGenerateAccountID, "generate account ID", StatusRunning, nil) - - known := listKnownAccountIDs(m) - - id, genErr := GenerateAccountID(known) - if genErr != nil { - emit(StepGenerateAccountID, "generate account ID", StatusFailed, genErr) - return plan, fmt.Errorf("generate account ID: %w", genErr) - } - - plan.AccountID = id - - emit(StepGenerateAccountID, "generate account ID="+id, StatusOK, nil) - } - } else if !IsValidAccountID(plan.AccountID) { - invalidErr := fmt.Errorf("invalid AccountID %q: must be exactly 7 digits", plan.AccountID) - emit(StepGenerateAccountID, "validate account ID", StatusFailed, invalidErr) - - return plan, invalidErr + plan, err = m.resolveAccountID(plan, info, emit) + if err != nil { + return plan, err } emit(StepDialWebSocket, "dial websocket", StatusRunning, nil) @@ -237,24 +191,118 @@ func (m *Manager) ExecuteInitPlan(ctx context.Context, plan InitPlan, progress P emit(st.kind, st.name, StatusOK, nil) } + if err := m.verifyPairing(plan, emit); err != nil { + return plan, err + } + + return plan, nil +} + +// applyInitPlanDefaults validates required fields and fills in defaults +// from Manager.ServerURL / sysLanguage 2 / "Bearer aftertouch". +func applyInitPlanDefaults(plan InitPlan, serverURL string) (InitPlan, error) { + if plan.DeviceIP == "" { + return plan, errors.New("InitPlan.DeviceIP is required") + } + + if plan.ServiceURL == "" { + plan.ServiceURL = serverURL + } + + if plan.ServiceURL == "" { + return plan, errors.New("InitPlan.ServiceURL is required (and Manager.ServerURL is empty)") + } + + if plan.Language == 0 { + plan.Language = LanguageEnglish + } + + if plan.AuthToken == "" { + plan.AuthToken = "Bearer aftertouch" + } + + return plan, nil +} + +// runURLRewrite applies the telnet envswitch URL rewrite step unless the +// caller asked to skip it. +func (m *Manager) runURLRewrite(plan InitPlan, emit func(StepKind, string, StepStatus, error)) error { + if plan.SkipURLRewrite { + emit(StepURLRewrite, "telnet URL rewrite", StatusSkipped, nil) + return nil + } + + emit(StepURLRewrite, "telnet URL rewrite", StatusRunning, nil) + + urls := defaultTelnetURLs(plan.ServiceURL) + if _, rwErr := m.migrateViaTelnet(plan.DeviceIP, plan.ServiceURL, urls); rwErr != nil { + emit(StepURLRewrite, "telnet URL rewrite", StatusFailed, rwErr) + return fmt.Errorf("URL rewrite: %w", rwErr) + } + + emit(StepURLRewrite, "telnet URL rewrite", StatusOK, nil) + + return nil +} + +// resolveAccountID populates plan.AccountID — reusing the device's +// existing margeAccountUUID, generating a fresh non-colliding 7-digit +// ID, or validating a user-supplied value. +func (m *Manager) resolveAccountID(plan InitPlan, info *DeviceInfoXML, emit func(StepKind, string, StepStatus, error)) (InitPlan, error) { + if plan.AccountID != "" { + if !IsValidAccountID(plan.AccountID) { + invalidErr := fmt.Errorf("invalid AccountID %q: must be exactly 7 digits", plan.AccountID) + emit(StepGenerateAccountID, "validate account ID", StatusFailed, invalidErr) + + return plan, invalidErr + } + + return plan, nil + } + + if info.MargeAccountUUID != "" && IsValidAccountID(info.MargeAccountUUID) { + plan.AccountID = info.MargeAccountUUID + emit(StepGenerateAccountID, "reuse existing margeAccountUUID="+plan.AccountID, StatusOK, nil) + + return plan, nil + } + + emit(StepGenerateAccountID, "generate account ID", StatusRunning, nil) + + id, genErr := GenerateAccountID(listKnownAccountIDs(m)) + if genErr != nil { + emit(StepGenerateAccountID, "generate account ID", StatusFailed, genErr) + return plan, fmt.Errorf("generate account ID: %w", genErr) + } + + plan.AccountID = id + + emit(StepGenerateAccountID, "generate account ID="+id, StatusOK, nil) + + return plan, nil +} + +// verifyPairing re-reads /info after the state machine finished and +// confirms the device's margeAccountUUID matches what we asked for. +func (m *Manager) verifyPairing(plan InitPlan, emit func(StepKind, string, StepStatus, error)) error { emit(StepVerify, "verify /info margeAccountUUID", StatusRunning, nil) verify, err := m.GetLiveDeviceInfo(plan.DeviceIP) if err != nil { emit(StepVerify, "verify /info", StatusFailed, err) - return plan, fmt.Errorf("verify /info: %w", err) + return fmt.Errorf("verify /info: %w", err) } if verify.MargeAccountUUID != plan.AccountID { - err := fmt.Errorf("post-init /info shows margeAccountUUID=%q, want %q", verify.MargeAccountUUID, plan.AccountID) - emit(StepVerify, "verify /info", StatusFailed, err) + mismatchErr := fmt.Errorf("post-init /info shows margeAccountUUID=%q, want %q", verify.MargeAccountUUID, plan.AccountID) + emit(StepVerify, "verify /info", StatusFailed, mismatchErr) - return plan, err + return mismatchErr } emit(StepVerify, "verify /info margeAccountUUID="+plan.AccountID, StatusOK, nil) - return plan, nil + return nil } // listKnownAccountIDs collects account IDs already known to the local diff --git a/pkg/service/setup/ssh_probe_apply.go b/pkg/service/setup/ssh_probe_apply.go index d516556..9ff3d80 100644 --- a/pkg/service/setup/ssh_probe_apply.go +++ b/pkg/service/setup/ssh_probe_apply.go @@ -32,7 +32,23 @@ func (m *Manager) applyProbeToSummary( summary.CurrentConfig = fmt.Sprintf("SSH connection failed: %v", probe.Err) } - // Current SoundTouchSdkPrivateCfg.xml (+ .original backup, if any) + m.applyProbeCurrentConfig(summary, probe, plannedCfg, proxyURL, targetURL, options) + applyProbeResolvConf(summary, probe) + applyProbeRemoteServices(summary, probe) + m.applyProbeCACert(summary, probe) +} + +// applyProbeCurrentConfig populates CurrentConfig / OriginalConfig and +// parses the on-disk SoundTouchSdkPrivateCfg.xml into ParsedCurrentConfig. +// Also applies proxy options to the planned config when the caller asks +// for it — that path needs the parsed current config. +func (m *Manager) applyProbeCurrentConfig( + summary *MigrationSummary, + probe *speakerProbe, + plannedCfg *PrivateCfg, + proxyURL, targetURL string, + options map[string]string, +) { if cfg, ok := probe.Files[SoundTouchSdkPrivateCfgPath]; ok && cfg != "" { summary.CurrentConfig = cfg fmt.Printf("Current config from %s (length: %d):\n%q\n", probeDeviceTagFor(summary), len(cfg), cfg) @@ -54,47 +70,68 @@ func (m *Manager) applyProbeToSummary( if orig, ok := probe.Files[SoundTouchSdkPrivateCfgPath+".original"]; ok && orig != "" { summary.OriginalConfig = orig } +} - // /etc/resolv.conf — cached so checkIsMigratedFromProbe doesn't dial again +// applyProbeResolvConf caches the /etc/resolv.conf contents on the +// summary so checkIsMigratedFromProbe doesn't have to make another dial. +func applyProbeResolvConf(summary *MigrationSummary, probe *speakerProbe) { if resolv, ok := probe.Files["/etc/resolv.conf"]; ok { summary.CurrentResolvConf = resolv } +} - // remote_services markers (SSH enablement state) +// applyProbeRemoteServices records which remote_services marker files +// exist on the device — these toggle SSH enablement state. +func applyProbeRemoteServices(summary *MigrationSummary, probe *speakerProbe) { for _, loc := range []string{"/etc/remote_services", "/mnt/nv/remote_services", "/tmp/remote_services"} { - if probe.Exists[loc] { - summary.RemoteServicesFound = append(summary.RemoteServicesFound, loc) - summary.RemoteServicesEnabled = true + if !probe.Exists[loc] { + continue + } - if loc != "/tmp/remote_services" { - summary.RemoteServicesPersistent = true - } + summary.RemoteServicesFound = append(summary.RemoteServicesFound, loc) + summary.RemoteServicesEnabled = true + + if loc != "/tmp/remote_services" { + summary.RemoteServicesPersistent = true } } +} - // CA trust: check the ca-bundle for our injection label first; fall - // back to matching the cert payload itself when Manager.Crypto is set - // (web-UI/in-process path; the remote CLI has no Crypto so this stays - // false until install-ca is run). - if bundle, ok := probe.Files["/etc/pki/tls/certs/ca-bundle.crt"]; ok && bundle != "" { - switch { - case strings.Contains(bundle, CALabel): - summary.CACertTrusted = true - case m.Crypto != nil: - if caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath()); err == nil { - for _, line := range strings.Split(string(caCertPEM), "\n") { - if line == "" || strings.Contains(line, "BEGIN CERTIFICATE") || strings.Contains(line, "END CERTIFICATE") { - continue - } +// applyProbeCACert checks the device's CA bundle for our injection. The +// fast path is the CALabel grep (works without Manager.Crypto and is the +// only path the CLI ever uses). The fallback that compares the actual +// cert payload runs only when Manager.Crypto is configured — i.e., in +// the web-UI / in-process flow. +func (m *Manager) applyProbeCACert(summary *MigrationSummary, probe *speakerProbe) { + bundle, ok := probe.Files["/etc/pki/tls/certs/ca-bundle.crt"] + if !ok || bundle == "" { + return + } - if strings.Contains(bundle, line) { - summary.CACertTrusted = true - } + if strings.Contains(bundle, CALabel) { + summary.CACertTrusted = true + return + } - break - } - } + if m.Crypto == nil { + return + } + + caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath()) + if err != nil { + return + } + + for _, line := range strings.Split(string(caCertPEM), "\n") { + if line == "" || strings.Contains(line, "BEGIN CERTIFICATE") || strings.Contains(line, "END CERTIFICATE") { + continue } + + if strings.Contains(bundle, line) { + summary.CACertTrusted = true + } + + break } }