mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
feat(setup): replace obsolete resolv method with persistent DHCP-aware DNS hook
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user