Files
Bose-SoundTouch/pkg/service/setup/ssh_probe_apply.go
T
Tobias GesellchenandClaude Opus 4.7 e3450ffd00 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>
2026-05-13 18:43:36 +02:00

222 lines
6.6 KiB
Go

package setup
import (
"encoding/xml"
"fmt"
"net/url"
"os"
"strings"
)
// applyProbeToSummary populates the SSH-derived fields of a
// MigrationSummary directly from a batched speakerProbe. Mirrors what
// the per-helper path (checkCurrentConfig + checkRemoteServices +
// checkCACertTrusted + the inline resolv read) used to do across
// multiple SSH dials.
//
// One subtle difference from the legacy path: the original
// checkCurrentConfig has a fallback that reads the file via base64 when
// `cat` returns empty but the file has size > 0. The batched script
// already does base64 for every file, so that fallback is implicit —
// any readable file appears in probe.Files.
func (m *Manager) applyProbeToSummary(
summary *MigrationSummary,
probe *speakerProbe,
plannedCfg *PrivateCfg,
proxyURL, targetURL string,
options map[string]string,
) {
summary.SSHSuccess = probe.SSHOK
if !probe.SSHOK && probe.Err != nil {
summary.CurrentConfig = fmt.Sprintf("SSH connection failed: %v", probe.Err)
}
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)
var currentCfg PrivateCfg
if xml.Unmarshal([]byte(cfg), &currentCfg) == nil {
summary.ParsedCurrentConfig = &currentCfg
if proxyURL == "" {
proxyURL = targetURL
}
if options != nil {
m.applyProxyOptions(plannedCfg, proxyURL, options, &currentCfg)
}
}
}
if orig, ok := probe.Files[SoundTouchSdkPrivateCfgPath+".original"]; ok && orig != "" {
summary.OriginalConfig = orig
}
}
// 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
}
}
// 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] {
continue
}
summary.RemoteServicesFound = append(summary.RemoteServicesFound, loc)
summary.RemoteServicesEnabled = true
if loc != "/tmp/remote_services" {
summary.RemoteServicesPersistent = true
}
}
}
// 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, CALabel) {
summary.CACertTrusted = true
return
}
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
}
}
// probeDeviceTagFor returns a short identifier for the device used in the
// "Current config from …" log line. We keep the legacy log shape so any
// downstream log scraping continues to work.
func probeDeviceTagFor(summary *MigrationSummary) string {
if summary.DeviceID != "" {
return summary.DeviceID
}
return "unknown"
}
// checkIsMigratedFromProbe is the probe-driven equivalent of
// checkIsMigrated. Unlike the legacy path it makes no fresh SSH dials —
// every file it inspects came from the single batched probe.
//
// Behavioural note: the legacy isResolvConfMigrated has a third fallback
// that DNS-resolves the target host via SSH (`getent` on the device) so
// it can match the *resolved* IP against resolv.conf. The probe path
// skips that — it would force a second SSH dial just for the corner
// case where the device's resolv.conf has the resolved IP but neither
// the marker comment nor the target hostname. In practice the hook file
// or marker comment is always present, so this is acceptable.
func (m *Manager) checkIsMigratedFromProbe(summary *MigrationSummary, probe *speakerProbe) {
summary.TelnetMigrated = m.isTelnetMigrated(summary)
if probe.SSHOK {
summary.XMLMigrated = m.isXMLMigrated(summary)
summary.HostsMigrated = isHostsMigratedFromProbe(probe, summary)
summary.ResolvMigrated = m.isResolvConfMigratedFromProbe(probe, summary)
}
summary.IsMigrated = summary.TelnetMigrated ||
summary.XMLMigrated ||
summary.HostsMigrated ||
summary.ResolvMigrated
}
func isHostsMigratedFromProbe(probe *speakerProbe, summary *MigrationSummary) bool {
hostsContent, ok := probe.Files["/etc/hosts"]
if !ok {
return false
}
boseDomains := []string{
"streaming.bose.com",
"updates.bose.com",
"stats.bose.com",
"bmx.bose.com",
}
for _, domain := range boseDomains {
if strings.Contains(hostsContent, domain) && summary.CACertTrusted {
return true
}
}
return false
}
func (m *Manager) isResolvConfMigratedFromProbe(probe *speakerProbe, summary *MigrationSummary) bool {
if probe.Exists["/mnt/nv/aftertouch.resolv.conf"] {
return summary.CACertTrusted
}
if summary.CurrentResolvConf == "" {
return false
}
if strings.Contains(summary.CurrentResolvConf, "# Priority nameserver for Bose service redirection") && summary.CACertTrusted {
return true
}
parsedTarget, err := url.Parse(m.ServerURL)
if err != nil {
return false
}
targetHost := parsedTarget.Hostname()
if strings.Contains(summary.CurrentResolvConf, targetHost) && summary.CACertTrusted {
return true
}
return false
}