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.
This commit is contained in:
Tobias Gesellchen
2026-02-16 18:52:25 +01:00
parent 95f5e9c831
commit 6aef2b807d
3 changed files with 198 additions and 13 deletions
+15 -7
View File
@@ -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`.
+48 -6
View File
@@ -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
}
+135
View File
@@ -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)
}