Files
T
86825c44af feat(backup): add soundtouch-backup tool for cloud and local speaker backup (#197)
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 <noreply@anthropic.com>
2026-05-02 14:00:23 +02:00

173 lines
3.2 KiB
Go

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) }