diff --git a/cmd/soundtouch-cli/cmd_setup.go b/cmd/soundtouch-cli/cmd_setup.go
index de00603a..45bd360f 100644
--- a/cmd/soundtouch-cli/cmd_setup.go
+++ b/cmd/soundtouch-cli/cmd_setup.go
@@ -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
},
diff --git a/cmd/soundtouch-cli/cmd_setup_test.go b/cmd/soundtouch-cli/cmd_setup_test.go
index d3a5e132..80ab346f 100644
--- a/cmd/soundtouch-cli/cmd_setup_test.go
+++ b/cmd/soundtouch-cli/cmd_setup_test.go
@@ -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"}}
diff --git a/docs/content/docs/analysis/TELNET-MIGRATION-METHOD.md b/docs/content/docs/analysis/TELNET-MIGRATION-METHOD.md
index 7a13bfb0..e2d214f6 100644
--- a/docs/content/docs/analysis/TELNET-MIGRATION-METHOD.md
+++ b/docs/content/docs/analysis/TELNET-MIGRATION-METHOD.md
@@ -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; `` empty. Time-bounded HTTP path fails; envswitch fallback succeeds. |
| BST20 Portable (factory reset) | ubittner | scheilch/opencloudtouch#167 | `` 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
diff --git a/docs/content/docs/guides/CLI-REFERENCE.md b/docs/content/docs/guides/CLI-REFERENCE.md
index b5b7b508..56ced07f 100644
--- a/docs/content/docs/guides/CLI-REFERENCE.md
+++ b/docs/content/docs/guides/CLI-REFERENCE.md
@@ -1399,17 +1399,37 @@ soundtouch-cli --host 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 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 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
diff --git a/docs/content/docs/guides/MIGRATION-GUIDE.md b/docs/content/docs/guides/MIGRATION-GUIDE.md
index fea2ebc0..f21c0337 100644
--- a/docs/content/docs/guides/MIGRATION-GUIDE.md
+++ b/docs/content/docs/guides/MIGRATION-GUIDE.md
@@ -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 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.
diff --git a/docs/content/docs/guides/MIGRATION-SAFETY.md b/docs/content/docs/guides/MIGRATION-SAFETY.md
index bcd9d5c6..3ea20bd1 100644
--- a/docs/content/docs/guides/MIGRATION-SAFETY.md
+++ b/docs/content/docs/guides/MIGRATION-SAFETY.md
@@ -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//` 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//` 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).
diff --git a/docs/content/docs/guides/SOUNDTOUCH-SERVICE.md b/docs/content/docs/guides/SOUNDTOUCH-SERVICE.md
index e830005d..1183e641 100644
--- a/docs/content/docs/guides/SOUNDTOUCH-SERVICE.md
+++ b/docs/content/docs/guides/SOUNDTOUCH-SERVICE.md
@@ -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.
diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go
index 1d9f9418..1e8e010b 100644
--- a/pkg/service/handlers/handlers_setup.go
+++ b/pkg/service/handlers/handlers_setup.go
@@ -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, ¬Ready) {
+
+ switch {
+ case errors.As(err, ¬Ready):
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)
diff --git a/pkg/service/handlers/handlers_setup_test.go b/pkg/service/handlers/handlers_setup_test.go
index 2a7b1bba..606e0e6a 100644
--- a/pkg/service/handlers/handlers_setup_test.go
+++ b/pkg/service/handlers/handlers_setup_test.go
@@ -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++
diff --git a/pkg/service/handlers/migration_options.go b/pkg/service/handlers/migration_options.go
index 22fcc140..7f76e182 100644
--- a/pkg/service/handlers/migration_options.go
+++ b/pkg/service/handlers/migration_options.go
@@ -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
+}
diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html
index 64bb1653..de50056e 100644
--- a/pkg/service/handlers/web/index.html
+++ b/pkg/service/handlers/web/index.html
@@ -826,6 +826,13 @@
>
Revert to Defaults
+