feat(setup): add telnet URL rollback

Expose telnet-only URL restore through the CLI, setup API, and web UI. Validate command-safe URLs, serialize per-speaker mutations, and verify runtime readback while reporting partial-state failures.
This commit is contained in:
Lukáš Lipinský
2026-09-05 22:32:50 +02:00
committed by Tobias Gesellchen
parent 082930cd32
commit 67d64c74ff
24 changed files with 1234 additions and 91 deletions
+64 -11
View File
@@ -1510,12 +1510,23 @@ func renderMigrationSummary(deviceIP, serviceURL string, s *setup.MigrationSumma
}
}
// setupRevertCmd wraps setup.Manager.RevertMigration — the same operation
// as the web UI's "Revert to Defaults" button (Migrate tab). Restores
// SoundTouchSdkPrivateCfg.xml, /etc/hosts, and /etc/resolv.conf from their
// .original backups, removes the AfterTouch DNS-hook artifacts, and strips
// just the AfterTouch-labeled cert out of the trust bundle. No --service-url
// needed: everything it touches already lives on the speaker.
var telnetRevertOverrideFlags = []string{"marge-url", "stats-url", "sw-update-url", "bmx-url"}
func validateRevertMethodOptions(method string, overrideFlags []string) error {
if method != "ssh" && method != string(setup.MigrationMethodTelnet) {
return fmt.Errorf("unsupported revert method %q; expected ssh or telnet", method)
}
if method != string(setup.MigrationMethodTelnet) && len(overrideFlags) > 0 {
return fmt.Errorf("--%s requires --method telnet", strings.Join(overrideFlags, ", --"))
}
return nil
}
// setupRevertCmd restores either the SSH/filesystem migration state or only
// the four URL fields written by a telnet migration. The default remains the
// existing SSH path for backwards compatibility.
//
// Deliberately out of scope (matches the web UI button): SSH/remote_services
// persistence (use `setup remote-services --remove`) and account pairing
@@ -1523,15 +1534,53 @@ func renderMigrationSummary(deviceIP, serviceURL string, s *setup.MigrationSumma
func setupRevertCmd() *cli.Command {
return &cli.Command{
Name: "revert",
Usage: "Undo a migration: restore SoundTouchSdkPrivateCfg.xml/hosts/resolv.conf from backups and remove the AfterTouch CA cert",
Usage: "Undo a migration via SSH backups or restore canonical Bose service URLs over telnet",
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{Name: "method", Value: "ssh", Usage: "ssh | telnet"},
&cli.StringFlag{Name: "marge-url", Usage: "Override the canonical Bose margeServerUrl (telnet only)"},
&cli.StringFlag{Name: "stats-url", Usage: "Override the canonical Bose statsServerUrl (telnet only)"},
&cli.StringFlag{Name: "sw-update-url", Usage: "Override the canonical Bose swUpdateUrl (telnet only)"},
&cli.StringFlag{Name: "bmx-url", Usage: "Override the canonical Bose bmxRegistryUrl (telnet only)"},
},
Action: func(c *cli.Context) error {
cfg := GetClientConfig(c)
method := c.String("method")
var overrideFlags []string
for _, flag := range telnetRevertOverrideFlags {
if c.IsSet(flag) {
overrideFlags = append(overrideFlags, flag)
}
}
if err := validateRevertMethodOptions(method, overrideFlags); err != nil {
return err
}
m := setup.NewManager("", nil, nil)
fmt.Printf("Reverting migration on %s...\n", cfg.Host)
fmt.Printf("Reverting migration on %s using method=%s...\n", cfg.Host, method)
var (
logs string
err error
)
switch method {
case "ssh":
logs, err = m.RevertMigration(cfg.Host)
case string(setup.MigrationMethodTelnet):
options := map[string]string{
"marge_url": c.String("marge-url"),
"stats_url": c.String("stats-url"),
"sw_update_url": c.String("sw-update-url"),
"bmx_url": c.String("bmx-url"),
}
logs, err = m.RevertTelnetURLs(cfg.Host, options)
}
logs, err := m.RevertMigration(cfg.Host)
if logs != "" {
fmt.Print(logs)
}
@@ -1541,8 +1590,12 @@ func setupRevertCmd() *cli.Command {
return err
}
PrintSuccess("Migration reverted. SSH access and account pairing are untouched by this — " +
"see `setup remote-services --remove` and `account unpair` if you want those cleared too.")
if method == string(setup.MigrationMethodTelnet) {
PrintSuccess("Canonical Bose URL configuration restored. Reboot the speaker to verify the persisted layer; filesystem, DNS, CA, SSH, and account state were not changed.")
} else {
PrintSuccess("Migration reverted. SSH access and account pairing are untouched by this — " +
"see `setup remote-services --remove` and `account unpair` if you want those cleared too.")
}
return nil
},
+32
View File
@@ -207,6 +207,38 @@ func TestRecommendMigrationMethod_EmptyWhenNoTransport(t *testing.T) {
}
}
func TestValidateRevertMethodOptions(t *testing.T) {
tests := []struct {
name string
method string
overrides []string
wantError string
}{
{name: "ssh defaults", method: "ssh"},
{name: "telnet defaults", method: "telnet"},
{name: "telnet overrides", method: "telnet", overrides: []string{"marge-url"}},
{name: "ssh rejects overrides", method: "ssh", overrides: []string{"marge-url"}, wantError: "requires --method telnet"},
{name: "unknown method", method: "serial", wantError: "unsupported revert method"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateRevertMethodOptions(tt.method, tt.overrides)
if tt.wantError == "" {
if err != nil {
t.Fatalf("validateRevertMethodOptions: %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantError) {
t.Fatalf("error = %v, want text %q", err, tt.wantError)
}
})
}
}
func TestBuildPlanSteps_NoOpWhenAlreadyMigratedAndPaired(t *testing.T) {
summary := &setup.MigrationSummary{IsMigrated: true, IsPaired: true, TelnetMigrated: true}
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "AABBCCDDEEFF"}}
@@ -450,11 +450,14 @@ that `setup.PairAccount` already implements.
| ST Portable / FW 27.0.6 | jmosen | #236 | After migration: `POST /marge/streaming/support/power_on` → 502; `<margeAccountUUID/>` empty. Time-bounded HTTP path fails; envswitch fallback succeeds. |
| BST20 Portable (factory reset) | ubittner | scheilch/opencloudtouch#167 | `<margeAccountUUID/>` empty; `/setMargeAccount` not in `/supportedURLs`. HTTP path skipped entirely; only the telnet fallback works. |
### 8.3 Likely to fail (but the failure is clean)
### 8.3 Likely to reject the telnet sequence
Our preflight + abort-on-first-rejection design (`TestMigrateViaTelnet_CommandNotFoundAborts`)
means none of these scenarios leave a device half-configured. The user is
told what failed and pointed to the XML or DNS method.
stops at the first rejected command and reports whether earlier runtime writes
or the persistence command may already have applied. A rejection of command #1
leaves the URL state untouched; after any later failure, read back all four URL
fields before retrying or rebooting. The user is also pointed to the XML or DNS
method.
| Device | Source | Likely cause |
|--------------------------------------------|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
@@ -482,8 +485,10 @@ negative claim: the author writes "I've made some educated guesses and come
up with the following valid commands" and never says they tested
`envswitch`. We do not down-weight `envswitch` availability on the strength
of S5 alone — but if a real-device run ever shows `envswitch` rejected on
an ST 10, our preflight catches it, the migration aborts on the first
non-OK response, and the user gets a clear error rather than partial state.
an ST 10, the migration aborts on the first unconfirmed response and reports
whether runtime writes were confirmed or persistence is uncertain. Because the
commands are sequential, the user must read back all four fields before retrying
or rebooting.
### 8.6 Failure-mode matrix
@@ -492,10 +497,12 @@ What `migrateViaTelnet` does in each failure mode (verified by
| Failure | Outcome | Test |
|------------------------------------------------|---------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------|
| Port 17000 closed / TCP unreachable | `Dial` errors before any command is sent; UI shows the error; nothing persisted | `TestMigrateViaTelnet_DialFailureReturnsError` |
| `sys configuration` rejected (cmd #1) | Sequence aborts; verification not sent; rest of commands not attempted | `TestMigrateViaTelnet_CommandNotFoundAborts` (envswitch variant — generalises) |
| `envswitch boseurls set` rejected | Sequence aborts; runtime-only `sys configuration` state reverts on reboot — no permanent damage | `TestMigrateViaTelnet_CommandNotFoundAborts` |
| Verification mismatch (URLs not echoed back) | Loud "verification failed" error; live state may persist until reboot but UI never claims success | `TestMigrateViaTelnet_VerifyMismatchFails` |
| Port 17000 closed / TCP unreachable | `Dial` errors before any command is sent; UI shows the error; nothing persisted | `TestMigrateViaTelnet_DialFailureReturnsError` |
| `sys configuration` rejected | Sequence aborts; earlier or attempted runtime writes may have applied; read back all four fields | `TestMigrateViaTelnet_GenericRuntimeRejectionReportsPartialState` |
| `envswitch boseurls set` rejected | Sequence aborts after four confirmed runtime writes; persistence outcome is uncertain; inspect before rebooting | `TestMigrateViaTelnet_EnvswitchRejectionReportsUncertainPersistence` |
| Verification mismatch (URLs not echoed back) | Loud error after accepted `envswitch`; runtime differs and persistence may already have changed; UI never claims success | `TestMigrateViaTelnet_VerifyMismatchFails` |
| Invalid or command-unsafe URL input | Rejected before a telnet client is created or any device connection is attempted | `TestMigrateViaTelnet_RejectsUnsafeURLsBeforeCreatingClient` |
| Concurrent URL mutations for one speaker | Process-local per-speaker lock keeps command sequences contiguous; different processes remain out of scope | `TestTelnetURLMutationsSameSpeakerAreSerialized` |
| `/setMargeAccount` 502 / hang | 5s connect + 12s total budget enforced; falls through to telnet `envswitch accountid set` | `TestPairAccount_FallsBackWhenHTTPReturnsServerError` |
| `/setMargeAccount` missing in `/supportedURLs` | HTTP path skipped; goes straight to telnet `envswitch accountid set` | `TestPairAccount_FallsBackWhenSetMargeAccountMissing` |
| Both pairing paths unavailable | Structured error: "use the official Bose app before EOS, or open SSH and use the XML method" | `TestPairAccount_NoTelnetAndHTTPMissingReturnsClearError`, `TestPairAccount_TelnetCommandNotFoundReportsBothPaths` |
@@ -504,7 +511,7 @@ What `migrateViaTelnet` does in each failure mode (verified by
- **Green light** — ST 10, ST 20, ST 300, Wave III, Wave IV on FW 27.0.6 (multi-reporter agreement).
- **Yellow** — ST Portable and BST20 Portable: migration works, pairing needs our fallback (already implemented).
- **Red, but fails cleanly** — SA-5 on FW 9.x, possibly newer ST Portable builds.
- **Red, aborts with explicit state diagnostics** — SA-5 on FW 9.x, possibly newer ST Portable builds.
- **Unverified but expected to work** — ST 30, ST 520, Wave Music System I/II.
The most useful next verification step is touching a real ST 30 and ST 520
+26 -6
View File
@@ -1399,17 +1399,37 @@ soundtouch-cli --host <device> setup migrate --method telnet \
#### `setup revert`
Undoes a migration the CLI equivalent of the web UI's "Revert to
Defaults" button. Restores `SoundTouchSdkPrivateCfg.xml`, `/etc/hosts`, and
`/etc/resolv.conf` from their `.original` backups, removes the AfterTouch
DNS-hook artifacts, and strips just the AfterTouch-labeled certificate out
of the trust bundle. No `--service-url` needed — everything it touches
already lives on the speaker.
Undoes a migration. The default `--method ssh` is the CLI equivalent of the
web UI's **Revert to Defaults** button: it restores
`SoundTouchSdkPrivateCfg.xml`, `/etc/hosts`, and `/etc/resolv.conf` from their
`.original` backups, removes the AfterTouch DNS-hook artifacts, and strips
just the AfterTouch-labeled certificate out of the trust bundle.
```bash
soundtouch-cli --host <device> setup revert
```
For a telnet-only migration, `--method telnet` restores the four canonical
Bose service URLs without requiring SSH or an XML backup:
```bash
soundtouch-cli --host <device> setup revert --method telnet
```
This only changes `margeServerUrl`, `statsServerUrl`, `swUpdateUrl`, and
`bmxRegistryUrl`. It does not restore filesystem, DNS, CA, SSH, or account
state. Reboot the speaker afterwards and verify all four persisted values.
The `--marge-url`, `--stats-url`, `--sw-update-url`, and `--bmx-url` flags can
override the canonical defaults for firmware- or region-specific values.
These flags require `--method telnet`; using them with the default SSH method
is an error. Each value must be an absolute HTTP or HTTPS service URL without
userinfo, query parameters, fragments, whitespace, control characters, or
shell metacharacters.
Telnet writes are sequential rather than transactional. If the command reports
an error, read back and reconcile all four fields before retrying or rebooting;
the error distinguishes a partial runtime update from an uncertain persistence
outcome after `envswitch`.
**Out of scope for this command** (matches the web UI button): SSH /
`remote_services` persistence (use `setup remote-services --remove`) and
account pairing (use `account unpair`) are untouched — revert them
+1 -1
View File
@@ -345,7 +345,7 @@ The wizard is still the recommended path for a one-off migration of an existing
If you need to undo a migration:
- **From the web UI**: Use the **Revert to Defaults** action on the device — this restores the `.original` backup files created on the speaker during the XML migration.
- **Telnet-only migrations**: the wizard writes both the runtime configuration layer (`sys configuration …`) and the persistent layer (`envswitch boseurls set …`) so the migration survives reboot. If you want to revert quickly, the cleanest path is to re-run the wizard with the original Bose URLs in the URL editor.
- **Telnet-only migrations**: the wizard writes both the runtime configuration layer (`sys configuration …`) and the persistent layer (`envswitch boseurls set …`) so the migration survives reboot. Use **Restore Bose URLs via Telnet** in the web UI or run `soundtouch-cli --host <device> setup revert --method telnet`. This restores only the four canonical Bose URL fields; use the CLI URL override flags if your original firmware- or region-specific values differ. The web action is offered whenever the live telnet configuration contains a non-canonical URL, including a URL for an older AfterTouch backend.
- **Via SSH**: The original XML config is backed up on the speaker with a `.original` suffix. Restore it manually if the UI is unreachable.
- **Factory reset**: As a last resort, perform a factory reset (see [Device Initial Setup](DEVICE-INITIAL-SETUP.md) for button sequences). This wipes all configuration and returns the speaker to out-of-box state.
+9 -6
View File
@@ -1,21 +1,21 @@
---
title: "Migration & Safety Guide"
---
Starting a migration on real hardware requires a "Safety First" approach. This guide outlines the safety features implemented in the `soundtouch-service` and provides a checklist for a successful migration.
Starting a migration on real hardware requires a "Safety First" approach. This guide outlines the safety features implemented in the `soundtouch-service` and provides a checklist for a successful migration. The available safeguards depend on the migration method: SSH-backed methods can preserve files, while telnet-only URL migration is sequential and creates no filesystem backup.
#### 🛠 Technical Safety Enhancements
The following features are built into the `soundtouch-service` to ensure stability and easy rollbacks:
1. **Off-Device Backups**: Before any migration starts, the service automatically fetches the original `SoundTouchSdkPrivateCfg.xml` and `/etc/hosts` from your speaker and saves them locally in your `data/default/devices/<SERIAL>/` directory. This ensures you have a recovery path even if the speaker's filesystem becomes inaccessible.
2. **Pre-flight Write Verification**: The migration process includes a mandatory check for SSH write access (`rw`) before attempting any modifications. This prevents "half-baked" migrations where a script might fail halfway through due to a read-only filesystem.
1. **Off-Device Backups**: Before an SSH-backed migration starts, the service fetches the original `SoundTouchSdkPrivateCfg.xml` and `/etc/hosts` from your speaker and saves them locally in your `data/default/devices/<SERIAL>/` directory. This ensures you have a recovery path even if the speaker's filesystem becomes inaccessible. Telnet-only migration does not create these files.
2. **Pre-flight Write Verification**: SSH-backed migration checks for write access (`rw`) before modifying files. Telnet migration instead checks each command response and reads back all four runtime URL fields; its writes remain sequential rather than atomic.
3. **Automatic Safety on Sync**: Running a "Sync" in the Web UI or CLI automatically triggers an off-device backup, making it the perfect first step for any new device discovery.
#### 📋 Professional Migration Checklist
Before you proceed with the actual migration, follow these steps:
1. **Enable SSH Access (Prerequisite)**: This toolkit requires SSH access to your speakers, which is not enabled by default.
1. **Enable SSH Access (SSH-backed methods only)**: SSH is not enabled by default. Skip this step for a telnet-only URL migration.
- Create a file named `remote_services` on a FAT-formatted USB drive. The drive may need its bootable flag set — see [SoundCork issue #172](https://github.com/deborahgu/soundcork/issues/172) for details.
- Insert the USB stick into the SoundTouch speaker's **SERVICE** port.
- Reboot the speaker (unplug and replug).
@@ -25,14 +25,15 @@ Before you proceed with the actual migration, follow these steps:
3. **Initial Discovery & Sync**:
- Run `soundtouch-cli discover devices` to ensure connectivity.
- Use the Web UI or CLI to "Sync" the device. This will automatically backup your presets and system configuration files to your local server.
4. **Validate SSH Access**: Confirm the device responds to SSH without a password.
4. **Validate SSH Access (SSH-backed methods only)**: Confirm the device responds to SSH without a password.
- In the Web UI **Migration** tab, select your speaker and verify that the "SSH Connection" status shows ✅ Success.
- This toolkit automatically handles the necessary SSH parameters (ciphers and key exchanges) required by older Bose firmware.
5. **Migration Methods**:
- **XML redirect (default)**: Uploads a config file to the speaker via the Web API. Less invasive — only changes the application-level service URLs. Best for testing or single-device migration.
- **Telnet URL redirect**: Writes the four service URLs through the port-17000 diagnostic shell without SSH. The commands are sequential, so a failed run can leave partial runtime state and must be inspected before retry or reboot.
- **DNS/DHCP redirect**: Configures the speaker to use a custom DNS server that resolves Bose hostnames to the local service. Best for all-device coverage; requires the AfterTouch DNS server running on port 53. The service includes a pre-flight check before applying this method.
The web UI walks you through both methods. Both require the CA certificate to be trusted on the speaker for HTTPS to work — the web UI handles this as part of the migration flow.
The web UI walks you through the available methods. When the target uses HTTPS, its CA certificate must be trusted on the speaker; the web UI handles this as part of the migration flow.
6. **Monitor Logs**: Run the `soundtouch-service` with `DEBUG` or `INFO` logging to see the step-by-step progress of the migration.
#### 🔄 Rollback Strategy
@@ -40,6 +41,8 @@ Before you proceed with the actual migration, follow these steps:
If something goes wrong or you want to return to the original Bose cloud services:
* **Standard Revert**: Use the "Revert Migration" button in the Web UI or the corresponding CLI command. This restores the `.original` files created on the device.
* **Telnet URL Restore**: A telnet-only migration creates no filesystem backup. Use **Restore Bose URLs via Telnet** or `setup revert --method telnet` to restore the four canonical Bose URL fields, then reboot and verify them. This does not restore DNS, CA, SSH, account, or filesystem state; pass explicit URL overrides when the device's original values differ from the canonical defaults. If any command fails, read back and reconcile all four fields before rebooting because earlier runtime writes or the `envswitch` persistence commit may already have taken effect.
* **Concurrent Telnet Operations**: The service keeps URL-changing telnet sequences and telnet reboot operations contiguous per speaker. This process-local serialization prevents two HTTP requests from interleaving commands, but it cannot coordinate a separate CLI process or another service instance and does not make the device's multi-command update transactional.
* **Emergency Recovery**: If the device is unreachable via the UI but SSH still works, you can manually restore the files from your local `data/` directory using `scp` or the backups created on-device (`.original`).
* **Factory Reset**: As a last resort, Bose SoundTouch devices can be factory reset (usually by holding '1' and 'Volume Down' while plugging in). This will wipe all settings and return the device to the stock firmware configuration (the firmware itself remains at the current version, but configurations are reset).
@@ -477,6 +477,24 @@ curl -X POST "http://localhost:8000/setup/migrate/192.0.2.100?method=telnet&targ
curl -X POST "http://localhost:8000/setup/migrate/192.0.2.100?method=resolv&target_url=https://my-server.com:8443"
```
#### `POST /setup/revert/{deviceID}`
Reverts either an SSH-backed migration or the URL fields written by a telnet migration.
- No `method` query parameter, or `method=ssh`, preserves the existing behavior:
restore the on-speaker `.original` files and related SSH-managed state.
- `method=telnet` restores the four canonical Bose service URLs without SSH.
The optional `marge_url`, `stats_url`, `sw_update_url`, and `bmx_url` query
parameters override individual canonical values.
- Supplying those URL parameters with the default SSH method returns `400`
instead of silently ignoring them. Invalid or command-unsafe telnet URLs also
return `400` before a speaker connection is attempted.
The telnet path changes URL configuration only and does not reboot the speaker.
Its commands are sequential, so an error can mean partial runtime state or an
uncertain persistence outcome. Read back and reconcile all four fields before
retrying or rebooting. A successful response confirms the runtime readback;
verify persistence after the subsequent reboot.
#### `POST /setup/telnet-probe/{deviceIP}`
SSH-less reachability check. Temporarily flips the speaker's `swUpdateUrl` via the port-17000 diagnostic shell, triggers `:8090/swUpdateCheck` on the device, and observes whether the resulting outbound lands on this service's `/probe/{token}` handler within 6 s. Always attempts to restore the original `swUpdateUrl` even on failure.
+51 -5
View File
@@ -722,8 +722,12 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
status := http.StatusInternalServerError
var notReady *setup.MigrationDataNotReadyError
if errors.As(err, &notReady) {
switch {
case errors.As(err, &notReady):
status = http.StatusConflict
case errors.Is(err, setup.ErrInvalidTelnetURL):
status = http.StatusBadRequest
}
w.Header().Set("Content-Type", "application/json")
@@ -745,7 +749,9 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
}
}
// HandleRevertMigration reverts the migration for a device.
// HandleRevertMigration reverts the migration for a device. The existing
// no-query path restores SSH/filesystem backups; method=telnet restores only
// the four canonical Bose service URLs and accepts the migration URL overrides.
func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
@@ -773,10 +779,50 @@ func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
return
}
output, err := s.sm.RevertMigration(deviceIP)
if err != nil {
method := r.URL.Query().Get("method")
if (method == "" || method == "ssh") && len(presentTelnetURLOverrides(r.URL.Query())) > 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
w.WriteHeader(http.StatusBadRequest)
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{
"ok": false,
"message": "Telnet URL overrides require method=telnet",
}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
return
}
var output string
switch method {
case "", "ssh":
output, err = s.sm.RevertMigration(deviceIP)
case string(setup.MigrationMethodTelnet):
output, err = s.sm.RevertTelnetURLs(deviceIP, parseMigrationOptions(r.URL.Query()))
default:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{
"ok": false,
"message": fmt.Sprintf("Unsupported revert method %q; expected ssh or telnet", method),
}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
return
}
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, setup.ErrInvalidTelnetURL) {
status = http.StatusBadRequest
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error(), "output": output}); encodeErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+130
View File
@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
@@ -704,6 +705,10 @@ func TestMigrationAndCA(t *testing.T) {
sm.NewSSH = func(host string) setup.SSHClient {
return &mockSSH{host: host}
}
telnetMock := &mockSetupTelnet{}
sm.NewTelnet = func(string) setup.TelnetClient {
return telnetMock
}
// Mock HTTPGet to avoid real network timeouts
sm.HTTPGet = func(url string) (*http.Response, error) {
@@ -776,6 +781,22 @@ func TestMigrationAndCA(t *testing.T) {
t.Errorf("Migrate: Expected output field in response")
}
// Unsafe telnet migration input is a client error and never reaches the speaker.
unsafeMigrateCommandCount := len(telnetMock.commands)
unsafeTarget := url.QueryEscape("http://192.0.2.100:8000\r\nsys reboot")
res, err = http.Post(ts.URL+"/setup/migrate/192.0.2.10?method=telnet&target_url="+unsafeTarget, "application/json", nil)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusBadRequest {
t.Errorf("Unsafe telnet migration: expected status 400, got %v", res.Status)
}
if len(telnetMock.commands) != unsafeMigrateCommandCount {
t.Errorf("Unsafe telnet migration sent commands: before=%d after=%d", unsafeMigrateCommandCount, len(telnetMock.commands))
}
// 3. Test POST /setup/trust-ca/{deviceIP}
res, err = http.Post(ts.URL+"/setup/trust-ca/192.0.2.10", "application/json", nil)
if err != nil {
@@ -838,6 +859,81 @@ func TestMigrationAndCA(t *testing.T) {
if _, ok := result["output"]; !ok {
t.Errorf("RemoveRemote: Expected output field in response")
}
// 6. Telnet-only revert uses the dedicated URL restore path.
res, err = http.Post(ts.URL+"/setup/revert/192.0.2.10?method=telnet", "application/json", nil)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Telnet revert: expected status OK, got %v", res.Status)
}
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
t.Fatalf("Telnet revert: failed to decode response: %v", err)
}
if result["ok"] != true {
t.Errorf("Telnet revert: expected ok=true, got %v", result["ok"])
}
commands := strings.Join(telnetMock.commands, "\n")
for _, want := range []string{
"sys configuration margeServerUrl https://streaming.bose.com",
"sys configuration statsServerUrl https://events.api.bosecm.com",
"sys configuration swUpdateUrl https://worldwide.bose.com/updates/soundtouch",
"sys configuration bmxRegistryUrl https://content.api.bose.io/bmx/registry/v1/services",
"envswitch boseurls set https://streaming.bose.com https://worldwide.bose.com/updates/soundtouch",
"getpdo CurrentSystemConfiguration",
} {
if !strings.Contains(commands, want) {
t.Errorf("Telnet revert commands missing %q:\n%s", want, commands)
}
}
// 7. SSH revert rejects telnet-only URL overrides instead of ignoring them.
commandCount := len(telnetMock.commands)
res, err = http.Post(ts.URL+"/setup/revert/192.0.2.10?method=ssh&marge_url=https%3A%2F%2Foverride.example%2Fmarge", "application/json", nil)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusBadRequest {
t.Errorf("SSH revert with telnet overrides: expected status 400, got %v", res.Status)
}
if len(telnetMock.commands) != commandCount {
t.Errorf("SSH revert with telnet overrides sent telnet commands: before=%d after=%d", commandCount, len(telnetMock.commands))
}
// 8. Unsafe telnet URL input fails before any command is sent.
unsafeURL := url.QueryEscape("https://override.example/marge\r\nsys reboot")
res, err = http.Post(ts.URL+"/setup/revert/192.0.2.10?method=telnet&marge_url="+unsafeURL, "application/json", nil)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusBadRequest {
t.Errorf("Unsafe telnet URL: expected status 400, got %v", res.Status)
}
if len(telnetMock.commands) != commandCount {
t.Errorf("Unsafe telnet URL sent commands: before=%d after=%d", commandCount, len(telnetMock.commands))
}
// 9. Unknown revert methods fail before touching either transport.
res, err = http.Post(ts.URL+"/setup/revert/192.0.2.10?method=invalid", "application/json", nil)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusBadRequest {
t.Errorf("Invalid revert method: expected status 400, got %v", res.Status)
}
if len(telnetMock.commands) != commandCount {
t.Errorf("Invalid revert method sent telnet commands: before=%d after=%d", commandCount, len(telnetMock.commands))
}
}
func TestRemoveDevice(t *testing.T) {
@@ -939,6 +1035,40 @@ type mockSSH struct {
uploaded map[string][]byte
}
type mockSetupTelnet struct {
commands []string
}
func (m *mockSetupTelnet) Dial() error { return nil }
func (m *mockSetupTelnet) Probe() (string, error) { return "->", nil }
func (m *mockSetupTelnet) SendCommand(command string) (string, error) {
m.commands = append(m.commands, command)
if command == "getpdo CurrentSystemConfiguration" {
return `margeServerUrl {
text: "https://streaming.bose.com"
}
statsServerUrl {
text: "https://events.api.bosecm.com"
}
swUpdateUrl {
text: "https://worldwide.bose.com/updates/soundtouch"
}
bmxRegistryUrl {
text: "https://content.api.bose.io/bmx/registry/v1/services"
}`, nil
}
if fields := strings.Fields(command); len(fields) == 5 &&
fields[0] == "envswitch" && fields[1] == "boseurls" && fields[2] == "set" {
return "Setting Bose Server URLs to " + fields[3] + " and " + fields[4] + "\n->OK\n->", nil
}
return "OK", nil
}
func (m *mockSetupTelnet) Close() error { return nil }
func (m *mockSSH) Run(command string) (string, error) {
if strings.Contains(command, "cat /etc/hosts") {
m.runCount++
+19
View File
@@ -2,6 +2,13 @@ package handlers
import "net/url"
var telnetMigrationURLKeys = []string{
"marge_url",
"stats_url",
"sw_update_url",
"bmx_url",
}
// migrationOptionKeys is the allow-list of query parameters carried into
// the migration manager's options map. Two families coexist:
//
@@ -43,3 +50,15 @@ func parseMigrationOptions(query url.Values) map[string]string {
return out
}
func presentTelnetURLOverrides(query url.Values) []string {
var present []string
for _, key := range telnetMigrationURLKeys {
if _, ok := query[key]; ok {
present = append(present, key)
}
}
return present
}
+7
View File
@@ -826,6 +826,13 @@
>
Revert to Defaults
</button>
<button
id="revert-telnet-btn"
class="btn-danger"
style="padding: 10px 20px; display: none"
>
Restore Bose URLs via Telnet
</button>
<button
id="reboot-speaker-btn"
style="padding: 10px 20px"
+52
View File
@@ -2364,6 +2364,11 @@ async function showSummary(deviceId) {
revertBtn.disabled = !summary.ssh_success;
revertBtn.style.display = summary.original_config ? "inline-block" : "none";
const revertTelnetBtn = document.getElementById("revert-telnet-btn");
revertTelnetBtn.onclick = () => revertTelnetURLs(deviceId);
revertTelnetBtn.disabled = !summary.telnet_reachable;
revertTelnetBtn.style.display = summary.telnet_revert_available ? "inline-block" : "none";
const rebootBtn = document.getElementById("reboot-speaker-btn");
rebootBtn.onclick = () => reboot(deviceId, ip);
rebootBtn.disabled = !anyTransport;
@@ -2443,6 +2448,53 @@ async function revert(deviceId, ip) {
}
}
async function revertTelnetURLs(deviceId) {
if (!deviceId) {
alert("Please select a device.");
return;
}
const display = getDeviceDisplayName(deviceId);
if (!confirm(
"Restore the canonical Bose service URLs on " + display + " via Telnet? " +
"This changes only the four URL fields; it does not restore filesystem, DNS, CA, SSH, or account state. " +
"The writes are sequential, so inspect all four fields if an error occurs.",
)) {
return;
}
const revertTelnetBtn = document.getElementById("revert-telnet-btn");
revertTelnetBtn.disabled = true;
const statusDiv = document.getElementById("status");
statusDiv.style.display = "block";
statusDiv.style.backgroundColor = "#ffffcc";
statusDiv.textContent = "Restoring canonical Bose URLs on " + display + " via Telnet...";
try {
const response = await fetch(
"/api/setup/revert/" + encodeURIComponent(deviceId) + "?method=telnet",
{method: "POST"},
);
const result = await response.json();
showCommandOutput(result);
if (result.ok) {
statusDiv.style.backgroundColor = "#ccffcc";
statusDiv.textContent = "Restored canonical Bose URLs on " + display +
". Reboot the speaker, then verify all four persisted URL fields.";
} else {
revertTelnetBtn.disabled = false;
statusDiv.style.backgroundColor = "#ffcccc";
statusDiv.textContent = "Telnet URL restore failed for " + display + ": " +
(result.message || "Unknown error");
}
} catch (error) {
revertTelnetBtn.disabled = false;
statusDiv.style.backgroundColor = "#ffcccc";
statusDiv.textContent = "Error restoring Bose URLs on " + display + ": " + error;
}
}
async function reboot(deviceId, ip) {
if (!deviceId) {
alert("Please select a device.");
+13
View File
@@ -104,6 +104,9 @@ func (m *Manager) runTelnetInjection(deviceIP string, forbidQuote, cmds []string
}
}
unlock := m.lockTelnetURLMutation(deviceIP)
defer unlock()
var logs strings.Builder
t := m.NewTelnet(deviceIP)
@@ -154,6 +157,9 @@ func (m *Manager) setBoseURLsViaTelnet(deviceIP, marge, swUpdate string) (string
return "", errors.New("boseurls values must not contain a double quote")
}
unlock := m.lockTelnetURLMutation(deviceIP)
defer unlock()
var logs strings.Builder
t := m.NewTelnet(deviceIP)
@@ -191,10 +197,17 @@ func (m *Manager) setBoseURLsViaTelnet(deviceIP, marge, swUpdate string) (string
// injection), this mirrors telnetURLs.Commands()'s full sequence so the
// envswitch commit captures fresh values for all four fields, not just two.
func (m *Manager) setAllBoseURLsViaTelnet(deviceIP string, urls telnetURLs) (string, error) {
if err := urls.validate(); err != nil {
return "", err
}
if m.NewTelnet == nil {
return "", errors.New("telnet not configured: Manager.NewTelnet is nil")
}
unlock := m.lockTelnetURLMutation(deviceIP)
defer unlock()
var logs strings.Builder
t := m.NewTelnet(deviceIP)
+1 -1
View File
@@ -242,7 +242,7 @@ func (m *Manager) runURLRewrite(plan InitPlan, emit func(StepKind, string, StepS
emit(StepURLRewrite, "telnet URL rewrite", StatusRunning, nil)
urls := defaultTelnetURLs(plan.ServiceURL)
if _, rwErr := m.migrateViaTelnet(plan.DeviceIP, plan.ServiceURL, urls); rwErr != nil {
if _, rwErr := m.migrateViaTelnet(plan.DeviceIP, urls); rwErr != nil {
emit(StepURLRewrite, "telnet URL rewrite", StatusFailed, rwErr)
return fmt.Errorf("URL rewrite: %w", rwErr)
}
@@ -29,6 +29,58 @@ func TestIsTelnetMigrated_DifferentHostname(t *testing.T) {
}
}
func TestCheckIsMigratedFromProbe_FormerBackendOffersTelnetRevert(t *testing.T) {
m := &Manager{ServerURL: "http://current.example:8000"}
summary := &MigrationSummary{
TelnetReachable: true,
TelnetVerifiedConfig: flatGetpdoResponse(telnetURLs{
Marge: "http://former.example:8000/marge",
Stats: "http://former.example:8000",
SwUpdate: "http://former.example:8000/updates/soundtouch",
BmxRegistry: "http://former.example:8000/bmx/registry/v1/services",
}),
}
m.checkIsMigratedFromProbe(summary, &speakerProbe{})
if summary.TelnetMigrated {
t.Error("TelnetMigrated = true, want false for a former backend")
}
if !summary.TelnetRevertAvailable {
t.Error("TelnetRevertAvailable = false, want rollback for a former backend")
}
}
func TestCheckIsMigratedFromProbe_CanonicalBoseURLsDoNotOfferTelnetRevert(t *testing.T) {
m := &Manager{ServerURL: "http://current.example:8000"}
summary := &MigrationSummary{
TelnetReachable: true,
TelnetVerifiedConfig: flatGetpdoResponse(canonicalBoseTelnetURLs()),
}
m.checkIsMigratedFromProbe(summary, &speakerProbe{})
if summary.TelnetRevertAvailable {
t.Error("TelnetRevertAvailable = true, want false for canonical Bose URLs")
}
}
func TestCheckIsMigratedFromProbe_PartialNonCanonicalConfigOffersTelnetRevert(t *testing.T) {
m := &Manager{ServerURL: "http://current.example:8000"}
summary := &MigrationSummary{
TelnetReachable: true,
TelnetVerifiedConfig: "margeServerUrl=https://streaming.bose.com\n" +
"statsServerUrl=http://legacy.example:8000\n",
}
m.checkIsMigratedFromProbe(summary, &speakerProbe{})
if !summary.TelnetRevertAvailable {
t.Error("TelnetRevertAvailable = false, want rollback for partial non-canonical config")
}
}
func TestIsTelnetMigrated_EmptyVerifiedConfig(t *testing.T) {
m := &Manager{ServerURL: "http://example:8000"}
@@ -77,6 +77,10 @@ func TestGetMigrationSummary_TelnetSucceedsSSHFails(t *testing.T) {
if !strings.Contains(summary.TelnetVerifiedConfig, target) {
t.Errorf("TelnetVerifiedConfig = %q, want it to contain %q", summary.TelnetVerifiedConfig, target)
}
if !summary.TelnetRevertAvailable {
t.Error("TelnetRevertAvailable = false, want rollback for live non-canonical URLs")
}
}
func TestGetMigrationSummary_TelnetFailsSSHFails(t *testing.T) {
+30 -5
View File
@@ -14,6 +14,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
@@ -113,10 +114,11 @@ type MigrationSummary struct {
// Telnet (port 17000) preflight state — populated when the user is about to
// or has just used MigrationMethodTelnet.
TelnetReachable bool `json:"telnet_reachable"`
TelnetBanner string `json:"telnet_banner,omitempty"`
TelnetVerifiedConfig string `json:"telnet_verified_config,omitempty"`
TelnetProbeError string `json:"telnet_probe_error,omitempty"`
TelnetReachable bool `json:"telnet_reachable"`
TelnetBanner string `json:"telnet_banner,omitempty"`
TelnetVerifiedConfig string `json:"telnet_verified_config,omitempty"`
TelnetProbeError string `json:"telnet_probe_error,omitempty"`
TelnetRevertAvailable bool `json:"telnet_revert_available"`
// KnownAccountIDs are accountIDs already present in the local datastore;
// the UI offers them as choices when pairing a fresh device.
@@ -161,6 +163,11 @@ type Manager struct {
NewSSH func(host string) SSHClient
NewTelnet func(host string) TelnetClient
// URL-changing telnet operations are multi-command sequences. Keep each
// speaker's sequence contiguous while allowing different speakers to run
// independently.
telnetURLMutationLocks sync.Map // device IP -> *sync.Mutex
// NewSession opens the WebSocket setup state-machine session used
// by ExecuteInitPlan. Tests inject an in-memory fake; the production
// default is DialSession.
@@ -177,6 +184,19 @@ type Manager struct {
MgmtPassword string
}
func (m *Manager) lockTelnetURLMutation(deviceIP string) func() {
value, _ := m.telnetURLMutationLocks.LoadOrStore(deviceIP, &sync.Mutex{})
mu, ok := value.(*sync.Mutex)
if !ok {
panic("setup: telnet URL mutation lock has unexpected type")
}
mu.Lock()
return mu.Unlock
}
// NewManager creates a new Manager with the given base server URL.
func NewManager(serverURL string, ds *datastore.DataStore, cm *certmanager.CertificateManager) *Manager {
return &Manager{
@@ -539,6 +559,7 @@ func (m *Manager) buildServerHTTPSURL(targetURL string) string {
// up in `getpdo CurrentSystemConfiguration`.
func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) {
summary.TelnetMigrated = m.isTelnetMigrated(summary)
summary.TelnetRevertAvailable = telnetRevertAvailable(summary.TelnetVerifiedConfig)
if summary.SSHSuccess {
client := m.NewSSH(deviceIP)
@@ -911,7 +932,7 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
// rooted via remote_services.
if method == MigrationMethodTelnet {
urls := telnetURLsFromOptions(targetURL, options)
telnetLogs, telnetErr := m.migrateViaTelnet(deviceIP, targetURL, urls)
telnetLogs, telnetErr := m.migrateViaTelnet(deviceIP, urls)
return preflightLogs + telnetLogs, telnetErr
}
@@ -2281,6 +2302,10 @@ func (m *Manager) rebootViaTelnet(deviceIP string) (string, error) {
return "", errors.New("telnet reboot not configured: Manager.NewTelnet is nil")
}
// Do not let a reboot cut through a multi-command URL mutation.
unlock := m.lockTelnetURLMutation(deviceIP)
defer unlock()
fmt.Printf("Rebooting speaker at %s via telnet\n", deviceIP)
t := m.NewTelnet(deviceIP)
+1
View File
@@ -159,6 +159,7 @@ func probeDeviceTagFor(summary *MigrationSummary) string {
// or marker comment is always present, so this is acceptable.
func (m *Manager) checkIsMigratedFromProbe(summary *MigrationSummary, probe *speakerProbe) {
summary.TelnetMigrated = m.isTelnetMigrated(summary)
summary.TelnetRevertAvailable = telnetRevertAvailable(summary.TelnetVerifiedConfig)
if probe.SSHOK {
summary.XMLMigrated = m.isXMLMigrated(summary)
+242 -17
View File
@@ -3,9 +3,15 @@ package setup
import (
"errors"
"fmt"
"net/url"
"strings"
"unicode"
)
// ErrInvalidTelnetURL identifies URL input rejected before any device
// connection or command is attempted.
var ErrInvalidTelnetURL = errors.New("invalid telnet URL")
// telnetURLs holds the four URLs the migration writes via telnet. Most
// users keep all four pointing at the same service base; per-field
// overrides exist mainly so soundcork users can append /marge to the
@@ -17,6 +23,20 @@ type telnetURLs struct {
BmxRegistry string
}
type telnetURLField struct {
configName string
value string
}
func (u telnetURLs) fields() []telnetURLField {
return []telnetURLField{
{configName: "margeServerUrl", value: u.Marge},
{configName: "statsServerUrl", value: u.Stats},
{configName: "swUpdateUrl", value: u.SwUpdate},
{configName: "bmxRegistryUrl", value: u.BmxRegistry},
}
}
// defaultTelnetURLs returns the canonical URL set derived from the
// soundtouch-service base targetURL.
func defaultTelnetURLs(targetURL string) telnetURLs {
@@ -28,6 +48,17 @@ func defaultTelnetURLs(targetURL string) telnetURLs {
}
}
// canonicalBoseTelnetURLs returns the public Bose endpoints restored by a
// telnet revert.
func canonicalBoseTelnetURLs() telnetURLs {
return telnetURLs{
Marge: "https://streaming.bose.com",
Stats: "https://events.api.bosecm.com",
SwUpdate: "https://worldwide.bose.com/updates/soundtouch",
BmxRegistry: "https://content.api.bose.io/bmx/registry/v1/services",
}
}
// telnetURLsFromOptions resolves the four URLs from targetURL plus
// per-field overrides supplied via the migration options map. Recognised
// keys are marge_url, stats_url, sw_update_url, bmx_url; missing or empty
@@ -39,8 +70,10 @@ func defaultTelnetURLs(targetURL string) telnetURLs {
// one base URL plus optional path suffixes — and let the service layer
// hold any non-trivial logic.
func telnetURLsFromOptions(targetURL string, options map[string]string) telnetURLs {
u := defaultTelnetURLs(targetURL)
return defaultTelnetURLs(targetURL).withOptions(options)
}
func (u telnetURLs) withOptions(options map[string]string) telnetURLs {
if v := options["marge_url"]; v != "" {
u.Marge = v
}
@@ -60,6 +93,89 @@ func telnetURLsFromOptions(targetURL string, options map[string]string) telnetUR
return u
}
func (u telnetURLs) validate() error {
for _, field := range u.fields() {
if err := validateTelnetURL(field.configName, field.value); err != nil {
return err
}
}
return nil
}
func validateTelnetURL(field, value string) error {
invalid := func(reason string) error {
return fmt.Errorf("%w for %s: %s", ErrInvalidTelnetURL, field, reason)
}
if value == "" {
return invalid("value is empty")
}
if strings.IndexFunc(value, func(r rune) bool {
return unicode.IsControl(r) || unicode.IsSpace(r)
}) >= 0 {
return invalid("whitespace and control characters are not allowed")
}
// These characters can change command parsing or the persisted shell
// expression used by Bose firmware. Clean service URLs do not need them.
if strings.ContainsAny(value, "\"'`;\\|&$<>(){}") {
return invalid("shell metacharacters are not allowed")
}
parsed, err := url.Parse(value)
if err != nil {
return invalid("value cannot be parsed")
}
if !strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https") {
return invalid("scheme must be http or https")
}
if parsed.Hostname() == "" {
return invalid("host is required")
}
if parsed.User != nil {
return invalid("user information is not allowed")
}
if parsed.RawQuery != "" || parsed.ForceQuery {
return invalid("query parameters are not allowed")
}
if parsed.Fragment != "" {
return invalid("fragments are not allowed")
}
return nil
}
func telnetRevertAvailable(response string) bool {
current := parseGetpdoConfig(response)
for _, field := range canonicalBoseTelnetURLs().fields() {
if value, ok := current[field.configName]; ok && value != "" && value != field.value {
return true
}
}
return false
}
// RevertTelnetURLs restores the canonical Bose URL configuration over the
// device's port-17000 shell. Per-field URL options take precedence over the
// defaults.
func (m *Manager) RevertTelnetURLs(deviceIP string, options map[string]string) (string, error) {
urls := canonicalBoseTelnetURLs().withOptions(options)
logs := "Restoring Bose URL configuration only; no factory reset, account change, or reboot will be performed. " +
"Telnet writes are sequential, not transactional; if the operation fails, read back all four URL fields before retrying or rebooting.\n"
migrationLogs, err := m.migrateViaTelnet(deviceIP, urls)
return logs + migrationLogs, err
}
// Commands returns the canonical sequence of telnet commands. Order
// matters: `sys configuration …` writes the runtime layer; the closing
// `envswitch boseurls set …` writes the parallel persistence layer that
@@ -85,19 +201,21 @@ func (u telnetURLs) Commands() []string {
// to the user, who triggers it via the existing reboot button (which now
// accepts a method=telnet|ssh selector).
//
// The sequence aborts on the first non-OK response so we never half-write the
// configuration; the caller can retry safely after fixing the underlying
// issue (closed port, hardened firmware, etc.).
//
// targetURL is kept as a separate verification anchor: most users have
// every URL share that base, so substring-matching it against the
// device's `getpdo` reply is the simplest "did the writes stick?" check
// that still works for the soundcork "/marge on one field" case.
func (m *Manager) migrateViaTelnet(deviceIP, targetURL string, urls telnetURLs) (string, error) {
// Commands are sent sequentially and cannot be rolled back atomically. Errors
// therefore report whether runtime writes were confirmed or the persistence
// command may already have taken effect.
func (m *Manager) migrateViaTelnet(deviceIP string, urls telnetURLs) (string, error) {
if err := urls.validate(); err != nil {
return "", err
}
if m.NewTelnet == nil {
return "", errors.New("telnet migration not configured: Manager.NewTelnet is nil")
}
unlock := m.lockTelnetURLMutation(deviceIP)
defer unlock()
var logs strings.Builder
t := m.NewTelnet(deviceIP)
@@ -112,28 +230,41 @@ func (m *Manager) migrateViaTelnet(deviceIP, targetURL string, urls telnetURLs)
fmt.Fprintf(&logs, "Telnet banner: %q\n", strings.TrimSpace(banner))
}
for _, cmd := range urls.Commands() {
commands := urls.Commands()
runtimeWrites := 0
for i, cmd := range commands {
persistenceCommand := i == len(commands)-1
resp, err := t.SendCommand(cmd)
if err != nil {
return logs.String(), fmt.Errorf("telnet command %q failed: %w", cmd, err)
return logs.String(), fmt.Errorf("telnet command %q failed: %w; %s", cmd, err,
telnetWriteFailureContext(runtimeWrites, persistenceCommand))
}
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 err := validateTelnetMutationResponse(cmd, resp, persistenceCommand, urls); err != nil {
return logs.String(), fmt.Errorf("%w; %s", err,
telnetWriteFailureContext(runtimeWrites, persistenceCommand))
}
if !persistenceCommand {
runtimeWrites++
}
}
verify, err := t.SendCommand("getpdo CurrentSystemConfiguration")
if err != nil {
return logs.String(), fmt.Errorf("verification command failed: %w", err)
return logs.String(), fmt.Errorf("verification command failed after envswitch was accepted: %w; "+
"persistence may already have changed, so read back and reconcile all four URL fields before rebooting", err)
}
fmt.Fprintf(&logs, "→ getpdo CurrentSystemConfiguration (runtime layer only — confirms the writes were accepted, not that they'll survive a reboot)\n%s\n", strings.TrimRight(verify, "\r\n"))
if !strings.Contains(verify, targetURL) {
return logs.String(), fmt.Errorf("verification failed: getpdo response does not contain %q (device may have rejected the new URLs)", targetURL)
if err := verifyTelnetURLs(verify, urls); err != nil {
return logs.String(), fmt.Errorf("%w; envswitch was accepted, so persistence may already have changed; "+
"read back and reconcile all four URL fields before rebooting", err)
}
logs.WriteString("Telnet writes accepted (runtime layer). Reboot the device so the envswitch-persisted layer takes over.\n")
@@ -141,6 +272,100 @@ func (m *Manager) migrateViaTelnet(deviceIP, targetURL string, urls telnetURLs)
return logs.String(), nil
}
func validateTelnetMutationResponse(cmd, response string, persistenceCommand bool, urls telnetURLs) error {
if isCommandNotFound(response) {
return fmt.Errorf("device rejected %q (firmware does not expose this command)", cmd)
}
if persistenceCommand {
expected := "Setting Bose Server URLs to " + urls.Marge + " and " + urls.SwUpdate
if hasTelnetPersistenceConfirmation(response, expected) {
return nil
}
} else if hasTelnetOKResponse(response) {
return nil
}
return fmt.Errorf("device did not confirm telnet command %q; response was %q", cmd, strings.TrimSpace(response))
}
func hasTelnetOKResponse(response string) bool {
return hasExactTelnetResponseLine(response, "OK", true)
}
func meaningfulTelnetResponseLines(response string) []string {
var meaningful []string
for _, raw := range strings.Split(response, "\n") {
line := strings.TrimSpace(raw)
for strings.HasPrefix(line, "->") {
line = strings.TrimSpace(strings.TrimPrefix(line, "->"))
}
if line != "" {
meaningful = append(meaningful, line)
}
}
return meaningful
}
func hasExactTelnetResponseLine(response, expected string, foldCase bool) bool {
meaningful := meaningfulTelnetResponseLines(response)
if len(meaningful) != 1 {
return false
}
if foldCase {
return strings.EqualFold(meaningful[0], expected)
}
return meaningful[0] == expected
}
// hasTelnetPersistenceConfirmation accepts exactly the two response shapes
// observed on SoundTouch firmware: a confirmation ending in a prompt marker,
// or the confirmation followed by a prompt-prefixed OK line.
func hasTelnetPersistenceConfirmation(response, expected string) bool {
meaningful := meaningfulTelnetResponseLines(response)
return len(meaningful) == 1 && meaningful[0] == expected+" ->" ||
len(meaningful) == 2 && meaningful[0] == expected && meaningful[1] == "OK"
}
func telnetWriteFailureContext(runtimeWrites int, persistenceAttempted bool) string {
if persistenceAttempted {
return "all four runtime URL writes were confirmed, but the persistence outcome is uncertain; " +
"read back all four URL fields before retrying or rebooting"
}
if runtimeWrites > 0 {
return fmt.Sprintf("%d runtime URL write(s) were confirmed, so the runtime configuration may be partial; "+
"read back all four URL fields before retrying or rebooting", runtimeWrites)
}
return "no URL write was confirmed, but the attempted command may have reached the speaker; " +
"read back all four URL fields before retrying or rebooting"
}
func verifyTelnetURLs(response string, want telnetURLs) error {
got := parseGetpdoConfig(response)
for _, field := range want.fields() {
value, ok := got[field.configName]
if !ok {
return fmt.Errorf("verification failed: getpdo response is missing %s", field.configName)
}
if value != field.value {
return fmt.Errorf("verification failed: %s is %q, want %q", field.configName, value, field.value)
}
}
return nil
}
// isCommandNotFound returns true if the device's response to a command
// indicates the command is not available on this firmware. Different firmware
// builds use slightly different wording; we accept any of the observed
+258 -16
View File
@@ -47,17 +47,47 @@ func newFakeTelnetManager(f *fakeTelnet) *Manager {
return m
}
func happyResponses(targetURL string) map[string]string {
func flatGetpdoResponse(urls telnetURLs) string {
return "margeServerUrl=" + urls.Marge + "\n" +
"statsServerUrl=" + urls.Stats + "\n" +
"swUpdateUrl=" + urls.SwUpdate + "\n" +
"bmxRegistryUrl=" + urls.BmxRegistry + "\n"
}
func protobufGetpdoResponse(urls telnetURLs) string {
return `margeServerUrl {
text: "` + urls.Marge + `"
}
statsServerUrl {
text: "` + urls.Stats + `"
}
swUpdateUrl {
text: "` + urls.SwUpdate + `"
}
bmxRegistryUrl {
text: "` + urls.BmxRegistry + `"
}
->OK
`
}
func telnetResponses(urls telnetURLs, verify string) map[string]string {
return map[string]string{
"sys configuration bmxRegistryUrl " + targetURL + "/bmx/registry/v1/services": "OK\n",
"sys configuration statsServerUrl " + targetURL: "OK\n",
"sys configuration margeServerUrl " + targetURL: "OK\n",
"sys configuration swUpdateUrl " + targetURL + "/updates/soundtouch": "OK\n",
"envswitch boseurls set " + targetURL + " " + targetURL + "/updates/soundtouch": "OK\n",
"getpdo CurrentSystemConfiguration": "margeServerUrl=" + targetURL + "\nbmxRegistryUrl=" + targetURL + "/bmx/registry/v1/services\n",
"sys configuration bmxRegistryUrl " + urls.BmxRegistry: "OK\n",
"sys configuration statsServerUrl " + urls.Stats: "OK\n",
"sys configuration margeServerUrl " + urls.Marge: "OK\n",
"sys configuration swUpdateUrl " + urls.SwUpdate: "OK\n",
"envswitch boseurls set " + urls.Marge + " " + urls.SwUpdate: "Setting Bose Server URLs to " + urls.Marge + " and " + urls.SwUpdate + " ->\n",
"getpdo CurrentSystemConfiguration": verify,
}
}
func happyResponses(targetURL string) map[string]string {
urls := defaultTelnetURLs(targetURL)
return telnetResponses(urls, flatGetpdoResponse(urls))
}
func TestMigrateViaTelnet_HappyPath(t *testing.T) {
target := "http://example:8000"
f := &fakeTelnet{
@@ -66,7 +96,7 @@ func TestMigrateViaTelnet_HappyPath(t *testing.T) {
}
m := newFakeTelnetManager(f)
logs, err := m.migrateViaTelnet("192.0.2.1", target, defaultTelnetURLs(target))
logs, err := m.migrateViaTelnet("192.0.2.1", defaultTelnetURLs(target))
if err != nil {
t.Fatalf("migrateViaTelnet: %v", err)
}
@@ -99,11 +129,116 @@ func TestMigrateViaTelnet_HappyPath(t *testing.T) {
}
}
func TestMigrateViaTelnet_AcceptsPromptedOKAfterEnvswitchConfirmation(t *testing.T) {
target := "http://example:8000"
urls := defaultTelnetURLs(target)
responses := happyResponses(target)
responses["envswitch boseurls set "+urls.Marge+" "+urls.SwUpdate] =
"Setting Bose Server URLs to " + urls.Marge + " and " + urls.SwUpdate + "\n->OK\n->"
f := &fakeTelnet{responses: responses}
m := newFakeTelnetManager(f)
if _, err := m.migrateViaTelnet("192.0.2.1", urls); err != nil {
t.Fatalf("migrateViaTelnet: %v", err)
}
}
func TestValidateTelnetMutationResponse_PersistenceShapes(t *testing.T) {
target := "http://example:8000"
urls := defaultTelnetURLs(target)
command := "envswitch boseurls set " + urls.Marge + " " + urls.SwUpdate
confirmation := "Setting Bose Server URLs to " + urls.Marge + " and " + urls.SwUpdate
tests := []struct {
name string
response string
accept bool
}{
{name: "legacy prompt suffix", response: confirmation + " ->\n", accept: true},
{name: "prompted ok", response: confirmation + "\n->OK\n->", accept: true},
{name: "bare confirmation", response: confirmation + "\n"},
{name: "bare ok", response: "OK\n"},
{name: "reversed order", response: "OK\n" + confirmation + "\n"},
{name: "duplicate ok", response: confirmation + "\n->OK\n->OK\n->"},
{name: "legacy plus ok", response: confirmation + " ->\n->OK\n->"},
{name: "unexpected line", response: confirmation + "\nwarning: not persisted\n->OK\n->"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateTelnetMutationResponse(command, tt.response, true, urls)
if tt.accept && err != nil {
t.Fatalf("response rejected: %v", err)
}
if !tt.accept && err == nil {
t.Fatal("response accepted")
}
})
}
}
func TestMigrateViaTelnet_RejectsUnsafeURLsBeforeCreatingClient(t *testing.T) {
tests := []struct {
name string
value string
}{
{name: "line break", value: "http://example:8000/marge\r\nsys reboot"},
{name: "space", value: "http://example:8000/not safe"},
{name: "shell metacharacter", value: "http://example:8000/;sys-reboot"},
{name: "unsupported scheme", value: "ftp://example:8000/marge"},
{name: "missing host", value: "http:/marge"},
{name: "userinfo", value: "http://user:secret@example:8000/marge"},
{name: "query", value: "http://example:8000/marge?mode=test"},
{name: "fragment", value: "http://example:8000/marge#section"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
urls := defaultTelnetURLs("http://example:8000")
urls.Marge = tt.value
clientCreated := false
m := &Manager{NewTelnet: func(string) TelnetClient {
clientCreated = true
return &fakeTelnet{}
}}
_, err := m.migrateViaTelnet("192.0.2.1", urls)
if !errors.Is(err, ErrInvalidTelnetURL) {
t.Fatalf("error = %v, want ErrInvalidTelnetURL", err)
}
if clientCreated {
t.Fatal("telnet client was created for rejected URL input")
}
})
}
}
func TestMigrateViaTelnet_AcceptsHTTPAndHTTPSServiceURLs(t *testing.T) {
for _, target := range []string{
"http://unifi:8001",
"HTTPS://example:8443/aftertouch",
"https://[2001:db8::1]:8443/aftertouch",
} {
t.Run(target, func(t *testing.T) {
urls := defaultTelnetURLs(target)
f := &fakeTelnet{responses: telnetResponses(urls, flatGetpdoResponse(urls))}
m := newFakeTelnetManager(f)
if _, err := m.migrateViaTelnet("192.0.2.1", urls); err != nil {
t.Fatalf("migrateViaTelnet: %v", err)
}
})
}
}
func TestMigrateViaTelnet_DialFailureReturnsError(t *testing.T) {
f := &fakeTelnet{dialErr: errors.New("connection refused")}
m := newFakeTelnetManager(f)
_, err := m.migrateViaTelnet("192.0.2.1", "http://example:8000", defaultTelnetURLs("http://example:8000"))
_, err := m.migrateViaTelnet("192.0.2.1", defaultTelnetURLs("http://example:8000"))
if err == nil {
t.Fatal("expected dial error, got nil")
}
@@ -126,7 +261,7 @@ func TestMigrateViaTelnet_CommandNotFoundAborts(t *testing.T) {
f := &fakeTelnet{responses: resp}
m := newFakeTelnetManager(f)
_, err := m.migrateViaTelnet("192.0.2.1", target, defaultTelnetURLs(target))
_, err := m.migrateViaTelnet("192.0.2.1", defaultTelnetURLs(target))
if err == nil {
t.Fatal("expected error when envswitch is rejected, got nil")
}
@@ -144,16 +279,69 @@ func TestMigrateViaTelnet_CommandNotFoundAborts(t *testing.T) {
}
}
func TestMigrateViaTelnet_VerifyMismatchFails(t *testing.T) {
func TestMigrateViaTelnet_GenericRuntimeRejectionReportsPartialState(t *testing.T) {
target := "http://example:8000"
resp := happyResponses(target)
// Device echoes the OLD URLs (envswitch/sys configuration silently dropped).
resp["getpdo CurrentSystemConfiguration"] = "margeServerUrl=https://streaming.bose.com\n"
resp["sys configuration statsServerUrl "+target] = "NOT OK\n"
f := &fakeTelnet{responses: resp}
m := newFakeTelnetManager(f)
_, err := m.migrateViaTelnet("192.0.2.1", target, defaultTelnetURLs(target))
_, err := m.migrateViaTelnet("192.0.2.1", defaultTelnetURLs(target))
if err == nil {
t.Fatal("expected generic rejection error, got nil")
}
if !strings.Contains(err.Error(), "1 runtime URL write(s) were confirmed") {
t.Errorf("err = %v, want partial-runtime classification", err)
}
for _, command := range f.commands {
if strings.Contains(command, "margeServerUrl") {
t.Errorf("migration continued after rejected runtime write: %v", f.commands)
}
}
}
func TestMigrateViaTelnet_EnvswitchRejectionReportsUncertainPersistence(t *testing.T) {
target := "http://example:8000"
urls := defaultTelnetURLs(target)
resp := happyResponses(target)
resp["envswitch boseurls set "+urls.Marge+" "+urls.SwUpdate] =
"Not setting Bose Server URLs to " + urls.Marge + " and " + urls.SwUpdate + " ->\n"
f := &fakeTelnet{responses: resp}
m := newFakeTelnetManager(f)
_, err := m.migrateViaTelnet("192.0.2.1", urls)
if err == nil {
t.Fatal("expected envswitch rejection error, got nil")
}
if !strings.Contains(err.Error(), "persistence outcome is uncertain") {
t.Errorf("err = %v, want uncertain-persistence classification", err)
}
for _, command := range f.commands {
if command == "getpdo CurrentSystemConfiguration" {
t.Errorf("verification was sent after unconfirmed envswitch response: %v", f.commands)
}
}
}
func TestMigrateViaTelnet_VerifyMismatchFails(t *testing.T) {
target := "http://example:8000"
urls := defaultTelnetURLs(target)
resp := happyResponses(target)
// The old substring check passed this response because the target still
// appeared in it, despite one field having the wrong exact value.
urls.Stats = target + "/wrong"
resp["getpdo CurrentSystemConfiguration"] = flatGetpdoResponse(urls)
f := &fakeTelnet{responses: resp}
m := newFakeTelnetManager(f)
_, err := m.migrateViaTelnet("192.0.2.1", defaultTelnetURLs(target))
if err == nil {
t.Fatal("expected verification mismatch error, got nil")
}
@@ -161,6 +349,40 @@ func TestMigrateViaTelnet_VerifyMismatchFails(t *testing.T) {
if !strings.Contains(err.Error(), "verification failed") {
t.Errorf("err = %v, want to mention verification failure", err)
}
if !strings.Contains(err.Error(), "persistence may already have changed") {
t.Errorf("err = %v, want post-envswitch uncertainty", err)
}
}
func TestMigrateViaTelnet_VerifyProtobufFormatSucceeds(t *testing.T) {
target := "http://example:8000"
urls := defaultTelnetURLs(target)
f := &fakeTelnet{responses: telnetResponses(urls, protobufGetpdoResponse(urls))}
m := newFakeTelnetManager(f)
if _, err := m.migrateViaTelnet("192.0.2.1", urls); err != nil {
t.Fatalf("migrateViaTelnet: %v", err)
}
}
func TestMigrateViaTelnet_VerifyMissingFieldFails(t *testing.T) {
target := "http://example:8000"
urls := defaultTelnetURLs(target)
verify := "margeServerUrl=" + urls.Marge + "\n" +
"statsServerUrl=" + urls.Stats + "\n" +
"swUpdateUrl=" + urls.SwUpdate + "\n"
f := &fakeTelnet{responses: telnetResponses(urls, verify)}
m := newFakeTelnetManager(f)
_, err := m.migrateViaTelnet("192.0.2.1", urls)
if err == nil {
t.Fatal("expected verification error for missing bmxRegistryUrl, got nil")
}
if !strings.Contains(err.Error(), "missing bmxRegistryUrl") {
t.Errorf("err = %v, want missing field name", err)
}
}
func TestMigrateViaTelnet_TransportErrorAborts(t *testing.T) {
@@ -173,7 +395,7 @@ func TestMigrateViaTelnet_TransportErrorAborts(t *testing.T) {
}
m := newFakeTelnetManager(f)
_, err := m.migrateViaTelnet("192.0.2.1", target, defaultTelnetURLs(target))
_, err := m.migrateViaTelnet("192.0.2.1", defaultTelnetURLs(target))
if err == nil {
t.Fatal("expected transport error, got nil")
}
@@ -183,10 +405,30 @@ func TestMigrateViaTelnet_TransportErrorAborts(t *testing.T) {
}
}
func TestMigrateViaTelnet_VerificationTransportFailureReportsUncertainPersistence(t *testing.T) {
target := "http://example:8000"
f := &fakeTelnet{
responses: happyResponses(target),
fail: map[string]error{
"getpdo CurrentSystemConfiguration": errors.New("read: connection reset"),
},
}
m := newFakeTelnetManager(f)
_, err := m.migrateViaTelnet("192.0.2.1", defaultTelnetURLs(target))
if err == nil {
t.Fatal("expected verification transport error, got nil")
}
if !strings.Contains(err.Error(), "persistence may already have changed") {
t.Errorf("err = %v, want post-envswitch uncertainty", err)
}
}
func TestMigrateViaTelnet_MissingNewTelnetIsClearError(t *testing.T) {
m := &Manager{ServerURL: "http://example:8000"} // NewTelnet deliberately nil
_, err := m.migrateViaTelnet("192.0.2.1", "http://example:8000", defaultTelnetURLs("http://example:8000"))
_, err := m.migrateViaTelnet("192.0.2.1", defaultTelnetURLs("http://example:8000"))
if err == nil {
t.Fatal("expected error when NewTelnet is nil")
}
@@ -0,0 +1,127 @@
package setup
import (
"reflect"
"sync"
"testing"
"time"
)
type serializedTelnetFactory struct {
mu sync.Mutex
nextID int
urlsByID map[int]telnetURLs
commandsByConnection []int
firstCommandStarted chan struct{}
releaseFirstCommand chan struct{}
secondDialed chan struct{}
}
type serializedTelnetClient struct {
factory *serializedTelnetFactory
id int
commandCount int
}
func (f *serializedTelnetFactory) newClient(string) TelnetClient {
f.mu.Lock()
defer f.mu.Unlock()
f.nextID++
return &serializedTelnetClient{factory: f, id: f.nextID}
}
func (c *serializedTelnetClient) Dial() error {
if c.id == 2 {
close(c.factory.secondDialed)
}
return nil
}
func (c *serializedTelnetClient) Probe() (string, error) { return "", nil }
func (c *serializedTelnetClient) Close() error { return nil }
func (c *serializedTelnetClient) SendCommand(command string) (string, error) {
c.commandCount++
c.factory.mu.Lock()
c.factory.commandsByConnection = append(c.factory.commandsByConnection, c.id)
c.factory.mu.Unlock()
if c.id == 1 && c.commandCount == 1 {
close(c.factory.firstCommandStarted)
<-c.factory.releaseFirstCommand
}
urls := c.factory.urlsByID[c.id]
if command == "getpdo CurrentSystemConfiguration" {
return flatGetpdoResponse(urls), nil
}
if command == "envswitch boseurls set "+urls.Marge+" "+urls.SwUpdate {
return "Setting Bose Server URLs to " + urls.Marge + " and " + urls.SwUpdate + " ->\n", nil
}
return "OK\n", nil
}
func TestTelnetURLMutationsSameSpeakerAreSerialized(t *testing.T) {
firstURLs := canonicalBoseTelnetURLs()
secondURLs := defaultTelnetURLs("http://next.example:8000")
factory := &serializedTelnetFactory{
urlsByID: map[int]telnetURLs{
1: firstURLs,
2: secondURLs,
},
firstCommandStarted: make(chan struct{}),
releaseFirstCommand: make(chan struct{}),
secondDialed: make(chan struct{}),
}
m := &Manager{NewTelnet: factory.newClient}
firstDone := make(chan error, 1)
secondDone := make(chan error, 1)
go func() {
_, err := m.RevertTelnetURLs("192.0.2.1", nil)
firstDone <- err
}()
<-factory.firstCommandStarted
secondStarted := make(chan struct{})
go func() {
close(secondStarted)
_, err := m.setAllBoseURLsViaTelnet("192.0.2.1", secondURLs)
secondDone <- err
}()
<-secondStarted
select {
case <-factory.secondDialed:
close(factory.releaseFirstCommand)
t.Fatal("second mutation dialed before the first sequence completed")
case <-time.After(100 * time.Millisecond):
}
close(factory.releaseFirstCommand)
if err := <-firstDone; err != nil {
t.Fatalf("first mutation: %v", err)
}
if err := <-secondDone; err != nil {
t.Fatalf("second mutation: %v", err)
}
factory.mu.Lock()
got := append([]int(nil), factory.commandsByConnection...)
factory.mu.Unlock()
want := []int{1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2}
if !reflect.DeepEqual(got, want) {
t.Fatalf("connection command order = %v, want contiguous blocks %v", got, want)
}
}
+53 -13
View File
@@ -69,6 +69,57 @@ func TestTelnetURLsFromOptions_PerFieldOverrides(t *testing.T) {
}
}
func TestRevertTelnetURLs_DefaultsAndCommandOrder(t *testing.T) {
urls := telnetURLs{
Marge: "https://streaming.bose.com",
Stats: "https://events.api.bosecm.com",
SwUpdate: "https://worldwide.bose.com/updates/soundtouch",
BmxRegistry: "https://content.api.bose.io/bmx/registry/v1/services",
}
f := &fakeTelnet{responses: telnetResponses(urls, flatGetpdoResponse(urls))}
m := newFakeTelnetManager(f)
logs, err := m.RevertTelnetURLs("192.0.2.1", nil)
if err != nil {
t.Fatalf("RevertTelnetURLs: %v", err)
}
wantCommands := append(urls.Commands(), "getpdo CurrentSystemConfiguration")
if !reflect.DeepEqual(f.commands, wantCommands) {
t.Errorf("commands =\n%v\nwant\n%v", f.commands, wantCommands)
}
if !strings.Contains(logs, "URL configuration only") {
t.Errorf("logs do not limit the operation to URL configuration:\n%s", logs)
}
}
func TestRevertTelnetURLs_OverridesTakePrecedence(t *testing.T) {
options := map[string]string{
"marge_url": "https://override.example/marge",
"stats_url": "https://override.example/stats",
"sw_update_url": "https://override.example/update",
"bmx_url": "https://override.example/bmx",
}
want := telnetURLs{
Marge: options["marge_url"],
Stats: options["stats_url"],
SwUpdate: options["sw_update_url"],
BmxRegistry: options["bmx_url"],
}
f := &fakeTelnet{responses: telnetResponses(want, flatGetpdoResponse(want))}
m := newFakeTelnetManager(f)
if _, err := m.RevertTelnetURLs("192.0.2.1", options); err != nil {
t.Fatalf("RevertTelnetURLs: %v", err)
}
wantCommands := append(want.Commands(), "getpdo CurrentSystemConfiguration")
if !reflect.DeepEqual(f.commands, wantCommands) {
t.Errorf("commands =\n%v\nwant overrides\n%v", f.commands, wantCommands)
}
}
// TestTelnetURLs_Commands_EnvswitchTracksMargeAndSwUpdate is the load-bearing
// test for the soundcork case: if the user added /marge to Marge, the
// envswitch arg1 must follow the same suffix verbatim, otherwise the
@@ -105,7 +156,6 @@ func TestTelnetURLs_Commands_EnvswitchTracksMargeAndSwUpdate(t *testing.T) {
}
func TestMigrateViaTelnet_SoundcorkMargeSuffixPropagatesToEnvswitch(t *testing.T) {
target := "http://example:8000"
urls := telnetURLs{
Marge: "http://example:8000/marge",
Stats: "http://example:8000",
@@ -113,20 +163,10 @@ func TestMigrateViaTelnet_SoundcorkMargeSuffixPropagatesToEnvswitch(t *testing.T
BmxRegistry: "http://example:8000/bmx/registry/v1/services",
}
// Build a happy-path responder that matches the *new* command set.
resp := map[string]string{
"sys configuration bmxRegistryUrl " + urls.BmxRegistry: "OK\n",
"sys configuration statsServerUrl " + urls.Stats: "OK\n",
"sys configuration margeServerUrl " + urls.Marge: "OK\n",
"sys configuration swUpdateUrl " + urls.SwUpdate: "OK\n",
"envswitch boseurls set " + urls.Marge + " " + urls.SwUpdate: "OK\n",
"getpdo CurrentSystemConfiguration": "margeServerUrl=" + urls.Marge + "\n",
}
f := &fakeTelnet{responses: resp}
f := &fakeTelnet{responses: telnetResponses(urls, flatGetpdoResponse(urls))}
m := newFakeTelnetManager(f)
if _, err := m.migrateViaTelnet("192.0.2.1", target, urls); err != nil {
if _, err := m.migrateViaTelnet("192.0.2.1", urls); err != nil {
t.Fatalf("migrateViaTelnet: %v", err)
}
+5
View File
@@ -14,6 +14,7 @@ import (
"net"
"os"
"strconv"
"strings"
"time"
)
@@ -123,6 +124,10 @@ func (c *Client) Probe() (string, error) {
// device's textual conventions vary by firmware: some commands return "OK",
// others echo state, others return nothing).
func (c *Client) SendCommand(cmd string) (string, error) {
if strings.ContainsAny(cmd, "\r\n") {
return "", errors.New("telnet: command must not contain line breaks")
}
if c.conn == nil {
return "", errors.New("telnet: not connected")
}
+22
View File
@@ -284,6 +284,28 @@ func TestSendCommand_CommandNotFound(t *testing.T) {
}
}
func TestSendCommand_RejectsLineBreaksWithoutWriting(t *testing.T) {
s := newScriptedServer(t, "", map[string]string{
"getpdo CurrentSystemConfiguration": "OK\n",
})
defer s.close()
c := newClientFor(t, s)
if err := c.Dial(); err != nil {
t.Fatalf("Dial: %v", err)
}
defer func() { _ = c.Close() }()
if _, err := c.SendCommand("getpdo CurrentSystemConfiguration\r\nsys reboot"); err == nil ||
!strings.Contains(err.Error(), "line breaks") {
t.Fatalf("SendCommand error = %v, want line-break rejection", err)
}
if _, err := c.SendCommand("getpdo CurrentSystemConfiguration"); err != nil {
t.Fatalf("safe command after rejection: %v", err)
}
}
func TestSendCommand_DeadlineFiresWhenDeviceHangs(t *testing.T) {
s := newScriptedServer(t, "", map[string]string{
"first": "OK\n",