diff --git a/pkg/discovery/dns_test.go b/pkg/discovery/dns_test.go index c515955..c5cdf2d 100644 --- a/pkg/discovery/dns_test.go +++ b/pkg/discovery/dns_test.go @@ -167,6 +167,106 @@ func TestDNSDiscovery_StartTCP(t *testing.T) { } } +func TestDNSDiscovery_SelfForwarding(t *testing.T) { + serviceIP := "soundtouch.local" + upstreamDNS := []string{"127.0.0.1:5357"} + d := NewDNSDiscovery(upstreamDNS, serviceIP) + + // Mock upstream DNS server for soundtouch.local + mux := dns.NewServeMux() + mux.HandleFunc("soundtouch.local.", func(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + rr, _ := dns.NewRR("soundtouch.local. 60 IN A 192.168.178.10") + m.Answer = append(m.Answer, rr) + _ = w.WriteMsg(m) + }) + ts := &dns.Server{Addr: "127.0.0.1:5357", Net: "udp", Handler: mux, ReadTimeout: 100 * time.Millisecond, WriteTimeout: 100 * time.Millisecond} + go func() { + _ = ts.ListenAndServe() + }() + defer func() { _ = ts.Shutdown() }() + + time.Sleep(100 * time.Millisecond) + + m := new(dns.Msg) + m.SetQuestion("soundtouch.local.", dns.TypeA) + rw := &mockResponseWriter{} + d.ServeDNS(rw, m) + + if rw.msg == nil { + t.Fatal("Expected a response for soundtouch.local") + } + + if rw.msg.Rcode != dns.RcodeSuccess { + t.Errorf("Expected Success (0) for soundtouch.local being forwarded, got %d", rw.msg.Rcode) + } + + if len(rw.msg.Answer) == 0 { + t.Fatal("Expected an answer in the response") + } + + if a, ok := rw.msg.Answer[0].(*dns.A); ok { + if a.A.String() != "192.168.178.10" { + t.Errorf("Expected IP 192.168.178.10, got %s", a.A.String()) + } + } + + // Check if d.recordQuery logged it correctly. + d.mu.RLock() + host, exists := d.discovered["soundtouch.local"] + d.mu.RUnlock() + + if !exists { + t.Error("Expected soundtouch.local to be recorded") + } + // It should NOT be intercepted anymore + if host != nil && host.IsIntercepted { + t.Error("Expected soundtouch.local NOT to be intercepted anymore, but forwarded") + } +} + +func TestDNSDiscovery_ForwardLocal(t *testing.T) { + serviceIP := "192.168.1.100" + upstreamDNS := []string{"127.0.0.1:5356"} + d := NewDNSDiscovery(upstreamDNS, serviceIP) + + m := new(dns.Msg) + m.SetQuestion("someone-else.local.", dns.TypeA) + rw := &mockResponseWriter{} + + // Start a mock upstream DNS server that returns SUCCESS for .local + mux := dns.NewServeMux() + mux.HandleFunc("someone-else.local.", func(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + rr, _ := dns.NewRR("someone-else.local. 60 IN A 192.168.1.50") + m.Answer = append(m.Answer, rr) + _ = w.WriteMsg(m) + }) + ts := &dns.Server{Addr: "127.0.0.1:5356", Net: "udp", Handler: mux, ReadTimeout: 100 * time.Millisecond, WriteTimeout: 100 * time.Millisecond} + go func() { + _ = ts.ListenAndServe() + }() + defer func() { _ = ts.Shutdown() }() + + time.Sleep(100 * time.Millisecond) + + d.ServeDNS(rw, m) + + if rw.msg == nil { + t.Fatal("Expected a response message") + } + + if rw.msg.Rcode != dns.RcodeSuccess { + t.Errorf("Expected Success (0) for .local being forwarded, got %d", rw.msg.Rcode) + } + + if len(rw.msg.Answer) == 0 { + t.Fatal("Expected an answer in the response") + } +} + func TestDNSDiscovery_IsRunning(t *testing.T) { serviceIP := "192.168.1.100" upstreamDNS := []string{"8.8.8.8"} diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go index d3322ed..9f74f4d 100644 --- a/pkg/service/setup/setup.go +++ b/pkg/service/setup/setup.go @@ -287,73 +287,87 @@ func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) { return } - // Case 1: XML Migration - // Check if any URL in the current config points to our server (targetURL) - if summary.ParsedCurrentConfig != nil { - targetURL := m.ServerURL - // Strip protocol for comparison if needed, or just check for substring - parsedTarget, err := url.Parse(targetURL) - if err == nil { - targetHost := parsedTarget.Hostname() - if strings.Contains(summary.ParsedCurrentConfig.MargeServerUrl, targetHost) || - strings.Contains(summary.ParsedCurrentConfig.StatsServerUrl, targetHost) || - strings.Contains(summary.ParsedCurrentConfig.SwUpdateUrl, targetHost) || - strings.Contains(summary.ParsedCurrentConfig.BmxRegistryUrl, targetHost) { - summary.IsMigrated = true - return - } - } - } - - // Case 2: /etc/hosts + Trust CA Migration - // Check if /etc/hosts contains redirections for Bose domains client := m.NewSSH(deviceIP) + if m.isXMLMigrated(summary) || m.isHostsMigrated(client, summary) || m.isResolvConfMigrated(client, summary) { + summary.IsMigrated = true + } +} + +// isXMLMigrated checks whether current XML config already points to our server. +func (m *Manager) isXMLMigrated(summary *MigrationSummary) bool { + if summary.ParsedCurrentConfig == nil { + return false + } + + parsedTarget, err := url.Parse(m.ServerURL) + if err != nil { + return false + } + + targetHost := parsedTarget.Hostname() + + return strings.Contains(summary.ParsedCurrentConfig.MargeServerUrl, targetHost) || + strings.Contains(summary.ParsedCurrentConfig.StatsServerUrl, targetHost) || + strings.Contains(summary.ParsedCurrentConfig.SwUpdateUrl, targetHost) || + strings.Contains(summary.ParsedCurrentConfig.BmxRegistryUrl, targetHost) +} + +// isHostsMigrated checks if /etc/hosts contains Bose domain redirections and CA is trusted. +func (m *Manager) isHostsMigrated(client SSHClient, summary *MigrationSummary) bool { hostsContent, err := client.Run("cat /etc/hosts") - if err == nil { - boseDomains := []string{ - "streaming.bose.com", - "updates.bose.com", - "stats.bose.com", - "bmx.bose.com", - } - for _, domain := range boseDomains { - if strings.Contains(hostsContent, domain) { - // If CA is also trusted, it's a strong indicator of migration - if summary.CACertTrusted { - summary.IsMigrated = true - return - } - } + if err != nil { + return false + } + + boseDomains := []string{ + "streaming.bose.com", + "updates.bose.com", + "stats.bose.com", + "bmx.bose.com", + } + for _, domain := range boseDomains { + if strings.Contains(hostsContent, domain) && summary.CACertTrusted { + return true } } - // 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 - } - } + return false +} - 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 - } - } - } - } +// isResolvConfMigrated checks for Aftertouch DNS migration signals and CA trust. +func (m *Manager) isResolvConfMigrated(client SSHClient, summary *MigrationSummary) bool { + // Hook file present + if _, err := client.Run("[ -f /mnt/nv/aftertouch.resolv.conf ]"); err == nil { + return summary.CACertTrusted } + + if summary.CurrentResolvConf == "" { + return false + } + + // Marker comment present + if strings.Contains(summary.CurrentResolvConf, "# Priority nameserver for Bose service redirection") && summary.CACertTrusted { + return true + } + + // Match hostname or resolved IP + parsedTarget, err := url.Parse(m.ServerURL) + if err != nil { + return false + } + + targetHost := parsedTarget.Hostname() + if strings.Contains(summary.CurrentResolvConf, targetHost) && summary.CACertTrusted { + return true + } + + resolvedIP := m.resolveIP(targetHost, client) + if resolvedIP != "" && strings.Contains(summary.CurrentResolvConf, resolvedIP) && summary.CACertTrusted { + return true + } + + return false } // populateDeviceInfo fills in device information from datastore and live info diff --git a/pkg/service/setup/setup_test.go b/pkg/service/setup/setup_test.go index 847a9cd..43fd689 100644 --- a/pkg/service/setup/setup_test.go +++ b/pkg/service/setup/setup_test.go @@ -1409,6 +1409,58 @@ func TestCheckIsMigrated(t *testing.T) { } }) + t.Run("ResolvConf Migrated (Marker)", func(t *testing.T) { + m.NewSSH = func(host string) SSHClient { + return &mockSSH{ + runFunc: func(command string) (string, error) { + if command == "cat /etc/hosts" { + return "127.0.0.1\tlocalhost", nil + } + if command == "[ -f /mnt/nv/aftertouch.resolv.conf ]" { + return "", fmt.Errorf("not found") + } + return "", nil + }, + } + } + summary := &MigrationSummary{ + SSHSuccess: true, + CACertTrusted: true, + CurrentResolvConf: "# Priority nameserver for Bose service redirection\nnameserver 192.168.1.1\n", + } + m.checkIsMigrated(summary, "127.0.0.1") + if !summary.IsMigrated { + t.Errorf("Expected IsMigrated to be true for resolv.conf migration with marker comment") + } + }) + + t.Run("ResolvConf Migrated (IP)", func(t *testing.T) { + m.NewSSH = func(host string) SSHClient { + return &mockSSH{ + runFunc: func(command string) (string, error) { + if command == "cat /etc/hosts" { + return "127.0.0.1\tlocalhost", nil + } + if command == "[ -f /mnt/nv/aftertouch.resolv.conf ]" { + return "", fmt.Errorf("not found") + } + // Mock resolveIP by mocking its SSH commands if any, or just wait for it to return targetHost + return "", nil + }, + } + } + // m.ServerURL is "http://aftertouch:8000" in this test (see top of TestCheckIsMigrated) + summary := &MigrationSummary{ + SSHSuccess: true, + CACertTrusted: true, + CurrentResolvConf: "nameserver aftertouch\n", + } + m.checkIsMigrated(summary, "127.0.0.1") + if !summary.IsMigrated { + t.Errorf("Expected IsMigrated to be true for resolv.conf migration with matching hostname/IP") + } + }) + t.Run("Not Migrated", func(t *testing.T) { m.NewSSH = func(host string) SSHClient { return &mockSSH{