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