mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea4d8bacac | ||
|
|
bcffbc7719 | ||
|
|
d14f4691a6 | ||
|
|
a1d0add5a1 | ||
|
|
8b196a8260 | ||
|
|
3be654da17 | ||
|
|
3891c08dd1 | ||
|
|
f6e733de6b | ||
|
|
84585b8034 | ||
|
|
ac44fdace6 | ||
|
|
06042f8728 | ||
|
|
ca19bb32f7 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user