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