diff --git a/docs/guides/SOUNDTOUCH-SERVICE.md b/docs/guides/SOUNDTOUCH-SERVICE.md index b7ba65a..5478c73 100644 --- a/docs/guides/SOUNDTOUCH-SERVICE.md +++ b/docs/guides/SOUNDTOUCH-SERVICE.md @@ -241,9 +241,11 @@ curl "http://192.168.1.100:8090/presets" curl "http://localhost:8000/events/192.168.1.100" ``` -#### Aftertouch Hook (DHCP-Aware DNS Redirection) +#### ResolvConf Migration (DHCP-Aware DNS Redirection) -The most robust and flexible migration method. It utilizes the device's persistent `/mnt/nv/rc.local` script to inject a priority DNS hook into the system's DHCP configuration. +The most robust and flexible DNS-based migration method. It utilizes the device's persistent `/mnt/nv/rc.local` script to inject a priority DNS hook into the system's DHCP configuration. + +> **Note**: This method requires the DNS Discovery Server to be bound to **port 53** on your local IP and **actually running**. Most devices do not support custom DNS ports in `/etc/resolv.conf`. If you use a custom port for testing, remember to switch back to `:53` and ensure the server has successfully bound to it (check Settings for status) before the actual migration. **Advantages:** - **Discovery**: Automatically discover all Bose endpoints queried by the device. @@ -280,18 +282,6 @@ The most robust and flexible migration method. It utilizes the device's persiste 4. Make the script executable: `chmod +x /mnt/nv/rc.local`. 5. Reboot the speaker. -#### Legacy ResolvConf Migration (Immutable File) - -An alternative method for older firmwares or specific use cases where the DHCP script cannot be easily patched. - -**Advantages:** -- Simple to apply. -- Guaranteed persistence via file attributes. - -**Setup:** -1. The service manually creates `/etc/resolv.conf`. -2. The service then makes `/etc/resolv.conf` immutable (`chattr +i`) to prevent DHCP overrides. - ### DNS Discovery Server The SoundTouch service includes a built-in DNS server specifically designed for Bose devices. diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html index d39746e..4c43ffc 100644 --- a/pkg/service/handlers/web/index.html +++ b/pkg/service/handlers/web/index.html @@ -229,8 +229,7 @@ @@ -309,10 +308,10 @@ diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js index 1e57a9c..6a58861 100644 --- a/pkg/service/handlers/web/js/script.js +++ b/pkg/service/handlers/web/js/script.js @@ -877,7 +877,7 @@ async function showSummary(ip) { document.getElementById('planned-config').innerText = summary.planned_config; document.getElementById('planned-hosts').innerText = summary.planned_hosts || ''; - document.getElementById('planned-resolv').innerText = `nameserver ${new URL(targetUrl).hostname}\n${summary.current_resolv_conf || ''}`; + document.getElementById('planned-resolv').innerText = summary.planned_resolv || ''; const currentResolvElem = document.getElementById('current-resolv-content'); if (currentResolvElem) { @@ -1297,23 +1297,19 @@ async function toggleMigrationMethod() { hostsTestPane.style.display = 'block'; dnsTestPane.style.display = 'none'; if (dnsWarning) dnsWarning.style.display = 'none'; - } else if (method === 'resolv' || method === 'aftertouch') { + } else if (method === 'resolv') { xmlDiffPane.style.display = 'none'; plannedXmlPane.style.display = 'none'; plannedHostsPane.style.display = 'none'; plannedResolvPane.style.display = 'block'; - currentResolvPane.style.display = method === 'resolv' ? 'block' : 'none'; + currentResolvPane.style.display = 'none'; serviceOptions.style.display = 'none'; hostsTestPane.style.display = 'none'; dnsTestPane.style.display = 'block'; const resolvNote = document.getElementById('resolv-note'); if (resolvNote) { - if (method === 'aftertouch') { - resolvNote.innerHTML = 'Note: This method injects a persistent DNS priority hook into the DHCP logic (/etc/udhcpc.d/50default). It preserves your router\'s search domain and secondary DNS servers. It also injects the Local Root CA.'; - } else { - resolvNote.innerHTML = 'Note: This method prepends AfterTouch as the nameserver and makes the file immutable (chattr +i). It also injects the Local Root CA.'; - } + resolvNote.innerHTML = 'Note: This method injects a persistent DNS priority hook into the DHCP logic (/etc/udhcpc.d/50default). It preserves your router\'s search domain and secondary DNS servers. It also injects the Local Root CA.'; } // Check DNS settings diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go index ece4b80..2d0551c 100644 --- a/pkg/service/setup/setup.go +++ b/pkg/service/setup/setup.go @@ -27,10 +27,8 @@ const ( MigrationMethodXML MigrationMethod = "xml" // MigrationMethodHosts redirects services by modifying /etc/hosts and updating the CA trust store. MigrationMethodHosts MigrationMethod = "hosts" - // MigrationMethodResolvConf redirects services by modifying /etc/resolv.conf and updating the CA trust store. + // MigrationMethodResolvConf redirects services by injecting a priority DNS hook into the DHCP logic and updating the CA trust store. MigrationMethodResolvConf MigrationMethod = "resolv" - // MigrationMethodAftertouch redirects services by injecting a priority DNS hook into the DHCP logic and updating the CA trust store. - MigrationMethodAftertouch MigrationMethod = "aftertouch" ) // SoundTouchSdkPrivateCfgPath is the path to the speaker's private configuration file on device. @@ -69,6 +67,7 @@ type MigrationSummary struct { CACertTrusted bool `json:"ca_cert_trusted"` ServerHTTPSURL string `json:"server_https_url,omitempty"` CurrentResolvConf string `json:"current_resolv_conf,omitempty"` + PlannedResolv string `json:"planned_resolv,omitempty"` IsMigrated bool `json:"is_migrated"` } @@ -219,7 +218,7 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti hostIP := m.resolveIP(hostName, client) // Predicted aftertouch.resolv.conf - summary.CurrentResolvConf = fmt.Sprintf("# Created by Aftertouch/SoundTouch-Service\n# Priority nameserver for Bose service redirection\nnameserver %s\n", hostIP) + 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", @@ -252,9 +251,7 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti if summary.SSHSuccess { client := m.NewSSH(deviceIP) if resolvConf, err := client.Run("cat /etc/resolv.conf"); err == nil { - if summary.CurrentResolvConf == "" { - summary.CurrentResolvConf = resolvConf - } + summary.CurrentResolvConf = resolvConf } } @@ -325,18 +322,28 @@ func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) { } } - // Case 3: /etc/resolv.conf Migration - // Check if /etc/resolv.conf contains our target nameserver - if summary.CurrentResolvConf != "" { - targetURL := m.ServerURL + // Case 3: /etc/resolv.conf Migration (including Aftertouch hook) + // Check if /etc/resolv.conf contains our target nameserver OR if hook marker exists + if summary.SSHSuccess { + // Check for aftertouch.resolv.conf + if _, err := client.Run("[ -f /mnt/nv/aftertouch.resolv.conf ]"); err == nil { + if summary.CACertTrusted { + summary.IsMigrated = true + return + } + } - parsedTarget, err := url.Parse(targetURL) - if err == nil { - targetHost := parsedTarget.Hostname() - if strings.Contains(summary.CurrentResolvConf, targetHost) { - if summary.CACertTrusted { - summary.IsMigrated = true - return + if summary.CurrentResolvConf != "" { + targetURL := m.ServerURL + + parsedTarget, err := url.Parse(targetURL) + if err == nil { + targetHost := parsedTarget.Hostname() + if strings.Contains(summary.CurrentResolvConf, targetHost) { + if summary.CACertTrusted { + summary.IsMigrated = true + return + } } } } @@ -576,15 +583,6 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m return logs + out, err - case MigrationMethodAftertouch: - if err := m.checkDNSPreFlight(); err != nil { - return logs, err - } - - out, err := m.migrateViaAftertouch(deviceIP, targetURL) - - return logs + out, err - case MigrationMethodXML: out, err := m.migrateViaXML(deviceIP, targetURL, proxyURL, options, client, rwCmd) return logs + out, err @@ -1060,7 +1058,7 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) { return logs, nil } -func (m *Manager) migrateViaAftertouch(deviceIP, targetURL string) (string, error) { +func (m *Manager) migrateViaResolvConf(deviceIP, targetURL string) (string, error) { client := m.NewSSH(deviceIP) rwCmd := "(rw || mount -o remount,rw /)" @@ -1164,101 +1162,6 @@ fi return logs, nil } -func (m *Manager) migrateViaResolvConf(deviceIP, targetURL string) (string, error) { - client := m.NewSSH(deviceIP) - rwCmd := "(rw || mount -o remount,rw /)" - - var logs string - - // 1. Resolve target hostname to IP - parsedURL, err := url.Parse(targetURL) - if err != nil { - return "", fmt.Errorf("failed to parse target URL: %w", err) - } - - hostName := parsedURL.Hostname() - if hostName == "" || hostName == "localhost" { - return "", fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName) - } - - hostIP := m.resolveIP(hostName, client) - logs += fmt.Sprintf("Resolved %s to %s\n", hostName, hostIP) - - // 2. Prepare /etc/resolv.conf content - // We prepend our nameserver to the existing ones - resolvConf, err := client.Run("cat /etc/resolv.conf") - - logs += "cat /etc/resolv.conf: " + resolvConf + "\n" - if err != nil { - return logs, fmt.Errorf("failed to read /etc/resolv.conf: %w", err) - } - - lines := strings.Split(resolvConf, "\n") - - var newLines []string - - newLines = append(newLines, "# Added by AfterTouch migration") - newLines = append(newLines, fmt.Sprintf("nameserver %s", hostIP)) - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "nameserver") { - fields := strings.Fields(trimmed) - if len(fields) >= 2 && fields[1] == hostIP { - // Avoid duplicate nameserver entry - continue - } - } - - newLines = append(newLines, line) - } - - resolvConf = strings.Join(newLines, "\n") - if !strings.HasSuffix(resolvConf, "\n") { - resolvConf += "\n" - } - - // 3. Upload new /etc/resolv.conf - out, _ := client.Run(rwCmd) - logs += rwCmd + ": " + out + "\n" - - // Backup /etc/resolv.conf if it doesn't exist - if _, err := client.Run("[ -f /etc/resolv.conf.original ]"); err != nil { - out, _ := client.Run("cp /etc/resolv.conf /etc/resolv.conf.original") - logs += "cp /etc/resolv.conf /etc/resolv.conf.original: " + out + "\n" - } - - if err := client.UploadContent([]byte(resolvConf), "/etc/resolv.conf"); err != nil { - return logs, fmt.Errorf("failed to update /etc/resolv.conf: %w", err) - } - - logs += "Uploaded updated /etc/resolv.conf\n" - - // 3b. Make it immutable if chattr is available - if _, err := client.Run("chattr +i /etc/resolv.conf"); err == nil { - logs += "Made /etc/resolv.conf immutable with chattr +i\n" - } - - fmt.Printf("Updated /etc/resolv.conf on %s:\n%s\n", deviceIP, resolvConf) - - // 4. Inject CA Certificate - summary := &MigrationSummary{} - m.checkCACertTrusted(summary, deviceIP) - - if !summary.CACertTrusted { - out, err := m.TrustCACert(deviceIP) - - logs += "Trusting CA:\n" + out + "\n" - if err != nil { - return logs, err - } - } else { - logs += "CA certificate already trusted, skipping injection\n" - } - - return logs, nil -} - // RevertMigration reverts the speaker to its original Bose cloud configuration. func (m *Manager) RevertMigration(deviceIP string) (string, error) { client := m.NewSSH(deviceIP) diff --git a/pkg/service/setup/setup_test.go b/pkg/service/setup/setup_test.go index 3f00792..e20eec8 100644 --- a/pkg/service/setup/setup_test.go +++ b/pkg/service/setup/setup_test.go @@ -265,9 +265,9 @@ func TestGetMigrationSummary_WithProxyOptions(t *testing.T) { t.Errorf("Expected default marge URL when SSH fails, got: %s", summary.PlannedConfig) } - // Test PlannedHosts - if !contains(summary.PlannedHosts, "target\tstreaming.bose.com") { - t.Errorf("Expected PlannedHosts to contain redirect for target, got: %s", summary.PlannedHosts) + // Test PlannedResolv + if !contains(summary.PlannedResolv, "nameserver target") { + t.Errorf("Expected PlannedResolv to contain nameserver target, got: %s", summary.PlannedResolv) } } @@ -1064,79 +1064,6 @@ func TestMigrateViaResolvConf(t *testing.T) { m := NewManager("http://192.168.1.100:8000", nil, cm) - runCalls := []string{} - m.NewSSH = func(host string) SSHClient { - return &mockSSH{ - runFunc: func(command string) (string, error) { - runCalls = append(runCalls, command) - if command == "cat /etc/resolv.conf" { - return "nameserver 8.8.8.8", nil - } - if strings.HasPrefix(command, "[ -f") { - return "", fmt.Errorf("file not found") - } - if strings.HasPrefix(command, "grep -F") { - return "", fmt.Errorf("not found") - } - return "", nil - }, - uploadContentFunc: func(content []byte, remotePath string) error { - if remotePath == "/etc/resolv.conf" { - if !strings.Contains(string(content), "nameserver 192.168.1.100") { - t.Errorf("Expected resolv.conf content to contain nameserver, got %s", string(content)) - } - if !strings.Contains(string(content), "nameserver 8.8.8.8") { - t.Errorf("Expected resolv.conf content to retain old nameserver, got %s", string(content)) - } - } - return nil - }, - } - } - - _, err = m.migrateViaResolvConf("192.168.1.10", "http://192.168.1.100:8000") - if err != nil { - t.Fatalf("migrateViaResolvConf failed: %v", err) - } - - // Verify backups were attempted - foundResolvBackup := false - for _, call := range runCalls { - if strings.Contains(call, "cp /etc/resolv.conf /etc/resolv.conf.original") { - foundResolvBackup = true - } - } - if !foundResolvBackup { - t.Errorf("Expected /etc/resolv.conf backup to be attempted") - } - - // Verify chattr +i was attempted - foundChattr := false - for _, call := range runCalls { - if strings.Contains(call, "chattr +i /etc/resolv.conf") { - foundChattr = true - break - } - } - if !foundChattr { - t.Errorf("Expected chattr +i /etc/resolv.conf to be attempted") - } -} - -func TestMigrateViaAftertouch(t *testing.T) { - tempDir, err := os.MkdirTemp("", "setup-test-aftertouch") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tempDir) - - cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs")) - if err := cm.EnsureCA(); err != nil { - t.Fatalf("Failed to ensure CA: %v", err) - } - - m := NewManager("http://192.168.1.100:8000", nil, cm) - runCalls := []string{} uploads := make(map[string]string) @@ -1159,9 +1086,9 @@ func TestMigrateViaAftertouch(t *testing.T) { } } - _, err = m.migrateViaAftertouch("192.168.1.10", "http://192.168.1.100:8000") + _, err = m.migrateViaResolvConf("192.168.1.10", "http://192.168.1.100:8000") if err != nil { - t.Fatalf("migrateViaAftertouch failed: %v", err) + t.Fatalf("migrateViaResolvConf failed: %v", err) } // Verify uploads