docs(analysis): add Telnet (port 17000) migration method analysis

Documents the SSH-free third migration path on top of the device's diagnostic
shell, synthesised from #221, #236, scheilch/opencloudtouch#167,
deborahgu/soundcork#228, and deborahgu/soundcork#141.

Captures the URL configuration command sequence, the dual persistence layers
(`sys configuration` + `envswitch boseurls set`), the `/setMargeAccount`
failure modes (404, hang, post-migration 502 on power_on) with their bounded
fallbacks, port-17000 preflight requirements, and account-ID sourcing rules
(reuse from `:8090/info`, pick from `DataStore.ListAccounts`, or 7-digit
manual/randomized entry). Cross-links the new doc from
DEVICE-REDIRECT-METHODS.md, marks the `/etc/hosts` method as deprecated, and
fixes the existing margeServerUrl example to use our service's bare-URL
convention with an explicit note for soundcork's `/marge` sub-path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-10 12:43:36 +02:00
co-authored by Claude Opus 4.7
parent 255dd9612a
commit d9894be7db
3 changed files with 376 additions and 23 deletions
+32 -23
View File
@@ -2,6 +2,8 @@
To enable offline operation or use custom services like **SoundCork** or **ÜberBöse API**, SoundTouch devices must be redirected from Bose's official cloud endpoints to a local or custom server. This document outlines the three known methods to achieve this, gathered from community reverse-engineering efforts in the **SoundCork** and **ÜberBöse API** projects.
> A fourth, **SSH-free** path — driving the device's diagnostic shell on TCP port 17000 — is being added as a peer to the XML and DNS methods. See **[TELNET-MIGRATION-METHOD.md](TELNET-MIGRATION-METHOD.md)** for the use cases, community findings, and feasibility analysis. The `/etc/hosts` method documented below is now deprecated and will not be exposed in the web UI.
## Overview of Redirection Targets
SoundTouch devices primarily communicate with the following domains:
@@ -32,19 +34,25 @@ The most robust and granular method involves modifying the device's private conf
Requires SSH access to the device.
```xml
<SoundTouchSdkPrivateCfg>
<margeServerUrl>http://192.168.1.10:8000/marge</margeServerUrl>
<margeServerUrl>http://192.168.1.10:8000</margeServerUrl>
<statsServerUrl>http://192.168.1.10:8000</statsServerUrl>
<swUpdateUrl>http://192.168.1.10:8000/updates/soundtouch</swUpdateUrl>
<bmxRegistryUrl>http://192.168.1.10:8000/bmx/registry/v1/services</bmxRegistryUrl>
</SoundTouchSdkPrivateCfg>
```
> **Note on `margeServerUrl`** — `soundtouch-service` mounts the marge endpoints
> at the **root** of port 8000, so the URL has no `/marge` suffix.
> [`deborahgu/soundcork`](https://github.com/deborahgu/soundcork) routes marge
> under a `/marge` sub-path, so users redirecting to soundcork must append it
> (`http://192.168.1.10:8000/marge`).
### Pros & Cons
| Pros | Cons |
| :--- | :--- |
| **Granular Control**: Redirect specific services while leaving others (e.g., updates) intact. | **Requires SSH**: Must have root/SSH access to the device. |
| **Persistent**: Survives software updates (usually). | **Syntax Sensitive**: Errors in XML can cause boot issues or service failures. |
| **Native**: Uses the device's built-in configuration mechanism. | |
| Pros | Cons |
|:----------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------|
| **Granular Control**: Redirect specific services while leaving others (e.g., updates) intact. | **Requires SSH**: Must have root/SSH access to the device. |
| **Persistent**: Survives software updates (usually). | **Syntax Sensitive**: Errors in XML can cause boot issues or service failures. |
| **Native**: Uses the device's built-in configuration mechanism. | |
---
@@ -66,11 +74,11 @@ Requires SSH access. Add entries for the target domains:
```
### Pros & Cons
| Pros | Cons |
| :--- | :--- |
| **Simple**: Easy to understand and implement. | **Requires SSH**: Must have root access. |
| Pros | Cons |
|:--------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Simple**: Easy to understand and implement. | **Requires SSH**: Must have root access. |
| **Universal**: Affects all processes on the device attempting to reach those domains. | **HTTPS Issues**: Redirecting HTTPS domains to a local IP will cause SSL certificate errors unless the device is patched to skip verification or trust a custom CA. |
| | **Brittle**: Some firmware versions may overwrite `/etc/hosts` on reboot. |
| | **Brittle**: Some firmware versions may overwrite `/etc/hosts` on reboot. |
---
@@ -104,12 +112,12 @@ sed "s#\^https:....bose.\+apigee..net..#http[aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
4. Restore execution permissions and reboot.
### Pros & Cons
| Pros | Cons |
| :--- | :--- |
| **Bypass Config**: Works even if the firmware ignores XML settings. | **High Risk**: Modifying binaries can lead to permanent bricks or boot loops. |
| Pros | Cons |
|:------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------|
| **Bypass Config**: Works even if the firmware ignores XML settings. | **High Risk**: Modifying binaries can lead to permanent bricks or boot loops. |
| **Hardcoded Redirects**: Can catch URLs that aren't exposed in configuration files. | **Length Constraint**: Custom URLs must fit within the space of the original strings. |
| | **Firmware Specific**: Patches must be reapplied after every software update. |
| | **Complexity**: Requires understanding of binary structures and potential checksums. |
| | **Firmware Specific**: Patches must be reapplied after every software update. |
| | **Complexity**: Requires understanding of binary structures and potential checksums. |
---
@@ -117,11 +125,11 @@ sed "s#\^https:....bose.\+apigee..net..#http[aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
### Summary Table
| Method | Primary Use Case | Ease | Safety | Persistence | Granularity |
| :--- | :--- | :---: | :---: | :---: | :---: |
| **XML Config** | Logical service redirection | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| **`/etc/hosts`** | Quick global DNS override | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
| **Binary Patch** | Bypassing hardcoded checks | ⭐ | ⭐ | ⭐ | ⭐⭐⭐ |
| Method | Primary Use Case | Ease | Safety | Persistence | Granularity |
|:-----------------|:----------------------------|:-----:|:------:|:-----------:|:-----------:|
| **XML Config** | Logical service redirection | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| **`/etc/hosts`** | Quick global DNS override | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
| **Binary Patch** | Bypassing hardcoded checks | ⭐ | ⭐ | ⭐ | ⭐⭐⭐ |
---
@@ -176,9 +184,10 @@ As suggested by community members, you can configure the device to trust your ow
- **Method B (Symlinks)**: Add the certificate to `/etc/ssl/certs/` and create a hash symlink using `c_rehash` (if available) or manual mapping.
**Pros & Cons**:
| Pros | Cons |
| :--- | :--- |
| **Secure**: Maintains end-to-end encryption. | **Requires SSH**: Must have root access to modify the trust store. |
| Pros | Cons |
|:-------------------------------------------------------|:-----------------------------------------------------------------------|
| **Secure**: Maintains end-to-end encryption. | **Requires SSH**: Must have root access to modify the trust store. |
| **Clean**: No binary patching required for SSL bypass. | **Update Risk**: Firmware updates might overwrite the `ca-bundle.crt`. |
### Option 2: SSL Verification Bypass
+343
View File
@@ -0,0 +1,343 @@
# Telnet (Port 17000) Migration Method — Analysis
This document captures the use cases, community findings, and feasibility analysis
for adding a **Telnet/port 17000** migration path to `soundtouch-service` as a
peer of the existing XML and DNS-based methods. The `/etc/hosts` method stays
deprecated and is intentionally kept off the visible UI options.
> **Sources** — community discussion synthesised from
> [gesellix/Bose-SoundTouch#221](https://github.com/gesellix/Bose-SoundTouch/issues/221),
> [gesellix/Bose-SoundTouch#236](https://github.com/gesellix/Bose-SoundTouch/issues/236),
> [scheilch/opencloudtouch#167](https://github.com/scheilch/opencloudtouch/issues/167),
> [deborahgu/soundcork#228](https://github.com/deborahgu/soundcork/issues/228),
> [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141),
> the post-EOS walkthrough PDF in `docs/`,
> [Bose SoundTouch Telnet Probing thread](https://www.reddit.com/r/bose/comments/1o5zkym/soundtouch_telnet_probing/),
> and [flarn2006's blog post on hacking SoundTouch](https://flarn2006.blogspot.com/2014/09/hacking-bose-soundtouch-and-its-linux.html).
---
## 1. Why a third method is needed
The two currently shipped methods both have hard preconditions that block real
users:
| Method | Preconditions | Failure modes seen in the wild |
|-----------------------------------------|--------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **XML** (`SoundTouchSdkPrivateCfg.xml`) | SSH/root access — needs `remote_services` USB unlock first | Some firmware revisions (e.g. SA-5, ST520, latest ST Portable) refuse the USB unlock entirely; `remote_services on` was removed from the telnet command set in firmware 7.x and later. |
| **DNS** (`resolv.conf` priority hook) | SSH/root access; service must own port 53 on the LAN gateway | Won't fit users behind ISP routers they can't reconfigure; still requires the device to be SSH-reachable to write the hook. |
The community has demonstrated a **third path that needs no SSH at all**:
the device's built-in **diagnostic Telnet shell on TCP port 17000** accepts
configuration commands that change exactly the same fields the XML method would.
### 1.1 Confirmed user reports (firmware 27.0.6.46330.5043500 unless noted)
| Reporter | Hardware | Outcome |
|--------------------|---------------------------|-------------------------------------------------------------------------------------------------------------|
| `foob61451` (#221) | ST 10, ST 20 (non-rooted) | All four URLs persisted via `sys configuration …`; `envswitch boseurls set …` survived `sys reboot`. |
| `bveenker` (#221) | Wave III | URLs accepted; presets work after pairing via `/setMargeAccount` (see §3). |
| `stephan48` (#221) | Wave IV | Telnet:1700 + USB stick `remote_services` did **not** work; **port 17000 telnet** worked for all four URLs. |
| `mcdona1d` (#141) | ST 20, ST 300 | Confirmed working with `sys configuration …` + `envswitch …` + `sys reboot`. |
| `TJGigs` (#228) | ST 20 ×2, ST 10 | Wraps telnet:17000 into an admin "Smart Inject" tool; uses `sys reboot` over telnet to nudge devices. |
So the method is plausible across **at least ST 10/20/300 and Wave III/IV** on
the most common firmware that survived the EOS cut, **without the USB unlock
dance** that newer firmware refuses.
---
## 2. The Telnet:17000 command set we rely on
### 2.1 URL configuration (the migration payload)
The sequence we send for `soundtouch-service` (community-validated in #221, #141):
```
sys configuration bmxRegistryUrl http://<service-host>:8000/bmx/registry/v1/services
sys configuration statsServerUrl http://<service-host>:8000
sys configuration margeServerUrl http://<service-host>:8000
sys configuration swUpdateUrl http://<service-host>:8000/updates/soundtouch
envswitch boseurls set http://<service-host>:8000 http://<service-host>:8000/updates/soundtouch
getpdo CurrentSystemConfiguration
sys reboot
```
Three important details from the discussion:
1. **`sys configuration` alone is not enough.** `stephan48` reported that
without the `envswitch boseurls set …` line his typo in `bmxRegistryUrl` was
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.**
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`
sets `MargeServerUrl: targetURL` without any suffix). Some community
recipes appended `/marge` because they were targeting
[`deborahgu/soundcork`](https://github.com/deborahgu/soundcork), which
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).
### 2.2 Account pairing fallback
`envswitch accountid set <numeric-id>` was reported by `bveenker` (#221) as an
in-band equivalent to the HTTP `/setMargeAccount` call, useful when the
`/setMargeAccount` endpoint is missing on the firmware (see §3).
### 2.3 Probing / preflight
- A bare TCP connect to `<deviceIP>:17000` answers (no auth) on devices we care
about.
- Useful read-only verification command: `getpdo CurrentSystemConfiguration`
prints the URLs after the changes have been applied so we can verify before
rebooting.
- `sys reboot` is the trigger that re-reads both layers.
### 2.4 What Telnet:17000 cannot do
- It does **not** install a custom CA. So if a user wants HTTPS rather than HTTP
redirection to our service (the DNS-method scenario, where `resolv.conf`
redirection collides with the device's TLS validation unless our root CA is
trusted on the device), telnet alone won't cover it. This is fine for our
default flow, which uses plain `http://` URLs to the service's port 8000.
- It does not give us a way to read or write `Sources.xml` (third-party
account credentials) — that still requires SSH, but for a migration we don't
actually need it.
---
## 3. The `/setMargeAccount` problem (issue #236, #228)
### 3.1 What it is
A factory-reset speaker has an empty `<margeAccountUUID/>` in `:8090/info`. The
marge endpoints fail with 502 / unhandled until that field is populated, which
is why several users (#221, #236) saw **everything except AUX** broken after
migration:
```
POST http://<deviceIP>:8090/setMargeAccount
Content-Type: application/xml
<PairDeviceWithAccount>
<accountId>1234567</accountId>
<userAuthToken>soundcorkdoesntcare</userAuthToken>
</PairDeviceWithAccount>
```
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.
### 3.2 Why it's broken in practice
There are **three independent failure modes** observed:
| Symptom | Cause | Detection |
|-----------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------|
| Endpoint returns 404 / "not implemented" | Newer firmware (e.g. some BST20 Portable, latest ST Portable) drops the endpoint entirely. | `GET /supportedURLs` does **not** list `/setMargeAccount` in `<URL location="…"/>`. |
| Endpoint hangs (no response / socket stays open) | "Broken state" the user explicitly called out — endpoint advertised, but handler is wedged. | Caller has to time out; we currently have no timeout, so the request appears to hang the migration UI indefinitely. |
| `POST /marge/streaming/support/power_on` → 502 unhandled (#236) | Device keeps polling marge after migration but no `margeAccountUUID` was ever assigned, so all subsequent calls fail. | `:8090/info` shows `<margeAccountUUID/>` empty after reboot. |
### 3.3 Required handling
Per the user's brief, the migration logic must:
1. **Probe** `GET http://<deviceIP>:8090/supportedURLs` and check whether
`/setMargeAccount` is in the list **before** trying to POST it.
2. **Time-bound** the POST aggressively (e.g. ≤5s connect + ≤10s read) and treat
anything over the budget as a failure rather than waiting indefinitely.
3. On either failure mode, **fall back** to the telnet equivalent
(`envswitch accountid set <id>` + `sys reboot`).
4. If telnet:17000 is **also** unreachable, surface a clear "your firmware does
not support unattended pairing — please pair manually via the official Bose
app *before* it goes EOS, or open SSH and use the XML method" error rather
than leaving the device in a half-migrated state.
### 3.4 Where the `<id>` comes from
The device's current account ID is already discoverable through endpoints we
control:
- **`GET :8090/info`** returns `<margeAccountUUID>…</margeAccountUUID>`. If it
is non-empty the device is already paired — **reuse that ID**, do not
reassign. Our local marge accepts any ID, so the existing one is fine.
- If it is empty (factory reset), the user picks one in the UI:
1. **Pick from existing accounts.** The setup UI lists IDs returned by
`DataStore.ListAccounts()` so a user can re-attach a fresh device to an
account that already has presets/recents/sources.
2. **Enter manually.** Free-form text input, validated as **exactly 7
numeric digits** (the format every Bose-cloud-issued ID has had in the
captures we've seen, and the format the wider community uses in their
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.
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
only show up when the device is genuinely fresh.
---
## 4. Port 17000 availability
The diagnostic shell is gated by firmware build and product family. Anecdotally:
- ST 10 / ST 20 / ST 300 / Wave III / Wave IV on FW 27.0.6 → **open**.
- SA-5 with FW 9.x → some commands present (`local_services on`) but
**no `remote_services on`** and no SSH on FW 9.0.43.23466 (#141).
- Modern firmware on some Portables → endpoint set has shrunk further.
Because of this, we cannot assume port 17000 is reachable. The migration flow
must:
1. **Probe** with a TCP connect to `<deviceIP>:17000`, with a tight timeout
(≤2s). A successful TCP handshake is necessary but not sufficient — some
hardened firmware closes the port immediately.
2. **Banner check.** After connecting, read whatever the device sends within
~1s. The diagnostic shell prints a small banner (firmware-dependent); a
blank read or an immediate close means we should treat it as "telnet not
usable" and disable the option.
3. **Capability check.** Issue a no-op like `getpdo CurrentSystemConfiguration`
and look for any non-empty response. If the device replies "Command not
found" we abort and suggest XML or DNS instead.
4. **Surface state to the UI.** The migration form should grey out the Telnet
option when the probe fails and show *why* (closed, banner missing,
command rejected) instead of letting the user click into a dead end.
---
## 5. Implementation feasibility — Telnet client in Go
This is a feasibility check only; no code is written yet.
### 5.1 Protocol
"Telnet" on port 17000 is effectively a line-oriented plain-TCP shell. The
device prints a small prompt (`->` in the SA-5 captures from #141) and reads
newline-terminated commands. There is **no** real Telnet option negotiation
(no `IAC`/`DO`/`WILL` exchanges visible in the wild captures), so we don't
need `golang.org/x/crypto/ssh`-class machinery.
### 5.2 Standard-library only
A minimal client is just `net.DialTimeout("tcp", host+":17000", 2*time.Second)` +
`bufio.Scanner` + `time.Time`-based deadlines on `Conn`. No third-party Telnet
library is needed; `github.com/reiver/go-telnet` would be overkill and adds
maintenance surface for no benefit. This matches the project's KISS principle
in `docs/CLAUDE.md` §3.
### 5.3 Cross-platform compatibility
`net.Dial` over TCP works identically on Windows, macOS, Linux and (with
limitations on listening) WASM. WASM-side: `soundtouch-service` runs server-side
anyway, so this only matters for `soundtouch-cli`, where TCP dial works in any
target other than browser-WASM — an acceptable carve-out documented separately.
### 5.4 Concurrency / safety
Each migration is a single goroutine driving one device. The client must:
- enforce per-command response deadlines so a wedged device cannot stall the
migration UI (mirrors the `/setMargeAccount` requirement);
- never send `sys reboot` until **all** preceding `OK` acks have arrived (so
partial config doesn't get persisted);
- always close the socket on error.
### 5.5 Testing strategy
We can test without a real speaker by spinning up a `net.Listen("tcp", "127.0.0.1:0")`
in the test, scripting it to consume our commands and emit canned `OK`/error
responses. That gives us deterministic coverage for:
- happy path (all four URLs accepted),
- single-command failure → no `sys reboot` sent,
- "command not found" on `envswitch …` → fallback path exercised,
- TCP closed mid-stream → migration aborts cleanly,
- read deadline triggers when the device hangs (the broken-state simulation).
The repo already follows the "real device responses preferred, mock servers
otherwise" rule (see `docs/CLAUDE.md` §1, §8). The tests above are the mock-server
half of that pattern.
### 5.6 Where it lives
The protocol client is **a standalone package**, not buried inside
`pkg/service/setup`, so it can be reused from CLI tools, future setup wizards,
and tests without dragging the migration manager in:
```
pkg/telnet/ # NEW reusable package
client.go # Dial / SendCommand / Probe / Close
client_test.go # mock-server tests against a net.Listen
pkg/service/setup/
telnet_migration.go # NEW thin wrapper that imports pkg/telnet
# and runs the URL config sequence
marge_pairing.go # NEW /setMargeAccount probe + post + telnet
# `envswitch accountid set` fallback
setup.go # add MigrationMethodTelnet const + case
```
UI plumbing is `pkg/service/handlers/web/index.html` (option list) and
`pkg/service/handlers/web/js/script.js` (`toggleMigrationMethod()`). The
deprecated `hosts` option is already hidden from the dropdown when we ship
this; we just add a `telnet` option next to `xml`/`resolv`.
### 5.7 Verdict
**Feasible and small.** Estimated scope: ~200 lines of client code in
`pkg/telnet`, ~300 lines of tests, plus a `MigrationMethodTelnet` branch in
`Manager.MigrateSpeaker`, plus the preflight probe described in §4 and the
`/setMargeAccount` guarding described in §3.
---
## 6. Decisions made (was: open questions)
1. **Account-ID generation.** Resolved — see §3.4. The migration form reads
`:8090/info` first; if `margeAccountUUID` is non-empty it is reused.
Otherwise the UI offers (a) pick from `DataStore.ListAccounts()`,
(b) manual entry validated as 7 numeric digits, (c) a "Generate" button
that randomizes a 7-digit number and re-rolls on collision.
2. **Reboot policy.** Migration is automatic, but the UI shows a **modal
confirmation** before the final `sys reboot` is sent ("Speaker will reboot
now to apply changes — continue?"). This matches the XML path, which
already reboots automatically, while preventing surprise reboots from a
stray click on a half-filled form.
3. **CA / HTTPS story.** Telnet has no way to install a custom CA. Documented
as an explicit limitation: telnet method = HTTP-only redirect to our
service. Users who need end-to-end TLS must use the XML or DNS method.
*Possible future enhancement* — a hybrid "install CA via SSH/XML, then drive
the URL flip via Telnet" path. Feasibility unknown; not in this iteration.
---
## 7. Summary of what changes when this lands
- **New reusable package `pkg/telnet`** — line-oriented TCP client with
`Dial`, `SendCommand`, `Probe`, `Close`, all deadline-driven. No external
dependencies, usable from CLI, service, and tests.
- **New `MigrationMethodTelnet = "telnet"`** constant in `pkg/service/setup/setup.go`
plus a `migrateViaTelnet` branch in `Manager.MigrateSpeaker`.
- **New `pkg/service/setup/telnet_migration.go`** orchestrating the URL
configuration sequence (§2.1) on top of `pkg/telnet`.
- **New `pkg/service/setup/marge_pairing.go`** with `PairAccount(deviceIP, id)`:
probes `/supportedURLs`, time-bounded `POST /setMargeAccount`, falls back to
telnet `envswitch accountid set <id>` on missing/wedged endpoint.
- **`MigrationSummary` gains** `TelnetReachable`, `TelnetBanner`,
`TelnetCommandsAccepted`, `SetMargeAccountSupported`, `CurrentAccountID`,
`KnownAccountIDs` so the UI can show preflight outcomes and offer reuse.
- **UI** — `web/index.html` dropdown gets a `telnet` option (greyed out when
preflight fails) and a new pane for picking/entering/randomizing a 7-digit
account ID when `:8090/info` reports an empty `margeAccountUUID`. A modal
confirmation gates the final `sys reboot`. The legacy `hosts` option stays
out of the dropdown (deprecated).