From 0f802e65c68a950cff0bc18102cbe06dcd1253c9 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 22 Feb 2026 13:33:35 +0100 Subject: [PATCH] Fallback to the system's dns resolver by default --- cmd/soundtouch-service/main.go | 12 +-- pkg/discovery/dns.go | 70 ++++++++-------- pkg/discovery/dns_test.go | 71 ++++++++++++++-- pkg/service/datastore/datastore.go | 2 +- pkg/service/handlers/handlers_setup.go | 22 ++++- pkg/service/handlers/server.go | 107 +++++++++++++++++++------ pkg/service/handlers/web/css/style.css | 37 +++++++++ pkg/service/handlers/web/index.html | 8 +- pkg/service/handlers/web/js/script.js | 7 ++ 9 files changed, 256 insertions(+), 80 deletions(-) diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 3446e71..5656c58 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -146,8 +146,8 @@ func main() { }, &cli.StringFlag{ Name: "dns-upstream", - Usage: "Upstream DNS server for non-Bose queries", - Value: "8.8.8.8", + Usage: "Upstream DNS server(s) for non-Bose queries (comma-separated). If empty, /etc/resolv.conf is used.", + Value: "", EnvVars: []string{"DNS_UPSTREAM"}, }, &cli.StringFlag{ @@ -219,7 +219,7 @@ func main() { server.SetHTTPServerURL(config.httpsServerURL) server.SetVersionInfo(version, commit, date) server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled) - server.SetDNSSettings(persisted.DNSEnabled, persisted.DNSUpstream, persisted.DNSBindAddr) + server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr) server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI) server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword) @@ -507,8 +507,8 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data config.enableSoundcorkProxy = persisted.EnableSoundcorkProxy config.dnsEnabled = persisted.DNSEnabled - if persisted.DNSUpstream != "" { - config.dnsUpstream = persisted.DNSUpstream + if len(persisted.DNSUpstream) > 0 { + config.dnsUpstream = strings.Join(persisted.DNSUpstream, ",") } if persisted.DNSBindAddr != "" { @@ -530,7 +530,7 @@ func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datast DiscoveryEnabled: true, EnableSoundcorkProxy: config.enableSoundcorkProxy, DNSEnabled: config.dnsEnabled, - DNSUpstream: config.dnsUpstream, + DNSUpstream: strings.Split(config.dnsUpstream, ","), DNSBindAddr: config.dnsBind, Shortcuts: map[string]int{ "/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound, diff --git a/pkg/discovery/dns.go b/pkg/discovery/dns.go index 18e97e3..2e42bfb 100644 --- a/pkg/discovery/dns.go +++ b/pkg/discovery/dns.go @@ -14,7 +14,7 @@ import ( // DNSDiscovery handles DNS queries and records discovered hosts. type DNSDiscovery struct { // Configuration - upstreamDNS string + upstreamDNS []string serviceIP string // State @@ -48,7 +48,7 @@ type DiscoveredHost struct { } // NewDNSDiscovery creates a new DNSDiscovery instance. -func NewDNSDiscovery(upstreamDNS, serviceIP string) *DNSDiscovery { +func NewDNSDiscovery(upstreamDNS []string, serviceIP string) *DNSDiscovery { return &DNSDiscovery{ upstreamDNS: upstreamDNS, serviceIP: serviceIP, @@ -83,7 +83,7 @@ func (d *DNSDiscovery) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { d.throttledLog(fmt.Sprintf("[DNS] Intercepting %s (type %d) -> %s", hostname, q.Qtype, d.serviceIP)) } else { // Forward to real DNS - if d.upstreamDNS == "" { + if len(d.upstreamDNS) == 0 { d.throttledLog("[DNS ERROR] No upstream DNS configured, cannot forward") m := new(dns.Msg) @@ -94,7 +94,7 @@ func (d *DNSDiscovery) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { return } - d.throttledLog(fmt.Sprintf("[DNS] Forwarding %s (type %d) to %s", hostname, q.Qtype, d.upstreamDNS)) + d.throttledLog(fmt.Sprintf("[DNS] Forwarding %s (type %d) to %v", hostname, q.Qtype, d.upstreamDNS)) d.forward(w, r) } } @@ -234,44 +234,44 @@ func (d *DNSDiscovery) forward(w dns.ResponseWriter, r *dns.Msg) { return } - // Add port 53 if not present - upstream := d.upstreamDNS - if !strings.Contains(upstream, ":") { - upstream += ":53" - } - - // Loop prevention: don't forward to ourselves - if upstream == d.bindAddr || (strings.HasPrefix(upstream, "127.0.0.1:") && strings.HasSuffix(d.bindAddr, upstream[9:])) { - d.throttledLog(fmt.Sprintf("[DNS ERROR] Refusing to forward %s to ourselves (%s)", q.Name, upstream)) - - m := new(dns.Msg) - m.SetReply(r) - m.Rcode = dns.RcodeServerFailure - _ = w.WriteMsg(m) - - return - } - c := new(dns.Client) c.Timeout = 2 * time.Second - in, _, err := c.Exchange(r, upstream) - if err != nil { - d.throttledLog(fmt.Sprintf("[DNS ERROR] Forward failed for %s (type %d): %v", q.Name, q.Qtype, err)) - // Return a failure response instead of just dropping - m := new(dns.Msg) - m.SetReply(r) - - m.Rcode = dns.RcodeServerFailure - if err := w.WriteMsg(m); err != nil { - log.Printf("[DNS ERROR] Failed to write failure response: %v", err) + for _, upstream := range d.upstreamDNS { + // Add port 53 if not present + if !strings.Contains(upstream, ":") { + upstream += ":53" } - return + // Loop prevention: don't forward to ourselves + if upstream == d.bindAddr || (strings.HasPrefix(upstream, "127.0.0.1:") && strings.HasSuffix(d.bindAddr, upstream[9:])) { + d.throttledLog(fmt.Sprintf("[DNS ERROR] Refusing to forward %s to ourselves (%s)", q.Name, upstream)) + continue + } + + in, _, err := c.Exchange(r, upstream) + if err == nil { + if in.Rcode == dns.RcodeSuccess { + if writeErr := w.WriteMsg(in); writeErr != nil { + log.Printf("[DNS ERROR] Failed to write forwarded response from %s: %v", upstream, writeErr) + } + + return + } + + d.throttledLog(fmt.Sprintf("[DNS] Upstream %s returned %s for %s, trying next", upstream, dns.RcodeToString[in.Rcode], q.Name)) + } else { + d.throttledLog(fmt.Sprintf("[DNS ERROR] Forward failed for %s (type %d) via %s: %v", q.Name, q.Qtype, upstream, err)) + } } - if err := w.WriteMsg(in); err != nil { - log.Printf("[DNS ERROR] Failed to write forwarded response: %v", err) + // If we reach here, all upstreams failed + m := new(dns.Msg) + m.SetReply(r) + m.Rcode = dns.RcodeServerFailure + + if err := w.WriteMsg(m); err != nil { + log.Printf("[DNS ERROR] Failed to write failure response: %v", err) } } diff --git a/pkg/discovery/dns_test.go b/pkg/discovery/dns_test.go index 71fcbb7..e6f4b96 100644 --- a/pkg/discovery/dns_test.go +++ b/pkg/discovery/dns_test.go @@ -12,7 +12,7 @@ import ( func TestDNSDiscovery_Interception(t *testing.T) { serviceIP := "192.168.1.100" - upstreamDNS := "8.8.8.8" + upstreamDNS := []string{"8.8.8.8"} d := NewDNSDiscovery(upstreamDNS, serviceIP) // Test intercepting Bose service @@ -79,7 +79,7 @@ func TestDNSDiscovery_Forwarding(t *testing.T) { // This test is harder because it needs a real upstream or a mock. // For now, let's just test that it calls forward and record. serviceIP := "192.168.1.100" - upstreamDNS := "127.0.0.1:5353" // Use a port that is likely closed or we can mock + upstreamDNS := []string{"127.0.0.1:5353"} // Use a port that is likely closed or we can mock d := NewDNSDiscovery(upstreamDNS, serviceIP) m := new(dns.Msg) @@ -120,7 +120,7 @@ func TestDNSDiscovery_Forwarding(t *testing.T) { func TestDNSDiscovery_StartTCP(t *testing.T) { serviceIP := "192.168.1.100" - upstreamDNS := "8.8.8.8" + upstreamDNS := []string{"8.8.8.8"} d := NewDNSDiscovery(upstreamDNS, serviceIP) addr := "127.0.0.1:5354" @@ -169,7 +169,7 @@ func TestDNSDiscovery_StartTCP(t *testing.T) { func TestDNSDiscovery_IsRunning(t *testing.T) { serviceIP := "192.168.1.100" - upstreamDNS := "8.8.8.8" + upstreamDNS := []string{"8.8.8.8"} d := NewDNSDiscovery(upstreamDNS, serviceIP) addr := "127.0.0.1:5355" @@ -214,7 +214,7 @@ func (m *mockResponseWriter) TsigTimersOnly(bool) {} func (m *mockResponseWriter) Hijack() {} func TestDNSDiscovery_LogThrottling(t *testing.T) { - d := NewDNSDiscovery("8.8.8.8", "192.168.1.100") + d := NewDNSDiscovery([]string{"8.8.8.8"}, "192.168.1.100") // Capture log output var logBuf strings.Builder @@ -247,7 +247,7 @@ func TestDNSDiscovery_LogThrottling(t *testing.T) { func TestDNSDiscovery_LoopPrevention(t *testing.T) { serviceIP := "192.168.1.100" bindAddr := "127.0.0.1:53" - upstreamDNS := "127.0.0.1:53" + upstreamDNS := []string{"127.0.0.1:53"} d := NewDNSDiscovery(upstreamDNS, serviceIP) d.bindAddr = bindAddr @@ -274,7 +274,7 @@ func TestDNSDiscovery_LoopPrevention(t *testing.T) { func TestDNSDiscovery_EmptyUpstream(t *testing.T) { serviceIP := "192.168.1.100" - upstreamDNS := "" // Empty upstream + var upstreamDNS []string // Empty upstream d := NewDNSDiscovery(upstreamDNS, serviceIP) d.bindAddr = ":53" @@ -298,7 +298,7 @@ func TestDNSDiscovery_EmptyUpstream(t *testing.T) { func TestDNSDiscovery_ForwardTimeout(t *testing.T) { serviceIP := "192.168.1.100" // Use an IP that is unroutable or doesn't exist on the network to ensure timeout - upstreamDNS := "192.0.2.1:53" // TEST-NET-1, usually non-routable + upstreamDNS := []string{"192.0.2.1:53"} // TEST-NET-1, usually non-routable d := NewDNSDiscovery(upstreamDNS, serviceIP) m := new(dns.Msg) @@ -318,3 +318,58 @@ func TestDNSDiscovery_ForwardTimeout(t *testing.T) { t.Errorf("Expected RcodeServerFailure after timeout") } } + +func TestDNSDiscovery_MultipleUpstreams(t *testing.T) { + serviceIP := "192.168.1.100" + + // Mock server 1: returns NXDOMAIN + mux1 := dns.NewServeMux() + mux1.HandleFunc("test.com.", func(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + m.Rcode = dns.RcodeNameError + _ = w.WriteMsg(m) + }) + ts1 := &dns.Server{Addr: "127.0.0.1:5356", Net: "udp", Handler: mux1} + go func() { _ = ts1.ListenAndServe() }() + defer func() { _ = ts1.Shutdown() }() + + // Mock server 2: succeeds + mux2 := dns.NewServeMux() + mux2.HandleFunc("test.com.", func(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + m.Answer = append(m.Answer, &dns.A{ + Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300}, + A: net.ParseIP("1.2.3.4"), + }) + _ = w.WriteMsg(m) + }) + ts2 := &dns.Server{Addr: "127.0.0.1:5357", Net: "udp", Handler: mux2} + go func() { _ = ts2.ListenAndServe() }() + defer func() { _ = ts2.Shutdown() }() + + time.Sleep(100 * time.Millisecond) + + upstreamDNS := []string{"127.0.0.1:5356", "127.0.0.1:5357"} + d := NewDNSDiscovery(upstreamDNS, serviceIP) + + m := new(dns.Msg) + m.SetQuestion("test.com.", dns.TypeA) + rw := &mockResponseWriter{} + + d.forward(rw, m) + + if rw.msg == nil { + t.Fatal("Expected a response message") + } + + // It should succeed because it falls back to the second upstream + if rw.msg.Rcode != dns.RcodeSuccess { + t.Errorf("Expected RcodeSuccess (0), got %d. Fallback failed.", rw.msg.Rcode) + } + + if len(rw.msg.Answer) == 0 { + t.Fatal("Expected an answer from the second upstream") + } +} diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index a3a4d09..c330403 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -709,7 +709,7 @@ type Settings struct { DiscoveryEnabled bool `json:"discovery_enabled"` EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"` DNSEnabled bool `json:"dns_enabled"` - DNSUpstream string `json:"dns_upstream,omitempty"` + DNSUpstream []string `json:"dns_upstream,omitempty"` DNSBindAddr string `json:"dns_bind_addr,omitempty"` Shortcuts map[string]int `json:"shortcuts,omitempty"` } diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go index 539fed4..c25170c 100644 --- a/pkg/service/handlers/handlers_setup.go +++ b/pkg/service/handlers/handlers_setup.go @@ -8,6 +8,7 @@ import ( "os" "sort" "strconv" + "strings" "time" "fmt" @@ -170,7 +171,7 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) { "dns_enabled": dnsEnabled, "dns_running": dnsRunning, "dns_actual_bind": actualBind, - "dns_upstream": dnsUpstream, + "dns_upstream": strings.Join(dnsUpstream, ","), "dns_bind_addr": dnsBindAddr, "enable_soundcork_proxy": enableSoundcorkProxy, "redact_logs": redact, @@ -223,7 +224,20 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { s.discoveryEnabled = settings.DiscoveryEnabled s.dnsEnabled = settings.DNSEnabled - s.dnsUpstream = settings.DNSUpstream + + // Handle comma-separated upstream DNS servers + var upstreamList []string + + if settings.DNSUpstream != "" { + for _, u := range strings.Split(settings.DNSUpstream, ",") { + u = strings.TrimSpace(u) + if u != "" { + upstreamList = append(upstreamList, u) + } + } + } + + s.dnsUpstream = upstreamList s.dnsBindAddr = settings.DNSBindAddr s.enableSoundcorkProxy = settings.EnableSoundcorkProxy @@ -260,12 +274,12 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { }) dnsEnabled := s.dnsEnabled - dnsUpstream := s.dnsUpstream + dnsUpstreamStr := strings.Join(s.dnsUpstream, ",") dnsBindAddr := s.dnsBindAddr s.mu.Unlock() - s.SetDNSSettings(dnsEnabled, dnsUpstream, dnsBindAddr) + s.SetDNSSettings(dnsEnabled, dnsUpstreamStr, dnsBindAddr) if err != nil { http.Error(w, "Failed to save settings: "+err.Error(), http.StatusInternalServerError) diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index 015f260..62b5b57 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "net/url" + "strings" "sync" "time" @@ -17,6 +18,7 @@ import ( "github.com/gesellix/bose-soundtouch/pkg/service/proxy" "github.com/gesellix/bose-soundtouch/pkg/service/setup" "github.com/gesellix/bose-soundtouch/pkg/service/spotify" + "github.com/miekg/dns" ) // Server handles HTTP requests for the SoundTouch service. @@ -34,7 +36,7 @@ type Server struct { discoveryInterval time.Duration discoveryEnabled bool dnsEnabled bool - dnsUpstream string + dnsUpstream []string dnsBindAddr string enableSoundcorkProxy bool shortcuts map[string]int @@ -88,6 +90,47 @@ func (s *Server) SetDiscoverySettings(interval time.Duration, enabled bool) { s.discoveryEnabled = enabled } +// parseUpstreamDNS splits a comma-separated string of DNS servers. +func parseUpstreamDNS(upstream string) []string { + var upstreamList []string + + if upstream != "" { + for _, u := range strings.Split(upstream, ",") { + u = strings.TrimSpace(u) + if u != "" { + upstreamList = append(upstreamList, u) + } + } + } + + return upstreamList +} + +// getSystemDNS returns the DNS servers from /etc/resolv.conf. +func getSystemDNS() []string { + config, _ := dns.ClientConfigFromFile("/etc/resolv.conf") + if config != nil && len(config.Servers) > 0 { + return config.Servers + } + + return nil +} + +// areUpstreamsEqual compares two slices of DNS server addresses. +func areUpstreamsEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i] != b[i] { + return false + } + } + + return true +} + // SetDNSSettings sets the DNS discovery settings for the server. func (s *Server) SetDNSSettings(enabled bool, upstream, bind string) { s.mu.Lock() @@ -97,11 +140,23 @@ func (s *Server) SetDNSSettings(enabled bool, upstream, bind string) { oldUpstream := s.dnsUpstream s.dnsEnabled = enabled - s.dnsUpstream = upstream s.dnsBindAddr = bind + upstreamList := parseUpstreamDNS(upstream) + + // Try to get system DNS if none provided + if enabled && len(upstreamList) == 0 { + upstreamList = getSystemDNS() + if len(upstreamList) > 0 { + log.Printf("[DNS] Using system DNS servers from /etc/resolv.conf: %v", upstreamList) + } + } + + s.dnsUpstream = upstreamList + upstreamChanged := !areUpstreamsEqual(upstreamList, oldUpstream) + if s.dnsDiscovery != nil { - if !enabled || bind != oldBind || upstream != oldUpstream { + if !enabled || bind != oldBind || upstreamChanged { log.Printf("[DNS] Settings changed, stopping DNS discovery server") _ = s.dnsDiscovery.Shutdown() @@ -109,8 +164,8 @@ func (s *Server) SetDNSSettings(enabled bool, upstream, bind string) { } } - if enabled && upstream == "" { - log.Printf("[DNS] Cannot start DNS discovery server: upstream DNS is empty") + if enabled && len(upstreamList) == 0 { + log.Printf("[DNS] Cannot start DNS discovery server: upstream DNS is empty and no system DNS found") s.dnsEnabled = false @@ -118,28 +173,32 @@ func (s *Server) SetDNSSettings(enabled bool, upstream, bind string) { } if enabled && s.dnsDiscovery == nil { - log.Printf("[DNS] Starting DNS discovery server on %s", bind) - - u, _ := url.Parse(s.serverURL) - - serviceIP := u.Hostname() - if serviceIP == "localhost" || serviceIP == "" { - serviceIP = "127.0.0.1" - } - - if s.sm != nil { - serviceIP = s.sm.GetResolvedIP(serviceIP) - } - - s.dnsDiscovery = discovery.NewDNSDiscovery(upstream, serviceIP) - go func(d *discovery.DNSDiscovery, addr string) { - if err := d.Start(addr); err != nil { - log.Printf("Warning: DNS discovery server error: %v", err) - } - }(s.dnsDiscovery, bind) + s.startDNSDiscovery(bind, upstreamList) } } +func (s *Server) startDNSDiscovery(bind string, upstreamList []string) { + log.Printf("[DNS] Starting DNS discovery server on %s", bind) + + u, _ := url.Parse(s.serverURL) + + serviceIP := u.Hostname() + if serviceIP == "localhost" || serviceIP == "" { + serviceIP = "127.0.0.1" + } + + if s.sm != nil { + serviceIP = s.sm.GetResolvedIP(serviceIP) + } + + s.dnsDiscovery = discovery.NewDNSDiscovery(upstreamList, serviceIP) + go func(d *discovery.DNSDiscovery, addr string) { + if err := d.Start(addr); err != nil { + log.Printf("Warning: DNS discovery server error: %v", err) + } + }(s.dnsDiscovery, bind) +} + // GetDNSRunning returns whether DNS discovery is active and its bind address. func (s *Server) GetDNSRunning() (bool, string) { s.mu.RLock() diff --git a/pkg/service/handlers/web/css/style.css b/pkg/service/handlers/web/css/style.css index ad2b4d5..8c321aa 100644 --- a/pkg/service/handlers/web/css/style.css +++ b/pkg/service/handlers/web/css/style.css @@ -103,6 +103,43 @@ pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px; .status-success { background-color: #e8f5e9; color: #2e7d32; } .status-error { background-color: #ffebee; color: #c62828; } +.info-toggle { + display: inline-block; + width: 18px; + height: 18px; + line-height: 18px; + text-align: center; + background-color: #607D8B; + color: white; + border-radius: 50%; + font-size: 12px; + cursor: pointer; + margin-left: 5px; + font-style: normal; + user-select: none; +} +.info-toggle:hover { + background-color: #455A64; +} +.info-details { + display: none; + background-color: #f0f7ff; + border: 1px solid #d0e0f0; + padding: 10px; + margin-top: 5px; + border-radius: 4px; + font-size: 0.85em; + color: #333; + line-height: 1.4; + max-width: 400px; +} +.info-details code { + background-color: #e3f2fd; + padding: 2px 4px; + border-radius: 3px; + font-family: monospace; +} + .badge { padding: 2px 8px; border-radius: 10px; diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html index f59f305..0824282 100644 --- a/pkg/service/handlers/web/index.html +++ b/pkg/service/handlers/web/index.html @@ -108,8 +108,12 @@
- - (For non-intercepted queries) + + +
+ Optional: comma-separated list of DNS servers (e.g., 1.1.1.1, 8.8.8.8). + If empty, AfterTouch defaults to the system nameservers (from /etc/resolv.conf). +
diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js index c3c0da3..e5b3a76 100644 --- a/pkg/service/handlers/web/js/script.js +++ b/pkg/service/handlers/web/js/script.js @@ -42,6 +42,13 @@ async function fetchSpotifyStatus() { } } +function toggleInfo(id) { + const el = document.getElementById(id); + if (el) { + el.style.display = el.style.display === 'block' ? 'none' : 'block'; + } +} + async function linkSpotify() { try { const response = await fetch('/mgmt/spotify/init', { method: 'POST' });