mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +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
@@ -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