package main import ( "bufio" "context" "fmt" "io" "net" "net/http" "net/url" "os" "strings" "syscall" "time" "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/gesellix/bose-soundtouch/pkg/service/constants" "github.com/gesellix/bose-soundtouch/pkg/service/setup" "github.com/urfave/cli/v2" "golang.org/x/term" ) // setupCommand assembles the `soundtouch-cli setup …` command group. Each // subcommand wraps an existing pkg/service/setup helper — there is no new // business logic in this file, only flag parsing and progress reporting. // // The group covers the manual provisioning loop documented in // docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md and docs/guides/DEVICE-INITIAL-SETUP.md: // // factory-reset → (manual: connect host to speaker's AP) // wifi-push → (manual: switch host back to home Wi-Fi) // wait-online → discover the speaker's new IP // urls → point the speaker at AfterTouch // pair → drive the WebSocket SETUP state machine // // The two "manual" lines are user-side Wi-Fi switches that cannot be // automated portably (macOS, Linux, and Windows each use a different // command). The wait-ap and wait-online subcommands poll for those // switches so the user doesn't need to time them manually. func setupCommand() *cli.Command { return &cli.Command{ Name: "setup", Usage: "Provision a SoundTouch speaker end-to-end (factory reset, Wi-Fi, URLs, pairing)", Subcommands: []*cli.Command{ setupInspectCmd(), setupFactoryResetCmd(), setupWiFiPushCmd(), setupWaitAPCmd(), setupWaitOnlineCmd(), setupSSHCheckCmd(), setupEnableSSHCmd(), setupRemoteServicesCmd(), setupInstallCACmd(), setupMigrateCmd(), setupRevertCmd(), setupRebootCmd(), setupVerifyCmd(), setupPlanCmd(), setupPairCmd(), setupSyncCmd(), }, } } func setupInspectCmd() *cli.Command { return &cli.Command{ Name: "inspect", Usage: "Print a non-destructive snapshot of the speaker (identity, pairing, Wi-Fi, sources, presets)", Before: RequireHost, Flags: []cli.Flag{ &cli.BoolFlag{Name: "telnet", Usage: "Also read runtime URLs via telnet getpdo (slower)"}, }, Action: func(c *cli.Context) error { cfg := GetClientConfig(c) m := setup.NewManager("", nil, nil) report := m.Inspect(cfg.Host, setup.InspectOptions{IncludeTelnet: c.Bool("telnet")}) renderInspectReport(report) if report.InfoErr != nil { return report.InfoErr } return nil }, } } 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)) 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)) 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)) 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)) 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)) 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 // have been made. We build the whole table in memory so column widths // can adjust to the widest entry — variable-length displayName and // sourceAccount values would otherwise misalign every following column. type sourceLine struct { provider string label string status string account string flags string } // renderSourceTable prints the inspect Sources block as a single tabular // view with auto-sized columns. Each row may carry a long displayName // (e.g. "AMAZON (amzn1.account.AFKTQOUN…)") or sourceAccount; rather // than truncating, we let the columns grow. func renderSourceTable(items []models.SourceItem) { if len(items) == 0 { fmt.Println(" (none)") return } rows := make([]sourceLine, 0, len(items)) for _, s := range items { account := s.SourceAccount if account == "" { account = "—" } // DisplayName is the app-facing label. Drop the parenthesised // suffix when it equals sourceAccount (pure duplication of the // next column) or the source key itself. Keep it for genuinely // distinct names like AUX (AUX IN). label := s.Source display := strings.TrimSpace(s.DisplayName) if display != "" && !strings.EqualFold(display, s.Source) && !strings.EqualFold(display, s.SourceAccount) { label = fmt.Sprintf("%s (%s)", s.Source, display) } // providerID is a cloud-catalog concept — not present on the // device's /sources response. Synthesize from // constants.StaticProviders. Local-only sources (AUX, BLUETOOTH, // LOCAL_INTERNET_RADIO) have no catalog entry by design. // Labeled "provider#N" so readers don't mistake it for a // per-source sourceID — no such field exists on /sources. provider := "provider#?" if id := lookupProviderID(s.Source); id > 0 { provider = fmt.Sprintf("provider#%d", id) } flags := "" if s.IsLocal { flags += " local" } if s.MultiroomAllowed { flags += " multiroom" } rows = append(rows, sourceLine{ provider: provider, label: label, status: "status=" + string(s.Status), account: "account=" + account, flags: strings.TrimSpace(flags), }) } maxProvider, maxLabel, maxStatus, maxAccount := 0, 0, 0, 0 for _, r := range rows { maxProvider = max(maxProvider, len(r.provider)) maxLabel = max(maxLabel, len(r.label)) maxStatus = max(maxStatus, len(r.status)) maxAccount = max(maxAccount, len(r.account)) } for _, r := range rows { fmt.Printf(" %-*s %-*s %-*s %-*s", maxProvider, r.provider, maxLabel, r.label, maxStatus, r.status, maxAccount, r.account, ) if r.flags != "" { fmt.Printf(" %s", r.flags) } fmt.Println() } } // lookupProviderID returns the AfterTouch-catalog providerID for the // given source name (TUNEIN, SPOTIFY, …), or 0 when the name is not in // constants.StaticProviders. Local-only sources like AUX, BLUETOOTH, // LOCAL_INTERNET_RADIO have no catalog entry by design — they aren't // cloud-provisioned. func lookupProviderID(sourceName string) int { for _, p := range constants.StaticProviders { if p.Name == sourceName { return p.ID } } return 0 } // deviceIDSuffix returns the last 6 characters of a SoundTouch device ID, // which is the canonical naming convention Bose uses ("Bose SoundTouch // DE4803"). Empty when the input is shorter than 6 characters. func deviceIDSuffix(deviceID string) string { if len(deviceID) < 6 { return "" } return deviceID[len(deviceID)-6:] } func setupFactoryResetCmd() *cli.Command { return &cli.Command{ Name: "factory-reset", Usage: "Issue `sys factorydefault` over telnet (wipes account, presets, Wi-Fi)", Before: RequireHost, Action: func(c *cli.Context) error { cfg := GetClientConfig(c) m := setup.NewManager("", nil, nil) fmt.Printf("Sending factory-default to %s...\n", cfg.Host) logs, err := m.FactoryReset(cfg.Host) if logs != "" { fmt.Print(logs) } if err != nil { PrintError(err.Error()) return err } PrintSuccess("Factory reset accepted. The speaker is rebooting into setup mode.") fmt.Println() fmt.Println("Heads-up: just before resetting, the speaker sends DELETE /streaming/account/{id}/device/{id} to its current marge URL. If that URL still pointed at streaming.bose.com (not AfterTouch), AfterTouch keeps a stale datastore entry — migrate the speaker first if you want a clean account/device record.") fmt.Println() fmt.Println("Next: connect this host to the speaker's Wi-Fi AP") fmt.Println(" - macOS: networksetup -setairportnetwork en0 \"Bose SoundTouch XXXX\"") fmt.Println(" - Linux: nmcli device wifi connect \"Bose SoundTouch XXXX\"") fmt.Println("Then run: soundtouch-cli setup wait-ap") return nil }, } } func setupWiFiPushCmd() *cli.Command { return &cli.Command{ Name: "wifi-push", Usage: "POST AddWirelessProfile to the speaker's setup-mode endpoint", Flags: []cli.Flag{ &cli.StringFlag{Name: "ssid", Required: true, Usage: "Home Wi-Fi SSID the speaker should join"}, &cli.StringFlag{Name: "pass", Required: true, Usage: "Home Wi-Fi password"}, &cli.StringFlag{Name: "security", Value: setup.DefaultWiFiSecurity, Usage: "Security type (wpa_or_wpa2, wep, open)"}, &cli.StringFlag{Name: "ap-host", Value: setup.SpeakerSetupAP, Usage: "Speaker's setup-mode IP"}, &cli.DurationFlag{Name: "request-timeout", Value: 30 * time.Second, Usage: "Per-request timeout (the speaker can be slow to ACK before tearing down AP mode; 10 s often races)"}, }, Action: func(c *cli.Context) error { params := setup.PushWiFiCredentialsParams{ APHost: c.String("ap-host"), SSID: c.String("ssid"), Password: c.String("pass"), Security: c.String("security"), } ctx, cancel := context.WithTimeout(c.Context, c.Duration("request-timeout")) defer cancel() fmt.Printf("Pushing Wi-Fi credentials to %s for SSID %q...\n", params.APHost, params.SSID) if err := setup.PushWiFiCredentials(ctx, params); err != nil { PrintError(err.Error()) return err } PrintSuccess("Credentials accepted. The speaker is leaving AP mode.") fmt.Println() fmt.Println("Next: switch this host back to your home Wi-Fi, then run:") fmt.Println(" soundtouch-cli setup wait-online --match=") return nil }, } } func setupWaitAPCmd() *cli.Command { return &cli.Command{ Name: "wait-ap", Usage: "Poll the speaker's setup-mode IP until /info responds", Flags: []cli.Flag{ &cli.StringFlag{Name: "ap-host", Value: setup.SpeakerSetupAP, Usage: "Speaker's setup-mode IP"}, &cli.DurationFlag{Name: "interval", Value: 2 * time.Second}, &cli.DurationFlag{Name: "timeout", Value: 5 * time.Minute}, }, Action: func(c *cli.Context) error { fmt.Printf("Waiting for %s to come up (interval=%s, timeout=%s)...\n", c.String("ap-host"), c.Duration("interval"), c.Duration("timeout")) info, err := setup.WaitForAP( c.Context, c.String("ap-host"), setup.PollConfig{Interval: c.Duration("interval"), Timeout: c.Duration("timeout")}, nil, ) if err != nil { PrintError(err.Error()) return err } PrintSuccess(fmt.Sprintf("Speaker reachable: deviceID=%s name=%q", info.DeviceID, info.Name)) return nil }, } } func setupWaitOnlineCmd() *cli.Command { return &cli.Command{ Name: "wait-online", Usage: "Poll mDNS until a speaker matching --match comes online", Flags: []cli.Flag{ &cli.StringFlag{Name: "match", Usage: "Substring matched against speaker name/serial/IP (empty = first speaker seen)"}, &cli.DurationFlag{Name: "interval", Value: 3 * time.Second}, &cli.DurationFlag{Name: "timeout", Value: 5 * time.Minute}, }, Action: func(c *cli.Context) error { fmt.Printf("Waiting for speaker matching %q via mDNS (interval=%s, timeout=%s)...\n", c.String("match"), c.Duration("interval"), c.Duration("timeout")) d, err := setup.WaitForOnline( c.Context, c.String("match"), setup.PollConfig{Interval: c.Duration("interval"), Timeout: c.Duration("timeout")}, nil, ) if err != nil { PrintError(err.Error()) return err } PrintSuccess(fmt.Sprintf("Speaker discovered: name=%q host=%s serial=%s", d.Name, d.Host, d.SerialNo)) return nil }, } } func setupSSHCheckCmd() *cli.Command { return &cli.Command{ Name: "ssh-check", Usage: "Probe whether port 22 is reachable on the speaker (we never auto-enable SSH on modern firmware)", Before: RequireHost, Flags: []cli.Flag{ &cli.DurationFlag{Name: "timeout", Value: 3 * time.Second}, }, Action: func(c *cli.Context) error { cfg := GetClientConfig(c) addr := fmt.Sprintf("%s:22", cfg.Host) fmt.Printf("Probing TCP %s (timeout=%s)...\n", addr, c.Duration("timeout")) conn, err := net.DialTimeout("tcp", addr, c.Duration("timeout")) if err != nil { PrintError(fmt.Sprintf("port 22 not reachable: %v", err)) fmt.Println() fmt.Println("Try enabling it over telnet first — this works on many (not all) FW 27.x") fmt.Println("speakers via the port-17000 envswitch trick (#471):") fmt.Println(" soundtouch-cli setup enable-ssh") fmt.Println("For stubborn devices (ST Portable, CineMate 520) where the default") fmt.Println("injection is accepted but sshd never starts, add --full-config.") fmt.Println() fmt.Println("If enable-ssh doesn't work on this device, fall back to the USB-stick method:") fmt.Println(" 1. Format a FAT32 USB stick.") fmt.Println(" 2. Create an empty file named `remote_services` at its root.") fmt.Println(" 3. Plug the stick into the speaker (rear USB port) while it is on.") fmt.Println(" 4. Wait ~30 s; the speaker imports the flag and re-enables sshd.") fmt.Println(" 5. Re-run `soundtouch-cli setup ssh-check` to confirm port 22.") fmt.Println("See docs/guides/DEVICE-INITIAL-SETUP.md and docs/analysis/TELNET-COMMAND-REFERENCE.md.") return err } _ = conn.Close() PrintSuccess("Port 22 is open — SSH is reachable.") return nil }, } } // runEnableSSHInjection runs the port-17000 SSH-enable injection over telnet, // printing the device transcript as it goes. With fullConfig it sends the // #515 sequence (all four config URLs with the injection on margeServerUrl, not // just envswitch), pausing commandDelay between each of the 6 steps (5 // commands + reboot) — see setup.DefaultTelnetCommandDelay for why the pause // exists — then reboots; otherwise it sends the single-envswitch default that // fires on the speaker's next boseurls check (no pause needed, it's one // command). func runEnableSSHInjection(m *setup.Manager, host, serviceURL string, fullConfig bool, commandDelay time.Duration) error { var ( logs string err error ) if fullConfig { // 6 steps total (5 commands + reboot), so 6 gaps between/around them. fmt.Printf("Enabling SSH on %s via telnet :17000 (full #515 sequence: all four config URLs with "+ "the injection on margeServerUrl, %s between each of 6 steps — about %s before the reboot fires "+ "— then reboot)...\n", host, commandDelay, 6*commandDelay) logs, err = m.EnableSSHViaTelnetFullConfig(host, serviceURL, commandDelay) } else { fmt.Printf("Enabling SSH on %s via telnet :17000 (runs on the speaker's next boseurls check, up to ~60s)...\n", host) logs, err = m.EnableSSHViaTelnet(host, serviceURL) } if logs != "" { fmt.Print(logs) } if err != nil { PrintError(err.Error()) return err } if !fullConfig { return nil } if commandDelay > 0 { time.Sleep(commandDelay) } fmt.Println("Rebooting the speaker to apply the new configuration...") rlogs, rerr := m.Reboot(host, setup.RebootMethodTelnet) if rlogs != "" { fmt.Print(rlogs) } if rerr != nil { PrintError(rerr.Error()) return rerr } return nil } // ensureMargeAccountPaired checks /info and pairs an unpaired device before // the SSH-enable injection runs — see setup.EnsureMargeAccountPaired for why. // Pairing failure is logged as a warning, not fatal: the claim that an // unpaired device never polls margeServerUrl is not yet confirmed on every // device this command targets, so the injection is still worth attempting // even if the pairing step itself couldn't be verified. func ensureMargeAccountPaired(m *setup.Manager, deviceIP, wantAccountID string) { var t setup.TelnetClient if m.NewTelnet != nil { t = m.NewTelnet(deviceIP) if dialErr := t.Dial(); dialErr != nil { t = nil } else { defer func() { _ = t.Close() }() } } accountID, alreadyPaired, logs, err := m.EnsureMargeAccountPaired(deviceIP, wantAccountID, t) if logs != "" { fmt.Print(logs) } switch { case err != nil: PrintWarning(fmt.Sprintf("Pairing check failed (%v) — continuing anyway; the SSH-enable injection may not "+ "fire on an unpaired device (#515).", err)) case alreadyPaired: fmt.Printf("Device already paired (margeAccountUUID=%s).\n", accountID) default: fmt.Printf("Device was unpaired — paired it with generated account %s so margeServerUrl gets polled (#515).\n", accountID) } } func setupEnableSSHCmd() *cli.Command { return &cli.Command{ Name: "enable-ssh", Usage: "Bootstrap SSH on a speaker with no prior access via the port-17000 envswitch trick (#471), " + "then restore clean URLs and persist it", Before: RequireHost, Flags: []cli.Flag{ &cli.StringFlag{ Name: "service-url", Usage: "AfterTouch service base URL to point the speaker at (e.g. https://192.0.2.10:8443). " + "Optional: enabling SSH does not need a live server (the injection fires when the speaker " + "parses its boseurls), so you can omit this now and set the real URLs later via migration", }, &cli.DurationFlag{ Name: "wait", Value: 90 * time.Second, Usage: "How long to wait for sshd (:22) after the envswitch injection (it runs on the speaker's next boseurls check, ~60s)", }, &cli.BoolFlag{ Name: "full-config", Usage: "For stubborn devices (ST Portable, CineMate 520) where the default single-envswitch injection is accepted but sshd never starts: " + "replicate the #515 manual sequence — write all four sys configuration URL keys with the SSH-enable injection on margeServerUrl (not just envswitch), then reboot", }, &cli.DurationFlag{ Name: "command-delay", Value: setup.DefaultTelnetCommandDelay, Usage: "Only affects --full-config: pause between each of its 6 steps (5 commands + reboot). " + "Raise this if the default doesn't work on your device; 0 sends everything back-to-back", }, &cli.BoolFlag{ Name: "no-auto-pair", Usage: "Skip the automatic pairing check: by default, enable-ssh reads /info first and pairs an unpaired " + "(factory-reset) device with an account ID, since an unpaired device reportedly never " + "polls margeServerUrl at all (#515) — the injection would have nothing to fire on otherwise", }, &cli.StringFlag{ Name: "account", Usage: "Only used when the device is unpaired and --no-auto-pair is not set: 7-digit account ID to pair " + "with (empty = generate one). Use this if you already know which account this device should end up " + "on (e.g. to match one already in the datastore) rather than getting a random one now", }, &cli.BoolFlag{ Name: "no-reset-urls", Usage: "Skip restoring clean boseurls after SSH is up (leaves the injected marge URL in place)", }, &cli.BoolFlag{ Name: "no-persist", Usage: "Skip persisting the remote_services marker (SSH would not survive a reboot)", }, &cli.StringFlag{ Name: "authorized-key", Usage: "Opt-in hardening: install this SSH public key for root (key auth instead of the empty-password login). Pass the key text, e.g. --authorized-key \"$(cat id_ed25519.pub)\"", }, &cli.BoolFlag{ Name: "close-17000", Usage: "Opt-in hardening: block port 17000 from the LAN (firewall rule applied now + persisted); loopback access is kept", }, }, Action: func(c *cli.Context) error { cfg := GetClientConfig(c) m := setup.NewManager("", nil, nil) // The URL is only the vehicle for the command injection; the // SSH-enable fires when the speaker parses its boseurls, whether // or not anything answers there. When the user has no service URL // yet, use a clearly-placeholder value and tell them to set the // real URLs during migration. serviceURL := c.String("service-url") placeholder := serviceURL == "" if placeholder { serviceURL = "https://aftertouch.invalid" } if !c.Bool("no-auto-pair") { ensureMargeAccountPaired(m, cfg.Host, c.String("account")) } if err := runEnableSSHInjection(m, cfg.Host, serviceURL, c.Bool("full-config"), c.Duration("command-delay")); err != nil { return err } fmt.Printf("Waiting up to %s for sshd (:22) to come up...\n", c.Duration("wait")) if err := setup.WaitForSSHPort(cfg.Host, c.Duration("wait")); err != nil { // Not a hard failure: on some devices (e.g. the Wireless Link // Adapter, see #471) the envswitch injection is accepted but // sshd only actually starts after the speaker restarts. We // deliberately leave the injected boseurls in place (no reset) // so a power-cycle re-triggers the unlock, and guide the user // to reboot and retry rather than exiting with an error. fmt.Println() PrintWarning(fmt.Sprintf("sshd (:22) did not come up within %s, but the speaker accepted the SSH-enable command.", c.Duration("wait"))) fmt.Println("On some devices sshd only starts after a restart. Next steps:") fmt.Println(" 1. Power-cycle the speaker (unplug it, wait a few seconds, plug it back in).") fmt.Println(" 2. Once it is back online, run this same command again, or just connect with:") fmt.Printf(" ssh -o HostKeyAlgorithms=+ssh-rsa,ssh-dss root@%s\n", cfg.Host) fmt.Println("The temporary boseurls were left in place on purpose, so the restart re-triggers the unlock.") if placeholder { fmt.Println("(No --service-url was given; you'll set the real service URLs later during migration.)") } return nil } PrintSuccess("SSH is up on " + cfg.Host) if !c.Bool("no-reset-urls") { fmt.Println("Restoring clean boseurls (so the marge URL is usable again)...") rlogs, rerr := m.ResetBoseURLs(cfg.Host, serviceURL) if rlogs != "" { fmt.Print(rlogs) } if rerr != nil { PrintError(rerr.Error()) return rerr } } if !c.Bool("no-persist") { fmt.Println("Persisting the remote_services marker (SSH survives reboot)...") plogs, perr := m.EnsureRemoteServices(cfg.Host) if plogs != "" { fmt.Print(plogs) } if perr != nil { PrintError(perr.Error()) return perr } } if key := c.String("authorized-key"); key != "" { fmt.Println("Installing authorized_keys for root (key auth)...") klogs, kerr := m.InstallAuthorizedKey(cfg.Host, key) if klogs != "" { fmt.Print(klogs) } if kerr != nil { PrintError(kerr.Error()) return kerr } } closed17000 := c.Bool("close-17000") if closed17000 { fmt.Println("Closing port 17000 to the LAN (loopback kept)...") clogs, cerr := m.Close17000(cfg.Host) if clogs != "" { fmt.Print(clogs) } if cerr != nil { PrintError(cerr.Error()) return cerr } } PrintSuccess("Done — SSH enabled on " + cfg.Host + ". From here, the usual migration / CA-install / inspect commands work.") if placeholder { fmt.Println("No --service-url was given, so the speaker's boseurls now point at a placeholder; run your migration next to set the real service URLs.") } if closed17000 { fmt.Println("Port 17000 is now blocked from the LAN (loopback kept).") } else { fmt.Println("Note: port 17000 is left open (opt-in --close-17000 to block it from the LAN).") } return nil }, } } func setupRemoteServicesCmd() *cli.Command { return &cli.Command{ Name: "remote-services", Usage: "Enable (default) or disable the remote_services SSH-enablement marker on the speaker", Before: RequireHost, Flags: []cli.Flag{ &cli.BoolFlag{ Name: "remove", Usage: "Remove all remote_services marker files (disables SSH after next reboot)", }, }, Action: func(c *cli.Context) error { cfg := GetClientConfig(c) m := setup.NewManager("", nil, nil) var ( logs string err error ) if c.Bool("remove") { logs, err = m.RemoveRemoteServices(cfg.Host) } else { logs, err = m.EnsureRemoteServices(cfg.Host) } if logs != "" { fmt.Print(logs) } if err != nil { PrintError(err.Error()) return err } if c.Bool("remove") { PrintSuccess("remote_services removed — SSH will no longer be enabled after next reboot") } else { PrintSuccess("remote_services enabled at a persistent location") } return nil }, } } func setupInstallCACmd() *cli.Command { return &cli.Command{ Name: "install-ca", Usage: "Fetch AfterTouch's CA cert and inject it into the speaker's trust store via SSH", Before: RequireHost, Flags: []cli.Flag{ &cli.StringFlag{Name: "service-url", Required: true, Usage: "AfterTouch base URL"}, &cli.StringFlag{Name: "auth", Usage: "Basic-auth credentials for AfterTouch as user:pass (omit to be prompted on 401)"}, }, Action: func(c *cli.Context) error { cfg := GetClientConfig(c) serviceURL := strings.TrimRight(c.String("service-url"), "/") if err := validateServiceURL(serviceURL); err != nil { PrintError(err.Error()) return err } certPEM, err := fetchCACert(serviceURL, c.String("auth")) if err != nil { PrintError(err.Error()) return err } fmt.Printf("Fetched %d bytes of CA PEM from %s/api/setup/ca.crt\n", len(certPEM), serviceURL) m := setup.NewManager(serviceURL, nil, nil) logs, err := m.TrustCACertFromBytes(cfg.Host, certPEM) if logs != "" { fmt.Print(logs) } if err != nil { PrintError(err.Error()) return err } PrintSuccess("CA certificate installed in the speaker's trust bundle.") return nil }, } } // fetchCACert pulls AfterTouch's CA bundle from /api/setup/ca.crt. On HTTP 401 // it prompts interactively for basic-auth credentials (or accepts --auth) // and retries once. func fetchCACert(serviceURL, authFlag string) ([]byte, error) { url := serviceURL + "/api/setup/ca.crt" doRequest := func(user, pass string) (*http.Response, error) { req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, err } if user != "" { req.SetBasicAuth(user, pass) } client := &http.Client{Timeout: 10 * time.Second} return client.Do(req) } user, pass := splitAuth(authFlag) resp, err := doRequest(user, pass) if err != nil { return nil, fmt.Errorf("GET %s: %w", url, err) } if resp.StatusCode == http.StatusUnauthorized { _ = resp.Body.Close() fmt.Printf("%s requires basic auth.\n", url) user, pass, err = promptBasicAuth() if err != nil { return nil, err } resp, err = doRequest(user, pass) if err != nil { return nil, fmt.Errorf("GET %s (with auth): %w", url, err) } } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("GET %s returned %d: %s", url, resp.StatusCode, strings.TrimSpace(string(body))) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read %s: %w", url, err) } if !strings.Contains(string(body), "BEGIN CERTIFICATE") { return nil, fmt.Errorf("response from %s is not a PEM certificate", url) } return body, nil } func splitAuth(spec string) (string, string) { if spec == "" { return "", "" } i := strings.Index(spec, ":") if i < 0 { return spec, "" } return spec[:i], spec[i+1:] } // promptBasicAuth asks the user for credentials when AfterTouch returns 401. // The password is read from stdin without echo so it doesn't end up in shell // history or screen scrollback. func promptBasicAuth() (string, string, error) { fmt.Fprint(os.Stderr, "Username: ") reader := bufio.NewReader(os.Stdin) user, err := reader.ReadString('\n') if err != nil { return "", "", fmt.Errorf("read username: %w", err) } user = strings.TrimRight(user, "\r\n") fmt.Fprint(os.Stderr, "Password: ") // syscall.Stdin is int on Unix but syscall.Handle on Windows; the cast is // required for the Windows cross-compile. pass, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert fmt.Fprintln(os.Stderr) if err != nil { return "", "", fmt.Errorf("read password: %w", err) } return user, string(pass), nil } // setupSyncCmd wraps POST /api/setup/sync/{deviceId} — the same operation // as the web UI's Devices → Sync Data button. It only reads from the // speaker (presets, recents, sources) into AfterTouch's datastore; it never // writes anything back to the speaker. Useful for scripting or reproducing // what Sync does in isolation (see issue #614: Sync's own code cannot wipe // the speaker's preset table, since it never sends anything back). func setupSyncCmd() *cli.Command { return &cli.Command{ Name: "sync", Usage: "Pull presets/recents/sources from the speaker into AfterTouch's datastore (same as the web UI's \"Sync Data\" button)", Before: RequireHost, Flags: []cli.Flag{ &cli.StringFlag{Name: "service-url", Required: true, Usage: "AfterTouch base URL"}, &cli.StringFlag{Name: "auth", Usage: "Basic-auth credentials for AfterTouch as user:pass (omit to be prompted on 401)"}, }, Action: func(c *cli.Context) error { cfg := GetClientConfig(c) serviceURL := strings.TrimRight(c.String("service-url"), "/") if err := validateServiceURL(serviceURL); err != nil { PrintError(err.Error()) return err } client, err := CreateSoundTouchClient(cfg) if err != nil { PrintError(fmt.Sprintf("Failed to create client: %v", err)) return err } deviceInfo, err := client.GetDeviceInfo() if err != nil { PrintError(fmt.Sprintf("Failed to get device info from speaker: %v", err)) return err } if deviceInfo.DeviceID == "" { err := fmt.Errorf("speaker at %s did not report a DeviceID", cfg.Host) PrintError(err.Error()) return err } PrintDeviceHeader(fmt.Sprintf("Syncing %s into AfterTouch", deviceInfo.DeviceID), cfg.Host, cfg.Port) if err := postSetupSync(serviceURL, deviceInfo.DeviceID, c.String("auth")); err != nil { PrintError(err.Error()) return err } PrintSuccess(fmt.Sprintf("Synced presets, recents, and sources for %s.", deviceInfo.DeviceID)) return nil }, } } // postSetupSync POSTs to AfterTouch's /api/setup/sync/{deviceId}, prompting // for basic-auth credentials on 401 (matches fetchCACert's pattern). func postSetupSync(serviceURL, deviceID, authFlag string) error { endpoint := fmt.Sprintf("%s/api/setup/sync/%s", serviceURL, deviceID) doRequest := func(user, pass string) (*http.Response, error) { req, err := http.NewRequest(http.MethodPost, endpoint, nil) if err != nil { return nil, err } if user != "" { req.SetBasicAuth(user, pass) } client := &http.Client{Timeout: 30 * time.Second} return client.Do(req) } user, pass := splitAuth(authFlag) resp, err := doRequest(user, pass) if err != nil { return fmt.Errorf("POST %s: %w", endpoint, err) } if resp.StatusCode == http.StatusUnauthorized { _ = resp.Body.Close() fmt.Printf("%s requires basic auth.\n", endpoint) user, pass, err = promptBasicAuth() if err != nil { return err } resp, err = doRequest(user, pass) if err != nil { return fmt.Errorf("POST %s (with auth): %w", endpoint, err) } } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("POST %s returned %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body))) } return nil } func setupMigrateCmd() *cli.Command { return &cli.Command{ Name: "migrate", Usage: "Apply a migration method (telnet|hosts|resolv|xml) to point the speaker at AfterTouch", Before: RequireHost, Flags: []cli.Flag{ &cli.StringFlag{Name: "service-url", Required: true, Usage: "AfterTouch base URL"}, &cli.StringFlag{Name: "method", Value: string(setup.MigrationMethodTelnet), Usage: "telnet | hosts | resolv | xml"}, &cli.StringFlag{Name: "proxy-url", Usage: "Optional upstream proxy URL (for --method=xml)"}, &cli.BoolFlag{Name: "skip-preflight", Usage: "Skip the AfterTouch settings preflight (use when AfterTouch's settings endpoint is unreachable)"}, &cli.StringFlag{Name: "marge-url", Usage: "Override margeServerUrl instead of deriving it from --service-url (e.g. to restore the original Bose cloud URL). Applies to --method=telnet and --method=xml"}, &cli.StringFlag{Name: "stats-url", Usage: "Override statsServerUrl (telnet/xml)"}, &cli.StringFlag{Name: "sw-update-url", Usage: "Override swUpdateUrl (telnet/xml)"}, &cli.StringFlag{Name: "bmx-url", Usage: "Override bmxRegistryUrl (telnet/xml)"}, }, Action: func(c *cli.Context) error { cfg := GetClientConfig(c) method := setup.MigrationMethod(c.String("method")) serviceURL := c.String("service-url") if err := validateServiceURL(serviceURL); err != nil { PrintError(err.Error()) return err } options := map[string]string{ "marge_url": c.String("marge-url"), "stats_url": c.String("stats-url"), "sw_update_url": c.String("sw-update-url"), "bmx_url": c.String("bmx-url"), } m := setup.NewManager(serviceURL, nil, nil) // For DNS-redirect methods check that AfterTouch's DNS listener // is reachable — both from this machine and from the speaker. if !c.Bool("skip-preflight") && (method == setup.MigrationMethodResolvConf || method == setup.MigrationMethodHosts) { if err := runDNSPreflight(cfg.Host, serviceURL, m.NewSSH); err != nil { PrintError(err.Error()) return err } // The migration's internal CA-install step uses // Manager.Crypto which is nil in the CLI flow. Pre-install // the cert via the same HTTP path as `setup install-ca` // so the migration finds CACertTrusted=true and skips. // Idempotent — TrustCACertFromBytes deduplicates by label. if err := preInstallCAForCLI(cfg.Host, serviceURL); err != nil { PrintError(err.Error()) return err } } fmt.Printf("Migrating %s → %s using method=%s\n", cfg.Host, serviceURL, method) logs, err := m.MigrateSpeaker(cfg.Host, serviceURL, c.String("proxy-url"), options, method) if logs != "" { fmt.Print(logs) } if err != nil { PrintError(err.Error()) return err } PrintSuccess(fmt.Sprintf("Migration committed (method=%s). Reboot the speaker to apply the next-boot persistence layer.", method)) return nil }, } } // preInstallCAForCLI fetches AfterTouch's CA cert and SSH-injects it // into the speaker. Mirrors what `setup install-ca` does but inline, so // the subsequent setup.MigrateSpeaker call (which would otherwise try // to install the CA via the nil Manager.Crypto and NPE) finds the cert // already trusted and skips its internal install step. // // All operations are idempotent: TrustCACertFromBytes rebuilds the // bundle stripping any prior CALabel block before re-appending. func preInstallCAForCLI(deviceIP, serviceURL string) error { certPEM, err := fetchCACert(serviceURL, "") if err != nil { return fmt.Errorf("pre-install CA: %w", err) } m := setup.NewManager(serviceURL, nil, nil) if _, err := m.TrustCACertFromBytes(deviceIP, certPEM); err != nil { return fmt.Errorf("pre-install CA: %w", err) } return nil } // validateServiceURL returns an error if serviceURL cannot be parsed or has no // hostname. A common mistake is a single-slash scheme (https:/host instead of // https://host); the error message hints at the correction in that case. func validateServiceURL(serviceURL string) error { parsed, err := url.Parse(serviceURL) if err != nil { return fmt.Errorf("invalid --service-url %q: %w", serviceURL, err) } if parsed.Hostname() == "" { hint := "" if parsed.Scheme != "" && parsed.Opaque != "" { hint = fmt.Sprintf(" (did you mean %s://%s?)", parsed.Scheme, strings.TrimPrefix(parsed.Opaque, "/")) } return fmt.Errorf("invalid --service-url %q: no hostname found%s", serviceURL, hint) } return nil } // dnsCheckResult holds the outcome of one DNS reachability probe. type dnsCheckResult struct { ok bool unknown bool // SSH unavailable or nslookup not present — result indeterminate detail string // "works" on success, error reason otherwise } func (r dnsCheckResult) label() string { switch { case r.ok: return "✓ works" case r.unknown: return "? " + r.detail default: return "✗ " + r.detail } } // cliDNSCheck sends a real DNS query for streaming.bose.com through the // AfterTouch DNS listener to verify it is alive from this machine. func cliDNSCheck(dnsHost string) dnsCheckResult { resolver := &net.Resolver{ PreferGo: true, Dial: func(ctx context.Context, _, _ string) (net.Conn, error) { d := net.Dialer{Timeout: 3 * time.Second} return d.DialContext(ctx, "udp", net.JoinHostPort(dnsHost, "53")) }, } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() ips, err := resolver.LookupHost(ctx, "streaming.bose.com") if err != nil { return dnsCheckResult{detail: err.Error()} } if len(ips) == 0 { return dnsCheckResult{detail: "no answers for streaming.bose.com — listener may be misconfigured"} } return dnsCheckResult{ok: true, detail: "works"} } // speakerDNSCheck SSHes into the speaker and runs nslookup streaming.bose.com // against the AfterTouch DNS server to verify reachability from the device. func speakerDNSCheck(deviceIP, dnsHost string, newSSH func(string) setup.SSHClient) dnsCheckResult { addrs, err := net.LookupHost(dnsHost) if err != nil || len(addrs) == 0 { return dnsCheckResult{unknown: true, detail: fmt.Sprintf("cannot resolve %s locally to run speaker-side check", dnsHost)} } dnsIP := addrs[0] client := newSSH(deviceIP) out, sshErr := client.Run(fmt.Sprintf("nslookup streaming.bose.com %s", dnsIP)) if sshErr != nil { if strings.Contains(out, "not found") || strings.Contains(out, "No such file") { return dnsCheckResult{unknown: true, detail: "nslookup not available on speaker"} } if strings.Contains(sshErr.Error(), "dial") || strings.Contains(sshErr.Error(), "connect") { return dnsCheckResult{unknown: true, detail: fmt.Sprintf("SSH unavailable: %s", sshErr)} } msg := strings.TrimSpace(out) if msg == "" { msg = sshErr.Error() } return dnsCheckResult{detail: msg} } return dnsCheckResult{ok: true, detail: "works"} } // runDNSPreflight checks AfterTouch DNS reachability from both the CLI machine // and the speaker, prints a table, and returns an error only when the speaker // side definitively cannot reach the DNS listener (CLI-only failures are // informational — the speaker's perspective is authoritative). func runDNSPreflight(deviceIP, serviceURL string, newSSH func(string) setup.SSHClient) error { parsed, _ := url.Parse(serviceURL) dnsHost := parsed.Hostname() type result struct { cli dnsCheckResult speaker dnsCheckResult } ch := make(chan result, 1) go func() { cliCh := make(chan dnsCheckResult, 1) speakerCh := make(chan dnsCheckResult, 1) go func() { cliCh <- cliDNSCheck(dnsHost) }() go func() { speakerCh <- speakerDNSCheck(deviceIP, dnsHost, newSSH) }() ch <- result{cli: <-cliCh, speaker: <-speakerCh} }() r := <-ch if r.cli.ok && r.speaker.ok { fmt.Printf("DNS preflight (%s:53) ✓ works\n", dnsHost) return nil } fmt.Printf("DNS preflight (%s:53)\n", dnsHost) fmt.Printf(" CLI host %s\n", r.cli.label()) fmt.Printf(" Speaker %s\n", r.speaker.label()) fmt.Println() if !r.speaker.ok && !r.speaker.unknown { return fmt.Errorf("AfterTouch DNS unreachable from speaker — %s migration would fail", serviceURL) } if r.speaker.unknown && !r.cli.ok { return fmt.Errorf("cannot confirm DNS reachability (SSH unavailable from speaker, CLI probe also failed) — use --skip-preflight to bypass") } return nil } func setupVerifyCmd() *cli.Command { return &cli.Command{ Name: "verify", Usage: "Read-only status probe across all migration axes — doubles as preflight before applying " + "and verification after applying. Wraps Manager.GetMigrationSummary.", Before: RequireHost, Flags: []cli.Flag{ &cli.StringFlag{Name: "service-url", Required: true, Usage: "AfterTouch base URL"}, &cli.StringFlag{Name: "proxy-url", Usage: "Optional upstream proxy URL (matches the --method=xml path)"}, }, Action: func(c *cli.Context) error { cfg := GetClientConfig(c) serviceURL := c.String("service-url") if err := validateServiceURL(serviceURL); err != nil { PrintError(err.Error()) return err } m := setup.NewManager(serviceURL, nil, nil) summary, err := m.GetMigrationSummary(cfg.Host, serviceURL, c.String("proxy-url"), nil) if err != nil { PrintError(err.Error()) return err } renderMigrationSummary(cfg.Host, serviceURL, summary) // Non-zero exit when nothing is migrated so this command is // usable as a CI gate. Warnings alone don't fail the call. if !summary.IsMigrated { return fmt.Errorf("speaker is not migrated (no method reports green)") } return nil }, } } // renderMigrationSummary prints a grouped, glyph-prefixed view of a // MigrationSummary. Each line uses [✓] / [✗] / ⚠ to make scan-reading // fast. Identical data shape to what the web UI's preflight panel // renders — see pkg/service/handlers/web/js/script.js. func renderMigrationSummary(deviceIP, serviceURL string, s *setup.MigrationSummary) { checkmark := func(b bool) string { if b { return "[✓]" } return "[✗]" } fmt.Printf("Status @ %s (target: %s)\n", deviceIP, serviceURL) fmt.Println(strings.Repeat("═", 60)) fmt.Println("Identity") fmt.Printf(" deviceID : %s\n", s.DeviceID) fmt.Printf(" name : %s\n", s.DeviceName) fmt.Printf(" model : %s\n", s.DeviceModel) fmt.Printf(" firmware : %s\n", s.FirmwareVersion) fmt.Printf(" serial : %s\n", s.DeviceSerial) fmt.Println() fmt.Println("Pairing") if s.IsPaired { fmt.Printf(" [✓] paired (margeAccountUUID=%s)\n", s.AccountID) } else { fmt.Println(" [✗] unpaired — device reports empty margeAccountUUID") } fmt.Println() fmt.Println("Transport") fmt.Printf(" %s SSH (port 22, full system access)\n", checkmark(s.SSHSuccess)) fmt.Printf(" %s Telnet (port 17000, diagnostic shell)\n", checkmark(s.TelnetReachable)) if s.TelnetBanner != "" { fmt.Printf(" banner: %q\n", s.TelnetBanner) } if s.TelnetProbeError != "" { fmt.Printf(" probe error: %s\n", s.TelnetProbeError) } fmt.Println() fmt.Println("SSH enablement (remote_services)") fmt.Printf(" %s enabled\n", checkmark(s.RemoteServicesEnabled)) fmt.Printf(" %s persistent across reboot\n", checkmark(s.RemoteServicesPersistent)) if len(s.RemoteServicesFound) > 0 { fmt.Printf(" found: %s\n", strings.Join(s.RemoteServicesFound, ", ")) } if s.RemoteServicesCheckErr != "" { fmt.Printf(" probe error: %s\n", s.RemoteServicesCheckErr) } fmt.Println() fmt.Println("CA certificate") fmt.Printf(" %s AfterTouch CA trusted by the speaker\n", checkmark(s.CACertTrusted)) if s.ServerHTTPSURL != "" { fmt.Printf(" HTTPS endpoint: %s\n", s.ServerHTTPSURL) } fmt.Println() fmt.Printf("Migration state (overall: %s migrated)\n", checkmark(s.IsMigrated)) fmt.Printf(" %s telnet (envswitch + sys configuration)\n", checkmark(s.TelnetMigrated)) fmt.Printf(" %s xml (SoundTouchSdkPrivateCfg.xml on disk)\n", checkmark(s.XMLMigrated)) fmt.Printf(" %s hosts (/etc/hosts entries — deprecated)\n", checkmark(s.HostsMigrated)) fmt.Printf(" %s resolv (/etc/resolv.conf via DHCP hook)\n", checkmark(s.ResolvMigrated)) fmt.Println() if len(s.Warnings) > 0 { fmt.Println("Warnings") for _, w := range s.Warnings { PrintWarning(w) } fmt.Println() } if s.ResolveIPError != "" { PrintError("Resolve IP error: " + s.ResolveIPError) } // Observability for the IP-resolve path. Source tells the user whether // the speaker itself was consulted (authoritative) or only the service // (best-effort). DurationMS lets us watch the SSH-ping cost trend in // the wild — historical comment claimed 2-5 s on firmware 27, worth // re-evaluating as data accumulates. if s.ResolveIPSource != "" { fmt.Printf("Resolve IP source : %s (%d ms)\n", s.ResolveIPSource, s.ResolveIPDurationMS) } else if s.ResolveIPDurationMS > 0 { fmt.Printf("Resolve IP : %d ms\n", s.ResolveIPDurationMS) } } // setupRevertCmd wraps setup.Manager.RevertMigration — the same operation // as the web UI's "Revert to Defaults" button (Migrate tab). Restores // SoundTouchSdkPrivateCfg.xml, /etc/hosts, and /etc/resolv.conf from their // .original backups, removes the AfterTouch DNS-hook artifacts, and strips // just the AfterTouch-labeled cert out of the trust bundle. No --service-url // needed: everything it touches already lives on the speaker. // // Deliberately out of scope (matches the web UI button): SSH/remote_services // persistence (use `setup remote-services --remove`) and account pairing // (use `account unpair`) — see #614 self-test notes for the full checklist. func setupRevertCmd() *cli.Command { return &cli.Command{ Name: "revert", Usage: "Undo a migration: restore SoundTouchSdkPrivateCfg.xml/hosts/resolv.conf from backups and remove the AfterTouch CA cert", Before: RequireHost, Action: func(c *cli.Context) error { cfg := GetClientConfig(c) m := setup.NewManager("", nil, nil) fmt.Printf("Reverting migration on %s...\n", cfg.Host) logs, err := m.RevertMigration(cfg.Host) if logs != "" { fmt.Print(logs) } if err != nil { PrintError(err.Error()) return err } PrintSuccess("Migration reverted. SSH access and account pairing are untouched by this — " + "see `setup remote-services --remove` and `account unpair` if you want those cleared too.") return nil }, } } func setupRebootCmd() *cli.Command { return &cli.Command{ Name: "reboot", Usage: "Reboot the speaker (forces the envswitch parallel-persistence layer to apply)", Before: RequireHost, Flags: []cli.Flag{ &cli.StringFlag{ Name: "method", Value: string(setup.RebootMethodTelnet), Usage: "ssh | telnet — telnet works without SSH on modern firmware", }, }, Action: func(c *cli.Context) error { cfg := GetClientConfig(c) method := setup.RebootMethod(c.String("method")) m := setup.NewManager("", nil, nil) fmt.Printf("Rebooting %s via %s...\n", cfg.Host, method) logs, err := m.Reboot(cfg.Host, method) if logs != "" { fmt.Print(logs) } if err != nil { PrintError(err.Error()) return err } PrintSuccess("Reboot signal sent. The speaker will be unreachable for ~30 s.") return nil }, } } // planStep is one recommended action in the output of `setup plan`. // title and reason go to the human; cmd is the exact CLI line to run. // manual=true means the user has to do it themselves (network switch). type planStep struct { title string cmd string reason string manual bool } func setupPlanCmd() *cli.Command { return &cli.Command{ Name: "plan", Usage: "Recommend the next setup/migration steps based on inspect + verify state. " + "Use --reset to plan a full factory-reset + re-provisioning sequence.", Before: RequireHost, Flags: []cli.Flag{ &cli.StringFlag{Name: "service-url", Required: true, Usage: "AfterTouch base URL"}, &cli.BoolFlag{Name: "reset", Usage: "Plan a full factory-reset → Wi-Fi → migrate → pair flow"}, &cli.StringFlag{Name: "wifi-ssid", Usage: "Home Wi-Fi SSID for the wifi-push step (default: re-use the SSID inspect found)"}, &cli.BoolFlag{Name: "include-pair", Value: true, Usage: "Include the pair step (disable if you'll pair manually)"}, }, Action: func(c *cli.Context) error { cfg := GetClientConfig(c) serviceURL := c.String("service-url") reset := c.Bool("reset") wifiSSID := c.String("wifi-ssid") includePair := c.Bool("include-pair") if err := validateServiceURL(serviceURL); err != nil { PrintError(err.Error()) return err } m := setup.NewManager(serviceURL, nil, nil) fmt.Printf("Probing %s …\n\n", cfg.Host) inspectReport := m.Inspect(cfg.Host, setup.InspectOptions{}) summary, summaryErr := m.GetMigrationSummary(cfg.Host, serviceURL, "", nil) renderPlanState(cfg.Host, inspectReport, summary, summaryErr) fmt.Println() if inspectReport.InfoErr != nil && !reset { PrintError("Speaker not reachable on :8090 — can't plan a non-reset migration.") fmt.Println("Either check the network / IP, or run again with --reset for the factory-reset path.") return inspectReport.InfoErr } steps := buildPlanSteps(cfg.Host, serviceURL, wifiSSID, includePair, reset, inspectReport, summary) if reset { renderPreResetNote() } renderPlanSteps(steps, includePair) return nil }, } } // renderPlanState prints the brief one-screen status used at the top of // `setup plan`. It's a deliberately compact summary — for the full // inspect output use `setup inspect`, for the full verify output use // `setup verify`. func renderPlanState(deviceIP string, inspect *setup.InspectReport, summary *setup.MigrationSummary, summaryErr error) { check := func(b bool) string { if b { return "[✓]" } return "[✗]" } fmt.Printf("State @ %s\n", deviceIP) fmt.Println(strings.Repeat("─", 40)) if inspect.Info != nil { i := inspect.Info fmt.Printf(" deviceID=%s name=%q firmware=%s\n", i.DeviceID, i.Name, firmwareOf(i)) fmt.Printf(" margeURL=%s\n", i.MargeURL) } else { PrintError(fmt.Sprintf("/info: %v", inspect.InfoErr)) } if summaryErr != nil { PrintError(fmt.Sprintf("verify: %v", summaryErr)) return } currentSSID := "" if inspect.Network != nil { for i := range inspect.Network.Interfaces.Interfaces { if ssid := inspect.Network.Interfaces.Interfaces[i].SSID; ssid != "" { currentSSID = ssid break } } } if currentSSID != "" { fmt.Printf(" ssid=%q\n", currentSSID) } fmt.Printf(" %s SSH (port 22) %s Telnet (port 17000) %s CA trusted\n", check(summary.SSHSuccess), check(summary.TelnetReachable), check(summary.CACertTrusted)) fmt.Printf(" %s paired %s migrated (telnet=%s xml=%s hosts=%s resolv=%s)\n", check(summary.IsPaired), check(summary.IsMigrated), yesNo(summary.TelnetMigrated), yesNo(summary.XMLMigrated), yesNo(summary.HostsMigrated), yesNo(summary.ResolvMigrated)) if summary.RemoteServicesEnabled && !summary.RemoteServicesPersistent { fmt.Println(" [⚠] remote_services enabled but not persistent (will be lost on reboot)") } } func firmwareOf(info *setup.DeviceInfoXML) string { for _, c := range info.Components { if c.SoftwareVersion != "" { return c.SoftwareVersion } } return "" } func yesNo(b bool) string { if b { return "y" } return "n" } // buildPlanSteps is the recommendation engine. Decision order: // // 1. --reset path adds factory-reset → manual AP switch → wait-ap → // wifi-push → manual home-Wi-Fi switch → wait-online at the top. // 2. If already migrated and paired (and not --reset): empty plan. // 3. Otherwise pick a migration method based on capabilities: // telnet first (no SSH required), resolv second (DNS-based, needs // SSH + CA), USB-stick advice when no transport works. // 4. Append pair step unless --include-pair=false or already paired. func buildPlanSteps( host, serviceURL, wifiSSID string, includePair, reset bool, inspect *setup.InspectReport, summary *setup.MigrationSummary, ) []planStep { var steps []planStep if reset { steps = append(steps, resetSteps(host, wifiSSID, inspect)...) host = "" // subsequent commands target the discovered IP } // Persist remote_services before anything else when it's only in /tmp. // SSH is reachable now, but the marker would be lost on the next reboot — // which could happen mid-migration if power is cut or the reboot step runs // before persistence is confirmed. if !reset && summary != nil && summary.RemoteServicesEnabled && !summary.RemoteServicesPersistent { steps = append(steps, planStep{ title: "Persist remote_services so SSH survives a reboot", cmd: fmt.Sprintf("soundtouch-cli --host=%s setup remote-services", host), reason: "Marker is currently in /tmp only — lost on next reboot, which would break SSH mid-migration.", }) } if !reset && summary != nil && summary.IsMigrated && (!includePair || summary.IsPaired) && len(steps) == 0 { return steps } if reset || summary == nil || !summary.IsMigrated { steps = append(steps, migrationSteps(host, serviceURL, summary, reset)...) } if includePair && (reset || (summary != nil && !summary.IsPaired)) { steps = append(steps, planStep{ title: "Pair the device with an AfterTouch account", cmd: fmt.Sprintf("soundtouch-cli --host=%s setup pair --service-url=%s", host, serviceURL), reason: "Required for preset persistence, streaming services, multi-room zones.", }) } 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 --host=%s setup factory-reset", 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 --host=%s setup install-ca --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 --host=%s setup migrate --service-url=%s --method=%s", host, serviceURL, method), reason: methodReason, }) steps = append(steps, planStep{ title: "Reboot the speaker", cmd: fmt.Sprintf("soundtouch-cli --host=%s setup reboot", 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. // // Preference order: // // 1. telnet (envswitch + sys configuration) — no SSH required, works on // firmware-27 devices that we've tested. Doesn't need CA when the // service URL is HTTP. // 2. resolv (DNS-redirect via /etc/resolv.conf) — needs SSH + CA, but is // the only method that doesn't depend on telnet `envswitch` being // accepted. // // We deliberately don't recommend xml (legacy SSH XML rewrite — fragile) // or hosts (deprecated — superseded by resolv). func recommendMigrationMethod(serviceURL string, summary *setup.MigrationSummary) (setup.MigrationMethod, string) { if summary != nil && summary.TelnetReachable { reason := "Telnet (port 17000) responds — simplest path, no SSH or CA cert required." if strings.HasPrefix(serviceURL, "https://") { reason += " Note: an HTTPS service URL still needs the speaker to trust AfterTouch's cert; run `setup install-ca` first." } return setup.MigrationMethodTelnet, reason } if summary != nil && summary.SSHSuccess { return setup.MigrationMethodResolvConf, "Telnet unavailable but SSH works — DNS redirect via /etc/resolv.conf is the most flexible alternative." } if summary == nil { return setup.MigrationMethodTelnet, "Speaker not reachable yet — telnet path will be tried after wifi-push." } return "", "" } func inspectedSSID(r *setup.InspectReport) string { if r == nil || r.Network == nil { return "" } for i := range r.Network.Interfaces.Interfaces { if ssid := r.Network.Interfaces.Interfaces[i].SSID; ssid != "" { return ssid } } return "" } // renderPreResetNote emits a one-liner explaining the DELETE-on-reset // semantic. Surfaced both here (in plan output) and in the factory-reset // command so users see it regardless of which verb they ran first. func renderPreResetNote() { fmt.Println("Note: when factory-reset runs, the speaker sends DELETE /streaming/account/{id}/device/{id} to its current marge URL just before wiping. If that URL still points at streaming.bose.com, AfterTouch never sees it and keeps a stale entry — run migrate (steps below) before reset for a clean datastore record.") fmt.Println() } func renderPlanSteps(steps []planStep, includePair bool) { if len(steps) == 0 { if includePair { PrintSuccess("Speaker is already migrated and paired. No action required.") } else { PrintSuccess("Speaker is already migrated. No action required.") } return } fmt.Println("Recommended steps") fmt.Println(strings.Repeat("─", 40)) for i, step := range steps { marker := "•" if step.manual { marker = "✋" } fmt.Printf(" %d. %s %s\n", i+1, marker, step.title) if step.reason != "" { fmt.Printf(" └─ %s\n", step.reason) } fmt.Printf(" $ %s\n\n", step.cmd) } fmt.Println("Run them in order. Manual lines (✋) require you to switch Wi-Fi networks on this host before proceeding.") } func setupPairCmd() *cli.Command { return &cli.Command{ Name: "pair", Usage: "Pair the speaker with an account via WebSocket SETUP state machine", Before: RequireHost, Flags: []cli.Flag{ &cli.StringFlag{Name: "account", Usage: "7-digit account ID (empty = generate)"}, &cli.StringFlag{Name: "mode", Value: "full", Usage: "full (state machine) or bare (setMargeAccount only — experimental)"}, &cli.StringFlag{Name: "service-url", Value: "http://aftertouch.local:8000", Usage: "AfterTouch base URL (also populates / in setMargeAccount)"}, &cli.StringFlag{Name: "name", Usage: "Speaker name to set during pairing (empty = keep current)"}, &cli.IntFlag{Name: "language", Value: setup.LanguageEnglish, Usage: "sysLanguage code (2 = English)"}, &cli.DurationFlag{Name: "step-timeout", Value: 8 * time.Second}, &cli.StringFlag{Name: "token", Usage: "userAuthToken value (empty = use built-in placeholder matching the Bose app token shape)"}, }, Action: func(c *cli.Context) error { cfg := GetClientConfig(c) mode := c.String("mode") accountID := c.String("account") if accountID == "" { generated, err := setup.GenerateAccountID(nil) if err != nil { return fmt.Errorf("generate account id: %w", err) } accountID = generated fmt.Printf("Generated account id: %s\n", accountID) } if !setup.IsValidAccountID(accountID) { return fmt.Errorf("invalid account id %q: must be 7 digits", accountID) } switch mode { case "bare": return runPairBare(c, cfg.Host, accountID) case "full": return runPairFull(c, cfg.Host, accountID) default: return fmt.Errorf("unknown --mode=%q (want full or bare)", mode) } }, } } func runPairBare(c *cli.Context, deviceIP, accountID string) error { m := setup.NewManager("", nil, nil) info, err := m.GetLiveDeviceInfo(deviceIP) if err != nil { return fmt.Errorf("read /info: %w", err) } fmt.Printf("pre /info deviceID=%s margeAccountUUID=%q margeURL=%q\n", info.DeviceID, info.MargeAccountUUID, info.MargeURL) // Service URL drives the extended payload // (boseServer/updateServer/accountEmail). When empty, the session // falls back to the minimal historical shape (accountId + // userAuthToken only). serviceURL := c.String("service-url") var extras setup.MargePairingExtras if serviceURL != "" { extras = setup.MargePairingExtras{BoseServer: serviceURL} } session, err := setup.DialSession(deviceIP, info.DeviceID, setup.SessionConfig{ StepTimeout: c.Duration("step-timeout"), PairingExtras: extras, }) if err != nil { return fmt.Errorf("dial WS: %w", err) } defer func() { _ = session.Close() }() ctx, cancel := context.WithTimeout(c.Context, c.Duration("step-timeout")+2*time.Second) defer cancel() if serviceURL != "" { fmt.Printf("→ setMargeAccount accountID=%s (extended: boseServer=%s)\n", accountID, serviceURL) } else { fmt.Printf("→ setMargeAccount accountID=%s (minimal payload, no SETUP bracket)\n", accountID) } if pairErr := session.SetMargeAccount(ctx, accountID, c.String("token")); pairErr != nil { PrintError(fmt.Sprintf("setMargeAccount: %v", pairErr)) return pairErr } time.Sleep(2 * time.Second) post, err := m.GetLiveDeviceInfo(deviceIP) if err != nil { return fmt.Errorf("read /info post: %w", err) } fmt.Printf("post /info margeAccountUUID=%q (want %q)\n", post.MargeAccountUUID, accountID) if post.MargeAccountUUID == accountID { PrintSuccess("Device accepted bare pairing.") } else { PrintWarning("Device did NOT persist the pairing — bare path likely refused silently.") } return nil } func runPairFull(c *cli.Context, deviceIP, accountID string) error { m := setup.NewManager(c.String("service-url"), nil, nil) needed, status, err := m.PreflightInitPlan(deviceIP) if err != nil { PrintError(fmt.Sprintf("preflight: %v", err)) return err } if !needed { PrintSuccess(fmt.Sprintf("Device already configured (status=%s) — nothing to do.", status)) return nil } plan := setup.InitPlan{ DeviceIP: deviceIP, ServiceURL: c.String("service-url"), AccountID: accountID, Language: c.Int("language"), DeviceName: c.String("name"), SkipURLRewrite: true, StepTimeout: c.Duration("step-timeout"), } ctx, cancel := context.WithTimeout(c.Context, 60*time.Second) defer cancel() _, err = m.ExecuteInitPlan(ctx, plan, func(e setup.StepEvent) { switch e.Status { case setup.StatusOK: fmt.Printf("[%d] %s — ok\n", e.Kind, e.Name) case setup.StatusSkipped: fmt.Printf("[%d] %s — skipped\n", e.Kind, e.Name) case setup.StatusFailed: fmt.Printf("[%d] %s — FAILED: %v\n", e.Kind, e.Name, e.Err) case setup.StatusRunning: fmt.Printf("[%d] %s — ...\n", e.Kind, e.Name) } }) if err != nil { PrintError(err.Error()) return err } PrintSuccess(fmt.Sprintf("Pairing complete: accountID=%s", accountID)) return nil }