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) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-06-22 21:23:15 +02:00
co-authored by Claude Opus 4.8
parent 4e25dacb0d
commit 9331e63b2d
5 changed files with 211 additions and 12 deletions
+53 -9
View File
@@ -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
}
@@ -225,7 +225,7 @@ These show up in `getpdo`, `network status`, and SSH-side hostnames. Useful for
- **Firmware 1.x7.x** (S1 era): everything — `help`, `remote_services on`, full `scm`, and an in-shell login prompt. `flarn2006` documents the original Linux insides.
- **Firmware 8.x14.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 "<serverURL>;touch /tmp/remote_services;/etc/init.d/sshd start" "<serverURL>/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 "<serverURL>/bmx/registry/v1/services"
sys configuration statsServerUrl "<serverURL>"
sys configuration margeServerUrl "<serverURL>;touch /tmp/remote_services;/etc/init.d/sshd start"
sys configuration swUpdateUrl "<serverURL>/updates/soundtouch"
envswitch boseurls set "<serverURL>;touch /tmp/remote_services;/etc/init.d/sshd start" "<serverURL>/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.
+6 -2
View File
@@ -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 <device-ip> 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
+82
View File
@@ -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 "<marge>" "<swUpdate>"`
// over the port-17000 shell. Both arguments are double-quoted so values
// containing spaces or semicolons (the SSH-enable injection) survive the
+37
View File
@@ -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"