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