diff --git a/cmd/soundtouch-cli/cmd_setup.go b/cmd/soundtouch-cli/cmd_setup.go index 65efe09..0ab10f0 100644 --- a/cmd/soundtouch-cli/cmd_setup.go +++ b/cmd/soundtouch-cli/cmd_setup.go @@ -661,9 +661,8 @@ func setupEnableSSHCmd() *cli.Command { &cli.DurationFlag{ Name: "command-delay", Value: setup.DefaultTelnetCommandDelay, - Usage: "Only affects --full-config: pause between each of its 6 steps (5 commands + reboot). Confirmed necessary on a real device (#515) — " + - "the same commands sent back-to-back left sshd down after reboot, but succeeded sent one at a time with ~7s gaps. Raise this if the " + - "default doesn't work on your device; 0 sends everything back-to-back (the old behavior)", + 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", diff --git a/docs/content/docs/analysis/TELNET-COMMAND-REFERENCE.md b/docs/content/docs/analysis/TELNET-COMMAND-REFERENCE.md index e95c329..08bf5e1 100644 --- a/docs/content/docs/analysis/TELNET-COMMAND-REFERENCE.md +++ b/docs/content/docs/analysis/TELNET-COMMAND-REFERENCE.md @@ -150,11 +150,15 @@ Each `sys configuration` setter is reported by users to return `OK` on success. `envswitch` writes to a separate, lower-level persistence store that **wins on next reboot** if the corresponding `sys configuration` value differs. So our migration writes both — see TELNET-MIGRATION-METHOD.md §2.1. -| Command | Purpose | Source | -|---------------------------------------------------|-----------------------------------------------------------------------------------------------|---------| -| `envswitch boseurls set ` | Persist the marge and update URLs. **Two arguments**, in that order. | S6 | -| `envswitch accountid set ` | Equivalent to the HTTP `/setMargeAccount` POST. Used as fallback in our `PairAccount` helper. | S6 | -| `envswitch accountid get` | Plausible by symmetry but **not yet confirmed** across firmwares; we probe it best-effort. | (probe) | +**It's a commit point, not just a two-field setter.** `envswitch boseurls set` persists whatever is currently in the runtime layer at the moment it runs — not only its own two arguments. Confirmed on five variants (`lisa`, `mojo`, `spotty`, `ginger`, `taigan`; [#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)): a `sys configuration` write survives a reboot **if and only if** an `envswitch boseurls set` runs after it. The same command sequence in reverse order silently loses the later `sys configuration` values on reboot — every command still answers, nothing looks wrong until the reboot. This is why our migration and SSH-enable sequences always issue all four `sys configuration` writes first and `envswitch boseurls set` last (see `telnetURLs.Commands()` / `EnableSSHViaTelnetFullConfig`). + +**It does not acknowledge with `OK`.** Unlike `sys configuration` (which does), `envswitch boseurls set` responds with a different string (observed: `Setting Bose Server URLs to and ->`, no `OK` substring). An implementation that waits for the literal token `OK` will hit its own timeout on this exact command. Our `pkg/telnet.Client.SendCommand` doesn't string-match at all — it reads until the connection goes idle — so this only matters if you're hand-typing the sequence or reimplementing the client elsewhere. + +| Command | Purpose | Source | +|-------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------| +| `envswitch boseurls set ` | Persist the marge and update URLs, committing the runtime layer as it stands (see above). **Two arguments**, in that order. | S6 | +| `envswitch accountid set ` | Equivalent to the HTTP `/setMargeAccount` POST. Used as fallback in our `PairAccount` helper. | S6 | +| `envswitch accountid get`, bare `envswitch`, `envswitch boseurls` | **Confirmed unsupported** — all answer `Invalid Command Option` on `lisa`/`mojo`/`spotty` ([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)). `envswitch` has no read form on any variant tested; the persisted layer can only be written, then observed indirectly after a reboot (e.g. via `getpdo`, which then reflects the *new* value). | (probe) | --- @@ -166,6 +170,8 @@ Each `sys configuration` setter is reported by users to return `OK` on success. |-------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------| | `getpdo CurrentSystemConfiguration` | Echoes the resolved URL set, including margeServerUrl/bmxRegistryUrl/statsServerUrl/swUpdateUrl. We grep our targetURL out of this to confirm a successful migration. | S6 | +**The two layers are inverted in `getpdo` visibility around a reboot** ([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)): *before* a reboot, `getpdo` shows the runtime (`sys configuration`) values immediately, while an `envswitch`-written value isn't visible yet; *after* a reboot, the `sys configuration` values are gone and the `envswitch`-persisted values are what's now applied. So a `getpdo` check run before rebooting confirms the writes were accepted, but it is **not** evidence the configuration will survive the reboot — only the `envswitch` write (in the right order, see above) determines that. This is why our own migration verification (`migrateViaTelnet`) checks `getpdo` before reboot only to confirm the runtime layer accepted the values, and never claims persistence from it. + --- ## The `scm` family — service control @@ -295,6 +301,12 @@ 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). +**On the `--command-delay` between steps:** originally added because a reporter's back-to-back run left `sshd` down while a ~7s-gapped run succeeded ([#515 comment 5228449448](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5228449448)). That theory was **retracted** by the same reporter after a controlled A/B across three variants showed identical outcomes at 0s and 5s gaps ([comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)) — the delay itself doesn't appear to matter. The default is kept small and non-zero (`setup.DefaultTelnetCommandDelay`) as a low-cost hedge for untested variants, not because the delay is known to help. + +**The account-pairing precondition** (raised by `Henri-be`, [#515 comment 5230785528](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5230785528), tracing back to [#471 comment 4903016740](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-4903016740); confirmed empirically by `bitranox`, [#515 comment 5232241580](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5232241580)): a genuinely unpaired (factory-reset, empty `margeAccountUUID`) device does not poll `margeServerUrl` **at all** — confirmed by pointing a reset device's marge URL at a listener and observing zero requests over 10+ minutes. The SSH-enable injection has no read cycle to fire on until the device is paired. `enable-ssh` handles this automatically by default (`EnsureMargeAccountPaired`, `--no-auto-pair` to skip). + +**Factory reset does not remove root access, if it was ever persisted.** Confirmed on a genuinely factory-reset `spotty` ([#471 comment 5232232575](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5232232575)): after the reset, `margeAccountUUID` was empty, all four service URLs were back to `streaming.bose.com`, and presets were gone — but `/etc/remote_services` and `/mnt/nv/remote_services` **survived**, and SSH (:22) and telnet (:17000) stayed open. So once a device has been through `setup enable-ssh` with persistence (`EnsureRemoteServices`, the default), a later factory reset only wipes configuration, not root access — recovery is re-migrate + re-pair + rename + restore presets, with **no USB stick and no re-running the injection**. + --- ## Out of scope here, but worth recording diff --git a/docs/content/docs/analysis/TELNET-MIGRATION-METHOD.md b/docs/content/docs/analysis/TELNET-MIGRATION-METHOD.md index c936a07..a856e34 100644 --- a/docs/content/docs/analysis/TELNET-MIGRATION-METHOD.md +++ b/docs/content/docs/analysis/TELNET-MIGRATION-METHOD.md @@ -82,6 +82,16 @@ Three important details from the discussion: silently restored on reboot — i.e. there is a parallel "envswitch" persistence layer that wins on next boot if you don't also write to it. **We must always issue both.** + + A later, more precise measurement ([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569), confirmed on + five variants: `lisa`/`mojo`/`spotty`/`ginger`/`taigan`) explains *why* + order matters: `envswitch boseurls set` is not just a two-field setter, it + **commits whatever is currently in the runtime layer at the moment it + runs**. A `sys configuration` write only survives a reboot if `envswitch + boseurls set` runs **after** it; the same commands in reverse order lose + the `sys configuration` values silently on reboot, with every individual + command still answering normally. This is why the sequence above is + ordered all-four-`sys-configuration`-then-`envswitch`, never the reverse. 2. **margeServerUrl path is bare for `soundtouch-service`.** We mount the marge endpoints at the **root** of port 8000, matching what the existing XML migration writes (`Manager.migrateViaXML` in `pkg/service/setup/setup.go` @@ -91,8 +101,15 @@ Three important details from the discussion: routes marge under that sub-path. **For our service: bare URL. For users redirecting to soundcork: append `/marge`** to both `margeServerUrl` and the first argument of `envswitch boseurls set`. -3. **Each command must be sent one at a time, waiting for the device's `OK` - response** before sending the next one (`foob61451`'s explicit warning). +3. **Each command must be sent one at a time, waiting for the device's + response** before sending the next one (`foob61451`'s original warning). + Note the exception: `sys configuration` commands ack with `OK`, but + `envswitch boseurls set` does **not** — it acks with a different string + entirely (observed: `Setting Bose Server URLs to and ->`, no `OK` + substring; [#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)). An implementation that waits for the + literal token `OK` will time out on exactly that command. Wait for the + shell's prompt (or, as our own `pkg/telnet.Client` does, for the + connection to go idle) rather than string-matching `OK`. ### 2.2 Account pairing fallback @@ -145,6 +162,22 @@ The values are not validated by the local service, so any numeric `accountId` will work — soundcork's runbook (#228) literally calls the token `soundcorkdoesntcare` to make the point. +> **Booby trap, confirmed on hardware: never send an empty or truncated body +> to this endpoint.** On one firmware, a `POST /setMargeAccount` with an +> empty body returned `HTTP 200` and cleared `margeAccountUUID`, un-pairing +> an already-working speaker +> ([#471 comment 5231977172](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5231977172)). +> A later retry on the same device instead returned `400` and changed +> nothing, so the same reporter corrected the finding to +> **state-dependent, not a reliable rule you can rely on either way** +> ([#471 comment 5232232575](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5232232575)). A `400` is not proof the +> endpoint rejected a bad request (a booting device also answers a bare +> `400` with an empty body before its services are ready, per +> `JRpersonal`), and a `200` is not proof it did what you wanted. Practical +> takeaway: our own `postSetMargeAccount` always sends a well-formed XML +> body, so this doesn't affect the CLI/service — but don't probe this +> endpoint by hand against a speaker that currently works. + ### 3.2 Why it's broken in practice There are **three independent failure modes** observed: @@ -189,10 +222,16 @@ control: recipes). 3. **Randomize.** A "Generate" button that picks a 7-digit number and re-rolls if it collides with an existing account in the local datastore. -- **Telnet read-back (best-effort).** `envswitch accountid get` is plausible by - symmetry with `envswitch accountid set` (#221) but is not yet confirmed - across firmwares. We will probe it during preflight; if it returns a value - we cross-check it against `:8090/info` and warn on mismatch. +- **Telnet read-back: confirmed unsupported.** `envswitch accountid get` was + originally listed as "plausible by symmetry with `envswitch accountid set` + (#221), not yet confirmed." It's now confirmed the other way: on + `lisa`/`mojo`/`spotty`, `envswitch` has **no read form at all** — both bare + `envswitch` and `envswitch boseurls` answer `Invalid Command Option` + ([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)). + The persisted layer can only be written, then + observed indirectly after a reboot (e.g. via `getpdo`, mindful of the + layer-visibility caveat in + [TELNET-COMMAND-REFERENCE.md](TELNET-COMMAND-REFERENCE.md)). This means the user is never *forced* to invent a number — the common path is "the device already has an ID, reuse it" — and the manual/randomize controls diff --git a/pkg/service/setup/enable_ssh.go b/pkg/service/setup/enable_ssh.go index 711000d..631e908 100644 --- a/pkg/service/setup/enable_ssh.go +++ b/pkg/service/setup/enable_ssh.go @@ -37,16 +37,19 @@ func (m *Manager) ResetBoseURLs(deviceIP, serviceURL string) (string, error) { } // DefaultTelnetCommandDelay is the pause between successive commands in -// EnableSSHViaTelnetFullConfig's sequence. Confirmed necessary on a real -// device (#515, issue comment 5228449448): the same six commands sent -// back-to-back left sshd down after reboot, but succeeded when sent one at a -// time with ~7s gaps — sending fast enough may not let the device fully -// process one command before the next arrives. Settled on 5s as the default -// (issue comment 5230881285): a bit more headroom than the original 3s -// guess, still well under the ~7s the reporter used without having tried to -// find the true minimum; the caller exposes it as a flag so a specific -// device can be tuned without a code change. -const DefaultTelnetCommandDelay = 5 * time.Second +// EnableSSHViaTelnetFullConfig's sequence. Originally set based on #515 +// comment 5228449448 (same six commands, back-to-back left sshd down after +// reboot but succeeded with ~7s gaps). That inter-command-delay theory was +// RETRACTED by the same reporter after a controlled A/B on three variants +// (issue comment 5231931569): back-to-back and 5s-gapped runs produced +// identical results (all writes applied, confirmed via verified reboots), +// so the delay itself does not appear to be the mechanism — the likely real +// gate was the account-pairing precondition (see EnsureMargeAccountPaired), +// fixed independently. The flag is kept at a small non-zero default (3s) +// as a low-cost hedge for firmware variants nobody has A/B-tested yet +// (only lisa/mojo/spotty/ginger/taigan are confirmed); 0 sends everything +// back-to-back. +const DefaultTelnetCommandDelay = 3 * time.Second // EnableSSHViaTelnetFullConfig is the #515 variant of EnableSSHViaTelnet for // devices where the single-envswitch injection is accepted and persisted but