feat(setup): replace obsolete resolv method with persistent DHCP-aware DNS hook

This commit is contained in:
Tobias Gesellchen
2026-02-16 18:04:28 +01:00
parent 2f04af872b
commit 92a5d3592c
5 changed files with 42 additions and 227 deletions
+3 -4
View File
@@ -229,8 +229,7 @@
<select id="migration-method" onchange="toggleMigrationMethod()">
<option value="xml">XML Configuration (Recommended - redirects specific services)</option>
<option value="hosts">/etc/hosts + Root CA (Advanced - global redirection)</option>
<option value="resolv">/etc/resolv.conf (DNS Discovery Mode - robust)</option>
<option value="aftertouch">Aftertouch Hook (DHCP-Aware - Most flexible)</option>
<option value="resolv">/etc/resolv.conf (DHCP-Aware - Most flexible)</option>
</select>
<div id="dns-port-warning" style="margin-top: 5px; color: #d32f2f; font-weight: bold; font-size: 0.9em; display: none;"></div>
</div>
@@ -309,10 +308,10 @@
</div>
</div>
<div id="planned-resolv-pane" class="diff-pane" style="display: none;">
<span class="config-header">Planned /etc/resolv.conf</span>
<span class="config-header">Planned /etc/resolv.conf Hook</span>
<pre id="planned-resolv"></pre>
<div id="resolv-note" style="margin-top: 10px; font-size: 0.9em; color: #666;">
<strong>Note:</strong> This method prepends AfterTouch as the nameserver and makes the file immutable (<code>chattr +i</code>). It also injects the Local Root CA.
<strong>Note:</strong> This method injects a persistent DNS priority hook into the DHCP logic (<code>/etc/udhcpc.d/50default</code>). It preserves your router's search domain and secondary DNS servers. It also injects the Local Root CA.
</div>
</div>
</div>
+4 -8
View File
@@ -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 = '<strong>Note:</strong> This method injects a persistent DNS priority hook into the DHCP logic (<code>/etc/udhcpc.d/50default</code>). It preserves your router\'s search domain and secondary DNS servers. It also injects the Local Root CA.';
} else {
resolvNote.innerHTML = '<strong>Note:</strong> This method prepends AfterTouch as the nameserver and makes the file immutable (<code>chattr +i</code>). It also injects the Local Root CA.';
}
resolvNote.innerHTML = '<strong>Note:</strong> This method injects a persistent DNS priority hook into the DHCP logic (<code>/etc/udhcpc.d/50default</code>). It preserves your router\'s search domain and secondary DNS servers. It also injects the Local Root CA.';
}
// Check DNS settings
+26 -123
View File
@@ -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)
+5 -78
View File
@@ -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