Compare commits

...
12 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Sonnet 4.6 ea4d8bacac revert(setup): revert OverrideSdkPrivateCfg.xml migration approach (#220)
The OverrideSdkPrivateCfg.xml override path introduced in #209 does not
work on SoundTouch 10 (and likely other models): the firmware ignores
the override file, leaving the device pointing at the original Bose
cloud URLs. Revert to editing SoundTouchSdkPrivateCfg.xml directly with
a .original backup, which is the approach known to work.

Relates to #214

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 08:43:34 +02:00
Tobias GesellchenandClaude Opus 4.7 bcffbc7719 fix(setup): test override file existence before treating cat output as config (#215)
client.Run uses CombinedOutput, so when
`/mnt/nv/OverrideSdkPrivateCfg.xml` is absent (the default for devices
migrated with pre-0.71.0 code) the cat stderr is returned as the
override config and surfaced to the migration page UI as "Current Config
(on Speaker)". Gate the branch on `[ -f ... ]` first, mirroring the
legacy .original check.

Relates to #209
Relates to #214

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:06:31 +02:00
Tobias GesellchenandClaude Sonnet 4.6 d14f4691a6 fix(amazon): use email and AMAZON type to match old Bose cloud format (#212)
Store the user's email address (not Amazon account ID) in
sourceKey.account and set source type to "AMAZON" so the speaker
firmware recognises Amazon Music sources the same way as the original
Bose cloud.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 08:23:04 +02:00
Tobias GesellchenandClaude Sonnet 4.6 a1d0add5a1 feat(ui): add CA certificate download to Settings tab and Migration tab (#210)
Adds a "Download CA Certificate" button in the Settings tab
(system-level convenience for importing the cert into browsers, curl,
Python clients, etc.) and a "Download CA cert" link next to the existing
"Trust CA Now" button in the Migration tab. Both link to the existing
/setup/ca.crt endpoint.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 20:51:40 +02:00
Tobias GesellchenandClaude Sonnet 4.6 8b196a8260 fix(setup): write XML migration to OverrideSdkPrivateCfg.xml instead of editing original (#209)
Use /mnt/nv/OverrideSdkPrivateCfg.xml (the firmware's override path)
rather than editing /opt/Bose/etc/SoundTouchSdkPrivateCfg.xml directly.
A malformed override cannot cause a reboot loop because the device falls
back to the untouched original.

Revert now removes the override file; legacy .original backups are still
restored for devices migrated with older code. checkCurrentConfig reads
the override path first so IsMigrated detection works correctly with the
new approach.

Credit: Ueberbose team, discovered via [soundcork
documentation](https://github.com/deborahgu/soundcork#configuring-the-bose-speaker-to-use-the-soundcork-server).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 20:51:05 +02:00
Tobias GesellchenandClaude Sonnet 4.6 3be654da17 chore(build): add -trimpath to GitHub workflow build commands
Consistent with the Makefile which already applies -trimpath globally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:55:14 +02:00
Tobias GesellchenandClaude Sonnet 4.6 3891c08dd1 docs(migration): add Docker Compose quickstart with .env config guidance
Adds a "Docker Compose (recommended for home servers and VMs)" section
to Step 1, pointing users to the existing docker-compose.yml and
.env.example. Clarifies the purpose of docker-compose.ci.yml (CI tests
only) and docker-compose.override.yml (local modifications, not in VCS).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:36:34 +02:00
Tobias GesellchenandClaude Sonnet 4.6 f6e733de6b fix(certmanager): use hostname as server cert CN instead of a random Bose domain
domains[0] was non-deterministic (Go map iteration) and could resolve to
any domain in the list including Bose-owned domains. Adds CommonName field
to CertificateManager, defaulting to "localhost", set to the device hostname
at startup. All Bose domains remain in the SAN where clients actually look.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:15:48 +02:00
Tobias GesellchenandClaude Sonnet 4.6 84585b8034 perf(service): move TLS cert generation off the startup path
On constrained hardware (e.g. ARMv7), RSA key generation can block
startup for minutes. HTTP now starts immediately; HTTPS is brought up
in a background goroutine once cert generation completes. A log message
informs the user that HTTPS will be available shortly after startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:11:41 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ac44fdace6 perf(certmanager): reduce CA key size from RSA-4096 to RSA-2048
RSA-4096 CA generation blocks service startup for minutes on slow ARM
hardware. The CA key is only used to sign server certs, never in TLS
handshakes, so 2048 bits provides sufficient security for a local CA
while being ~4-8x faster to generate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:11:41 +02:00
Tobias GesellchenandClaude Sonnet 4.6 06042f8728 chore(build): add ARMv7 target and apply trimpath/-s/-w flags globally
Adds build-linux-armv7 target (GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0)
for deployment to old embedded Linux devices (kernel 3.14+). Introduces
BUILDFLAGS=-trimpath -ldflags="-s -w" applied to all build targets for
smaller, reproducible binaries without local path leakage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:11:41 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ca19bb32f7 feat(setup): harden hostname resolution before migration (#204)
- resolveIP now returns (string, error): error when result did not come
  from the device's own SSH ping (service-side fallback or total
failure)
- migrateViaResolvConf and parseTargetURLAndResolveIP abort on error,
  preventing a bad IP from being written to the device
- GetMigrationSummary captures the error in ResolveIPError and falls
back
  to the hostname for the preview display; XML migration is unaffected
- Web UI shows a warning box with the error and a docs link when
resolution
  is uncertain; migrate button stays enabled for the XML method
- Add hostname resolution troubleshooting section to TROUBLESHOOTING.md

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 20:00:24 +02:00
15 changed files with 391 additions and 136 deletions
+2 -2
View File
@@ -112,7 +112,7 @@ jobs:
if [ "${{ matrix.goos }}" = "windows" ]; then
output_name="${output_name}.exe"
fi
go build -o "$output_name" ./cmd/soundtouch-cli
go build -trimpath -ldflags="-s -w" -o "$output_name" ./cmd/soundtouch-cli
- name: Upload build artifacts
uses: actions/upload-artifact@v7
@@ -220,7 +220,7 @@ jobs:
- name: Test CLI build and help
run: |
go build -o soundtouch-cli ./cmd/soundtouch-cli
go build -trimpath -ldflags="-s -w" -o soundtouch-cli ./cmd/soundtouch-cli
./soundtouch-cli -help
- name: Test library imports
+1
View File
@@ -151,6 +151,7 @@ jobs:
rm -f "$OUTPUT_NAME" "$OUTPUT_NAME.sha256" "$OUTPUT_NAME.sha512"
if ! go build \
-trimpath \
-ldflags="-s -w" \
-o "$OUTPUT_NAME" \
"$CMD_PATH"; then
+43 -35
View File
@@ -28,8 +28,8 @@ BACKUP_NAME=soundtouch-backup
BACKUP_PATH=./cmd/$(BACKUP_NAME)
BUILD_DIR=./build
# Version info
# No ldflags needed - using debug.BuildInfo since Go 1.18
# Build flags: strip debug info/DWARF for smaller binaries, remove local paths for reproducibility
BUILDFLAGS=-trimpath -ldflags="-s -w"
all: check build
@@ -38,78 +38,85 @@ build: build-cli build-service build-web build-examples build-favicon-gen build-
build-cli:
@echo "Building $(BINARY_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME) $(BINARY_PATH)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(BINARY_PATH)
build-service:
@echo "Building $(SERVICE_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME) $(SERVICE_PATH)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME) $(SERVICE_PATH)
build-web:
@echo "Building $(WEB_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) -o $(BUILD_DIR)/$(WEB_NAME) $(WEB_PATH)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(WEB_NAME) $(WEB_PATH)
build-examples:
@echo "Building $(EXAMPLE_MDNS_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME) $(EXAMPLE_MDNS_PATH)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME) $(EXAMPLE_MDNS_PATH)
@echo "Building $(EXAMPLE_UPNP_NAME)..."
$(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME) $(EXAMPLE_UPNP_PATH)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME) $(EXAMPLE_UPNP_PATH)
@echo "Building $(SCANNER_NAME)..."
$(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
build-favicon-gen:
@echo "Building $(FAVICON_GEN_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) -o $(BUILD_DIR)/$(FAVICON_GEN_NAME) $(FAVICON_GEN_PATH)
$(GOBUILD) $(BUILDFLAGS) -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)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME) $(BACKUP_PATH)
build-all: build-linux build-darwin build-windows build-examples-all
build-all: build-linux build-linux-armv7 build-darwin build-windows build-examples-all
build-linux:
@echo "Building for 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)
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(BINARY_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-linux-amd64 $(SERVICE_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-linux-amd64 $(BACKUP_PATH)
build-linux-armv7:
@echo "Building for Linux ARMv7 (CGO_ENABLED=0 for kernel 3.14+ compatibility)..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-linux-armv7 $(SERVICE_PATH)
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 $(BINARY_PATH)
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-linux-armv7 $(BACKUP_PATH)
build-darwin:
@echo "Building for macOS..."
@mkdir -p $(BUILD_DIR)
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(BINARY_PATH)
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)
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(BINARY_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(BINARY_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-amd64 $(SERVICE_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-arm64 $(SERVICE_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-darwin-amd64 $(BACKUP_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -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)
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(BINARY_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-windows-amd64.exe $(SERVICE_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-windows-amd64.exe $(BACKUP_PATH)
build-examples-all:
@echo "Building examples for all platforms..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-linux-amd64 $(EXAMPLE_MDNS_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-amd64 $(EXAMPLE_MDNS_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-arm64 $(EXAMPLE_MDNS_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-windows-amd64.exe $(EXAMPLE_MDNS_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-linux-amd64 $(EXAMPLE_UPNP_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-amd64 $(EXAMPLE_UPNP_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-arm64 $(EXAMPLE_UPNP_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-windows-amd64.exe $(EXAMPLE_UPNP_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-linux-amd64 $(SCANNER_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-amd64 $(SCANNER_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-arm64 $(SCANNER_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-windows-amd64.exe $(SCANNER_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-linux-amd64 $(EXAMPLE_MDNS_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-amd64 $(EXAMPLE_MDNS_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-arm64 $(EXAMPLE_MDNS_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-windows-amd64.exe $(EXAMPLE_MDNS_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-linux-amd64 $(EXAMPLE_UPNP_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-amd64 $(EXAMPLE_UPNP_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-arm64 $(EXAMPLE_UPNP_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-windows-amd64.exe $(EXAMPLE_UPNP_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-linux-amd64 $(SCANNER_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-amd64 $(SCANNER_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-arm64 $(SCANNER_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-windows-amd64.exe $(SCANNER_PATH)
test:
@echo "Running tests..."
@@ -338,6 +345,7 @@ help:
@echo " build-favicon-gen - Build the favicon generator"
@echo " build-examples - Build only the example programs"
@echo " build-all - Build for all platforms"
@echo " build-linux-armv7 - Build for Linux ARMv7 (kernel 3.14+ compatible, CGO_ENABLED=0)"
@echo " test - Run tests"
@echo " test-coverage - Run tests with coverage report"
@echo " check - Run fmt, vet, and tests"
+18 -9
View File
@@ -387,7 +387,7 @@ func main() {
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname)
cm := initCertificateManager(config.dataDir)
cm := initCertificateManager(config.dataDir, config.hostname)
sm := setup.NewManager(config.serverURL, ds, cm)
sm.MgmtUsername = config.mgmtUsername
sm.MgmtPassword = config.mgmtPassword
@@ -460,20 +460,25 @@ func main() {
initializeDefaultSources(ds)
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
if err != nil {
log.Printf("Warning: Failed to setup TLS: %v", err)
}
startDeviceDiscovery(server)
r := setupRouter(server)
log.Printf("Go service starting on %s", config.serverURL)
if tlsConfig != nil {
// TLS cert generation can be slow on constrained hardware; run it in the
// background so the HTTP server is available immediately.
log.Printf("HTTPS setup running in background; %s will be available shortly", config.httpsServerURL)
go func() {
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
if err != nil {
log.Printf("Warning: Failed to setup TLS: %v", err)
return
}
startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL)
}
}()
return http.ListenAndServe(config.addr, r)
},
@@ -507,6 +512,7 @@ type serviceConfig struct {
bindAddr string
addr string
dataDir string
hostname string
serverURL string
httpsServerURL string
httpsAddr string
@@ -618,6 +624,7 @@ func loadConfig(c *cli.Context) serviceConfig {
bindAddr: bindAddr,
addr: addr,
dataDir: dataDir,
hostname: hostname,
serverURL: serverURL,
httpsServerURL: httpsServerURL,
httpsAddr: httpsAddr,
@@ -809,8 +816,10 @@ func initDataStore(dataDir string) *datastore.DataStore {
return ds
}
func initCertificateManager(dataDir string) *certmanager.CertificateManager {
func initCertificateManager(dataDir, hostname string) *certmanager.CertificateManager {
cm := certmanager.NewCertificateManager(filepath.Join(dataDir, "certs"))
cm.CommonName = hostname
if err := cm.EnsureCA(); err != nil {
log.Printf("Warning: Failed to ensure CA: %v", err)
}
+22 -2
View File
@@ -28,7 +28,27 @@ soundtouch-service
The service starts on port 8000. Open `http://localhost:8000` in your browser.
### Docker (Linux — with host networking for device discovery)
### Docker Compose (recommended for home servers and VMs)
The repository ships a `docker-compose.yml` ready for this use case. Clone or download it, copy the example config, then edit `.env` before starting:
```bash
cp .env.example .env
# Edit .env:
# SOUNDTOUCH_HOSTNAME=192.168.1.100 ← your server's address
# SOUNDTOUCH_VERSION=v0.70.0 ← pin to a release tag instead of 'latest'
docker compose up -d
```
`SOUNDTOUCH_HOSTNAME` is the address your speakers will use to reach the service — use a hostname or IP reachable from the speaker, not `localhost`.
On **Linux** (Debian, Proxmox VE, Raspberry Pi OS, etc.) you can enable host networking for automatic speaker discovery. Uncomment the `network_mode: host` line in `docker-compose.yml` and remove the `ports:` section (they conflict with host networking). Without host networking, add your speakers by IP address in Step 4 instead.
For local overrides (e.g. switching to `build: .` during development), create a `docker-compose.override.yml` — Docker Compose picks it up automatically and it is not tracked in version control.
> **Note on `docker-compose.ci.yml`**: this file contains mock services used only for automated integration tests. It is not needed for your own deployment.
### Docker run (Linux — with host networking for device discovery)
```bash
docker run -d \
@@ -38,7 +58,7 @@ docker run -d \
ghcr.io/gesellix/bose-soundtouch:latest
```
### Docker (macOS / Windows — manual device IP required)
### Docker run (macOS / Windows — manual device IP required)
```bash
docker run -d \
+79
View File
@@ -853,6 +853,85 @@ cat data/accounts/3230304/devices/*/DeviceInfo.xml | grep macAddress
---
## 🌐 **Hostname Resolution** {#hostname-resolution}
### Why the service resolves the hostname from the device
When you migrate a speaker using the resolv.conf method, the service needs to write a raw IP address into the speaker's network configuration. That IP must be the address the *speaker itself* can reach — which is not necessarily the same address your computer resolves.
In environments with NAT, split-horizon DNS, or Docker/container networking, `soundtouch.local` (or whatever you set as `SERVER_URL`) may resolve to a different IP depending on who is asking. The service therefore resolves the hostname by running `ping -c 1 <hostname>` over SSH on the speaker and extracting the IP from the output. This is the authoritative result: it is exactly what the speaker would use.
If that SSH ping fails, migration is aborted. Writing an unresolvable or incorrectly resolved hostname into `aftertouch.resolv.conf` would silently break the speaker's DNS config and prevent it from reaching the service after reboot.
**The XML migration method is different.** It writes the full URL (e.g. `http://soundtouch.local:8000`) into `SoundTouchSdkPrivateCfg.xml`. The speaker resolves the hostname at connect time, not at migration time. This means migration can proceed even if the hostname is not yet reachable — for example, when the service will be deployed under that hostname but is not running yet. A warning is still shown in the UI so you are aware, but the Confirm Migration button remains enabled.
### ❌ "Cannot resolve target hostname for migration"
**Symptoms** (migration log or web UI warning):
```
cannot resolve target hostname for migration: cannot resolve "soundtouch.local":
SSH ping from device failed and service-side DNS lookup also failed
```
or:
```
resolved "soundtouch.local" to 192.168.1.100 from service, not from device —
result may be wrong if NAT or split-DNS is in use
```
**What this means:**
The service could not confirm the IP by running `ping` on the speaker via SSH. Either:
- the `ping` binary is not available or not in `$PATH` on this firmware, or
- the hostname is not resolvable from the speaker's network context.
**Diagnosis — run manually over SSH:**
```bash
# SSH into the speaker
ssh root@<speaker-ip>
# Try to resolve the service hostname
ping -c 1 soundtouch.local
# or use the IP directly to verify connectivity
ping -c 1 192.168.1.100
# Check the speaker's current DNS config
cat /etc/resolv.conf
# Check if ping is available
which ping
busybox ping --help
```
**Solutions:**
#### 1. Use an IP address as SERVER_URL
The most reliable fix. If the hostname cannot be resolved from the device, use a raw IP instead. Resolution is skipped entirely when `SERVER_URL` contains an IP.
```bash
# In your .env
SERVER_URL=http://192.168.1.100:8000
HTTPS_SERVER_URL=https://192.168.1.100:8443
```
HTTPS works correctly with IP addresses — the service certificate includes the IP as a Subject Alternative Name (SAN).
#### 2. Ensure the hostname resolves on the speaker's network segment
If you use `soundtouch.local`, verify mDNS is working from another device on the same subnet:
```bash
avahi-resolve -n soundtouch.local # Linux
dns-sd -G v4 soundtouch.local # macOS
```
#### 3. Use the XML migration method
Select the XML method in the migration UI. It writes the full URL and the speaker resolves it at connect time, so hostname resolution is not required during migration. This also allows migrating to a hostname that is not yet live.
---
## 🛟 **Getting More Help**
### Information to Gather
+9 -3
View File
@@ -17,7 +17,8 @@ import (
// CertificateManager handles CA and certificate generation.
type CertificateManager struct {
CertsDir string
CertsDir string
CommonName string // CN for generated server certs; defaults to "localhost" if empty
}
// NewCertificateManager creates a new CertificateManager.
@@ -137,7 +138,7 @@ func (cm *CertificateManager) GetServerTLSConfig(domains []string) (*tls.Config,
// GenerateCA generates a new CA certificate and key.
func (cm *CertificateManager) GenerateCA() error {
priv, err := rsa.GenerateKey(rand.Reader, 4096)
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
@@ -255,11 +256,16 @@ func (cm *CertificateManager) GenerateCertificate(domains []string) ([]byte, []b
}
}
cn := cm.CommonName
if cn == "" {
cn = "localhost"
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"AfterTouch"},
CommonName: domains[0],
CommonName: cn,
},
NotBefore: notBefore,
NotAfter: notAfter,
+3 -2
View File
@@ -16,6 +16,7 @@ func TestCertificateManager(t *testing.T) {
defer os.RemoveAll(tempDir)
cm := NewCertificateManager(filepath.Join(tempDir, "certs"))
cm.CommonName = "test.local"
// Test CA generation
if err := cm.EnsureCA(); err != nil {
@@ -67,8 +68,8 @@ func TestCertificateManager(t *testing.T) {
t.Fatalf("Failed to parse certificate: %v", err)
}
if cert.Subject.CommonName != domains[0] {
t.Errorf("Expected CommonName %s, got %s", domains[0], cert.Subject.CommonName)
if cert.Subject.CommonName != cm.CommonName {
t.Errorf("Expected CommonName %s, got %s", cm.CommonName, cert.Subject.CommonName)
}
// Check DNS names
+1 -1
View File
@@ -309,7 +309,7 @@ func TestMargeAccountFullExcludesEmptyAmazonSource(t *testing.T) {
t.Fatalf("Failed to write first device DeviceInfo.xml: %v", err)
}
if err := os.WriteFile(filepath.Join(firstDir, "Sources.xml"), []byte(`<sources>
<source id="10006" type="Audio" createdOn="2026-01-01T00:00:00.000+00:00" updatedOn="2026-01-01T00:00:00.000+00:00" displayName="Amazon Music" secret="" secretType="token" sourceproviderid="20">
<source id="10006" type="AMAZON" createdOn="2026-01-01T00:00:00.000+00:00" updatedOn="2026-01-01T00:00:00.000+00:00" displayName="Amazon Music" secret="" secretType="token" sourceproviderid="20">
<sourceKey type="AMAZON" account=""/>
</source>
</sources>`), 0644); err != nil {
+4 -4
View File
@@ -578,7 +578,7 @@ func (s *Server) bridgeAmazonToMarge(accountID string) {
}
for _, acc := range accounts {
log.Printf("[Amazon Bridge] Registering Amazon user %s in Marge for account %s", acc.UserID, accountID)
log.Printf("[Amazon Bridge] Registering Amazon user %s in Marge for account %s", acc.Email, accountID)
// Build the AmazonSecret credential envelope expected by the speaker firmware.
credMap := map[string]interface{}{
@@ -594,7 +594,7 @@ func (s *Server) bridgeAmazonToMarge(accountID string) {
continue
}
_, err = marge.AddSource(s.ds, accountID, acc.UserID, strconv.Itoa(constants.AmazonProviderID), string(credJSON), constants.CredentialTypeToken, acc.DisplayName)
_, err = marge.AddSource(s.ds, accountID, acc.Email, strconv.Itoa(constants.AmazonProviderID), string(credJSON), constants.CredentialTypeToken, acc.DisplayName)
if err != nil {
log.Printf("[Amazon Bridge] Failed to register source in Marge: %v", err)
continue
@@ -623,7 +623,7 @@ func (s *Server) bridgeAmazonToMarge(accountID string) {
cfg.Host = d.IPAddress
cfg.Timeout = 5 * time.Second
c := client.NewClient(cfg)
creds := models.NewAmazonOAuthCredentials(acc.UserID, string(credJSON), acc.DisplayName)
creds := models.NewAmazonOAuthCredentials(acc.Email, string(credJSON), acc.DisplayName)
if err := c.SetMusicServiceOAuthAccount(creds); err != nil {
log.Printf("[Amazon Bridge] Failed to notify speaker %s via OAuth: %v", d.Name, err)
@@ -633,7 +633,7 @@ func (s *Server) bridgeAmazonToMarge(accountID string) {
log.Printf("[Amazon Bridge] Sync notification failed for speaker %s: %v", d.Name, err)
log.Printf("[Amazon Bridge] Falling back to legacy account creation for speaker %s", d.Name)
legacyCreds := models.NewAmazonMusicCredentials(acc.UserID, string(credJSON))
legacyCreds := models.NewAmazonMusicCredentials(acc.Email, string(credJSON))
if err := c.SetMusicServiceAccount(legacyCreds); err != nil {
log.Printf("[Amazon Bridge] Legacy fallback failed for speaker %s: %v", d.Name, err)
} else {
+33
View File
@@ -333,6 +333,20 @@
</div>
</div>
<div style="margin-bottom: 20px">
<strong>Root CA Certificate:</strong>
<div style="margin-top: 8px; font-size: 0.9em; color: #555; max-width: 600px">
Import this certificate into your OS or browser trust store to
trust HTTPS connections to this AfterTouch server from other
clients (e.g. curl, Python scripts, browsers).
</div>
<div style="margin-top: 8px">
<a href="/setup/ca.crt" download="soundtouch-ca.crt">
<button type="button">Download CA Certificate</button>
</a>
</div>
</div>
<div style="margin-bottom: 20px">
<button onclick="updateSettings()">Save Settings</button>
<span
@@ -531,6 +545,12 @@
>
Trust CA Now
</button>
<a
href="/setup/ca.crt"
download="soundtouch-ca.crt"
style="margin-left: 10px; font-size: 0.85em"
title="Download CA cert to import into other clients"
>Download CA cert</a>
</p>
<div
@@ -854,6 +874,19 @@
>Planned Config (AfterTouch)</span
>
<pre id="planned-config"></pre>
<div
id="resolve-ip-error"
style="display: none; margin-top: 10px; padding: 10px; background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; color: #856404;"
>
⚠️ <strong>Hostname resolution warning:</strong>
<span id="resolve-ip-error-msg"></span>
<br/>
The planned IP shown above may be incorrect.
Migration methods that write IPs to the device
(hosts, resolv.conf) will refuse to proceed until
the hostname can be resolved from the device itself.
<a href="https://github.com/gesellix/bose-soundtouch/blob/main/docs/guides/TROUBLESHOOTING.md#hostname-resolution" target="_blank" style="color: #856404;">Learn more →</a>
</div>
</div>
<div
id="planned-hosts-pane"
+8
View File
@@ -1816,6 +1816,14 @@ async function showSummary(deviceId) {
document.getElementById("planned-hosts").innerText = summary.planned_hosts || "";
document.getElementById("planned-resolv").innerText = summary.planned_resolv || "";
const resolveErrEl = document.getElementById("resolve-ip-error");
if (summary.resolve_ip_error) {
document.getElementById("resolve-ip-error-msg").innerText = summary.resolve_ip_error;
resolveErrEl.style.display = "block";
} else {
resolveErrEl.style.display = "none";
}
const currentResolvElem = document.getElementById("current-resolv-content");
if (currentResolvElem) {
currentResolvElem.innerText = summary.current_resolv_conf || "Not available";
+5 -1
View File
@@ -106,7 +106,11 @@ func ensureTimestamps(s *models.ConfiguredSource) {
func ensureSourceType(s *models.ConfiguredSource) {
if s.Type == "" || (s.SourceKey.Type != "" && s.SourceKey.Type != constants.ProviderAux && s.SourceKey.Type != constants.ProviderBluetooth) {
s.Type = "Audio"
if s.SourceKey.Type == constants.ProviderAmazon {
s.Type = constants.ProviderAmazon
} else {
s.Type = "Audio"
}
}
}
+107 -65
View File
@@ -72,6 +72,7 @@ type MigrationSummary struct {
CurrentResolvConf string `json:"current_resolv_conf,omitempty"`
PlannedResolv string `json:"planned_resolv,omitempty"`
IsMigrated bool `json:"is_migrated"`
ResolveIPError string `json:"resolve_ip_error,omitempty"`
MirrorEnabled bool `json:"mirror_enabled"`
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
SkipMirrorEndpoints []string `json:"skip_mirror_endpoints,omitempty"`
@@ -257,40 +258,8 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
summary.PlannedConfig = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" + string(xmlContent)
// 2b. Initial planned hosts config
parsedURL, err := url.Parse(targetURL)
if err == nil {
hostName := parsedURL.Hostname()
if hostName != "" && hostName != "localhost" {
client := m.NewSSH(deviceIP)
hostIP := m.resolveIP(hostName, client)
// Predicted aftertouch.resolv.conf
summary.PlannedResolv = fmt.Sprintf("# Created by Aftertouch/SoundTouch-Service\n# Priority nameserver for Bose service redirection\nnameserver %s\n", hostIP)
domains := []string{
"streaming.bose.com",
"updates.bose.com",
"stats.bose.com",
"bmx.bose.com",
"content.api.bose.io",
"events.api.bosecm.com",
"bose-prod.apigee.net",
"worldwide.bose.com",
"music.api.bose.com",
"media.bose.io",
"downloads.bose.com",
"voice.api.bose.io",
}
var hostsLines []string
for _, domain := range domains {
hostsLines = append(hostsLines, fmt.Sprintf("%s\t%s", hostIP, domain))
}
summary.PlannedHosts = strings.Join(hostsLines, "\n")
}
}
// 2b. Planned network config (hosts entries, resolv.conf preview, resolve error)
m.populatePlannedNetworkConfig(summary, deviceIP, targetURL)
// 3. Check for remote services files
m.checkRemoteServices(summary, deviceIP)
@@ -307,18 +276,7 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
}
// 5. Provide HTTPS URL for testing
if parsedURL, err := url.Parse(targetURL); err == nil {
hostIP := parsedURL.Hostname()
if hostIP != "" {
// Find HTTPS port from environment or default
httpsPort := os.Getenv("HTTPS_PORT")
if httpsPort == "" {
httpsPort = "8443"
}
summary.ServerHTTPSURL = fmt.Sprintf("https://%s:%s/health", hostIP, httpsPort)
}
}
summary.ServerHTTPSURL = m.buildServerHTTPSURL(targetURL)
// 6. Check if migrated
m.checkIsMigrated(summary, deviceIP)
@@ -337,6 +295,67 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
return summary, nil
}
func (m *Manager) populatePlannedNetworkConfig(summary *MigrationSummary, deviceIP, targetURL string) {
parsedURL, err := url.Parse(targetURL)
if err != nil {
return
}
hostName := parsedURL.Hostname()
if hostName == "" || hostName == "localhost" {
return
}
client := m.NewSSH(deviceIP)
hostIP, resolveErr := m.resolveIP(hostName, client)
if resolveErr != nil {
summary.ResolveIPError = resolveErr.Error()
}
if hostIP == "" {
hostIP = hostName
}
summary.PlannedResolv = fmt.Sprintf("# Created by Aftertouch/SoundTouch-Service\n# Priority nameserver for Bose service redirection\nnameserver %s\n", hostIP)
domains := []string{
"streaming.bose.com",
"updates.bose.com",
"stats.bose.com",
"bmx.bose.com",
"content.api.bose.io",
"events.api.bosecm.com",
"bose-prod.apigee.net",
"worldwide.bose.com",
"music.api.bose.com",
"media.bose.io",
"downloads.bose.com",
"voice.api.bose.io",
}
hostsLines := make([]string, len(domains))
for i, domain := range domains {
hostsLines[i] = fmt.Sprintf("%s\t%s", hostIP, domain)
}
summary.PlannedHosts = strings.Join(hostsLines, "\n")
}
func (m *Manager) buildServerHTTPSURL(targetURL string) string {
parsedURL, err := url.Parse(targetURL)
if err != nil || parsedURL.Hostname() == "" {
return ""
}
httpsPort := os.Getenv("HTTPS_PORT")
if httpsPort == "" {
httpsPort = "8443"
}
return fmt.Sprintf("https://%s:%s/health", parsedURL.Hostname(), httpsPort)
}
// checkIsMigrated determines if the device is already migrated to AfterTouch.
func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) {
if !summary.SSHSuccess {
@@ -418,7 +437,7 @@ func (m *Manager) isResolvConfMigrated(client SSHClient, summary *MigrationSumma
return true
}
resolvedIP := m.resolveIP(targetHost, client)
resolvedIP, _ := m.resolveIP(targetHost, client)
if resolvedIP != "" && strings.Contains(summary.CurrentResolvConf, resolvedIP) && summary.CACertTrusted {
return true
}
@@ -765,11 +784,11 @@ func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options ma
if backupOut, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err != nil {
logs += fmt.Sprintf("Backing up original config to %s.original (check: %s)\n", remotePath, backupOut)
fmt.Printf("Backing up original config to %s.original\n", remotePath)
// Try to copy existing config to .original, ensuring filesystem is writable
if output, err := client.Run(fmt.Sprintf("%s && cp %s %s.original", rwCmd, remotePath, remotePath)); err != nil {
logs += fmt.Sprintf("cp backup failed: %v (output: %s)\n", err, output)
fmt.Printf("cp backup failed: %v (output: %s)\n", err, output)
// Fallback to manual upload if cp failed (might not have cp?)
if config, err := client.Run(fmt.Sprintf("cat %s", remotePath)); err == nil && config != "" {
if err := client.UploadContent([]byte(config), remotePath+".original"); err != nil {
logs += "failed to upload backup config: " + err.Error() + "\n"
@@ -787,10 +806,7 @@ func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options ma
logs += "Backup .original already exists\n"
}
// 1. Upload the configuration (rw is handled by calling it before if needed, but UploadContent uses cat > which needs rw)
// We'll wrap the upload in a way that EnsureRemoteServices and others might benefit,
// but UploadContent is a separate method. We should probably add rw to UploadContent or call it before.
// Actually, let's call rw before UploadContent here.
// 1. Upload the configuration
out, _ = client.Run(rwCmd)
logs += rwCmd + ": " + out + "\n"
@@ -1062,7 +1078,11 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) {
return "", fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
}
hostIP := m.resolveIP(hostName, client)
hostIP, err := m.resolveIP(hostName, client)
if err != nil {
return logs, fmt.Errorf("cannot resolve target hostname for migration: %w", err)
}
logs += fmt.Sprintf("Resolved %s to %s\n", hostName, hostIP)
// 2. Prepare /etc/hosts entries
@@ -1218,7 +1238,11 @@ func (m *Manager) migrateViaResolvConf(deviceIP, targetURL string) (string, erro
return "", fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
}
hostIP := m.resolveIP(hostName, client)
hostIP, err := m.resolveIP(hostName, client)
if err != nil {
return logs, fmt.Errorf("cannot resolve target hostname for migration: %w", err)
}
logs += fmt.Sprintf("Resolved %s to %s\n", hostName, hostIP)
// 2. Prepare /mnt/nv/soundtouch-service/aftertouch.resolv.conf content
@@ -1888,7 +1912,12 @@ func (m *Manager) parseTargetURLAndResolveIP(targetURL string, client SSHClient)
return "", nil, fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
}
return m.resolveIP(hostName, client), parsedURL, nil
hostIP, err := m.resolveIP(hostName, client)
if err != nil {
return "", nil, fmt.Errorf("cannot resolve target hostname: %w", err)
}
return hostIP, parsedURL, nil
}
func (m *Manager) addTemporaryHostEntry(client SSHClient, deviceIP, testDomain, testEntry, rwCmd string) error {
@@ -2035,15 +2064,21 @@ func (m *Manager) TestConnection(deviceIP, targetURL string, useExplicitCA bool)
// GetResolvedIP returns the resolved IP for a hostname, attempting to resolve it from any connected device first.
func (m *Manager) GetResolvedIP(host string) string {
return m.resolveIP(host, nil)
ip, _ := m.resolveIP(host, nil)
return ip
}
func (m *Manager) resolveIP(host string, client SSHClient) string {
// resolveIP resolves a hostname to an IP address.
// It first tries to resolve from the device via SSH ping (authoritative for migration).
// If that fails, it falls back to resolving from the service itself.
// An error is returned whenever the SSH ping did not produce the IP, so callers that
// write config to the device can abort rather than risk writing an unresolvable hostname.
func (m *Manager) resolveIP(host string, client SSHClient) (string, error) {
if net.ParseIP(host) != nil {
return host
return host, nil
}
// 1. Try resolving FROM the device via SSH (best for containers/NAT)
// 1. Try resolving FROM the device via SSH (authoritative: gives the IP the device will actually use)
if client != nil {
// Use ping to resolve hostname on the device.
// Busybox ping output usually looks like: PING host (1.2.3.4): 56 data bytes
@@ -2057,26 +2092,33 @@ func (m *Manager) resolveIP(host string, client SSHClient) string {
ip := output[start+1 : end]
if net.ParseIP(ip) != nil {
fmt.Printf("Resolved %s to %s from device\n", host, ip)
return ip
return ip, nil
}
}
}
}
// 2. Fallback: resolve FROM the service itself
// 2. Fallback: resolve FROM the service itself (unreliable for migration — NAT/split-DNS may differ)
ips, err := net.LookupIP(host)
if err != nil || len(ips) == 0 {
return host // Fallback to host if resolution fails
return "", fmt.Errorf("cannot resolve %q: SSH ping from device failed and service-side DNS lookup also failed", host)
}
// Prefer IPv4
var resolved string
for _, ip := range ips {
if ip.To4() != nil {
return ip.String()
resolved = ip.String()
break
}
}
return ips[0].String()
if resolved == "" {
resolved = ips[0].String()
}
return resolved, fmt.Errorf("resolved %q to %s from service, not from device — result may be wrong if NAT or split-DNS is in use", host, resolved)
}
// SyncDeviceData fetches presets, recents and sources from the device and saves them to the datastore.
+56 -12
View File
@@ -597,17 +597,22 @@ func TestTestHostsRedirection(t *testing.T) {
func TestResolveIP(t *testing.T) {
m := &Manager{}
// Test with IP
if m.resolveIP("1.2.3.4", nil) != "1.2.3.4" {
t.Errorf("Expected 1.2.3.4, got %s", m.resolveIP("1.2.3.4", nil))
// IP passthrough: no resolution needed, no error
ip, err := m.resolveIP("1.2.3.4", nil)
if ip != "1.2.3.4" || err != nil {
t.Errorf("Expected 1.2.3.4/nil, got %s/%v", ip, err)
}
// Test with localhost
if m.resolveIP("localhost", nil) != "127.0.0.1" && m.resolveIP("localhost", nil) != "::1" {
t.Errorf("Expected localhost resolution, got %s", m.resolveIP("localhost", nil))
// localhost resolves from service DNS; error expected (no SSH client)
ip, err = m.resolveIP("localhost", nil)
if ip != "127.0.0.1" && ip != "::1" {
t.Errorf("Expected localhost resolution, got %s", ip)
}
if err == nil {
t.Errorf("Expected error for service-side fallback, got nil")
}
// Test with device resolution (mocked)
// Device SSH ping succeeds: IP returned, no error
mock := &mockSSH{
runFunc: func(command string) (string, error) {
if strings.Contains(command, "ping -c 1 myhost") {
@@ -616,13 +621,18 @@ func TestResolveIP(t *testing.T) {
return "", nil
},
}
if m.resolveIP("myhost", mock) != "10.0.0.5" {
t.Errorf("Expected 10.0.0.5 from device, got %s", m.resolveIP("myhost", mock))
ip, err = m.resolveIP("myhost", mock)
if ip != "10.0.0.5" || err != nil {
t.Errorf("Expected 10.0.0.5/nil from device, got %s/%v", ip, err)
}
// Test with non-existent host (should fallback to input)
if m.resolveIP("non-existent.host.fake", nil) != "non-existent.host.fake" {
t.Errorf("Expected fallback to input, got %s", m.resolveIP("non-existent.host.fake", nil))
// Non-existent host, no SSH client: both methods fail, error returned
ip, err = m.resolveIP("non-existent.host.fake", nil)
if ip != "" {
t.Errorf("Expected empty IP on failure, got %s", ip)
}
if err == nil {
t.Errorf("Expected error for unresolvable host, got nil")
}
}
@@ -1525,6 +1535,40 @@ func TestCheckIsMigrated(t *testing.T) {
})
}
// TestCheckCurrentConfig_ReadsOriginalPath verifies that checkCurrentConfig reads
// from SoundTouchSdkPrivateCfgPath on an unmigrated device (issue #214 regression test).
func TestCheckCurrentConfig_ReadsOriginalPath(t *testing.T) {
m := NewManager("http://aftertouch:8000", nil, nil)
originalCfg := "<SoundTouchSdkPrivateCfg><margeServerUrl>http://streaming.bose.com</margeServerUrl></SoundTouchSdkPrivateCfg>"
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
if strings.HasPrefix(command, "[ -f ") && strings.Contains(command, ".original") {
return "", fmt.Errorf("exit status 1")
}
if command == fmt.Sprintf("cat %s", SoundTouchSdkPrivateCfgPath) {
return originalCfg, nil
}
return "", nil
},
}
}
summary := &MigrationSummary{}
cfg, err := m.checkCurrentConfig(summary, "127.0.0.1")
if err != nil {
t.Fatalf("checkCurrentConfig returned unexpected error: %v", err)
}
if cfg != originalCfg {
t.Errorf("Expected current config to be the original SoundTouchSdkPrivateCfg.xml, got %q", cfg)
}
if !summary.SSHSuccess {
t.Errorf("Expected SSHSuccess to be true when original config is readable")
}
}
func TestMigrateSpeaker_ResolvBlocking(t *testing.T) {
tempDir, err := os.MkdirTemp("", "setup-test")
if err != nil {