From 86825c44af4f1a75b1710f630baed9120afbc575 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 2 May 2026 14:00:23 +0200 Subject: [PATCH] feat(backup): add soundtouch-backup tool for cloud and local speaker backup (#197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a standalone `soundtouch-backup` CLI with three subcommands: - `all`: authenticates with the Bose cloud, backs up account data, then reads device IPs from devices.xml and backs up each reachable speaker - `cloud`: fetches account profile, devices, sources, presets, and full endpoint from streaming.bose.com - `local`: backs up each speaker via HTTP API (12 endpoints) and optionally via SSH (individual files + /opt/Bose/etc/ and /mnt/nv/BoseApp-Persistence/1/ directories) Also centralises pkg/service/ssh → pkg/ssh so both the service and the backup tool share the same SSH client; adds ReadFile and ReadDir methods, and handles the firmware quirk where cat exits 1 on empty files. Output is a single dated .tar.gz or .zip archive. Example flow: ```shell gesellix@Mac Bose-SoundTouch % go run ./cmd/soundtouch-backup all --output _/cloud-backup --email user@example.com Password: Authenticating as user@example.com... ✓ Authenticated (account ID: 1234567) ✓ email address (107 bytes) ✓ devices (1492 bytes) ✓ sources (1111 bytes) ✓ presets (2585 bytes) ✓ full account (55037 bytes) Found 2 device(s) in cloud account, attempting local backup... ✓ ST20: 12 files via HTTP ⚠ ST20: SSH skipped /etc/remote_services (Process exited with status 1) ⚠ ST20: SSH empty file /mnt/nv/remote_services ✓ ST20: 64 files via SSH ✓ ST10: 12 files via HTTP ⚠ ST10: SSH empty file /etc/remote_services ⚠ ST10: SSH skipped /mnt/nv/remote_services (Process exited with status 1) ✓ ST10: 48 files via SSH Archive written: _/cloud-backup/soundtouch-backup-2026-05-02.tar.gz (141 files) ``` --------- Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/release.yml | 26 ++- .gitignore | 1 + Makefile | 32 +++- cmd/soundtouch-backup/README.md | 212 +++++++++++++++++++++ cmd/soundtouch-backup/cmd_all.go | 119 ++++++++++++ cmd/soundtouch-backup/cmd_cloud.go | 252 +++++++++++++++++++++++++ cmd/soundtouch-backup/cmd_local.go | 288 +++++++++++++++++++++++++++++ cmd/soundtouch-backup/common.go | 172 +++++++++++++++++ cmd/soundtouch-backup/main.go | 37 ++++ docs/README.md | 2 + docs/SUMMARY.md | 1 + go.mod | 1 + pkg/service/setup/setup.go | 2 +- pkg/{service => }/ssh/ssh.go | 64 +++++-- pkg/{service => }/ssh/ssh_test.go | 26 --- 15 files changed, 1182 insertions(+), 53 deletions(-) create mode 100644 cmd/soundtouch-backup/README.md create mode 100644 cmd/soundtouch-backup/cmd_all.go create mode 100644 cmd/soundtouch-backup/cmd_cloud.go create mode 100644 cmd/soundtouch-backup/cmd_local.go create mode 100644 cmd/soundtouch-backup/common.go create mode 100644 cmd/soundtouch-backup/main.go rename pkg/{service => }/ssh/ssh.go (64%) rename pkg/{service => }/ssh/ssh_test.go (53%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ffda80..5ad78e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -171,6 +171,9 @@ jobs: # Build Web build_binary "soundtouch-web" "./cmd/soundtouch-web" + + # Build Backup + build_binary "soundtouch-backup" "./cmd/soundtouch-backup" id: build - name: Generate individual checksums @@ -178,6 +181,7 @@ jobs: CLI_NAME="${{ steps.build.outputs.soundtouch-cli }}" SVC_NAME="${{ steps.build.outputs.soundtouch-service }}" WEB_NAME="${{ steps.build.outputs.soundtouch-web }}" + BCK_NAME="${{ steps.build.outputs.soundtouch-backup }}" # Use atomic operations to avoid conflicts TEMP_DIR=$(mktemp -d) @@ -194,6 +198,7 @@ jobs: generate_checksums "$CLI_NAME" generate_checksums "$SVC_NAME" generate_checksums "$WEB_NAME" + generate_checksums "$BCK_NAME" # Cleanup rm -rf "$TEMP_DIR" @@ -207,6 +212,7 @@ jobs: build/soundtouch-cli-v* build/soundtouch-service-v* build/soundtouch-web-v* + build/soundtouch-backup-v* retention-days: 1 checksums: @@ -233,7 +239,7 @@ jobs: mkdir -p release-files # Move all files from subdirectories to the collection directory - find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" -o -name "soundtouch-web-*" \) -exec mv {} release-files/ \; + find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" -o -name "soundtouch-web-*" -o -name "soundtouch-backup-*" \) -exec mv {} release-files/ \; # Remove empty directories find . -type d -empty -delete @@ -248,14 +254,14 @@ jobs: # Generate combined checksums (exclude individual .sha256/.sha512 files) if ls soundtouch-* 1> /dev/null 2>&1; then # Only checksum the actual binaries, not the .sha256/.sha512 files - ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256 - ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512 + ls soundtouch-cli-* soundtouch-service-* soundtouch-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256 + ls soundtouch-cli-* soundtouch-service-* soundtouch-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512 echo "📋 Generated combined checksums:" cat checksums.sha256 # Verify all expected files are present (binaries only, not checksum files) - EXPECTED_COUNT=21 # 7 platforms * 3 binaries + EXPECTED_COUNT=28 # 7 platforms * 4 binaries ACTUAL_COUNT=$(ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | wc -l) if [[ $ACTUAL_COUNT -ne $EXPECTED_COUNT ]]; then @@ -392,6 +398,12 @@ jobs: ./soundtouch-web \`\`\` + ### SoundTouch Backup + \`\`\`bash + # Back up cloud account and all paired speakers in one go + ./soundtouch-backup all + \`\`\` + ## 🧪 Tested Hardware - Bose SoundTouch 10 @@ -410,7 +422,7 @@ jobs: - Windows (amd64) - FreeBSD (amd64) - `soundtouch-cli`, `soundtouch-service`, and `soundtouch-web` are included. + `soundtouch-cli`, `soundtouch-service`, `soundtouch-web`, and `soundtouch-backup` are included. ## 🔐 Checksums @@ -466,6 +478,7 @@ jobs: release-assets/soundtouch-cli-v* release-assets/soundtouch-service-v* release-assets/soundtouch-web-v* + release-assets/soundtouch-backup-v* release-assets/checksums.sha256 release-assets/checksums.sha512 fail_on_unmatched_files: true @@ -493,6 +506,7 @@ jobs: release-assets/soundtouch-cli-v* release-assets/soundtouch-service-v* release-assets/soundtouch-web-v* + release-assets/soundtouch-backup-v* release-assets/checksums.sha256 release-assets/checksums.sha512 fail_on_unmatched_files: true @@ -572,7 +586,7 @@ jobs: - name: Notify success run: | echo "🎉 Release ${{ needs.validate.outputs.version }} completed successfully!" - echo "📦 Binaries built for 7 platforms (CLI, Service, and Web)" + echo "📦 Binaries built for 7 platforms (CLI, Service, Web, and Backup)" echo "🐳 Docker image published to ghcr.io" echo "🔐 Checksums generated and verified" echo "📋 Release notes automatically generated" diff --git a/.gitignore b/.gitignore index 3798871..eff3e0d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ dist/ #example-upnp # Root-level binary executables (exclude built binaries in root) +/soundtouch-backup /soundtouch-cli /soundtouch-service /soundtouch-web diff --git a/Makefile b/Makefile index 5784a0c..ab2687d 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,8 @@ SCANNER_NAME=mdns-scanner SCANNER_PATH=./cmd/$(SCANNER_NAME) FAVICON_GEN_NAME=favicon-gen FAVICON_GEN_PATH=./cmd/$(FAVICON_GEN_NAME) +BACKUP_NAME=soundtouch-backup +BACKUP_PATH=./cmd/$(BACKUP_NAME) BUILD_DIR=./build # Version info @@ -31,7 +33,7 @@ BUILD_DIR=./build all: check build -build: build-cli build-service build-web build-examples build-favicon-gen +build: build-cli build-service build-web build-examples build-favicon-gen build-backup build-cli: @echo "Building $(BINARY_NAME)..." @@ -62,6 +64,11 @@ build-favicon-gen: @mkdir -p $(BUILD_DIR) $(GOBUILD) -o $(BUILD_DIR)/$(FAVICON_GEN_NAME) $(FAVICON_GEN_PATH) +build-backup: + @echo "Building $(BACKUP_NAME)..." + @mkdir -p $(BUILD_DIR) + $(GOBUILD) -o $(BUILD_DIR)/$(BACKUP_NAME) $(BACKUP_PATH) + build-all: build-linux build-darwin build-windows build-examples-all build-linux: @@ -69,6 +76,7 @@ build-linux: @mkdir -p $(BUILD_DIR) GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(BINARY_PATH) GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-linux-amd64 $(SERVICE_PATH) + GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BACKUP_NAME)-linux-amd64 $(BACKUP_PATH) build-darwin: @echo "Building for macOS..." @@ -77,12 +85,15 @@ build-darwin: GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(BINARY_PATH) GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-amd64 $(SERVICE_PATH) GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-arm64 $(SERVICE_PATH) + GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BACKUP_NAME)-darwin-amd64 $(BACKUP_PATH) + GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(BACKUP_NAME)-darwin-arm64 $(BACKUP_PATH) build-windows: @echo "Building for Windows..." @mkdir -p $(BUILD_DIR) GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(BINARY_PATH) GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-windows-amd64.exe $(SERVICE_PATH) + GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BACKUP_NAME)-windows-amd64.exe $(BACKUP_PATH) build-examples-all: @echo "Building examples for all platforms..." @@ -262,6 +273,18 @@ dev-web-port: build-web fi cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME) -port $(PORT) +dev-backup: build-backup + @echo "Running backup tool..." + $(BUILD_DIR)/$(BACKUP_NAME) --help + +dev-backup-cloud: build-backup + @echo "Running cloud backup..." + $(BUILD_DIR)/$(BACKUP_NAME) cloud + +dev-backup-local: build-backup + @echo "Running local backup (auto-discover)..." + $(BUILD_DIR)/$(BACKUP_NAME) local --discover + dev-web-host: build-web @echo "Starting web UI with specific host..." @if [ -z "$(HOST)" ]; then \ @@ -270,11 +293,12 @@ dev-web-host: build-web fi cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME) -host $(HOST) -install: build-cli build-service build-web +install: build-cli build-service build-web build-backup @echo "Installing binaries to $(GOPATH)/bin..." cp $(BUILD_DIR)/$(BINARY_NAME) $(GOPATH)/bin/ cp $(BUILD_DIR)/$(SERVICE_NAME) $(GOPATH)/bin/ cp $(BUILD_DIR)/$(WEB_NAME) $(GOPATH)/bin/ + cp $(BUILD_DIR)/$(BACKUP_NAME) $(GOPATH)/bin/ clean: @echo "Cleaning..." @@ -310,6 +334,7 @@ help: @echo " build - Build the CLI tool, service, and examples" @echo " build-cli - Build only the CLI tool" @echo " build-service - Build only the service" + @echo " build-backup - Build only the backup tool" @echo " build-favicon-gen - Build the favicon generator" @echo " build-examples - Build only the example programs" @echo " build-all - Build for all platforms" @@ -334,6 +359,9 @@ help: @echo " dev-scan-all - Scan all mDNS services on network" @echo " dev-scan-soundtouch - Scan specifically for SoundTouch mDNS services" @echo " dev-scan-http - Scan for HTTP mDNS services" + @echo " dev-backup - Build and show backup tool help" + @echo " dev-backup-cloud - Build and run cloud backup (prompts for credentials)" + @echo " dev-backup-local - Build and run local backup (auto-discover speakers)" @echo " dev-web - Build and run web UI (default port 8080)" @echo " dev-web-port - Build and run web UI on custom port (PORT=8888)" @echo " dev-web-host - Build and run web UI with specific device (HOST=ip)" diff --git a/cmd/soundtouch-backup/README.md b/cmd/soundtouch-backup/README.md new file mode 100644 index 0000000..898eb3d --- /dev/null +++ b/cmd/soundtouch-backup/README.md @@ -0,0 +1,212 @@ +# soundtouch-backup + +A standalone tool for backing up Bose SoundTouch data — both your **cloud account** (presets, devices, sources) and the **local filesystem** of each speaker — before the Bose cloud services shut down on May 6, 2026. + +## Overview + +| Subcommand | What it backs up | +|------------|----------------------------------------------------------------------------------------------------| +| `all` | Cloud account **and** all paired speakers in one step — the recommended starting point | +| `cloud` | Bose account profile, paired devices, cloud presets, music service sources | +| `local` | Speaker HTTP API data (presets, sources, volume, …) and optionally device filesystem files via SSH | + +Output is a single `.tar.gz` archive (or `.zip`) with a dated root directory. + +## Building + +```bash +make build-backup +# binary: ./build/soundtouch-backup +``` + +Or install alongside the other tools: + +```bash +make install +``` + +## Usage + +### Combined backup (recommended) + +The `all` command is the simplest way to capture everything: it authenticates with the Bose cloud, backs up your account data, then reads the IP addresses from `devices.xml` and backs up each reachable speaker over HTTP. + +```bash +# Interactive — prompts for email and password +soundtouch-backup all + +# Non-interactive +soundtouch-backup all --email you@example.com --password secret + +# Include SSH filesystem backup for each speaker +soundtouch-backup all --ssh + +# Environment variables +BOSE_EMAIL=you@example.com BOSE_PASSWORD=secret soundtouch-backup all --ssh +``` + +**Flags** + +| Flag | Short | Default | Description | +|--------------|--------|---------------------------------------|--------------------------------------------------------| +| `--email` | `-e` | — | Bose account email (`$BOSE_EMAIL`) | +| `--password` | `--pw` | — | Bose account password (`$BOSE_PASSWORD`) | +| `--ssh` | | on | Also capture filesystem files via SSH for each speaker | +| `--output` | `-o` | `soundtouch-backup-YYYY-MM-DD.tar.gz` | Output archive path | +| `--format` | | `tar.gz` | Archive format: `tar.gz` or `zip` | + +Speakers that are offline or unreachable at the time of backup are skipped with a `✗` warning; the cloud data is still saved. + +--- + +### Cloud backup + +Backs up data from your Bose account at `streaming.bose.com`. Credentials are prompted interactively if not supplied as flags. + +```bash +# Interactive — prompts for email, masked password input +soundtouch-backup cloud + +# Non-interactive +soundtouch-backup cloud --email you@example.com --password secret + +# Environment variables (avoids secrets in shell history) +BOSE_EMAIL=you@example.com BOSE_PASSWORD=secret soundtouch-backup cloud + +# Zip output +soundtouch-backup cloud --format zip --output my-bose-cloud.zip +``` + +**Flags** + +| Flag | Short | Default | Description | +|--------------|--------|---------------------------------------|---------------------------------------------------| +| `--email` | `-e` | — | Bose account email (`$BOSE_EMAIL`) | +| `--password` | `--pw` | — | Bose account password (`$BOSE_PASSWORD`) | +| `--output` | `-o` | `soundtouch-backup-YYYY-MM-DD.tar.gz` | Output archive path (`$SOUNDTOUCH_BACKUP_OUTPUT`) | +| `--format` | | `tar.gz` | Archive format: `tar.gz` or `zip` | + +**What gets fetched** + +| File in archive | Source endpoint | +|--------------------------|---------------------------------------------------------------------------------| +| `cloud/emailaddress.xml` | `GET /streaming/account/{id}/emailaddress` | +| `cloud/devices.xml` | `GET /streaming/account/{id}/devices` | +| `cloud/sources.xml` | `GET /streaming/account/{id}/sources` | +| `cloud/presets.xml` | `GET /streaming/account/{id}/presets/all` | +| `cloud/full.xml` | `GET /streaming/account/{id}/full` (may overlap with the above; skipped if 4xx) | + +--- + +### Local backup + +Backs up each speaker over its HTTP API on port 8090. With `--ssh`, also captures key filesystem files via SSH. + +```bash +# Auto-discover all speakers on the local network +soundtouch-backup local + +# Specific speaker +soundtouch-backup local --host 192.168.178.28 + +# Multiple speakers +soundtouch-backup local --host 192.168.178.28 --host 192.168.178.35 + +# Include SSH filesystem backup +soundtouch-backup local --ssh + +# Longer discovery window on busy networks +soundtouch-backup local --discover-timeout 10s +``` + +**Flags** + +| Flag | Short | Default | Description | +|----------------------|-------|---------------------------------------|--------------------------------------------------| +| `--host` | `-H` | — | Speaker host/IP, repeatable (`$SOUNDTOUCH_HOST`) | +| `--port` | `-p` | `8090` | Speaker HTTP port (`$SOUNDTOUCH_PORT`) | +| `--discover` | `-d` | auto | Force mDNS/UPnP discovery | +| `--discover-timeout` | | `5s` | Discovery timeout | +| `--ssh` | | on | Also capture filesystem files via SSH | +| `--output` | `-o` | `soundtouch-backup-YYYY-MM-DD.tar.gz` | Output archive path | +| `--format` | | `tar.gz` | Archive format: `tar.gz` or `zip` | + +**What gets fetched via HTTP** + +| File | Device endpoint | +|---------------------|-----------------| +| `info.xml` | `/info` | +| `name.xml` | `/name` | +| `presets.xml` | `/presets` | +| `sources.xml` | `/sources` | +| `now_playing.xml` | `/now_playing` | +| `volume.xml` | `/volume` | +| `bass.xml` | `/bass` | +| `balance.xml` | `/balance` | +| `capabilities.xml` | `/capabilities` | +| `network_info.xml` | `/networkInfo` | +| `clock_display.xml` | `/clockDisplay` | +| `zone.xml` | `/getZone` | + +Endpoints that return HTTP 4xx (not supported on the device model) are silently skipped. + +**What gets fetched via SSH** (`--ssh`) + +SSH connects as `root@:22` with an empty password, which is the default for SoundTouch firmware. + +Individual files: + +| Remote path | Notes | +|---------------------------|--------------------------------------------| +| `/etc/hosts` | DNS redirect state | +| `/etc/resolv.conf` | DNS resolver configuration | +| `/etc/remote_services` | Service registration (post-migration only) | +| `/mnt/nv/remote_services` | Alternative location for remote services | + +Directories (all regular files recursively): + +| Remote path | Contents | +|----------------------------------|----------------------------------------------------------------------------| +| `/opt/Bose/etc/` | Full Bose configuration directory, including `SoundTouchSdkPrivateCfg.xml` | +| `/mnt/nv/BoseApp-Persistence/1/` | Persisted app state | + +Missing files and directories are silently skipped with a `⚠` warning. + +--- + +## Archive structure + +Both subcommands write into a single dated archive: + +``` +soundtouch-backup-2026-05-02/ +├── cloud/ +│ ├── emailaddress.xml +│ ├── devices.xml +│ ├── sources.xml +│ └── presets.xml +└── local/ + ├── A_Sound_Machine/ + │ ├── info.xml + │ ├── presets.xml + │ ├── sources.xml + │ ├── volume.xml + │ ├── … + │ └── ssh/ + │ ├── etc/ + │ │ ├── hosts + │ │ └── resolv.conf + │ ├── opt/Bose/etc/ + │ │ └── SoundTouchSdkPrivateCfg.xml + │ └── mnt/nv/BoseApp-Persistence/1/ + └── Sound_Machinechen/ + └── … +``` + +Running `cloud` and `local` separately produces two archives. To combine them, use the same `--output` path for both invocations — each adds its own subdirectory so they won't collide (`.tar.gz` does not support appending; use `--format zip` if you need a single archive from two runs, or just keep them separate). + +## See also + +- [Cloud Shutdown Survival Guide](../../docs/guides/SURVIVAL-GUIDE.md) — full migration context +- [`soundtouch-cli`](../soundtouch-cli/) — live device control +- [`soundtouch-service`](../soundtouch-service/) — local cloud replacement diff --git a/cmd/soundtouch-backup/cmd_all.go b/cmd/soundtouch-backup/cmd_all.go new file mode 100644 index 0000000..1cbb2b7 --- /dev/null +++ b/cmd/soundtouch-backup/cmd_all.go @@ -0,0 +1,119 @@ +package main + +import ( + "encoding/xml" + "fmt" + "net/http" + "time" + + "github.com/urfave/cli/v2" +) + +func allCommand() *cli.Command { + return &cli.Command{ + Name: "all", + Usage: "Back up cloud account then all paired speakers in one go", + Description: "Authenticates with the Bose cloud, backs up account data, then reads" + + " the device IP addresses from the cloud device list and backs up each reachable" + + " speaker over HTTP (and optionally SSH).", + Flags: append(outputFlags, + &cli.StringFlag{ + Name: "email", + Aliases: []string{"e"}, + Usage: "Bose account email", + EnvVars: []string{"BOSE_EMAIL"}, + }, + &cli.StringFlag{ + Name: "password", + Aliases: []string{"pw"}, + Usage: "Bose account password", + EnvVars: []string{"BOSE_PASSWORD"}, + }, + &cli.BoolFlag{ + Name: "ssh", + Usage: "Also back up device filesystem files via SSH (root@host:22, no password required)", + Value: true, + }, + ), + Action: runAllBackup, + } +} + +func runAllBackup(c *cli.Context) error { + doSSH := c.Bool("ssh") + output := resolveOutputPath(c.String("output"), c.String("format")) + format := c.String("format") + + // 1. Cloud backup + client, err := setupCloudClient(c.String("email"), c.String("password")) + if err != nil { + return err + } + + root := archiveRoot() + files := collectCloudFiles(client, root) + + if len(files) == 0 { + return fmt.Errorf("no cloud data fetched") + } + + // 2. Resolve speakers from devices.xml, then back each one up + devicesData := files[root+"/cloud/devices.xml"] + if devicesData == nil { + printWarn("devices.xml not available — skipping local backup") + } else { + targets := parseDevicesXML(devicesData) + if len(targets) == 0 { + printWarn("no device IP addresses found in devices.xml") + } else { + fmt.Printf("Found %d device(s) in cloud account, attempting local backup...\n", len(targets)) + } + + hc := &http.Client{Timeout: 10 * time.Second} + + for k, v := range collectLocalFiles(hc, targets, root, doSSH) { + files[k] = v + } + } + + if err := writeArchive(output, format, files); err != nil { + return fmt.Errorf("writing archive: %w", err) + } + + fmt.Printf("Archive written: %s (%d files)\n", output, len(files)) + + return nil +} + +type xmlDevice struct { + Name string `xml:"name"` + IPAddress string `xml:"ipaddress"` +} + +type xmlDevices struct { + XMLName xml.Name `xml:"devices"` + Devices []xmlDevice `xml:"device"` +} + +// parseDevicesXML extracts speaker targets from a devices.xml cloud response. +func parseDevicesXML(data []byte) []speakerTarget { + var d xmlDevices + + if err := xml.Unmarshal(data, &d); err != nil { + return nil + } + + var targets []speakerTarget + + for _, dev := range d.Devices { + if dev.IPAddress == "" { + continue + } + + // Pass name as a hint for error messages; backupSpeakerHTTP re-fetches + // from /info to get the current name and include info.xml in the archive. + targets = append(targets, speakerTarget{host: dev.IPAddress, port: 8090, name: dev.Name}) + } + + return targets +} diff --git a/cmd/soundtouch-backup/cmd_cloud.go b/cmd/soundtouch-backup/cmd_cloud.go new file mode 100644 index 0000000..df68b5e --- /dev/null +++ b/cmd/soundtouch-backup/cmd_cloud.go @@ -0,0 +1,252 @@ +package main + +import ( + "bytes" + "encoding/xml" + "fmt" + "io" + "net/http" + "regexp" + "time" + + "github.com/urfave/cli/v2" +) + +const ( + streamingBase = "https://streaming.bose.com" + streamingCT = "application/vnd.bose.streaming-v1.1+xml" + stockholmVer = "27.0.13-4277+8963611.epdbuild.develop.hepdswbld04.2025-10-02T13:17:00" + nativeFrameVer = "27.0.2 -3353+4ae7c78.epdbuild.HEAD.ssgbld02.2023-10-12T15:10Z" + protocolVer = "67" + appGUID = "b94dedd1-a61b-492b-b86b-2bc32c9261f4" + appUserAgent = "Mozilla/5.0 (Linux; Android 13; Android SDK built for arm64 Build/TE1A.220922.034; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/101.0.4951.61 Mobile Safari/537.36 Manufacturer/unknown DeviceModel/Android-SDK-built-for-arm64 SOUNDTOUCH_MOBILE_APP/" + appGUID +) + +func cloudCommand() *cli.Command { + return &cli.Command{ + Name: "cloud", + Usage: "Back up your Bose SoundTouch cloud account (devices, presets, sources)", + Flags: append(outputFlags, + &cli.StringFlag{ + Name: "email", + Aliases: []string{"e"}, + Usage: "Bose account email", + EnvVars: []string{"BOSE_EMAIL"}, + }, + &cli.StringFlag{ + Name: "password", + Aliases: []string{"pw"}, + Usage: "Bose account password", + EnvVars: []string{"BOSE_PASSWORD"}, + }, + ), + Action: runCloudBackup, + } +} + +func runCloudBackup(c *cli.Context) error { + output := resolveOutputPath(c.String("output"), c.String("format")) + format := c.String("format") + + client, err := setupCloudClient(c.String("email"), c.String("password")) + if err != nil { + return err + } + + root := archiveRoot() + files := collectCloudFiles(client, root) + + if len(files) == 0 { + return fmt.Errorf("no data fetched") + } + + if err := writeArchive(output, format, files); err != nil { + return fmt.Errorf("writing archive: %w", err) + } + + fmt.Printf("Archive written: %s (%d files)\n", output, len(files)) + + return nil +} + +// setupCloudClient prompts for missing credentials, then authenticates with the Bose cloud. +func setupCloudClient(email, password string) (*cloudClient, error) { + if email == "" || password == "" { + var err error + + email, password, err = promptCredentials(email) + if err != nil { + return nil, fmt.Errorf("credentials: %w", err) + } + } + + if email == "" || password == "" { + return nil, fmt.Errorf("email and password are required") + } + + fmt.Printf("Authenticating as %s...\n", email) + + client, err := loginToCloud(email, password) + if err != nil { + return nil, fmt.Errorf("authentication failed: %w", err) + } + + printOK(fmt.Sprintf("Authenticated (account ID: %s)", client.accountID)) + + return client, nil +} + +// collectCloudFiles fetches all cloud account data and returns a files map ready for +// archiving. Keys are prefixed with root (e.g. "soundtouch-backup-2026-05-02/cloud/"). +func collectCloudFiles(client *cloudClient, root string) map[string][]byte { + type cloudEndpoint struct { + label string + filename string + fetch func(*cloudClient) ([]byte, error) + } + + endpoints := []cloudEndpoint{ + {"email address", "emailaddress.xml", fetchEmailAddress}, + {"devices", "devices.xml", fetchDevices}, + {"sources", "sources.xml", fetchSources}, + {"presets", "presets.xml", fetchPresets}, + {"full account", "full.xml", fetchFull}, + } + + files := make(map[string][]byte) + + for _, ep := range endpoints { + data, err := ep.fetch(client) + if err != nil { + printFail(fmt.Sprintf("%s: %v", ep.label, err)) + + continue + } + + files[root+"/cloud/"+ep.filename] = data + printOK(fmt.Sprintf("%s (%d bytes)", ep.label, len(data))) + } + + return files +} + +type cloudClient struct { + http *http.Client + accountID string + token string +} + +type loginXML struct { + XMLName xml.Name `xml:"login"` + Username string `xml:"username"` + Password string `xml:"password"` +} + +var accountIDRe = regexp.MustCompile(``) + body = append(body, loginBody...) + + req, err := http.NewRequest("POST", streamingBase+"/streaming/account/login", bytes.NewReader(body)) + if err != nil { + return nil, err + } + + setStreamingHeaders(req, "") + + hc := &http.Client{Timeout: 30 * time.Second} + + resp, err := hc.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + token := resp.Header.Get("credentials") + if token == "" { + return nil, fmt.Errorf("no credentials in response — check your email and password") + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if err != nil { + return nil, err + } + + m := accountIDRe.FindSubmatch(data) + if len(m) < 2 { + return nil, fmt.Errorf("could not extract account ID from login response") + } + + return &cloudClient{http: hc, accountID: string(m[1]), token: token}, nil +} + +func setStreamingHeaders(req *http.Request, token string) { + req.Header.Set("content-type", streamingCT) + req.Header.Set("accept", streamingCT) + req.Header.Set("clienttype", "SOUNDTOUCH_MOBILE_APP") + req.Header.Set("version_stockholmversion", stockholmVer) + req.Header.Set("version_nativeframeversion", nativeFrameVer) + req.Header.Set("version_protocolversion", protocolVer) + req.Header.Set("user-agent", appUserAgent) + req.Header.Set("guid", appGUID) + req.Header.Set("x-requested-with", "com.bose.soundtouch") + req.Header.Set("pragma", "no-cache") + req.Header.Set("cache-control", "no-cache") + + if token != "" { + req.Header.Set("authorization", token) + } +} + +func (c *cloudClient) get(path string) ([]byte, error) { + url := fmt.Sprintf("%s%s?_=%d", streamingBase, path, time.Now().UnixMilli()) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + setStreamingHeaders(req, c.token) + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + return io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) +} + +func fetchEmailAddress(c *cloudClient) ([]byte, error) { + return c.get("/streaming/account/" + c.accountID + "/emailaddress") +} + +func fetchDevices(c *cloudClient) ([]byte, error) { + return c.get("/streaming/account/" + c.accountID + "/devices") +} + +func fetchSources(c *cloudClient) ([]byte, error) { + return c.get("/streaming/account/" + c.accountID + "/sources") +} + +func fetchPresets(c *cloudClient) ([]byte, error) { + return c.get("/streaming/account/" + c.accountID + "/presets/all") +} + +func fetchFull(c *cloudClient) ([]byte, error) { + return c.get("/streaming/account/" + c.accountID + "/full") +} diff --git a/cmd/soundtouch-backup/cmd_local.go b/cmd/soundtouch-backup/cmd_local.go new file mode 100644 index 0000000..594852f --- /dev/null +++ b/cmd/soundtouch-backup/cmd_local.go @@ -0,0 +1,288 @@ +package main + +import ( + "context" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/config" + "github.com/gesellix/bose-soundtouch/pkg/discovery" + "github.com/gesellix/bose-soundtouch/pkg/ssh" + "github.com/urfave/cli/v2" +) + +var localEndpoints = []struct { + path string + file string +}{ + {"/info", "info.xml"}, + {"/name", "name.xml"}, + {"/presets", "presets.xml"}, + {"/sources", "sources.xml"}, + {"/now_playing", "now_playing.xml"}, + {"/volume", "volume.xml"}, + {"/bass", "bass.xml"}, + {"/balance", "balance.xml"}, + {"/capabilities", "capabilities.xml"}, + {"/networkInfo", "network_info.xml"}, + {"/clockDisplay", "clock_display.xml"}, + {"/getZone", "zone.xml"}, +} + +// sshFiles lists individual device filesystem paths captured via SSH. +// Paths that may not exist on all devices are silently skipped. +var sshFiles = []string{ + "/etc/hosts", + "/etc/resolv.conf", + "/etc/remote_services", + "/mnt/nv/remote_services", +} + +// sshDirs lists device directories whose contents are recursively captured via SSH. +var sshDirs = []string{ + "/opt/Bose/etc", + "/mnt/nv/BoseApp-Persistence/1", +} + +func localCommand() *cli.Command { + return &cli.Command{ + Name: "local", + Usage: "Back up one or more SoundTouch speakers on your local network", + Flags: append(outputFlags, + &cli.StringSliceFlag{ + Name: "host", + Aliases: []string{"H"}, + Usage: "Speaker host/IP (repeatable for multiple speakers)", + EnvVars: []string{"SOUNDTOUCH_HOST"}, + }, + &cli.IntFlag{ + Name: "port", + Aliases: []string{"p"}, + Usage: "Speaker HTTP port", + Value: 8090, + EnvVars: []string{"SOUNDTOUCH_PORT"}, + }, + &cli.BoolFlag{ + Name: "discover", + Aliases: []string{"d"}, + Usage: "Auto-discover speakers on the local network", + }, + &cli.DurationFlag{ + Name: "discover-timeout", + Usage: "Discovery timeout", + Value: 5 * time.Second, + }, + &cli.BoolFlag{ + Name: "ssh", + Usage: "Also back up device filesystem files via SSH (root@host:22, no password required)", + Value: true, + }, + ), + Action: runLocalBackup, + } +} + +type speakerTarget struct { + host string + port int + name string +} + +func runLocalBackup(c *cli.Context) error { + hosts := c.StringSlice("host") + port := c.Int("port") + doDiscover := c.Bool("discover") || len(hosts) == 0 + discoverTimeout := c.Duration("discover-timeout") + doSSH := c.Bool("ssh") + output := resolveOutputPath(c.String("output"), c.String("format")) + format := c.String("format") + + var targets []speakerTarget + + if doDiscover { + fmt.Printf("Discovering speakers (timeout: %s)...\n", discoverTimeout) + + ctx, cancel := context.WithTimeout(c.Context, discoverTimeout) + defer cancel() + + cfg, _ := config.LoadFromEnv() + svc := discovery.NewUnifiedDiscoveryService(cfg) + + found, discErr := svc.DiscoverDevices(ctx) + if discErr != nil { + printWarn(fmt.Sprintf("Discovery failed: %v", discErr)) + } + + for _, d := range found { + targets = append(targets, speakerTarget{host: d.Host, port: d.Port, name: d.Name}) + printOK(fmt.Sprintf("Found: %s (%s:%d)", d.Name, d.Host, d.Port)) + } + } + + for _, h := range hosts { + targets = append(targets, speakerTarget{host: h, port: port}) + } + + if len(targets) == 0 { + return fmt.Errorf("no speakers found — use --host or --discover") + } + + hc := &http.Client{Timeout: 10 * time.Second} + root := archiveRoot() + files := collectLocalFiles(hc, targets, root, doSSH) + + if len(files) == 0 { + return fmt.Errorf("no data collected") + } + + if err := writeArchive(output, format, files); err != nil { + return fmt.Errorf("writing archive: %w", err) + } + + fmt.Printf("Archive written: %s (%d files)\n", output, len(files)) + + return nil +} + +// collectLocalFiles backs up all targets over HTTP (and optionally SSH) and returns +// a files map ready for archiving. Keys are prefixed with root. +func collectLocalFiles(hc *http.Client, targets []speakerTarget, root string, doSSH bool) map[string][]byte { + files := make(map[string][]byte) + + for _, t := range targets { + name, entries, err := backupSpeakerHTTP(hc, t) + if err != nil { + printFail(fmt.Sprintf("%s:%d — %v", t.host, t.port, err)) + + continue + } + + dir := root + "/local/" + sanitizeName(name) + "/" + + for filename, data := range entries { + files[dir+filename] = data + } + + printOK(fmt.Sprintf("%s: %d files via HTTP", name, len(entries))) + + if doSSH { + sshEntries := backupSpeakerSSH(t.host, name) + + for filename, data := range sshEntries { + files[dir+filename] = data + } + + if len(sshEntries) > 0 { + printOK(fmt.Sprintf("%s: %d files via SSH", name, len(sshEntries))) + } + } + } + + return files +} + +func backupSpeakerHTTP(hc *http.Client, t speakerTarget) (name string, files map[string][]byte, err error) { + base := fmt.Sprintf("http://%s:%d", t.host, t.port) + files = make(map[string][]byte) + name = t.name + infoFetched := false + + if name == "" { + data, ferr := fetchRaw(hc, base+"/info") + if ferr != nil { + return "", nil, fmt.Errorf("cannot reach %s: %w", base, ferr) + } + + files["info.xml"] = data + infoFetched = true + + if extracted := xmlFirst(data, "name"); extracted != "" { + name = extracted + } else { + name = t.host + } + } + + for _, ep := range localEndpoints { + if ep.path == "/info" && infoFetched { + continue + } + + data, ferr := fetchRaw(hc, base+ep.path) + if ferr != nil { + printWarn(fmt.Sprintf("%s: skipped %s (%v)", name, ep.file, ferr)) + continue + } + + files[ep.file] = data + } + + return name, files, nil +} + +// backupSpeakerSSH connects to the device via SSH and reads the key filesystem paths. +// Files that don't exist on the device are silently skipped. +// Returned map keys are relative paths within the device backup directory (e.g. "ssh/etc/hosts"). +func backupSpeakerSSH(host, deviceName string) map[string][]byte { + client := ssh.NewClient(host) + files := make(map[string][]byte) + + for _, remotePath := range sshFiles { + data, err := client.ReadFile(remotePath) + if err != nil { + // Most missing files are expected (e.g. /etc/remote_services only exists post-migration) + printWarn(fmt.Sprintf("%s: SSH skipped %s (%v)", deviceName, remotePath, err)) + + continue + } + + if len(data) == 0 { + printWarn(fmt.Sprintf("%s: SSH empty file %s", deviceName, remotePath)) + } + + files["ssh"+remotePath] = data + } + + for _, remoteDir := range sshDirs { + dirFiles, err := client.ReadDir(remoteDir) + if err != nil { + printWarn(fmt.Sprintf("%s: SSH skipped dir %s (%v)", deviceName, remoteDir, err)) + continue + } + + for path, data := range dirFiles { + files["ssh"+path] = data + } + } + + return files +} + +func fetchRaw(hc *http.Client, url string) ([]byte, error) { + resp, err := hc.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + return io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) +} + +func xmlFirst(data []byte, field string) string { + re := regexp.MustCompile(`<` + regexp.QuoteMeta(field) + `[^>]*>([^<]+)`) + + m := re.FindSubmatch(data) + if len(m) >= 2 { + return strings.TrimSpace(string(m[1])) + } + + return "" +} diff --git a/cmd/soundtouch-backup/common.go b/cmd/soundtouch-backup/common.go new file mode 100644 index 0000000..54014d2 --- /dev/null +++ b/cmd/soundtouch-backup/common.go @@ -0,0 +1,172 @@ +package main + +import ( + "archive/tar" + "archive/zip" + "bufio" + "compress/gzip" + "fmt" + "os" + "strings" + "time" + + "github.com/urfave/cli/v2" + "golang.org/x/term" +) + +const ( + FormatTarGz = "tar.gz" + FormatZip = "zip" +) + +var outputFlags = []cli.Flag{ + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "Output archive file (default: soundtouch-backup-YYYY-MM-DD.tar.gz)", + EnvVars: []string{"SOUNDTOUCH_BACKUP_OUTPUT"}, + }, + &cli.StringFlag{ + Name: "format", + Usage: "Archive format: tar.gz or zip", + Value: FormatTarGz, + }, +} + +func resolveOutputPath(output, format string) string { + date := time.Now().Format("2006-01-02") + + ext := ".tar.gz" + if format == FormatZip { + ext = ".zip" + } + + filename := "soundtouch-backup-" + date + ext + + if output == "" { + return filename + } + + if info, err := os.Stat(output); err == nil && info.IsDir() { + return output + string(os.PathSeparator) + filename + } + + return output +} + +func archiveRoot() string { + return "soundtouch-backup-" + time.Now().Format("2006-01-02") +} + +func writeArchive(outputPath, format string, files map[string][]byte) error { + if format == FormatZip { + return writeZip(outputPath, files) + } + + return writeTarGz(outputPath, files) +} + +func writeTarGz(outputPath string, files map[string][]byte) error { + f, err := os.Create(outputPath) + if err != nil { + return err + } + defer f.Close() + + gz := gzip.NewWriter(f) + defer gz.Close() + + tw := tar.NewWriter(gz) + defer tw.Close() + + now := time.Now() + for name, data := range files { + hdr := &tar.Header{ + Name: name, + Mode: 0644, + Size: int64(len(data)), + ModTime: now, + Typeflag: tar.TypeReg, + } + if err := tw.WriteHeader(hdr); err != nil { + return fmt.Errorf("tar header %s: %w", name, err) + } + + if _, err := tw.Write(data); err != nil { + return fmt.Errorf("tar write %s: %w", name, err) + } + } + + return nil +} + +func writeZip(outputPath string, files map[string][]byte) error { + f, err := os.Create(outputPath) + if err != nil { + return err + } + defer f.Close() + + zw := zip.NewWriter(f) + defer zw.Close() + + for name, data := range files { + w, err := zw.Create(name) + if err != nil { + return fmt.Errorf("zip entry %s: %w", name, err) + } + + if _, err := w.Write(data); err != nil { + return fmt.Errorf("zip write %s: %w", name, err) + } + } + + return nil +} + +func promptCredentials(emailHint string) (email, password string, err error) { + r := bufio.NewReader(os.Stdin) + + if emailHint != "" { + email = emailHint + } else { + fmt.Print("Bose account email: ") + + email, err = r.ReadString('\n') + if err != nil { + return + } + + email = strings.TrimSpace(email) + } + + fmt.Print("Password: ") + + raw, termErr := term.ReadPassword(int(os.Stdin.Fd())) + + fmt.Println() + + if termErr != nil { + err = fmt.Errorf("reading password: %w (tip: use --password flag or BOSE_PASSWORD env var)", termErr) + return + } + + password = string(raw) + + return +} + +func sanitizeName(name string) string { + r := strings.NewReplacer( + "/", "_", "\\", "_", ":", "_", + "*", "_", "?", "_", "\"", "_", + "<", "_", ">", "_", "|", "_", + " ", "_", + ) + + return r.Replace(name) +} + +func printOK(msg string) { fmt.Printf(" ✓ %s\n", msg) } +func printFail(msg string) { fmt.Printf(" ✗ %s\n", msg) } +func printWarn(msg string) { fmt.Printf(" ⚠ %s\n", msg) } diff --git a/cmd/soundtouch-backup/main.go b/cmd/soundtouch-backup/main.go new file mode 100644 index 0000000..6a89c63 --- /dev/null +++ b/cmd/soundtouch-backup/main.go @@ -0,0 +1,37 @@ +// Package main implements the soundtouch-backup tool for backing up Bose SoundTouch +// cloud account data and local speaker filesystem files. +package main + +import ( + "log" + "os" + "runtime/debug" + + "github.com/urfave/cli/v2" +) + +var version = "dev" + +func init() { + if info, ok := debug.ReadBuildInfo(); ok { + if info.Main.Version != "" && info.Main.Version != "(devel)" { + version = info.Main.Version + } + } +} + +func main() { + app := &cli.App{ + Name: "soundtouch-backup", + Usage: "Back up Bose SoundTouch account and speaker data", + Version: version, + Commands: []*cli.Command{ + allCommand(), + cloudCommand(), + localCommand(), + }, + } + if err := app.Run(os.Args); err != nil { + log.Fatal(err) + } +} diff --git a/docs/README.md b/docs/README.md index 532e69c..f778bbf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ Welcome to the documentation for the Bose SoundTouch Toolkit. This comprehensive ### For Existing Users - **[Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)** - Prepare for the May 2026 shutdown +- **[Backup Tool](../cmd/soundtouch-backup/README.md)** - Back up your cloud account and speaker data before shutdown - **[SoundTouch Service Guide](guides/SOUNDTOUCH-SERVICE.md)** - Advanced service configuration ## 📋 Essential Documentation @@ -40,6 +41,7 @@ The documentation is organized into three main categories: ### Advanced Features - [MAC Address Mapping](guides/MAC-ADDRESS-MAPPING.md) - Device identification - [CLI Reference](guides/CLI-REFERENCE.md) - Command-line tools +- [Backup Tool](../cmd/soundtouch-backup/README.md) - Cloud account and speaker data backup - [IoT Implementation Guide](guides/IOT-IMPLEMENTATION-GUIDE.md) - IoT integrations - [MQTT Integration Design](guides/MQTT-INTEGRATION-DESIGN.md) - MQTT setup diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 09a2b2f..28a212a 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -8,6 +8,7 @@ * [Connecting Music Services](guides/MUSIC-SERVICES.md) * [Migration & Safety Guide](guides/MIGRATION-SAFETY.md) * [CLI Reference](guides/CLI-REFERENCE.md) +* [Backup Tool](../cmd/soundtouch-backup/README.md) * [Getting Started](guides/GETTING-STARTED.md) * [SoundTouch Service](guides/SOUNDTOUCH-SERVICE.md) * [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md) diff --git a/go.mod b/go.mod index 5470f40..6d6c784 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef github.com/urfave/cli/v2 v2.27.7 golang.org/x/crypto v0.50.0 + golang.org/x/term v0.42.0 ) require ( diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go index c1f4eb3..e47c32e 100644 --- a/pkg/service/setup/setup.go +++ b/pkg/service/setup/setup.go @@ -19,7 +19,7 @@ import ( "github.com/gesellix/bose-soundtouch/pkg/service/certmanager" "github.com/gesellix/bose-soundtouch/pkg/service/constants" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" - "github.com/gesellix/bose-soundtouch/pkg/service/ssh" + "github.com/gesellix/bose-soundtouch/pkg/ssh" ) // MigrationMethod represents the method used to migrate a speaker. diff --git a/pkg/service/ssh/ssh.go b/pkg/ssh/ssh.go similarity index 64% rename from pkg/service/ssh/ssh.go rename to pkg/ssh/ssh.go index 8b5a882..f273a91 100644 --- a/pkg/service/ssh/ssh.go +++ b/pkg/ssh/ssh.go @@ -1,4 +1,4 @@ -// Package ssh provides simple SSH operations used during device setup and migration. +// Package ssh provides simple SSH operations for SoundTouch speakers. package ssh import ( @@ -16,7 +16,7 @@ type Client struct { User string } -// NewClient creates a new SSH client for the given host. +// NewClient creates a new SSH client for the given host. The default user is "root". func NewClient(host string) *Client { return &Client{ Host: host, @@ -24,12 +24,13 @@ func NewClient(host string) *Client { } } -// getConfig returns the SSH client configuration. +// getConfig returns the SSH client configuration with the legacy cipher/kex suites +// required by older SoundTouch device firmware. func (c *Client) getConfig() *ssh.ClientConfig { return &ssh.ClientConfig{ User: c.User, Auth: []ssh.AuthMethod{ - ssh.Password(""), // Default password for SoundTouch root is often empty or not used with these settings + ssh.Password(""), }, HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 10 * time.Second, @@ -88,9 +89,46 @@ func (c *Client) Run(command string) (string, error) { return string(output), err } -// UploadContent uploads the given content to a file on the remote host. -// It uses a simple approach: echoing the content into a file. -// For larger files, a proper SCP or SFTP implementation would be better. +// ReadFile downloads the content of a file on the remote host. +// An empty file that causes cat to exit non-zero (a firmware quirk on some devices) +// is returned as empty bytes rather than an error. +func (c *Client) ReadFile(remotePath string) ([]byte, error) { + output, err := c.Run(fmt.Sprintf("cat %s", remotePath)) + if err != nil && strings.TrimSpace(output) != "" { + return nil, err + } + + return []byte(output), nil +} + +// ReadDir downloads all regular files under remotePath, returning a map of +// absolute remote path → file content. Missing or unreadable files are skipped. +func (c *Client) ReadDir(remotePath string) (map[string][]byte, error) { + listing, err := c.Run(fmt.Sprintf("find %s -type f 2>/dev/null", remotePath)) + if err != nil || strings.TrimSpace(listing) == "" { + return nil, fmt.Errorf("cannot list %s: %w", remotePath, err) + } + + result := make(map[string][]byte) + + for _, path := range strings.Split(strings.TrimSpace(listing), "\n") { + path = strings.TrimSpace(path) + if path == "" { + continue + } + + data, readErr := c.ReadFile(path) + if readErr != nil { + continue + } + + result[path] = data + } + + return result, nil +} + +// UploadContent uploads the given content to a file on the remote host using stdin piping. func (c *Client) UploadContent(content []byte, remotePath string) error { config := c.getConfig() @@ -108,28 +146,20 @@ func (c *Client) UploadContent(content []byte, remotePath string) error { defer func() { _ = session.Close() }() - // Use a pipe to write content to the remote command's stdin stdin, err := session.StdinPipe() if err != nil { return fmt.Errorf("failed to get stdin pipe: %w", err) } - // Capture stderr to get better error messages stderr, err := session.StderrPipe() if err != nil { return fmt.Errorf("failed to get stderr pipe: %w", err) } - // Read content from stdin and write to the remote file - cmd := fmt.Sprintf("cat > %s", remotePath) - - // Start the command - startErr := session.Start(cmd) - if startErr != nil { + if startErr := session.Start(fmt.Sprintf("cat > %s", remotePath)); startErr != nil { return fmt.Errorf("failed to start upload command: %w", startErr) } - // Write content and close stdin _, err = stdin.Write(content) _ = stdin.Close() @@ -137,12 +167,10 @@ func (c *Client) UploadContent(content []byte, remotePath string) error { return fmt.Errorf("failed to write content to stdin: %w", err) } - // Read stderr in case of failure stderrBuf := new(strings.Builder) go func() { _, _ = io.Copy(stderrBuf, stderr) }() - // Wait for the command to finish if err := session.Wait(); err != nil { return fmt.Errorf("failed to finish upload: %w (stderr: %s)", err, stderrBuf.String()) } diff --git a/pkg/service/ssh/ssh_test.go b/pkg/ssh/ssh_test.go similarity index 53% rename from pkg/service/ssh/ssh_test.go rename to pkg/ssh/ssh_test.go index 6c38a5e..49a1e51 100644 --- a/pkg/service/ssh/ssh_test.go +++ b/pkg/ssh/ssh_test.go @@ -44,29 +44,3 @@ func TestRun_DialFailure(t *testing.T) { t.Errorf("Expected 'failed to dial' error, got: %v", err) } } - -// Note: Testing Run and UploadContent with a real SSH server is complex in a unit test. -// We've already verified the implementation manually and with setup tests. -// Below is a skeleton of how one might mock it if needed, but for now we focus on the basic logic. - -/* -// MockClient can be used to test components that depend on SSH without a real server. -type MockClient struct { - RunFunc func(command string) (string, error) - UploadContentFunc func(content []byte, remotePath string) error -} - -func (m *MockClient) Run(command string) (string, error) { - if m.RunFunc != nil { - return m.RunFunc(command) - } - return "", nil -} - -func (m *MockClient) UploadContent(content []byte, remotePath string) error { - if m.UploadContentFunc != nil { - return m.UploadContentFunc(content, remotePath) - } - return nil -} -*/