mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
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:
co-authored by
Claude Opus 4.8
parent
4e25dacb0d
commit
9331e63b2d
@@ -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.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 "<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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user