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:
Tobias Gesellchen
2026-05-03 20:00:24 +02:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent c931510384
commit ca19bb32f7
5 changed files with 226 additions and 71 deletions
+13
View File
@@ -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"
+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";
+104 -59
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
}
@@ -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.
+22 -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")
}
}