From 6aef2b807dadc1103e81604cff3d4a0e17c08e2c Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Mon, 16 Feb 2026 18:48:46 +0100 Subject: [PATCH] Enhance ResolvConf migration to support multiple DHCP script variants This update allows the service to correctly patch both /etc/udhcpc.d/50default and /opt/Bose/udhcpc.script (used in SoundTouch 10 firmware) for DNS redirection. It also improves robustness by adding file existence checks in rc.local and ensures clean state by reverting to .original backups during migration. --- docs/guides/SOUNDTOUCH-SERVICE.md | 22 +++-- pkg/service/setup/setup.go | 54 ++++++++++-- pkg/service/setup/setup_test.go | 135 ++++++++++++++++++++++++++++++ 3 files changed, 198 insertions(+), 13 deletions(-) diff --git a/docs/guides/SOUNDTOUCH-SERVICE.md b/docs/guides/SOUNDTOUCH-SERVICE.md index 5478c73..a518e9b 100644 --- a/docs/guides/SOUNDTOUCH-SERVICE.md +++ b/docs/guides/SOUNDTOUCH-SERVICE.md @@ -257,9 +257,9 @@ The most robust and flexible DNS-based migration method. It utilizes the device' **How it works:** 1. **Configuration**: A custom file named `/mnt/nv/aftertouch.resolv.conf` is created on the device's persistent partition. -2. **Boot Hook**: On every boot, `/mnt/nv/rc.local` checks if the system's DHCP script (`/etc/udhcpc.d/50default`) has been patched. -3. **Surgical Patch**: If not patched, it injects a one-line check into the DHCP script. -4. **Resolution**: Whenever the device acquires a DHCP lease, the script now reads your `aftertouch.resolv.conf` first, placing your DNS server at the top of `/etc/resolv.conf` while keeping all other DHCP-provided settings. +2. **Boot Hook**: On every boot, `/mnt/nv/rc.local` checks if the system's DHCP scripts (`/etc/udhcpc.d/50default` or `/opt/Bose/udhcpc.script`) have been patched. +3. **Surgical Patch**: If not patched, it injects a one-line check into the relevant DHCP scripts. +4. **Resolution**: Whenever the device acquires a DHCP lease, the scripts now read your `aftertouch.resolv.conf` first, placing your DNS server at the top of `/etc/resolv.conf` while keeping all other DHCP-provided settings. **Setup:** 1. Enable SSH via the `remote_services` USB trick. @@ -272,11 +272,19 @@ The most robust and flexible DNS-based migration method. It utilizes the device' 3. Update `/mnt/nv/rc.local` with the idempotent patch: ```sh #!/bin/sh - TARGET_FILE="/etc/udhcpc.d/50default" + # Aftertouch DNS hook: prioritizes our custom nameserver if it exists HOOK_MARKER="/mnt/nv/aftertouch.resolv.conf" - if ! grep -q "$HOOK_MARKER" "$TARGET_FILE"; then - # Inject our config after the search domain line - sed -i '/echo "search \$domain"/a \ [ -f '"$HOOK_MARKER"' ] && cat '"$HOOK_MARKER" "$TARGET_FILE" + if [ -f "$HOOK_MARKER" ]; then + # Patch 50default if it exists + TARGET_FILE="/etc/udhcpc.d/50default" + if [ -f "$TARGET_FILE" ] && ! grep -q "$HOOK_MARKER" "$TARGET_FILE"; then + sed -i '/echo "search \$domain"/a \ [ -f '"$HOOK_MARKER"' ] && cat '"$HOOK_MARKER"' && dns=""' "$TARGET_FILE" + fi + # Patch udhcpc.script if it exists (e.g. SoundTouch 10) + TARGET_SCRIPT="/opt/Bose/udhcpc.script" + if [ -f "$TARGET_SCRIPT" ] && ! grep -q "$HOOK_MARKER" "$TARGET_SCRIPT"; then + sed -i '/echo "search \$search_list # \$interface" >> \$RESOLV_CONF/a \ [ -f '"$HOOK_MARKER"' ] && cat '"$HOOK_MARKER"' >> '"$RESOLV_CONF"' && dns=""' "$TARGET_SCRIPT" + fi fi ``` 4. Make the script executable: `chmod +x /mnt/nv/rc.local`. diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go index 5b788dd..727ecfa 100644 --- a/pkg/service/setup/setup.go +++ b/pkg/service/setup/setup.go @@ -1104,11 +1104,18 @@ func (m *Manager) migrateViaResolvConf(deviceIP, targetURL string) (string, erro patchLogic := fmt.Sprintf(` # Aftertouch DNS hook: prioritizes our custom nameserver if it exists -if ! grep -q "%s" "%s"; then - logger -t "aftertouch" "Patching %s with Aftertouch DNS hook" - sed -i '/echo "search \$domain"/a \ [ -f '"%s"' ] && cat '"%s" "%s" +if [ -f "%s" ]; then + if [ -f "%s" ] && ! grep -q "%s" "%s"; then + logger -t "aftertouch" "Patching %s with Aftertouch DNS hook" + sed -i '/echo "search \$domain"/a \ [ -f '"%s"' ] && cat '"%s"' && dns=""' "%s" + fi + targetScript="/opt/Bose/udhcpc.script" + if [ -f "$targetScript" ] && ! grep -q "%s" "$targetScript"; then + logger -t "aftertouch" "Patching $targetScript with Aftertouch DNS hook" + sed -i '/echo "search \$search_list # \$interface" >> \$RESOLV_CONF/a \ [ -f '"%s"' ] && cat '"%s"' >> '"$RESOLV_CONF"' && dns=""' "$targetScript" + fi fi -`, hookMarker, targetDHCPFile, targetDHCPFile, hookMarker, hookMarker, targetDHCPFile) +`, hookMarker, targetDHCPFile, hookMarker, targetDHCPFile, targetDHCPFile, hookMarker, hookMarker, targetDHCPFile, hookMarker, hookMarker, hookMarker) if !strings.Contains(currentRcLocal, hookMarker) { newRcLocal := currentRcLocal @@ -1147,16 +1154,39 @@ fi if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetDHCPFile)); err != nil { out, _ := client.Run(fmt.Sprintf("cp %s %s.original", targetDHCPFile, targetDHCPFile)) logs += fmt.Sprintf("cp %s %s.original: %s\n", targetDHCPFile, targetDHCPFile, out) + } else { + // If backup exists, revert to it first to ensure we start from a clean state + _, _ = client.Run(fmt.Sprintf("cp %s.original %s", targetDHCPFile, targetDHCPFile)) } // Run the patch logic via SSH to apply it now - patchCmd := fmt.Sprintf("sed -i '/echo \"search \\$domain\"/a \\ [ -f '\"%s\"' ] && cat '\"%s\" %s", hookMarker, hookMarker, targetDHCPFile) + patchCmd := fmt.Sprintf("sed -i '/echo \"search \\$domain\"/a \\ [ -f '\"%s\"' ] && cat '\"%s\"' && dns=\"\"' %s", hookMarker, hookMarker, targetDHCPFile) if _, err := client.Run(patchCmd); err != nil { - logs += fmt.Sprintf("Failed to apply patch immediately: %v\n", err) + logs += fmt.Sprintf("Failed to apply patch immediately to %s: %v\n", targetDHCPFile, err) } else { logs += fmt.Sprintf("Applied patch to %s\n", targetDHCPFile) } + // Apply patch immediately to /opt/Bose/udhcpc.script if it exists + targetScript := "/opt/Bose/udhcpc.script" + if _, err := client.Run(fmt.Sprintf("[ -f %s ]", targetScript)); err == nil { + // Backup if it doesn't exist + if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetScript)); err != nil { + out, _ := client.Run(fmt.Sprintf("cp %s %s.original", targetScript, targetScript)) + logs += fmt.Sprintf("cp %s %s.original: %s\n", targetScript, targetScript, out) + } else { + // If backup exists, revert to it first to ensure we start from a clean state + _, _ = client.Run(fmt.Sprintf("cp %s.original %s", targetScript, targetScript)) + } + + patchCmdScript := fmt.Sprintf("sed -i '/echo \"search \\$search_list # \\$interface\" >> \\$RESOLV_CONF/a \\ [ -f '\"%s\"' ] && cat '\"%s\"' >> '\"$RESOLV_CONF\"' && dns=\"\"' %s", hookMarker, hookMarker, targetScript) + if _, err := client.Run(patchCmdScript); err != nil { + logs += fmt.Sprintf("Failed to apply patch immediately to %s: %v\n", targetScript, err) + } else { + logs += fmt.Sprintf("Applied patch to %s\n", targetScript) + } + } + // 6. Inject CA Certificate summary := &MigrationSummary{} m.checkCACertTrusted(summary, deviceIP) @@ -1332,6 +1362,18 @@ func (m *Manager) revertAftertouchHook(client SSHClient, rwCmd string) string { } } + targetScript := "/opt/Bose/udhcpc.script" + if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetScript)); err == nil { + logs += fmt.Sprintf("Reverting %s from backup\n", targetScript) + fmt.Printf("Reverting %s from backup\n", targetScript) + out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, targetScript, targetScript)) + + logs += fmt.Sprintf("cp %s.original %s: %s\n", targetScript, targetScript, out) + if err != nil { + fmt.Printf("Warning: failed to revert %s: %v\n", targetScript, err) + } + } + return logs } diff --git a/pkg/service/setup/setup_test.go b/pkg/service/setup/setup_test.go index 76e08ef..9dd6274 100644 --- a/pkg/service/setup/setup_test.go +++ b/pkg/service/setup/setup_test.go @@ -1207,6 +1207,141 @@ func TestMigrateViaResolvConf_CorruptedRcLocal(t *testing.T) { } } +func TestMigrateViaResolvConf_UdhcpcScript(t *testing.T) { + tempDir, err := os.MkdirTemp("", "setup-test-resolv-script") + 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) + + targetScript := "/opt/Bose/udhcpc.script" + + m.NewSSH = func(host string) SSHClient { + return &mockSSH{ + runFunc: func(command string) (string, error) { + runCalls = append(runCalls, command) + if command == "cat /mnt/nv/rc.local" { + return "#!/bin/sh\n", nil + } + if command == "[ -f "+targetScript+" ]" { + return "", nil // file exists + } + if strings.HasPrefix(command, "[ -f") { + return "", fmt.Errorf("file not found") + } + return "", nil + }, + uploadContentFunc: func(content []byte, remotePath string) error { + uploads[remotePath] = 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 immediate patch to udhcpc.script + foundPatch := false + for _, call := range runCalls { + if strings.Contains(call, "sed -i") && strings.Contains(call, targetScript) { + foundPatch = true + break + } + } + if !foundPatch { + t.Errorf("Expected immediate patch to %s", targetScript) + } + + // Verify rc.local contains patch for udhcpc.script + rcLocal := uploads["/mnt/nv/rc.local"] + if !strings.Contains(rcLocal, "targetScript=\"/opt/Bose/udhcpc.script\"") { + t.Errorf("rc.local missing targetScript definition: %s", rcLocal) + } + if !strings.Contains(rcLocal, "sed -i '/echo \"search \\$search_list # \\$interface\" >> \\$RESOLV_CONF/a") { + t.Errorf("rc.local missing sed patch for udhcpc.script: %s", rcLocal) + } +} + +func TestRevertMigration_ResolvConf(t *testing.T) { + tempDir, err := os.MkdirTemp("", "setup-test-revert-resolv") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + m := NewManager("http://192.168.1.100:8000", nil, nil) + + runCalls := []string{} + uploads := make(map[string]string) + targetDHCPFile := "/etc/udhcpc.d/50default" + targetScript := "/opt/Bose/udhcpc.script" + + m.NewSSH = func(host string) SSHClient { + return &mockSSH{ + runFunc: func(command string) (string, error) { + runCalls = append(runCalls, command) + if command == "cat /mnt/nv/rc.local" { + return "#!/bin/sh\n# Aftertouch DNS hook\nif [ -f \"/mnt/nv/aftertouch.resolv.conf\" ]; then\n sed ...\nfi\n", nil + } + if strings.Contains(command, ".original ]") { + return "", nil // backup exists + } + if strings.Contains(command, "[ -f /mnt/nv/aftertouch.resolv.conf ]") { + return "", nil + } + return "", nil + }, + uploadContentFunc: func(content []byte, remotePath string) error { + uploads[remotePath] = string(content) + return nil + }, + } + } + + _, err = m.RevertMigration("192.168.1.10") + if err != nil { + t.Fatalf("RevertMigration failed: %v", err) + } + + // Verify backups were restored + foundDHCPRestore := false + foundScriptRestore := false + for _, call := range runCalls { + if strings.Contains(call, "cp "+targetDHCPFile+".original "+targetDHCPFile) { + foundDHCPRestore = true + } + if strings.Contains(call, "cp "+targetScript+".original "+targetScript) { + foundScriptRestore = true + } + } + + if !foundDHCPRestore { + t.Errorf("Expected %s to be restored from backup", targetDHCPFile) + } + if !foundScriptRestore { + t.Errorf("Expected %s to be restored from backup", targetScript) + } + + // Verify rc.local was cleaned up + rcLocal := uploads["/mnt/nv/rc.local"] + if strings.Contains(rcLocal, "# Aftertouch DNS hook") { + t.Errorf("rc.local still contains hook logic after revert: %s", rcLocal) + } +} + func contains(s, substr string) bool { return strings.Contains(s, substr) }