From 9331e63b2da54814a0cc8507d6e7c2072f199078 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Mon, 22 Jun 2026 21:18:23 +0200 Subject: [PATCH] feat(setup): add `enable-ssh --full-config` for devices where sshd never starts (#515) The default `setup enable-ssh` injects the remote_services/sshd payload only via `envswitch boseurls set` and relies on the speaker re-reading its boseurls (~60s) without a reboot. On the SoundTouch Portable (Series I, FW 27.0.6.46330.5043500) and some CineMate 520 units the device accepts and persists that injection (getpdo confirms) but sshd never comes up, so :22 stays "Connection refused". @Henri-be got root on the ST Portable by typing a different sequence by hand over telnet :17000: the injection rides `sys configuration margeServerUrl` (the runtime layer) as well as `envswitch`, all four URL keys are written, and the device is rebooted so it re-parses the config at boot. Add an opt-in `--full-config` flag that replicates that exact sequence (EnableSSHViaTelnetFullConfig + telnet reboot via the existing RebootMethodTelnet). The default single-envswitch path is unchanged, so the field-confirmed flow on the Wireless Link Adapter and CineMate 520 `lisa` variant does not regress. Docs (TELNET-COMMAND-REFERENCE, DEVICE-LOGGING) document both paths and which device models/firmware need `--full-config`. The flag automation is candidate behaviour awaiting reporter confirmation: the manual sequence is confirmed on the ST Portable, the flag is not yet. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/soundtouch-cli/cmd_setup.go | 62 ++++++++++++-- .../docs/analysis/TELNET-COMMAND-REFERENCE.md | 34 +++++++- docs/content/docs/appendix/DEVICE-LOGGING.md | 8 +- pkg/service/setup/enable_ssh.go | 82 +++++++++++++++++++ pkg/service/setup/enable_ssh_test.go | 37 +++++++++ 5 files changed, 211 insertions(+), 12 deletions(-) diff --git a/cmd/soundtouch-cli/cmd_setup.go b/cmd/soundtouch-cli/cmd_setup.go index edb7f19..a64520e 100644 --- a/cmd/soundtouch-cli/cmd_setup.go +++ b/cmd/soundtouch-cli/cmd_setup.go @@ -538,6 +538,53 @@ func setupSSHCheckCmd() *cli.Command { } } +// 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) and reboots afterwards; otherwise it sends the single- +// envswitch default that fires on the speaker's next boseurls check. +func runEnableSSHInjection(m *setup.Manager, host, serviceURL string, fullConfig bool) error { + var ( + logs string + err error + ) + + if fullConfig { + fmt.Printf("Enabling SSH on %s via telnet :17000 (full #515 sequence: all four config URLs with the injection on margeServerUrl, then reboot)...\n", host) + logs, err = m.EnableSSHViaTelnetFullConfig(host, serviceURL) + } 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 + } + + 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 +} + func setupEnableSSHCmd() *cli.Command { return &cli.Command{ Name: "enable-ssh", @@ -556,6 +603,11 @@ func setupEnableSSHCmd() *cli.Command { 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.BoolFlag{ Name: "no-reset-urls", Usage: "Skip restoring clean boseurls after SSH is up (leaves the injected marge URL in place)", @@ -589,15 +641,7 @@ func setupEnableSSHCmd() *cli.Command { serviceURL = "https://aftertouch.invalid" } - fmt.Printf("Enabling SSH on %s via telnet :17000 (runs on the speaker's next boseurls check, up to ~60s)...\n", cfg.Host) - - logs, err := m.EnableSSHViaTelnet(cfg.Host, serviceURL) - if logs != "" { - fmt.Print(logs) - } - - if err != nil { - PrintError(err.Error()) + if err := runEnableSSHInjection(m, cfg.Host, serviceURL, c.Bool("full-config")); err != nil { return err } diff --git a/docs/content/docs/analysis/TELNET-COMMAND-REFERENCE.md b/docs/content/docs/analysis/TELNET-COMMAND-REFERENCE.md index 350b95d..b393e59 100644 --- a/docs/content/docs/analysis/TELNET-COMMAND-REFERENCE.md +++ b/docs/content/docs/analysis/TELNET-COMMAND-REFERENCE.md @@ -225,7 +225,7 @@ These show up in `getpdo`, `network status`, and SSH-side hostnames. Useful for - **Firmware 1.x–7.x** (S1 era): everything — `help`, `remote_services on`, full `scm`, and an in-shell login prompt. `flarn2006` documents the original Linux insides. - **Firmware 8.x–14.x** (S2 era): `remote_services on` removed; `network`, `sys`, `envswitch`, `getpdo` still present. `local_services on` works on some Wave/SA-5 models. -- **Firmware 27.x** (S5/S6 era — the long-lived "frozen" build that survived through EOS): `help`, `remote_services on`, and `sys ver` removed in some builds; `sys configuration …` and `envswitch …` confirmed working on ST 10, ST 20, ST 300, Wave III, Wave IV. **This is the firmware our migration targets**. The Portable on more recent firmware drops further commands and is the hardest target. +- **Firmware 27.x** (S5/S6 era — the long-lived "frozen" build that survived through EOS): `help`, `remote_services on`, and `sys ver` removed in some builds; `sys configuration …` and `envswitch …` confirmed working on ST 10, ST 20, ST 300, Wave III, Wave IV. **This is the firmware our migration targets**. The Portable on more recent firmware drops further commands and is the hardest target; on the ST Portable (Series I, FW `27.0.6.46330.5043500`) and some CineMate 520 units the SSH-enable injection persists but `sshd` does not start via the default path, which is what `setup enable-ssh --full-config` addresses (see "What we use to enable SSH" above). S5 enumerated the **top-level command roots** that don't return "Command not found" on a vanilla ST 10 (`rhino`) running `27.0.6.46330.5043500`: @@ -265,6 +265,38 @@ Reboot is **not** part of these sequences — it stays a user-initiated action v --- +## What we use to enable SSH (`setup enable-ssh`, #471) + +To open SSH on a speaker that has never had it (no USB recovery), the CLI abuses the boseurls value as a command-injection vehicle: when the device next parses it, the appended shell snippet touches the `remote_services` marker and starts `sshd`. The injected suffix is: + +``` +;touch /tmp/remote_services;/etc/init.d/sshd start +``` + +**Default path** (`soundtouch-cli setup enable-ssh`) writes that injection only via the persistence layer, then waits for `:22`: + +``` +envswitch boseurls set ";touch /tmp/remote_services;/etc/init.d/sshd start" "/update" +``` + +This is field-confirmed on the Wireless Link Adapter and on the CineMate 520 `lisa` variant (FW 27.0.6). + +**`--full-config` path** (`soundtouch-cli setup enable-ssh --full-config`) is for devices where the default injection is *accepted and persisted* (`getpdo` confirms the value) but `sshd` never comes up, so `:22` stays "Connection refused". It mirrors the manual telnet sequence @Henri-be confirmed by hand on issue #515: it puts the injection on the runtime `sys configuration margeServerUrl` key as well as `envswitch`, writes all four URL keys, then reboots so the device re-parses the config at boot: + +``` +sys configuration bmxRegistryUrl "/bmx/registry/v1/services" +sys configuration statsServerUrl "" +sys configuration margeServerUrl ";touch /tmp/remote_services;/etc/init.d/sshd start" +sys configuration swUpdateUrl "/updates/soundtouch" +envswitch boseurls set ";touch /tmp/remote_services;/etc/init.d/sshd start" "/updates/soundtouch" +getpdo CurrentSystemConfiguration +sys reboot +``` + +**Which devices need `--full-config`:** observed on the **SoundTouch Portable (Series I, model 412540, FW `27.0.6.46330.5043500`)** (#515) and on some **CineMate 520** units where the default path leaves `sshd` down. The structural differences from the default path that appear to matter are (1) the injection riding `sys configuration margeServerUrl`, not just `envswitch`, and (2) the explicit `sys reboot`. The `--full-config` automation is **candidate behaviour awaiting reporter confirmation** — the manual sequence is confirmed working on the ST Portable, but the flag that automates it has not yet been re-confirmed on hardware. Not every device responds even to the manual sequence (some ST10 and CineMate 520 units never start `sshd` over telnet at all and need the serial / U-Boot route). + +--- + ## Out of scope here, but worth recording - **Setup-mode WiFi onboarding via 192.0.2.1.** The community uses this to add a fresh device to a network without the Bose app. Our `soundtouch-service` does not currently automate this, but `network wifi profiles add` is the entry point if we ever do. diff --git a/docs/content/docs/appendix/DEVICE-LOGGING.md b/docs/content/docs/appendix/DEVICE-LOGGING.md index 5b0b743..16fc0bf 100644 --- a/docs/content/docs/appendix/DEVICE-LOGGING.md +++ b/docs/content/docs/appendix/DEVICE-LOGGING.md @@ -21,13 +21,17 @@ Most SoundTouch devices run a modified Linux distribution. Accessing these logs Community research (SoundCork Issue #112) has identified a "backdoor" to enable developer services: -1. **USB Method**: +1. **CLI Method (recommended, no USB needed)**: + - `soundtouch-cli --host setup enable-ssh` drives the port-17000 diagnostic shell to inject the `remote_services` marker and start `sshd`, then waits for `:22`. This is the #471 bootstrap; it needs no prior SSH and no USB stick. + - If the command is accepted (the device persists it, confirmed by `getpdo`) but `sshd` never comes up and `:22` stays "Connection refused", retry with `--full-config`. That variant mirrors the manual telnet sequence confirmed on issue #515: it puts the injection on `sys configuration margeServerUrl` as well as `envswitch`, writes all four URL keys, and reboots. + - **`--full-config` is meant for:** the **SoundTouch Portable (Series I, model 412540, FW `27.0.6.46330.5043500`)** and some **CineMate 520** units, where the default single-`envswitch` path leaves `sshd` down. The default path is sufficient on the Wireless Link Adapter and the CineMate 520 `lisa` variant. Some units (e.g. certain ST10 and CineMate 520 firmwares) do not respond to either path and need the serial / U-Boot console route instead. See [TELNET-COMMAND-REFERENCE.md](../analysis/TELNET-COMMAND-REFERENCE.md#what-we-use-to-enable-ssh-setup-enable-ssh-471) for the exact commands and current confirmation status. +2. **USB Method**: - Format a USB stick to **FAT32**. - Create an empty file named `remote_services` (no extension) in the root of the USB stick. - Insert the stick into the SoundTouch device. - Reboot the device (power cycle). - On some models, you may need to hold **4** and **Volume -** on the device while powering on to force a USB check. -2. **TAP Command (Legacy)**: +3. **TAP Command (Legacy)**: - On older firmware versions, you can connect to port 17000 via Telnet and issue the command: `remote_services on`. ### Making Root Access Persistent diff --git a/pkg/service/setup/enable_ssh.go b/pkg/service/setup/enable_ssh.go index 30bc89c..89a198f 100644 --- a/pkg/service/setup/enable_ssh.go +++ b/pkg/service/setup/enable_ssh.go @@ -36,6 +36,88 @@ func (m *Manager) ResetBoseURLs(deviceIP, serviceURL string) (string, error) { return m.setBoseURLsViaTelnet(deviceIP, serviceURL, serviceURL+"/update") } +// EnableSSHViaTelnetFullConfig is the #515 variant of EnableSSHViaTelnet for +// devices where the single-envswitch injection is accepted and persisted but +// sshd never starts (ST Portable, CineMate 520; see also memory note #471). It +// replicates the sequence @Henri-be confirmed by hand over telnet :17000: it +// writes all four `sys configuration` URL keys with the remote_services +// injection on margeServerUrl (the runtime layer, not just the envswitch +// persistence layer), mirrors the injection into `envswitch boseurls set`, and +// verifies with getpdo. The caller should reboot afterwards (the injection +// fires on the speaker's next full config re-parse at boot) and then +// WaitForSSHPort. +// +// serviceURL is the AfterTouch service base the speaker should point at +// (e.g. https://192.0.2.10:8443). It must not contain a double quote. +func (m *Manager) EnableSSHViaTelnetFullConfig(deviceIP, serviceURL string) (string, error) { + u := defaultTelnetURLs(serviceURL) + margeInjected := serviceURL + remoteServicesInjection + + // All values are double-quoted: margeInjected contains spaces and + // semicolons, so the device's command parser needs the quotes to keep it + // one argument (the unquoted telnetURLs.Commands() is only safe for clean + // migration URLs). + cmds := []string{ + `sys configuration bmxRegistryUrl "` + u.BmxRegistry + `"`, + `sys configuration statsServerUrl "` + u.Stats + `"`, + `sys configuration margeServerUrl "` + margeInjected + `"`, + `sys configuration swUpdateUrl "` + u.SwUpdate + `"`, + `envswitch boseurls set "` + margeInjected + `" "` + u.SwUpdate + `"`, + } + + return m.runTelnetInjection(deviceIP, []string{serviceURL, u.SwUpdate}, cmds) +} + +// runTelnetInjection opens the port-17000 shell, runs an ordered list of +// commands (aborting on the first transport error or "command not found" +// rejection), then logs a getpdo verification. forbidQuote values are checked +// for an embedded double quote, which would break the command parsing. +// Verification is best-effort (logged, never fatal) to match enable-ssh's +// forgiving philosophy and tolerate the aftertouch.invalid placeholder. +func (m *Manager) runTelnetInjection(deviceIP string, forbidQuote, cmds []string) (string, error) { + if m.NewTelnet == nil { + return "", errors.New("telnet not configured: Manager.NewTelnet is nil") + } + + for _, v := range forbidQuote { + if strings.Contains(v, `"`) { + return "", errors.New("boseurls values must not contain a double quote") + } + } + + var logs strings.Builder + + t := m.NewTelnet(deviceIP) + if err := t.Dial(); err != nil { + return logs.String(), fmt.Errorf("telnet dial %s:17000: %w", deviceIP, err) + } + + defer func() { _ = t.Close() }() + + if banner, _ := t.Probe(); banner != "" { + fmt.Fprintf(&logs, "Telnet banner: %q\n", strings.TrimSpace(banner)) + } + + for _, cmd := range cmds { + resp, err := t.SendCommand(cmd) + if err != nil { + return logs.String(), fmt.Errorf("telnet command %q failed: %w", cmd, err) + } + + fmt.Fprintf(&logs, "→ %s\n%s\n", cmd, strings.TrimRight(resp, "\r\n")) + + if isCommandNotFound(resp) { + return logs.String(), fmt.Errorf("device rejected %q (firmware does not expose this command)", cmd) + } + } + + if verify, err := t.SendCommand("getpdo CurrentSystemConfiguration"); err == nil { + fmt.Fprintf(&logs, "→ getpdo CurrentSystemConfiguration\n%s\n", strings.TrimRight(verify, "\r\n")) + } + + return logs.String(), nil +} + // setBoseURLsViaTelnet runs `envswitch boseurls set "" ""` // over the port-17000 shell. Both arguments are double-quoted so values // containing spaces or semicolons (the SSH-enable injection) survive the diff --git a/pkg/service/setup/enable_ssh_test.go b/pkg/service/setup/enable_ssh_test.go index 1a5aab6..257aa16 100644 --- a/pkg/service/setup/enable_ssh_test.go +++ b/pkg/service/setup/enable_ssh_test.go @@ -22,6 +22,43 @@ func TestEnableSSHViaTelnet_BuildsInjectedCommand(t *testing.T) { } } +func TestEnableSSHViaTelnetFullConfig_BuildsInjectedSequence(t *testing.T) { + const svc = "https://192.0.2.10:8443" + + const injected = `https://192.0.2.10:8443;touch /tmp/remote_services;/etc/init.d/sshd start` + + want := []string{ + `sys configuration bmxRegistryUrl "https://192.0.2.10:8443/bmx/registry/v1/services"`, + `sys configuration statsServerUrl "https://192.0.2.10:8443"`, + `sys configuration margeServerUrl "` + injected + `"`, + `sys configuration swUpdateUrl "https://192.0.2.10:8443/updates/soundtouch"`, + `envswitch boseurls set "` + injected + `" "https://192.0.2.10:8443/updates/soundtouch"`, + `getpdo CurrentSystemConfiguration`, + } + + resp := map[string]string{} + for _, c := range want { + resp[c] = "OK\n" + } + + f := &fakeTelnet{responses: resp} + m := newFakeTelnetManager(f) + + if _, err := m.EnableSSHViaTelnetFullConfig("192.0.2.10", svc); err != nil { + t.Fatalf("EnableSSHViaTelnetFullConfig: %v", err) + } + + if len(f.commands) != len(want) { + t.Fatalf("sent %d commands %q\n want %d %q", len(f.commands), f.commands, len(want), want) + } + + for i, c := range want { + if f.commands[i] != c { + t.Errorf("command %d = %q\n want %q", i, f.commands[i], c) + } + } +} + func TestResetBoseURLs_BuildsCleanCommand(t *testing.T) { const svc = "https://192.0.2.10:8443"