mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 16:46:17 +00:00
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>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
c931510384
commit
ca19bb32f7
@@ -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
|
||||
|
||||
@@ -854,6 +854,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";
|
||||
|
||||
+104
-59
@@ -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
|
||||
}
|
||||
@@ -1062,7 +1081,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 +1241,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 +1915,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 +2067,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 +2095,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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user