refactor(setup): split high-complexity functions into per-axis helpers

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) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-13 18:43:36 +02:00
co-authored by Claude Opus 4.7
parent 9e384840ba
commit e3450ffd00
4 changed files with 465 additions and 329 deletions
+236 -198
View File
@@ -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 = "<HOME_SSID>"
}
}
steps = append(steps, planStep{
title: "Push home Wi-Fi credentials to the speaker",
cmd: fmt.Sprintf(`soundtouch-cli setup wifi-push --ssid=%q --pass=<HOME_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 <HOME_PASS>`, 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 = "<deviceID-suffix>"
}
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 = "<NEW_IP>" // 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 = "<HOME_SSID>"
}
}
match := ""
if inspect != nil && inspect.Info != nil {
match = deviceIDSuffix(inspect.Info.DeviceID)
}
if match == "" {
match = "<deviceID-suffix>"
}
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=<HOME_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 <HOME_PASS>`, 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.
//
+56 -43
View File
@@ -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 <clockDisplay> 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 <clockConfig> 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
+107 -59
View File
@@ -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
+66 -29
View File
@@ -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
}
}